├── .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 | [](https://pypi.org/project/datasette-render-images/)
4 | [](https://github.com/simonw/datasette-render-images/releases)
5 | [](https://github.com/simonw/datasette-render-images/actions?query=workflow%3ATest)
6 | [](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("![]()