├── .gitignore ├── .github └── workflows │ ├── test.yml │ ├── publish.yml │ └── deploy-demo.yml ├── setup.py ├── README.md ├── datasette_render_images.py └── test_datasette_render_images.py /.gitignore: -------------------------------------------------------------------------------- 1 | .venv 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | venv 6 | .eggs 7 | .pytest_cache 8 | *.egg-info 9 | build/ 10 | dist/ 11 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: Test 2 | 3 | on: [push] 4 | 5 | jobs: 6 | test: 7 | runs-on: ubuntu-latest 8 | strategy: 9 | matrix: 10 | python-version: ["3.7", "3.8", "3.9", "3.10", "3.11"] 11 | steps: 12 | - uses: actions/checkout@v2 13 | - name: Set up Python ${{ matrix.python-version }} 14 | uses: actions/setup-python@v2 15 | with: 16 | python-version: ${{ matrix.python-version }} 17 | - uses: actions/cache@v2 18 | name: Configure pip caching 19 | with: 20 | path: ~/.cache/pip 21 | key: ${{ runner.os }}-pip-${{ hashFiles('**/setup.py') }} 22 | restore-keys: | 23 | ${{ runner.os }}-pip- 24 | - name: Install dependencies 25 | run: | 26 | pip install -e '.[test]' 27 | - name: Run tests 28 | run: | 29 | pytest 30 | 31 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | from setuptools import setup 2 | import os 3 | 4 | VERSION = "0.4" 5 | 6 | 7 | def get_long_description(): 8 | with open( 9 | os.path.join(os.path.dirname(os.path.abspath(__file__)), "README.md"), 10 | encoding="utf8", 11 | ) as fp: 12 | return fp.read() 13 | 14 | 15 | setup( 16 | name="datasette-render-images", 17 | description="Datasette plugin that renders binary blob images using data-uris", 18 | long_description=get_long_description(), 19 | long_description_content_type="text/markdown", 20 | author="Simon Willison", 21 | url="https://github.com/simonw/datasette-render-images", 22 | license="Apache License, Version 2.0", 23 | version=VERSION, 24 | py_modules=["datasette_render_images"], 25 | entry_points={"datasette": ["render_images = datasette_render_images"]}, 26 | install_requires=["datasette"], 27 | extras_require={"test": ["pytest"]}, 28 | tests_require=["datasette-auth-tokens[test]"], 29 | ) 30 | -------------------------------------------------------------------------------- /.github/workflows/publish.yml: -------------------------------------------------------------------------------- 1 | name: Publish Python Package 2 | 3 | on: 4 | release: 5 | types: [created] 6 | 7 | jobs: 8 | test: 9 | runs-on: ubuntu-latest 10 | strategy: 11 | matrix: 12 | python-version: ["3.7", "3.8", "3.9", "3.10", "3.11"] 13 | steps: 14 | - uses: actions/checkout@v2 15 | - name: Set up Python ${{ matrix.python-version }} 16 | uses: actions/setup-python@v2 17 | with: 18 | python-version: ${{ matrix.python-version }} 19 | - uses: actions/cache@v2 20 | name: Configure pip caching 21 | with: 22 | path: ~/.cache/pip 23 | key: ${{ runner.os }}-pip-${{ hashFiles('**/setup.py') }} 24 | restore-keys: | 25 | ${{ runner.os }}-pip- 26 | - name: Install dependencies 27 | run: | 28 | pip install -e '.[test]' 29 | - name: Run tests 30 | run: | 31 | pytest 32 | deploy: 33 | runs-on: ubuntu-latest 34 | needs: [test] 35 | steps: 36 | - uses: actions/checkout@v2 37 | - name: Set up Python 38 | uses: actions/setup-python@v2 39 | with: 40 | python-version: "3.11" 41 | - uses: actions/cache@v2 42 | name: Configure pip caching 43 | with: 44 | path: ~/.cache/pip 45 | key: ${{ runner.os }}-publish-pip-${{ hashFiles('**/setup.py') }} 46 | restore-keys: | 47 | ${{ runner.os }}-publish-pip- 48 | - name: Install dependencies 49 | run: | 50 | pip install setuptools wheel twine build 51 | - name: Publish 52 | env: 53 | TWINE_USERNAME: __token__ 54 | TWINE_PASSWORD: ${{ secrets.PYPI_TOKEN }} 55 | run: | 56 | python -m build 57 | twine upload dist/* 58 | 59 | -------------------------------------------------------------------------------- /.github/workflows/deploy-demo.yml: -------------------------------------------------------------------------------- 1 | name: Deploy demo 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | 8 | jobs: 9 | deploy: 10 | runs-on: ubuntu-latest 11 | steps: 12 | - uses: actions/checkout@v2 13 | - name: Set up Python 14 | uses: actions/setup-python@v2 15 | with: 16 | python-version: "3.10" 17 | - uses: actions/cache@v2 18 | name: Configure pip caching 19 | with: 20 | path: ~/.cache/pip 21 | key: ${{ runner.os }}-pip- 22 | restore-keys: | 23 | ${{ runner.os }}-pip- 24 | - uses: actions/cache@v2 25 | name: Configure HTTP download caching 26 | with: 27 | path: ~/data 28 | key: ${{ runner.os }}-http-download-cache 29 | - name: Install Python dependencies 30 | run: pip install sqlite-utils datasette datasette-publish-vercel conditional-get 31 | - name: Download database 32 | run: |- 33 | mkdir -p ~/data 34 | conditional-get \ 35 | --etags ~/data/etags.json \ 36 | --output ~/data/favicons.db \ 37 | 'https://static.simonwillison.net/static/2020/datasette-render-images-favicons.db' 38 | - name: Create Metadata 39 | run: |- 40 | echo '{ 41 | "source": "datasette-render-images", 42 | "source_url": "https://github.com/simonw/datasette-render-images", 43 | "title": "datasette-render-images demo" 44 | }' > metadata.json 45 | - name: Deploy to Vercel 46 | env: 47 | NOW_TOKEN: ${{ secrets.NOW_TOKEN }} 48 | run: |- 49 | datasette publish vercel ~/data/favicons.db \ 50 | -m metadata.json \ 51 | --install=https://github.com/simonw/datasette-render-images/archive/$GITHUB_SHA.zip \ 52 | --project datasette-render-images-demo \ 53 | --token $NOW_TOKEN 54 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # datasette-render-images 2 | 3 | [![PyPI](https://img.shields.io/pypi/v/datasette-render-images.svg)](https://pypi.org/project/datasette-render-images/) 4 | [![Changelog](https://img.shields.io/github/v/release/simonw/datasette-render-images?include_prereleases&label=changelog)](https://github.com/simonw/datasette-render-images/releases) 5 | [![Tests](https://github.com/simonw/datasette-render-images/workflows/Test/badge.svg)](https://github.com/simonw/datasette-render-images/actions?query=workflow%3ATest) 6 | [![License](https://img.shields.io/badge/license-Apache%202.0-blue.svg)](https://github.com/simonw/datasette-render-images/blob/main/LICENSE) 7 | 8 | A Datasette plugin that renders binary blob images with data-uris, using the [render_cell() plugin hook](https://docs.datasette.io/en/stable/plugins.html#render-cell-value-column-table-database-datasette). 9 | 10 | ## Installation 11 | 12 | Install this plugin in the same environment as Datasette. 13 | 14 | $ pip install datasette-render-images 15 | 16 | ## Usage 17 | 18 | If a database row contains binary image data (PNG, GIF or JPEG), this plugin will detect that it is an image (using the [imghdr module](https://docs.python.org/3/library/imghdr.html) and render that cell using an `` element. 19 | 20 | Here's a [demo of the plugin in action](https://datasette-render-images-demo.datasette.io/favicons/favicons). 21 | 22 | ## Creating a compatible database table 23 | 24 | You can use the [sqlite-utils insert-files](https://sqlite-utils.datasette.io/en/stable/cli.html#inserting-data-from-files) command to insert image files into a database table: 25 | 26 | $ pip install sqlite-utils 27 | $ sqlite-utils insert-files gifs.db images *.gif 28 | 29 | See [Fun with binary data and SQLite](https://simonwillison.net/2020/Jul/30/fun-binary-data-and-sqlite/) for more on this tool. 30 | 31 | ## Configuration 32 | 33 | By default the plugin will only render images that are smaller than 100KB. You can adjust this limit using the `size_limit` plugin configuration option - for example, to increase the limit to 1MB (1000000 bytes) use the following in `metadata.json`: 34 | 35 | ```json 36 | { 37 | "plugins": { 38 | "datasette-render-images": { 39 | "size_limit": 1000000 40 | } 41 | } 42 | } 43 | ``` 44 | -------------------------------------------------------------------------------- /datasette_render_images.py: -------------------------------------------------------------------------------- 1 | from datasette import hookimpl 2 | import base64 3 | from markupsafe import Markup 4 | from os import PathLike 5 | 6 | DEFAULT_SIZE_LIMIT = 100 * 1024 7 | 8 | 9 | @hookimpl 10 | def render_cell(value, datasette): 11 | size_limit = DEFAULT_SIZE_LIMIT 12 | if datasette: 13 | plugin_config = datasette.plugin_config("datasette-render-images") or {} 14 | size_limit = plugin_config.get("size_limit") or DEFAULT_SIZE_LIMIT 15 | # Only act on byte columns 16 | if not isinstance(value, bytes): 17 | return None 18 | # Only render images < size_limit (default 100kb) 19 | if len(value) > size_limit: 20 | return None 21 | # Is this an image? 22 | image_type = what(None, h=value) 23 | if image_type not in ("png", "jpeg", "gif"): 24 | return None 25 | # Render as a data-uri 26 | return Markup( 27 | ''.format( 28 | image_type, base64.b64encode(value).decode("utf8") 29 | ) 30 | ) 31 | 32 | 33 | # Below: vendored copy of imghdr since it will be removed in Python 3.13 34 | 35 | 36 | def what(file, h=None): 37 | f = None 38 | try: 39 | if h is None: 40 | if isinstance(file, (str, PathLike)): 41 | f = open(file, "rb") 42 | h = f.read(32) 43 | else: 44 | location = file.tell() 45 | h = file.read(32) 46 | file.seek(location) 47 | for tf in tests: 48 | res = tf(h, f) 49 | if res: 50 | return res 51 | finally: 52 | if f: 53 | f.close() 54 | return None 55 | 56 | 57 | tests = [] 58 | 59 | 60 | def test_jpeg(h, f): 61 | """JPEG data with JFIF or Exif markers; and raw JPEG""" 62 | if h[6:10] in (b"JFIF", b"Exif"): 63 | return "jpeg" 64 | elif h[:4] == b"\xff\xd8\xff\xdb": 65 | return "jpeg" 66 | 67 | 68 | tests.append(test_jpeg) 69 | 70 | 71 | def test_png(h, f): 72 | if h.startswith(b"\211PNG\r\n\032\n"): 73 | return "png" 74 | 75 | 76 | tests.append(test_png) 77 | 78 | 79 | def test_gif(h, f): 80 | """GIF ('87 and '89 variants)""" 81 | if h[:6] in (b"GIF87a", b"GIF89a"): 82 | return "gif" 83 | 84 | 85 | tests.append(test_gif) 86 | 87 | 88 | def test_tiff(h, f): 89 | """TIFF (can be in Motorola or Intel byte order)""" 90 | if h[:2] in (b"MM", b"II"): 91 | return "tiff" 92 | 93 | 94 | tests.append(test_tiff) 95 | 96 | 97 | def test_rgb(h, f): 98 | """SGI image library""" 99 | if h.startswith(b"\001\332"): 100 | return "rgb" 101 | 102 | 103 | tests.append(test_rgb) 104 | 105 | 106 | def test_pbm(h, f): 107 | """PBM (portable bitmap)""" 108 | if len(h) >= 3 and h[0] == ord(b"P") and h[1] in b"14" and h[2] in b" \t\n\r": 109 | return "pbm" 110 | 111 | 112 | tests.append(test_pbm) 113 | 114 | 115 | def test_pgm(h, f): 116 | """PGM (portable graymap)""" 117 | if len(h) >= 3 and h[0] == ord(b"P") and h[1] in b"25" and h[2] in b" \t\n\r": 118 | return "pgm" 119 | 120 | 121 | tests.append(test_pgm) 122 | 123 | 124 | def test_ppm(h, f): 125 | """PPM (portable pixmap)""" 126 | if len(h) >= 3 and h[0] == ord(b"P") and h[1] in b"36" and h[2] in b" \t\n\r": 127 | return "ppm" 128 | 129 | 130 | tests.append(test_ppm) 131 | 132 | 133 | def test_rast(h, f): 134 | """Sun raster file""" 135 | if h.startswith(b"\x59\xA6\x6A\x95"): 136 | return "rast" 137 | 138 | 139 | tests.append(test_rast) 140 | 141 | 142 | def test_xbm(h, f): 143 | """X bitmap (X10 or X11)""" 144 | if h.startswith(b"#define "): 145 | return "xbm" 146 | 147 | 148 | tests.append(test_xbm) 149 | 150 | 151 | def test_bmp(h, f): 152 | if h.startswith(b"BM"): 153 | return "bmp" 154 | 155 | 156 | tests.append(test_bmp) 157 | 158 | 159 | def test_webp(h, f): 160 | if h.startswith(b"RIFF") and h[8:12] == b"WEBP": 161 | return "webp" 162 | 163 | 164 | tests.append(test_webp) 165 | 166 | 167 | def test_exr(h, f): 168 | if h.startswith(b"\x76\x2f\x31\x01"): 169 | return "exr" 170 | 171 | 172 | tests.append(test_exr) 173 | -------------------------------------------------------------------------------- /test_datasette_render_images.py: -------------------------------------------------------------------------------- 1 | from datasette_render_images import render_cell 2 | from datasette.app import Datasette 3 | from markupsafe import Markup 4 | import pytest 5 | 6 | GIF_1x1 = ( 7 | b"GIF89a\x01\x00\x01\x00\x80\x00\x00\x00\x00\x00\xff\xff\xff!\xf9\x04\x01\x00" 8 | b"\x00\x00\x00,\x00\x00\x00\x00\x01\x00\x01\x00\x00\x02\x01D\x00;" 9 | ) 10 | 11 | GIF_EXPECTED = ( 12 | '' 14 | ) 15 | 16 | PNG_1x1 = ( 17 | b"\x89PNG\r\n\x1a\n\x00\x00\x00\rIHDR\x00\x00\x00\x01\x00\x00\x00\x01" 18 | b"\x08\x00\x00\x00\x00:~\x9bU\x00\x00\x00\nIDATx\x9cc\xfa\x0f\x00\x01" 19 | b"\x05\x01\x02\xcf\xa0.\xcd\x00\x00\x00\x00IEND\xaeB`\x82" 20 | ) 21 | 22 | PNG_EXPECTED = ( 23 | '' 25 | ) 26 | 27 | # https://github.com/python/cpython/blob/master/Lib/test/imghdrdata/python.jpg 28 | JPEG = ( 29 | b"\xff\xd8\xff\xe0\x00\x10JFIF\x00\x01\x01\x01\x00\x01\x00\x01\x00\x00" 30 | b"\xff\xdb\x00C\x00\x03\x02\x02\x02\x02\x02\x03\x02\x02\x02\x03" 31 | b"\x03\x03\x03\x04\x06\x04\x04\x04\x04\x04\x08\x06\x06\x05\x06\t\x08\n\n\t" 32 | b"\x08\t\t\n\x0c\x0f\x0c\n\x0b\x0e\x0b\t\t\r\x11\r\x0e\x0f\x10\x10" 33 | b"\x11\x10\n\x0c\x12\x13\x12\x10\x13\x0f\x10\x10\x10\xff\xdb\x00C\x01\x03\x03" 34 | b"\x03\x04\x03\x04\x08\x04\x04\x08\x10\x0b\t\x0b\x10\x10\x10\x10" 35 | b"\x10\x10\x10\x10\x10\x10\x10\x10\x10\x10\x10\x10\x10\x10\x10\x10" 36 | b"\x10\x10\x10\x10\x10\x10\x10\x10\x10\x10\x10\x10\x10\x10\x10\x10" 37 | b"\x10\x10\x10\x10\x10\x10\x10\x10\x10\x10\x10\x10\x10\x10\xff\xc0" 38 | b'\x00\x11\x08\x00\x10\x00\x10\x03\x01"\x00\x02\x11\x01\x03\x11' 39 | b"\x01\xff\xc4\x00\x16\x00\x01\x01\x01\x00\x00\x00\x00\x00\x00\x00" 40 | b"\x00\x00\x00\x00\x00\x00\x07\x04\x05\xff\xc4\x00$\x10\x00\x01" 41 | b"\x04\x01\x04\x02\x02\x03\x00\x00\x00\x00\x00\x00\x00\x00\x01\x02" 42 | b'\x03\x04\x06\x05\x07\x08\x12\x13\x11"\x00\x14\t12\xff\xc4\x00\x15\x01' 43 | b"\x01\x01\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00" 44 | b"\x00\x06\xff\xc4\x00#\x11\x00\x01\x02\x05\x03\x05\x00\x00\x00" 45 | b"\x00\x00\x00\x00\x00\x00\x00\x01\x02\x11\x03\x04\x05\x06!\x00\x121\x15\x16" 46 | b"a\x81\xe1\xff\xda\x00\x0c\x03\x01\x00\x02\x11\x03\x11\x00?\x00\x14\xa6\xd2" 47 | b"j\x1bs\xc1\xe6\x13\x12\xd4\x95\x1c\xf3\x11c\xe4%e\xbe\xbaZ\xeciE@\xb1" 48 | b"\xe5 \xb2T\xa5\x1f\xd2\xca\xb8\xfa\xf2 \xab\x96=\x97l\x935\xe6\x9bw\xd7\xe6" 49 | b'm\xa7\x17\x81\xa5W\x1c\x7f\x1c\xeaq\xe2K9\xd7\xe3"S\xf2\x1ai\xde\xd4q' 50 | b"J8\xb4\x82\xe8K\x89*qi\x1e\xcd-!;\xf1\xef\xb9\x1at\xac\xee\xa1Zu\x8e\xd5H" 51 | b"\xace[\x85\x8b\x81\x85{!)\x98g\xa9k\x94\xb9IeO\xb9\xc8\x85)\x11K\x81*\xf0" 52 | b"z\xd9\xf2<\x80~U\xbe\r\xf6b\xa1@\xcc\xe8\xe6\x9a=\\\xb7C\xb3\xd7zeX\xb1\xd9" 53 | b"Q!\x88\xbfd\xb8\xd3\xf1\xc3h\x04)\xc0\xd0\xfe\xbb<\x02\xe0' 70 | ) 71 | 72 | 73 | @pytest.mark.parametrize( 74 | "input,expected", 75 | [ 76 | ("hello", None), 77 | (1, None), 78 | (True, None), 79 | (GIF_1x1, GIF_EXPECTED), 80 | # If it's a unicode string, not bytes, it is ignored: 81 | (GIF_1x1.decode("latin1"), None), 82 | # 1x1 transparent PNG: 83 | (PNG_1x1, PNG_EXPECTED), 84 | (PNG_1x1.decode("latin1"), None), 85 | # Smallest possible JPEG, from https://github.com/mathiasbynens/small/ 86 | (JPEG, JPEG_EXPECTED), 87 | (JPEG.decode("latin1"), None), 88 | ], 89 | ) 90 | def test_render_cell(input, expected): 91 | actual = render_cell(input, None) 92 | assert expected == actual 93 | assert actual is None or isinstance(actual, Markup) 94 | 95 | 96 | def test_render_cell_maximum_image_size(): 97 | max_length = 100 * 1024 98 | max_image = GIF_1x1 + (b"b" * (max_length - len(GIF_1x1))) 99 | rendered = render_cell(max_image, None) 100 | assert rendered is not None 101 | assert rendered.startswith("