webpthumb 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,142 @@
1
+ Metadata-Version: 2.4
2
+ Name: webpthumb
3
+ Version: 0.1.0
4
+ Summary: WebP thumbnails for images and PDFs, from the CLI, HTTP, or a folder watcher
5
+ License: MIT
6
+ Keywords: webp,thumbnail,pdf,image,heif,heic,watchdog
7
+ Requires-Python: >=3.9
8
+ Description-Content-Type: text/markdown
9
+ Requires-Dist: pdfthumb>=0.1.2
10
+ Requires-Dist: image-to-webp[heif]>=0.1.0
11
+ Requires-Dist: watchdog>=4
12
+ Requires-Dist: fastapi>=0.110
13
+ Requires-Dist: uvicorn>=0.27
14
+ Requires-Dist: python-multipart>=0.0.9
15
+ Provides-Extra: dev
16
+ Requires-Dist: pytest>=7; extra == "dev"
17
+ Requires-Dist: httpx>=0.27; extra == "dev"
18
+
19
+ # webpthumb
20
+
21
+ WebP thumbnails for images and PDFs. One command covers a one-shot convert, an HTTP endpoint, and a directory watcher.
22
+
23
+ PDFs are rendered by [pdfthumb](https://github.com/stakmi/pdfthumb) (PyMuPDF). Images are resized and encoded by [image-to-webp](https://github.com/stakmi/image-to-webp) (Pillow). The watcher uses [watchdog](https://github.com/gorakhargosh/watchdog). A thumbnail is a single still frame: the first frame of an animated GIF or APNG, or the requested PDF page.
24
+
25
+ Images are only shrunk, to fit the requested width (and height, when you set one). PDFs are rendered at that width. The defaults match pdfthumb: 256 pixels wide, WebP quality 80, page 1.
26
+
27
+ ## Install
28
+
29
+ Python 3.9 or newer. `install.sh` prefers `python3.12` when it is on `PATH`.
30
+
31
+ ```bash
32
+ ./install.sh # .venv, editable install, link ~/.local/bin/webpthumb
33
+ ./install.sh --dev # also install pytest
34
+ ./install.sh --uninstall
35
+ ```
36
+
37
+ Override the link location with `BIN_DIR` (default `~/.local/bin`). The script uses `sudo` only when that directory is not writable.
38
+
39
+ The default install reads the formats Pillow ships with (PNG, JPEG, GIF, BMP, TIFF, WebP, ICO, and others), HEIF (`.heic`, `.heif`, `.hif`, and AVIF, via pillow-heif), and PDF. Camera RAW and SVG need the optional extras of `image-to-webp` (`pip install "image-to-webp[raw]"` or `[svg]`). SVG also needs the system cairo library. RAW and SVG are not in the Docker image.
40
+
41
+ ## CLI
42
+
43
+ ```bash
44
+ webpthumb convert photo.png doc.pdf -o ./thumbnails
45
+ webpthumb convert ./inbox -r -o ./thumbnails -w 512 -q 80
46
+ webpthumb convert doc.pdf -p all # doc_p1.webp, doc_p2.webp, ...
47
+ webpthumb convert doc.pdf --height 300 # fit inside 256x300
48
+ webpthumb convert doc.pdf --crop-top # PDF only: top square
49
+ webpthumb convert doc.pdf -c 0.5 # PDF only: top band, 256x128
50
+
51
+ webpthumb serve --host 127.0.0.1 --port 8080
52
+
53
+ webpthumb watch ./inbox -o ./thumbnails -r
54
+ webpthumb watch ./inbox -o ./thumbnails -r --background
55
+ webpthumb start ./inbox -o ./thumbnails -r
56
+ webpthumb status
57
+ webpthumb stop
58
+ ```
59
+
60
+ `convert` sends PDFs through `pdfthumb.generate_thumbnails` and images through image-to-webp. Output names are `<name>.webp` for an image or for PDF page 1. Any other page spec uses `<name>_p<N>.webp`. Existing files are skipped unless you pass `--overwrite`.
61
+
62
+ Exit codes: `0` when every file succeeded or was skipped, `1` when a file failed, `2` for bad arguments. `status` exits `1` when the background watcher is not running. `stop` exits `0` when it is already stopped.
63
+
64
+ ## HTTP
65
+
66
+ `GET /health` returns `{"status":"ok"}`.
67
+
68
+ `POST /v1/thumbnail` takes one multipart field named `file` and returns `image/webp`. Query parameters: `width`, `height`, `quality`, `page`, `crop_top`. `crop_top` applies only to PDFs. Interactive docs are at `/docs`.
69
+
70
+ ```bash
71
+ curl -sS -D- --output thumb.webp \
72
+ -F "file=@photo.png" \
73
+ "http://127.0.0.1:8080/v1/thumbnail?width=256&quality=80"
74
+ ```
75
+
76
+ A file that is not an image or a PDF, or a PDF that cannot be read, returns `400`. An upload larger than `WEBTHUMB_MAX_UPLOAD_MB` (default 32) returns `413`. The endpoint reads only the upload. It does not accept a filesystem path.
77
+
78
+ ## Watch
79
+
80
+ `webpthumb watch` thumbnails files already in the directory, then stays running until SIGINT or SIGTERM. New and updated files are picked up with watchdog. The watcher waits until a file's size stops changing (about half a second) so a copy in progress is not read early.
81
+
82
+ `webpthumb watch --background` and `webpthumb start` do that work in the background and return. `webpthumb status` prints whether that watcher is running. `webpthumb stop` sends SIGTERM and waits for it to exit. A second start is refused while the first is still running. The pid, the directories, and the log live under `WEBTHUMB_RUN_DIR` (`$XDG_STATE_HOME/webpthumb`, or `~/.local/state/webpthumb`). A foreground `watch` is not tracked. Keep `watch` in the foreground when it is the main process of a container.
83
+
84
+ Watched extensions: `.png`, `.jpg`, `.jpeg`, `.gif`, `.bmp`, `.tif`, `.tiff`, `.webp`, `.ico`, `.heic`, `.heics`, `.heif`, `.heifs`, `.hif`, `.avif`, `.avifs`, `.pdf`. Hidden files and `*.part` files are ignored. The output directory must not sit inside the input directory. With `-r`, the output tree mirrors the input tree.
85
+
86
+ A thumbnail is rewritten when the source is newer than the WebP, or when `--overwrite` is set.
87
+
88
+ `webpthumb serve` starts the same watcher in the background when both `WEBTHUMB_WATCH` and `WEBTHUMB_OUTPUT` are set.
89
+
90
+ ## Environment
91
+
92
+ Used when the matching flag or query parameter is omitted.
93
+
94
+ | Variable | Default | Used by |
95
+ |---|---|---|
96
+ | `WEBTHUMB_WIDTH` | `256` | convert, serve, watch |
97
+ | `WEBTHUMB_HEIGHT` | unset | convert, serve, watch |
98
+ | `WEBTHUMB_QUALITY` | `80` | convert, serve, watch |
99
+ | `WEBTHUMB_PAGES` | `1` | convert, watch |
100
+ | `WEBTHUMB_CROP_TOP` | unset | convert, serve, watch |
101
+ | `WEBTHUMB_OVERWRITE` | off | convert, watch (`1`, `true`, `yes`, `on`) |
102
+ | `WEBTHUMB_RECURSIVE` | off | convert, watch |
103
+ | `WEBTHUMB_JOBS` | CPU count | convert |
104
+ | `WEBTHUMB_OUTPUT` | `./thumbnails` for convert | convert, watch, serve |
105
+ | `WEBTHUMB_WATCH` | unset | serve, with `WEBTHUMB_OUTPUT` |
106
+ | `WEBTHUMB_HOST` | `127.0.0.1` | serve (the image sets `0.0.0.0`) |
107
+ | `WEBTHUMB_PORT` | `8080` | serve |
108
+ | `WEBTHUMB_MAX_UPLOAD_MB` | `32` | serve |
109
+ | `WEBTHUMB_RUN_DIR` | `~/.local/state/webpthumb` | start, stop, status |
110
+
111
+ ## Docker
112
+
113
+ The image is `python:3.12-slim` plus this package. PyMuPDF carries its own MuPDF build, so poppler and ImageMagick are not installed.
114
+
115
+ ```bash
116
+ docker build -t webpthumb .
117
+ docker run --rm -p 8080:8080 webpthumb
118
+
119
+ # API and watcher together
120
+ docker compose up --build
121
+
122
+ # watcher only
123
+ docker run --rm -v "$PWD/in:/data/in" -v "$PWD/out:/data/out" \
124
+ webpthumb watch /data/in -o /data/out -r
125
+ ```
126
+
127
+ `docker compose up` publishes port 8080 and mounts `./in` and `./out`. Drop a PNG, HEIC, or PDF into `./in` and the thumbnail appears in `./out`.
128
+
129
+ ## Tests
130
+
131
+ ```bash
132
+ pytest
133
+ ```
134
+
135
+ ## Library
136
+
137
+ ```python
138
+ from webpthumb import ThumbOptions, make_thumbnail, write_thumbnail
139
+
140
+ webp = make_thumbnail(open("doc.pdf", "rb").read(), "doc.pdf", ThumbOptions(width=256))
141
+ paths = write_thumbnail("photo.png", "thumbnails", ThumbOptions(width=256, quality=80))
142
+ ```
@@ -0,0 +1,124 @@
1
+ # webpthumb
2
+
3
+ WebP thumbnails for images and PDFs. One command covers a one-shot convert, an HTTP endpoint, and a directory watcher.
4
+
5
+ PDFs are rendered by [pdfthumb](https://github.com/stakmi/pdfthumb) (PyMuPDF). Images are resized and encoded by [image-to-webp](https://github.com/stakmi/image-to-webp) (Pillow). The watcher uses [watchdog](https://github.com/gorakhargosh/watchdog). A thumbnail is a single still frame: the first frame of an animated GIF or APNG, or the requested PDF page.
6
+
7
+ Images are only shrunk, to fit the requested width (and height, when you set one). PDFs are rendered at that width. The defaults match pdfthumb: 256 pixels wide, WebP quality 80, page 1.
8
+
9
+ ## Install
10
+
11
+ Python 3.9 or newer. `install.sh` prefers `python3.12` when it is on `PATH`.
12
+
13
+ ```bash
14
+ ./install.sh # .venv, editable install, link ~/.local/bin/webpthumb
15
+ ./install.sh --dev # also install pytest
16
+ ./install.sh --uninstall
17
+ ```
18
+
19
+ Override the link location with `BIN_DIR` (default `~/.local/bin`). The script uses `sudo` only when that directory is not writable.
20
+
21
+ The default install reads the formats Pillow ships with (PNG, JPEG, GIF, BMP, TIFF, WebP, ICO, and others), HEIF (`.heic`, `.heif`, `.hif`, and AVIF, via pillow-heif), and PDF. Camera RAW and SVG need the optional extras of `image-to-webp` (`pip install "image-to-webp[raw]"` or `[svg]`). SVG also needs the system cairo library. RAW and SVG are not in the Docker image.
22
+
23
+ ## CLI
24
+
25
+ ```bash
26
+ webpthumb convert photo.png doc.pdf -o ./thumbnails
27
+ webpthumb convert ./inbox -r -o ./thumbnails -w 512 -q 80
28
+ webpthumb convert doc.pdf -p all # doc_p1.webp, doc_p2.webp, ...
29
+ webpthumb convert doc.pdf --height 300 # fit inside 256x300
30
+ webpthumb convert doc.pdf --crop-top # PDF only: top square
31
+ webpthumb convert doc.pdf -c 0.5 # PDF only: top band, 256x128
32
+
33
+ webpthumb serve --host 127.0.0.1 --port 8080
34
+
35
+ webpthumb watch ./inbox -o ./thumbnails -r
36
+ webpthumb watch ./inbox -o ./thumbnails -r --background
37
+ webpthumb start ./inbox -o ./thumbnails -r
38
+ webpthumb status
39
+ webpthumb stop
40
+ ```
41
+
42
+ `convert` sends PDFs through `pdfthumb.generate_thumbnails` and images through image-to-webp. Output names are `<name>.webp` for an image or for PDF page 1. Any other page spec uses `<name>_p<N>.webp`. Existing files are skipped unless you pass `--overwrite`.
43
+
44
+ Exit codes: `0` when every file succeeded or was skipped, `1` when a file failed, `2` for bad arguments. `status` exits `1` when the background watcher is not running. `stop` exits `0` when it is already stopped.
45
+
46
+ ## HTTP
47
+
48
+ `GET /health` returns `{"status":"ok"}`.
49
+
50
+ `POST /v1/thumbnail` takes one multipart field named `file` and returns `image/webp`. Query parameters: `width`, `height`, `quality`, `page`, `crop_top`. `crop_top` applies only to PDFs. Interactive docs are at `/docs`.
51
+
52
+ ```bash
53
+ curl -sS -D- --output thumb.webp \
54
+ -F "file=@photo.png" \
55
+ "http://127.0.0.1:8080/v1/thumbnail?width=256&quality=80"
56
+ ```
57
+
58
+ A file that is not an image or a PDF, or a PDF that cannot be read, returns `400`. An upload larger than `WEBTHUMB_MAX_UPLOAD_MB` (default 32) returns `413`. The endpoint reads only the upload. It does not accept a filesystem path.
59
+
60
+ ## Watch
61
+
62
+ `webpthumb watch` thumbnails files already in the directory, then stays running until SIGINT or SIGTERM. New and updated files are picked up with watchdog. The watcher waits until a file's size stops changing (about half a second) so a copy in progress is not read early.
63
+
64
+ `webpthumb watch --background` and `webpthumb start` do that work in the background and return. `webpthumb status` prints whether that watcher is running. `webpthumb stop` sends SIGTERM and waits for it to exit. A second start is refused while the first is still running. The pid, the directories, and the log live under `WEBTHUMB_RUN_DIR` (`$XDG_STATE_HOME/webpthumb`, or `~/.local/state/webpthumb`). A foreground `watch` is not tracked. Keep `watch` in the foreground when it is the main process of a container.
65
+
66
+ Watched extensions: `.png`, `.jpg`, `.jpeg`, `.gif`, `.bmp`, `.tif`, `.tiff`, `.webp`, `.ico`, `.heic`, `.heics`, `.heif`, `.heifs`, `.hif`, `.avif`, `.avifs`, `.pdf`. Hidden files and `*.part` files are ignored. The output directory must not sit inside the input directory. With `-r`, the output tree mirrors the input tree.
67
+
68
+ A thumbnail is rewritten when the source is newer than the WebP, or when `--overwrite` is set.
69
+
70
+ `webpthumb serve` starts the same watcher in the background when both `WEBTHUMB_WATCH` and `WEBTHUMB_OUTPUT` are set.
71
+
72
+ ## Environment
73
+
74
+ Used when the matching flag or query parameter is omitted.
75
+
76
+ | Variable | Default | Used by |
77
+ |---|---|---|
78
+ | `WEBTHUMB_WIDTH` | `256` | convert, serve, watch |
79
+ | `WEBTHUMB_HEIGHT` | unset | convert, serve, watch |
80
+ | `WEBTHUMB_QUALITY` | `80` | convert, serve, watch |
81
+ | `WEBTHUMB_PAGES` | `1` | convert, watch |
82
+ | `WEBTHUMB_CROP_TOP` | unset | convert, serve, watch |
83
+ | `WEBTHUMB_OVERWRITE` | off | convert, watch (`1`, `true`, `yes`, `on`) |
84
+ | `WEBTHUMB_RECURSIVE` | off | convert, watch |
85
+ | `WEBTHUMB_JOBS` | CPU count | convert |
86
+ | `WEBTHUMB_OUTPUT` | `./thumbnails` for convert | convert, watch, serve |
87
+ | `WEBTHUMB_WATCH` | unset | serve, with `WEBTHUMB_OUTPUT` |
88
+ | `WEBTHUMB_HOST` | `127.0.0.1` | serve (the image sets `0.0.0.0`) |
89
+ | `WEBTHUMB_PORT` | `8080` | serve |
90
+ | `WEBTHUMB_MAX_UPLOAD_MB` | `32` | serve |
91
+ | `WEBTHUMB_RUN_DIR` | `~/.local/state/webpthumb` | start, stop, status |
92
+
93
+ ## Docker
94
+
95
+ The image is `python:3.12-slim` plus this package. PyMuPDF carries its own MuPDF build, so poppler and ImageMagick are not installed.
96
+
97
+ ```bash
98
+ docker build -t webpthumb .
99
+ docker run --rm -p 8080:8080 webpthumb
100
+
101
+ # API and watcher together
102
+ docker compose up --build
103
+
104
+ # watcher only
105
+ docker run --rm -v "$PWD/in:/data/in" -v "$PWD/out:/data/out" \
106
+ webpthumb watch /data/in -o /data/out -r
107
+ ```
108
+
109
+ `docker compose up` publishes port 8080 and mounts `./in` and `./out`. Drop a PNG, HEIC, or PDF into `./in` and the thumbnail appears in `./out`.
110
+
111
+ ## Tests
112
+
113
+ ```bash
114
+ pytest
115
+ ```
116
+
117
+ ## Library
118
+
119
+ ```python
120
+ from webpthumb import ThumbOptions, make_thumbnail, write_thumbnail
121
+
122
+ webp = make_thumbnail(open("doc.pdf", "rb").read(), "doc.pdf", ThumbOptions(width=256))
123
+ paths = write_thumbnail("photo.png", "thumbnails", ThumbOptions(width=256, quality=80))
124
+ ```
@@ -0,0 +1,33 @@
1
+ [build-system]
2
+ requires = ["setuptools>=64"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "webpthumb"
7
+ version = "0.1.0"
8
+ description = "WebP thumbnails for images and PDFs, from the CLI, HTTP, or a folder watcher"
9
+ readme = "README.md"
10
+ requires-python = ">=3.9"
11
+ license = { text = "MIT" }
12
+ keywords = ["webp", "thumbnail", "pdf", "image", "heif", "heic", "watchdog"]
13
+ dependencies = [
14
+ "pdfthumb>=0.1.2",
15
+ "image-to-webp[heif]>=0.1.0",
16
+ "watchdog>=4",
17
+ "fastapi>=0.110",
18
+ "uvicorn>=0.27",
19
+ "python-multipart>=0.0.9",
20
+ ]
21
+
22
+ [project.optional-dependencies]
23
+ dev = ["pytest>=7", "httpx>=0.27"]
24
+
25
+ [project.scripts]
26
+ webpthumb = "webpthumb.cli:main"
27
+
28
+ [tool.setuptools.packages.find]
29
+ where = ["src"]
30
+
31
+ [tool.pytest.ini_options]
32
+ testpaths = ["tests"]
33
+ pythonpath = ["src", "tests"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,15 @@
1
+ """WebP thumbnails for images and PDFs."""
2
+
3
+ from .core import ThumbError, ThumbResult, make_thumbnail, write_thumbnail
4
+ from .options import ThumbOptions
5
+
6
+ __version__ = "0.1.0"
7
+
8
+ __all__ = [
9
+ "ThumbError",
10
+ "ThumbOptions",
11
+ "ThumbResult",
12
+ "make_thumbnail",
13
+ "write_thumbnail",
14
+ "__version__",
15
+ ]
@@ -0,0 +1,4 @@
1
+ from .cli import main
2
+
3
+ if __name__ == "__main__":
4
+ raise SystemExit(main())
@@ -0,0 +1,125 @@
1
+ """HTTP endpoint that returns a WebP thumbnail for an uploaded image or PDF."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ import re
7
+ from pathlib import Path
8
+
9
+ from fastapi import FastAPI, HTTPException, UploadFile
10
+ from fastapi.responses import Response
11
+
12
+ from . import __version__
13
+ from .core import ThumbError, make_thumbnail
14
+ from .options import ThumbOptions
15
+
16
+ _SAFE = re.compile(r"[^A-Za-z0-9._-]+")
17
+
18
+
19
+ def _env_int(name: str, default: int) -> int:
20
+ raw = os.environ.get(name)
21
+ if not raw:
22
+ return default
23
+ try:
24
+ return int(raw)
25
+ except ValueError:
26
+ return default
27
+
28
+
29
+ def _env_optional_int(name: str) -> int | None:
30
+ raw = os.environ.get(name)
31
+ if not raw:
32
+ return None
33
+ try:
34
+ return int(raw)
35
+ except ValueError:
36
+ return None
37
+
38
+
39
+ def _env_optional_float(name: str) -> float | None:
40
+ raw = os.environ.get(name)
41
+ if not raw:
42
+ return None
43
+ try:
44
+ return float(raw)
45
+ except ValueError:
46
+ return None
47
+
48
+
49
+ def upload_limit_bytes() -> int:
50
+ megabytes = _env_int("WEBTHUMB_MAX_UPLOAD_MB", 32)
51
+ if megabytes < 1:
52
+ megabytes = 32
53
+ return megabytes * 1024 * 1024
54
+
55
+
56
+ def safe_stem(filename: str | None) -> str:
57
+ stem = Path(filename or "thumbnail").stem
58
+ cleaned = _SAFE.sub("_", stem).strip("._")
59
+ return (cleaned or "thumbnail")[:120]
60
+
61
+
62
+ def create_app(max_upload_bytes: int | None = None) -> FastAPI:
63
+ """Build the API. Query defaults fall back to ``WEBTHUMB_*`` environment variables."""
64
+ limit = upload_limit_bytes() if max_upload_bytes is None else max_upload_bytes
65
+ app = FastAPI(title="webpthumb", version=__version__)
66
+ app.state.max_upload_bytes = limit
67
+
68
+ @app.get("/health")
69
+ def health() -> dict[str, str]:
70
+ return {"status": "ok"}
71
+
72
+ @app.post("/v1/thumbnail")
73
+ async def thumbnail(
74
+ file: UploadFile,
75
+ width: int | None = None,
76
+ height: int | None = None,
77
+ quality: int | None = None,
78
+ page: int = 1,
79
+ crop_top: float | None = None,
80
+ ) -> Response:
81
+ data = await _read_limited(file, app.state.max_upload_bytes)
82
+ if not data:
83
+ raise HTTPException(status_code=400, detail="empty upload")
84
+ resolved_width = width if width is not None else _env_int("WEBTHUMB_WIDTH", 256)
85
+ resolved_height = height if height is not None else _env_optional_int("WEBTHUMB_HEIGHT")
86
+ resolved_quality = quality if quality is not None else _env_int("WEBTHUMB_QUALITY", 80)
87
+ resolved_crop = crop_top if crop_top is not None else _env_optional_float("WEBTHUMB_CROP_TOP")
88
+ filename = file.filename or "thumbnail"
89
+ try:
90
+ options = ThumbOptions(
91
+ width=resolved_width,
92
+ height=resolved_height,
93
+ quality=resolved_quality,
94
+ crop_top=resolved_crop,
95
+ )
96
+ webp = make_thumbnail(data, filename, options, page=page)
97
+ except ThumbError as exc:
98
+ raise HTTPException(status_code=400, detail=str(exc)) from exc
99
+ except ValueError as exc:
100
+ raise HTTPException(status_code=400, detail=str(exc)) from exc
101
+ stem = safe_stem(filename)
102
+ return Response(
103
+ content=webp,
104
+ media_type="image/webp",
105
+ headers={"Content-Disposition": f'inline; filename="{stem}.webp"'},
106
+ )
107
+
108
+ return app
109
+
110
+
111
+ async def _read_limited(file: UploadFile, limit: int) -> bytes:
112
+ chunks: list[bytes] = []
113
+ total = 0
114
+ while True:
115
+ block = await file.read(1024 * 1024)
116
+ if not block:
117
+ break
118
+ total += len(block)
119
+ if total > limit:
120
+ raise HTTPException(status_code=413, detail="upload exceeds limit")
121
+ chunks.append(block)
122
+ return b"".join(chunks)
123
+
124
+
125
+ app = create_app()