From 232e4c515427adca609e7a4712dd098310defe39 Mon Sep 17 00:00:00 2001 From: sarib Date: Fri, 7 Aug 2026 15:04:36 +0500 Subject: [PATCH 01/13] Hold the two engines together in CI, on every pull request MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The product's central claim is that the browser scores an image the same way the Python reference does. Nothing enforced that. ss2_validate.mjs existed and had to be remembered, which means it was one distracted afternoon away from never running again — and a drift there is the kind of break nobody notices, because the app keeps working, it just stops being right. The job regenerates the vectors from the reference implementation and holds the JS port to them. It runs on every pull request rather than only ones that touch ss2.js: a path filter would miss the case that actually worries us, which is quality.py or a pinned dependency moving the numbers out from under a file nobody edited. make_ss2_vectors.py now survives a Pillow built without libavif — most Windows wheels are — and says out loud that the twelve AVIF pairs are missing rather than quietly shrinking the corpus and still printing VALIDATED. CI tries to install pillow-avif-plugin for the full set and is allowed to fail. Locally: 48/48, mean |Δ| 0.0042, worst 0.0177 against a tolerance of 0.25. Co-Authored-By: Claude Opus 5 (1M context) --- .github/workflows/ci.yml | 37 +++++++++++++++++++++++++++++++++++ tests/web/make_ss2_vectors.py | 34 ++++++++++++++++++++++++++++---- 2 files changed, 67 insertions(+), 4 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 340a44c..612441f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -52,6 +52,43 @@ jobs: imgcompress --check python -m unittest discover -s tests -v + engine-parity: + # The product's central claim is that the browser scores an image the same + # way the Python reference does. Every unvalidated edit to ss2.js is a slow + # leak in that claim, and a leak nobody would notice: the app keeps working, + # it just stops being right. This job regenerates the vectors from the + # reference implementation and holds the JS port to them. + # + # It runs on every pull request rather than only on ones touching ss2.js. + # Path filters would miss the case that actually worries us - a change to + # quality.py, or to the reference package's pinned version, moving the + # numbers out from under a file nobody edited. + name: JS scorer matches the Python reference + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: "3.12" + - uses: actions/setup-node@v4 + with: + node-version: "20" + - name: Install the reference implementation + run: | + python -m pip install --upgrade pip + python -m pip install -e ".[full]" + - name: Add an AVIF encoder if one is installable + # Only affects how many distorted pairs the corpus contains. Pillow + # ships AVIF support only where the wheel was built against libavif, so + # this is allowed to fail; make_ss2_vectors.py says out loud when the + # AVIF pairs are missing rather than quietly shrinking the corpus. + continue-on-error: true + run: python -m pip install pillow-avif-plugin + - name: Build the validation vectors from the Python reference + run: python tests/web/make_ss2_vectors.py + - name: The JS port must match them + run: node tests/web/ss2_validate.mjs + lint: runs-on: ubuntu-latest steps: diff --git a/tests/web/make_ss2_vectors.py b/tests/web/make_ss2_vectors.py index b15105b..22fa0c0 100644 --- a/tests/web/make_ss2_vectors.py +++ b/tests/web/make_ss2_vectors.py @@ -84,6 +84,27 @@ def rgb_bytes(img: Image.Image) -> bytes: return np.asarray(img.convert("RGB"), dtype=np.uint8).tobytes() +def _avif_available() -> bool: + """Can this Pillow write an AVIF? + + Pillow only carries AVIF where the wheel was built against libavif, which + most Windows wheels are not. The AVIF pairs are worth having - they are the + most aggressive distortion in the corpus - but they are not worth failing + the whole validation run over, so their absence is reported rather than + raised. + """ + if "AVIF" in Image.SAVE: + return True + try: # the plugin registers itself on import + import pillow_avif # noqa: F401 + except Exception: + return False + return "AVIF" in Image.SAVE + + +HAVE_AVIF = _avif_available() + + def variants(img: Image.Image): """Distortions across the whole quality range, several codecs.""" out = [] @@ -95,10 +116,11 @@ def variants(img: Image.Image): buf = io.BytesIO() img.save(buf, "WEBP", quality=q, method=4) out.append((f"webp{q}", Image.open(io.BytesIO(buf.getvalue())).convert("RGB"))) - for q in (40, 70): - buf = io.BytesIO() - img.save(buf, "AVIF", quality=q, speed=8) - out.append((f"avif{q}", Image.open(io.BytesIO(buf.getvalue())).convert("RGB"))) + if HAVE_AVIF: + for q in (40, 70): + buf = io.BytesIO() + img.save(buf, "AVIF", quality=q, speed=8) + out.append((f"avif{q}", Image.open(io.BytesIO(buf.getvalue())).convert("RGB"))) out.append(("pal32", img.convert("RGB").quantize(colors=32).convert("RGB"))) out.append(("identical", img.convert("RGB"))) return out @@ -133,6 +155,10 @@ def main(): (OUT / "vectors.json").write_text(json.dumps(vectors, indent=1)) print(f"\n{len(vectors)} vectors written") + if not HAVE_AVIF: + print("NOTE: this Pillow cannot write AVIF, so the 12 AVIF pairs are " + "missing from the corpus. Install pillow-avif-plugin for full " + "coverage.") if __name__ == "__main__": From 7fa2b7c6ea4bb7bcc7ca583f26b1324657d102bb Mon Sep 17 00:00:00 2001 From: sarib Date: Fri, 7 Aug 2026 16:01:38 +0500 Subject: [PATCH 02/13] Name the presets after where the image is going MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The default was `figma`. It capped every image at 4096px and refused WebP, which is correct for someone exporting into a design file and wrong for almost everyone else: a person compressing a photograph for their website silently got no WebP, for a reason about Figma's plugin API that had nothing to do with them. The restriction was researched and right. Making it the default for everybody was not. There were also two settings for one idea — `--preset` chose size and quality, `--target` chose the format list — and both were named after a product or a technical concept rather than after something a person knows about themselves. One list replaces both, named after the only question somebody can answer without knowing anything about compression: web all formats, 2560px, match 90 <- the new default documents JPEG/PNG only, 4096px enforced, match 90 email JPEG/PNG only, 1920px, match 88 thumbnail all formats, 512px, match 85 original all formats, never resized, match 95 `documents` inherits every restriction `figma` had, because the restriction is the feature — those tools re-encode WebP to PNG on import, so a 40 KB file becomes a multi-megabyte one inside the saved document. What changed is who pays for it: the people actually sending images there. destinations.py is the single table. It imports nothing from the rest of the package because three other engines mirror it, and a table with logic in it is a table that cannot be mirrored. The desktop UI now builds its list from the server rather than holding a fourth copy of five numbers that must agree. Picking a destination applies all three of its numbers in both interfaces. Setting only the format list would make "Thumbnail or avatar" mean nothing but a shorter list and leave the person to discover that two more controls in Advanced needed changing for it to do what it says. AVIF becomes a Python encoder, feature-detected — Pillow only carries it where the wheel was built against libavif, so on most machines nothing changes. It is what lets the table be literally the same in all four places rather than "the same except Python." Old names keep working: --preset is a synonym for --for, figma resolves to documents, archive to original, and the CLI says when you have used one. Measured: bench.mjs is byte-identical on both documents and web. It now pins the size cap rather than taking the destination's, because camera-12mp.jpg is 4000x3000 and letting the destination move the frame would mean a moved byte no longer said which change moved it. 33 Python tests, 72 e2e asserts, ss2_validate, verify_tokens, verify_fonts and four probes all pass. Two behaviour changes worth stating plainly: `--for documents` resizes to 4096px where `--preset figma` resized to 2560px, and `--preset thumbnail` moves from 800px/80 to 512px/85. Both follow the destination table. The README's own argument is that the 2560 cap saves more than the encoder does, so `-m 2560` remains the better setting for anything bound for a canvas. One e2e assertion changed rather than being fixed: "ui winner is png8" was pinning the old default, not the promise. Flat artwork wins on a palette or lossless format and which one depends on what the destination allows; it now asserts the winner is the smallest version that passed and never a lossy photo codec, which is stronger and destination-independent. Co-Authored-By: Claude Opus 5 (1M context) --- CHANGELOG.md | 79 ++++++++ GUIDE.md | 83 ++++++--- README.md | 73 +++++--- imgcompress/cli.py | 119 ++++++++---- imgcompress/core.py | 29 +-- imgcompress/destinations.py | 169 ++++++++++++++++++ imgcompress/encoders.py | 53 ++++-- imgcompress/server.py | 25 ++- imgcompress/webui/app.html | 57 +++++- tests/BENCHMARK.md | 24 +-- tests/bench_vs_alternatives.py | 4 +- tests/bench_web_out.mjs | 2 +- .../{figma => documents}/camera_12mp.jpg | Bin .../{figma => documents}/gradient.png | Bin .../{figma => documents}/logo_alpha.png | Bin .../{figma => documents}/photo.jpg | Bin .../screenshot_retina.png | Bin .../{figma => documents}/ui_text.png | Bin tests/test_compress.py | 111 +++++++++++- tests/web/README.md | 2 +- tests/web/bench.mjs | 22 ++- tests/web/e2e.mjs | 19 +- tests/web/probe_controls.mjs | 6 +- .../{snap-figma.json => snap-documents.json} | 0 tests/web/speed_by_choice.mjs | 2 +- web/app.js | 59 ++++-- web/index.html | 26 +-- web/worker.js | 35 +++- 28 files changed, 810 insertions(+), 189 deletions(-) create mode 100644 imgcompress/destinations.py rename tests/bench_web_out/{figma => documents}/camera_12mp.jpg (100%) rename tests/bench_web_out/{figma => documents}/gradient.png (100%) rename tests/bench_web_out/{figma => documents}/logo_alpha.png (100%) rename tests/bench_web_out/{figma => documents}/photo.jpg (100%) rename tests/bench_web_out/{figma => documents}/screenshot_retina.png (100%) rename tests/bench_web_out/{figma => documents}/ui_text.png (100%) rename tests/web/{snap-figma.json => snap-documents.json} (100%) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2e5e80d..c81ba83 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,85 @@ All notable changes to this project are documented here. This project follows [Semantic Versioning](https://semver.org/). +## [Unreleased] + +### Changed +- **Presets are now destinations, and the default is no longer a design tool.** + There used to be two overlapping settings — `--preset` chose size and + quality, `--target` chose which formats were allowed — and both defaulted to + `figma`. That meant a person compressing a photograph for their website got + a 4096px ceiling and no WebP, for a reason that is true of Figma and of + nothing they were doing. The restriction was researched and correct; making + it everyone's default was not. + + One list replaces both, named after the only question somebody can answer + without knowing anything about compression — where is this image going? + + | `--for` | Formats | Size | Visual match | + | --- | --- | --- | --- | + | `web` *(new default)* | all, incl. WebP and AVIF | 2560px | 90 | + | `documents` | JPEG / PNG only | 4096px, enforced | 90 | + | `email` | JPEG / PNG only | 1920px | 88 | + | `thumbnail` | all | 512px | 85 | + | `original` | all | never resized | 95 | + + `--preset` still works as a synonym and the old names (`figma` → `documents`, + `archive` → `original`) still resolve, so existing scripts do not break. The + CLI says out loud when you have used one. +- **`documents` keeps every restriction `figma` had**, because the restriction + is the feature: those tools re-encode WebP to PNG on import, so a beautifully + compressed 40 KB file becomes a multi-megabyte one inside the saved document. + What changed is who pays for it — the people actually sending images there. +- **Choosing a destination applies all three of its numbers**, in both + interfaces. Setting only the format list would make "Thumbnail or avatar" + mean nothing but a shorter list, and leave the person to work out that two + more controls in Advanced needed changing for it to do what it says. Both + remain editable afterwards; this moves the starting point, it does not lock + it. +- **The desktop app builds its destination list from the server's table** + rather than carrying its own copy of five numbers that have to agree. +- **`imgcompress --help` no longer names a specific product**, and prints what + each destination actually does. Its output is ASCII, because a middot that + arrives as a replacement character on a cp1252 console undoes the point of + writing readable help. + +### Added +- **The two engines are held together by CI on every pull request.** The claim + that the browser scores an image the way the Python reference does had + nothing enforcing it — `ss2_validate.mjs` existed and had to be remembered. + A drift there is the worst kind of break: the app keeps working, it just + stops being right. The job runs on every PR rather than only ones touching + `ss2.js`, because the case that actually worries us is `quality.py` or a + pinned dependency moving the numbers out from under a file nobody edited. +- **AVIF is a Python encoder**, feature-detected. Pillow only carries AVIF + where the wheel was built against libavif, so on most machines this changes + nothing; where it is present, AVIF now competes in the bake-off on the same + terms as everything else — it ships only if it is both smaller and still + clears the floor. This is what lets the destination table be literally the + same in all four places rather than "the same except Python." +- Nine tests pinning every destination's formats, size cap and minimum visual + match, that only `documents` enforces a ceiling, and that the old names still + resolve. Previously the 4096px cap was tested but *only* the half that fires + — nothing asserted that `original` leaves an image alone. + +### Fixed +- `make_ss2_vectors.py` no longer dies on a Pillow built without libavif. It + says the twelve AVIF pairs are missing instead of quietly shrinking the + corpus and still printing VALIDATED. + +### Notes for anyone measuring this +- **Output is byte-identical at matched settings.** `bench.mjs` passes clean on + both `documents` and `web`. It now pins the size cap rather than taking the + destination's, because `camera-12mp.jpg` is 4000×3000: letting the + destination move the frame would mean a moved byte no longer said which + change moved it. +- **`--for documents` resizes to 4096px where `--preset figma` resized to + 2560px.** This follows the destination table and is a real behaviour change + for CLI users. Worth knowing: this project's own README argues the 2560 cap + saves more than the encoder does, so if your images are bound for a canvas + rather than a print, `-m 2560` is still the better setting. +- **`--preset thumbnail` changed** from 800px/80 to 512px/85. + ## [2.6.0] - 2026-08-07 ### Changed diff --git a/GUIDE.md b/GUIDE.md index 378240e..a3250a3 100644 --- a/GUIDE.md +++ b/GUIDE.md @@ -1,6 +1,6 @@ # Guide to this repository -About 1,600 lines total, four source files that matter. Here's the tour. +About 1,800 lines total, five source files that matter. Here's the tour. ## The mental model @@ -13,7 +13,24 @@ The second rule follows from the first: **the best format is content-dependent.* A photograph wants JPEG, a screenshot wants palette PNG, a smooth gradient wants lossless PNG. So the tool doesn't pick — it tries them all and keeps the winner. -## The four files that matter +## The five files that matter + +### `imgcompress/destinations.py` — "where is this going?" + +Five entries — `web` (the default), `documents`, `email`, `thumbnail`, +`original` — each naming the formats it may write, how large the frame may be, +and how close the result has to look. It is deliberately the smallest file here +and imports nothing from the rest of the package, because three other engines +mirror it and a table with logic in it is a table that cannot be mirrored. + +A destination is the one question a person can answer without knowing anything +about compression. Before 2.7 there were two overlapping ideas — `--preset` set +size and quality, `--target` set the format list — and both defaulted to +`figma`, so someone compressing a photograph for their website silently got no +WebP for a reason about design tools. + +`hard_cap` is the only conditional behaviour: `documents` enforces 4096px even +when asked for more. Aliases keep `figma` and `archive` working. ### `imgcompress/quality.py` — "how good does this look?" @@ -41,12 +58,16 @@ Two things here are subtle and worth not breaking: ### `imgcompress/encoders.py` — "how do I write the bytes?" -Five candidates — `jpeg`, `png8`, `png`, `webp`, `webp-lossless` — each exposing -an ascending ladder of quality levels, so the search can bisect over any of them -generically without knowing what the levels mean. +Six candidates — `jpeg`, `png8`, `png`, `webp`, `webp-lossless`, `avif` — each +exposing an ascending ladder of quality levels, so the search can bisect over any +of them generically without knowing what the levels mean. `avif` only reports +`available()` where Pillow was built against libavif, which most Windows wheels +are not; the browser engine has had it since the WASM codec tier landed. -`TARGETS` maps `figma` / `web` / `lossless` to which candidates are allowed. -**This is the single place the Figma format policy lives.** +Which candidates a run is allowed to use comes from `destinations.py`, not from +here. **That is the single place the format policy lives**, and it is shared with +`web/worker.js`, `web/app.js` and the desktop UI — the same five entries with the +same numbers in all four. `JpegEncoder` is hardcoded to 4:4:4 chroma. That's deliberate: on saturated content, matching 4:4:4's quality-76 score with 4:2:0 required quality 97 and @@ -96,9 +117,10 @@ actually installed. Worth running first on any new machine. | You want to… | Go to | | --- | --- | -| Change what formats Figma gets | `encoders.py` → `TARGETS` | -| Add a format (AVIF, JPEG XL) | Subclass `Encoder`, add to `ALL` and to a target | -| Change quality or size defaults | `cli.py` → `PRESETS` | +| Change what formats a destination gets | `destinations.py` → `DESTINATIONS` | +| Add a format (JPEG XL) | Subclass `Encoder`, add to `ALL` and to a destination | +| Change quality or size defaults | `destinations.py` → `DESTINATIONS` | +| Add or rename a destination | `destinations.py`, then mirror it in `worker.js`, `app.js`, `app.html` | | Change how quality is judged | `quality.py` → `Metric` | | Change the search strategy | `core.py` → `_search_one` | | Change resize / metadata behaviour | `core.py` → `_normalise` | @@ -127,9 +149,13 @@ learn: * the percentile aggregation really is stricter than the mean * transparent pixels are composited, not dropped * JPEG output is 4:4:4, asserted by reading the sampling factors back out -* the `figma` target never offers WebP +* every destination's formats, size cap and minimum visual match, entry by entry +* the `documents` destination never offers WebP or AVIF * images with alpha are never routed to JPEG -* the `figma` target caps at 4096px even when you ask for unlimited +* `documents` caps at 4096px even when you ask for unlimited — and no other + destination does, which is the half that used to be untested when the cap + applied to the default and therefore to everybody +* the older names (`figma`, `archive`) still resolve * the bake-off winner is the smallest passing candidate, not just any candidate If you change behaviour and one of these fails, read the README section it maps @@ -137,11 +163,14 @@ to before "fixing" the test. ## Two things to know before extending it -**The Figma format policy rests on one unverified claim** — that Figma +**The `documents` format policy rests on one unverified claim** — that Figma transcodes WebP to PNG on import. It comes from a Figma forum expert, not a changelog. The downside if it's true is severe and the upside is a few percent, -so JPEG/PNG is the right default either way. But if you ever add a format or -loosen `TARGETS`, re-check that first: it's the hinge the whole policy turns on. +so JPEG/PNG is the right answer for that destination either way. But if you ever +add a format or loosen it, re-check that first: it's the hinge the whole policy +turns on. Note this is now one destination's rule rather than everyone's — it was +the default until 2.7, which meant people who had never opened a design tool +silently got no WebP. To settle it: import a WebP into Figma and have any plugin call `getBytesAsync()` on it. Bytes starting `RIFF` mean WebP survived. @@ -308,14 +337,16 @@ Two rules, both learned the hard way: The toolbar asks for two decisions and defaults both to delegation. -* **Format** is one `` spanning the five destinations (`web`, + `documents`, `email`, `thumbnail`, `original`) and `one-jpeg` / `one-webp` / + `one-png` / `one-avif`. The `one-` prefix is parsed in `parseFormatChoice`. + Picking a destination applies all three of its numbers — formats, size cap + and minimum visual match — because otherwise "Thumbnail or avatar" would mean + nothing but a shorter format list and the person would have to know to open + Advanced and change two more things. A single-format pick sets + `settings.formats` and *keeps* the destination, so someone who chose "Email + or chat" and then "JPEG only" still gets something that fits in an email. + Pre-2.7 stored names are mapped by `destinationOf`. * **Quality** is `#quality-preset` (words) sitting on top of `#quality` (the 60–99 floor, in Advanced). *One setting, two views* — the words write the number and `reflectQualityHint` writes back, showing a hidden `custom` @@ -456,9 +487,9 @@ Two related rules, both straight out of the system's layout primitives: ### Speed, and the invariants that make it safe The engine got about **2.2× faster** (min-of-3 on a mixed corpus with a 12MP -photograph: 30.9s → 14.1s) with **byte-identical output** on both the Figma and -Web targets. Four changes did it, and each rests on an invariant that must hold -if anyone touches this code: +photograph: 30.9s → 14.1s) with **byte-identical output** on both the documents +and web destinations. Four changes did it, and each rests on an invariant that +must hold if anyone touches this code: * **oxipng runs only where it could change the winner.** It was 37% of all worker CPU, most of it spent losslessly shrinking a 25MB PNG of a photograph diff --git a/README.md b/README.md index 97ca6d5..b9136d4 100644 --- a/README.md +++ b/README.md @@ -107,23 +107,41 @@ browser otherwise. Both are the same full application. imgcompress # ./input -> ./output imgcompress photos/ -o small/ # any folder imgcompress hero.png # a single file -imgcompress input/ --target web # allow WebP output -imgcompress input/ -q 95 # near-lossless +imgcompress input/ --for documents # safe to import into a design tool +imgcompress input/ --for email # small enough to attach +imgcompress input/ -q 95 # hold a higher visual match imgcompress input/ --fast # quicker, a few percent bigger imgcompress --check # which engines are active ``` +### Where is it going? + +That is the only question you have to answer, and you can answer it without +knowing anything about compression. Everything else follows from it — which +formats are allowed, how large the frame may be, and how close the result has +to look. + +| `--for` | For | Formats | Size | Visual match | +| --- | --- | --- | --- | --- | +| `web` | **Default.** Anything that loads in a browser | all, incl. WebP + AVIF | 2560px | 90 | +| `documents` | Design tools, office suites, docs | JPEG / PNG only | 4096px, enforced | 90 | +| `email` | Attachments and chat | JPEG / PNG only | 1920px | 88 | +| `thumbnail` | Avatars, list icons, previews | all | 512px | 85 | +| `original` | Print, masters, archives | all | never resized | 95 | + +`--preset` is accepted as a synonym, and the older names (`figma`, `archive`) +still resolve, so existing scripts keep working. + | Flag | What it does | | --- | --- | -| `--target figma \| web \| lossless` | Which formats may be emitted. `figma` (default) = JPEG/PNG only | -| `--preset figma \| web \| thumbnail \| archive` | Size + quality starting points | +| `--for web \| documents \| email \| thumbnail \| original` | Where the image is going (default: `web`) | | `-m, --max-dimension 1920` | Cap the longest edge. `0` keeps original dimensions | -| `-q, --quality-target 95` | Perceptual floor on the SSIMULACRA 2 scale | +| `-q, --quality-target 95` | Minimum visual match, 0–100 | | `--metric ssimulacra2 \| ssim` | `ssim` is ~5× faster and cruder | -| `-f, --format jpeg` | Force a candidate; repeat to allow several | +| `-f, --format jpeg` | Always use this format; repeat to allow several | | `--fast` / `--no-zopfli` | Trade a few percent of size for speed | | `--keep-metadata` | Preserve EXIF/ICC instead of stripping it | -| `-j 8` / `-v` | Workers / show every candidate | +| `-j 8` / `-v` | Workers / show every version tried | ### Choosing a quality target @@ -139,31 +157,36 @@ SSIMULACRA 2 runs to 100. The author's published scale: --- -## Why it defaults to JPEG and PNG, not WebP +## Why `documents` refuses WebP -"Just use WebP" is the standard advice and it is wrong if your images are going -into Figma. +"Just use WebP" is the standard advice and it is wrong if your image is going +into a design tool or a document. This looks like a limitation and is the +feature. Figma's docs list WebP as an accepted upload format. But Figma's plugin API only knows PNG, JPEG and GIF — `figma.createImage` rejects everything else — and the standing community answer is that a WebP dropped onto the canvas is **decoded and re-encoded as PNG**, with no way to recover the original. TIFF import working *only in Safari* points the same way: Figma leans on the browser's -decoder, then re-encodes. - -If that's right, handing Figma a beautifully compressed 40 KB WebP photo gets you -a multi-megabyte PNG inside the `.fig`. The downside is severe and the upside is -a few percent, so the default target sticks to formats Figma is documented to -store byte-for-byte. AVIF isn't supported by Figma at all, and neither is JPEG XL. - -`--target web` re-enables WebP for anything not bound for Figma. - -Two other Figma facts are baked in: anything over **4096px** is downscaled -destructively on import (so this caps dimensions itself, with Lanczos, and never -lets the `figma` target exceed it), and Figma's memory pressure comes from pixel -dimensions more than from bytes — which is why the default 2560px cap is doing +decoder, then re-encodes. Office suites and document editors behave much the +same way. + +If that's right, handing one of these tools a beautifully compressed 40 KB WebP +photo gets you a multi-megabyte PNG inside the saved file. The downside is +severe and the upside is a few percent, so `--for documents` sticks to formats +those tools are documented to store byte-for-byte. AVIF isn't supported by +Figma at all, and neither is JPEG XL. + +`--for documents` also enforces a **4096px** ceiling even when you ask for more: +anything above it is downscaled destructively on import, with no control over +the resampling, so the choice is between our Lanczos and theirs. Memory pressure +in these tools comes from pixel dimensions more than from bytes, so if your +images are bound for a canvas rather than a print, `-m 2560` is usually doing more work than the encoder is. +Every other destination allows the modern formats, which is why `web` is the +default: the restriction is a fact about design tools, not about images. + --- ## Install @@ -218,7 +241,7 @@ survives in most hand-rolled compressors. ```bash git clone https://github.com/SyedSaribSultan/imgcompress && cd imgcompress pip install -e ".[full,app,dev]" -python -m unittest discover -s tests # 20 tests, ~20s +python -m unittest discover -s tests # 33 tests, ~40s python tests/make_fixtures.py # build the benchmark corpus python tests/bench_formats.py # the format table above python tests/bench_versions.py # matched-quality comparison vs v1 @@ -229,7 +252,7 @@ change that affects output needs a measurement at **matched perceptual quality** — a smaller file at a lower score isn't an improvement, it's a different setting. And never validate a metric change using that same metric. -The screenshots in this README were compressed by the tool (`--target web`), +The screenshots in this README were compressed by the tool (`--for web`), which is the least I could do. ## Licence diff --git a/imgcompress/cli.py b/imgcompress/cli.py index 1180ed5..6909485 100644 --- a/imgcompress/cli.py +++ b/imgcompress/cli.py @@ -7,18 +7,11 @@ from pathlib import Path from . import __version__ +from . import destinations as dest from . import encoders as enc from .core import CompressionResult, Settings, compress_tree from .quality import HAVE_SSIMULACRA2, get_metric -PRESETS = { - # name: (max_dimension, ssimulacra2 target, ssim target) - "figma": (2560, 90.0, 0.97), - "web": (1920, 85.0, 0.96), - "thumbnail": (800, 80.0, 0.95), - "archive": (0, 95.0, 0.99), -} - def human(n: int) -> str: value = float(n) @@ -55,45 +48,75 @@ def describe(res: CompressionResult, verbose: bool = False) -> str: return line +def destination_help() -> str: + """The five destinations, spelled out, for the bottom of --help. + + Deliberately ASCII: this prints to a Windows console under cp1252 as often + as not, and a middot that arrives as a replacement character undoes the + point of writing readable help. + """ + lines = ["where the image is going:"] + for d in dest.visible(): + head = f" --for {d.name}" + size = f"up to {d.max_dimension}px" if d.max_dimension else "never resized" + lines.append(f"{head.ljust(20)} {d.label}") + lines.append(f"{' ' * 20} {d.help}") + lines.append(f"{' ' * 20} {', '.join(d.formats)}" + f" | {size} | visual match {d.ss2_target:g}") + return "\n".join(lines) + + def build_parser() -> argparse.ArgumentParser: here = Path(__file__).resolve().parent.parent parser = argparse.ArgumentParser( - prog="compress", + prog="imgcompress", description=( - "Shrink images hard while holding a measured perceptual quality floor. " - "Encodes each image several ways, decodes and scores every candidate, " - "and keeps the smallest one that still looks right." + "Make images as small as they go without you being able to see the " + "difference. Each image is written several ways, every version is " + "measured against the original, and the smallest one that still " + "looks close enough is the one you get." ), formatter_class=argparse.RawDescriptionHelpFormatter, epilog=( + destination_help() + "\n\n" "examples:\n" - " python compress.py ./input -> ./output\n" - " python compress.py photos/ -o small/ compress a folder\n" - " python compress.py input/ --target web allow WebP output\n" - " python compress.py input/ -q 95 near-lossless\n" - " python compress.py input/ --fast quicker, slightly bigger\n" - " python compress.py --check show which engines are active\n" + " imgcompress ./input -> ./output\n" + " imgcompress photos/ -o small/ compress a folder\n" + " imgcompress hero.png --for documents safe to import into a design tool\n" + " imgcompress input/ --for email small enough to attach\n" + " imgcompress input/ -q 95 hold a higher visual match\n" + " imgcompress input/ --fast quicker, slightly bigger\n" + " imgcompress --check show which engines are active\n" ), ) parser.add_argument("source", nargs="?", default=str(here / "input"), help="file or folder to compress (default: ./input)") parser.add_argument("-o", "--output", default=str(here / "output"), - help="destination folder (default: ./output)") - parser.add_argument("--preset", choices=sorted(PRESETS), default="figma", - help="starting point for size and quality (default: figma)") - parser.add_argument("--target", choices=["figma", "web", "lossless"], default=None, - help="which output formats are allowed. figma = JPEG/PNG only " - "(default), web adds WebP, lossless is pixel-exact") + help="where to write the results (default: ./output)") + # Validated by hand rather than with `choices`, so that the older names go + # on working without argparse listing them back at anyone who mistypes. + parser.add_argument("--for", "--preset", dest="destination", + default=dest.DEFAULT, metavar="DESTINATION", + help="where the image is going: " + + " | ".join(dest.names()) + + f" (default: {dest.DEFAULT}). Sets the formats, the size " + "cap and the minimum visual match; see the list below") + # Kept working for scripts written against 2.6 and earlier, where `--target` + # chose the format list and `--preset` chose size and quality. Both now name + # the same thing, so both land here. Not advertised. + parser.add_argument("--target", dest="legacy_target", default=None, + help=argparse.SUPPRESS) parser.add_argument("-m", "--max-dimension", type=int, - help="cap the longest edge in pixels. 0 keeps original dimensions") + help="cap the longest edge in pixels. 0 keeps the original size") parser.add_argument("-q", "--quality-target", type=float, - help="perceptual floor. SSIMULACRA2 scale 0-100 (90 = visually " - "lossless), or 0-1 if using --metric ssim") + help="minimum visual match, 0-100 where 100 is indistinguishable " + "(90 = you will not see the difference), or 0-1 with " + "--metric ssim") parser.add_argument("--metric", choices=["ssimulacra2", "ssim"], default=None, help="quality metric (default: ssimulacra2 when installed)") parser.add_argument("-f", "--format", dest="formats", action="append", choices=sorted(enc.ALL), - help="force a candidate format; repeat to allow several") + help="always use this format; repeat to allow several") parser.add_argument("--fast", action="store_true", help="skip the slowest final passes; a few percent bigger") parser.add_argument("--no-zopfli", action="store_true", @@ -105,7 +128,7 @@ def build_parser() -> argparse.ArgumentParser: parser.add_argument("-j", "--workers", type=int, default=0, help="parallel workers (default: auto)") parser.add_argument("-v", "--verbose", action="store_true", - help="show every candidate encoding, not just the winner") + help="show every version that was tried, not just the winner") parser.add_argument("--check", action="store_true", help="report which optional engines are installed, then exit") parser.add_argument("--version", action="version", version=f"imgcompress {__version__}") @@ -142,8 +165,19 @@ def main(argv=None) -> int: print(str(exc), file=sys.stderr) return 2 - max_dim, ss2_target, ssim_target = PRESETS[args.preset] - target = ss2_target if metric.name == "ssimulacra2" else ssim_target + # `--target` is the pre-2.7 spelling of the same idea and wins when given, + # so a script that says `--target figma` keeps landing on the design-tool + # rules under their new name. + asked_for = args.legacy_target or args.destination + if not dest.exists(asked_for): + print(f"There's no destination called '{asked_for}'. " + f"Choose one of: {', '.join(dest.names())}.", file=sys.stderr) + return 2 + going_to = dest.get(asked_for) + renamed = asked_for if asked_for != going_to.name else "" + + max_dim = going_to.max_dimension + target = going_to.ss2_target if metric.name == "ssimulacra2" else going_to.ssim_target if args.max_dimension is not None: max_dim = args.max_dimension if args.quality_target is not None: @@ -155,7 +189,7 @@ def main(argv=None) -> int: return 2 settings = Settings( - target=args.target or ("web" if args.formats else "figma"), + target=going_to.name, max_dimension=max_dim, metric=metric.name, quality_target=target, @@ -165,13 +199,20 @@ def main(argv=None) -> int: formats=args.formats, ) - destination = Path(args.output).expanduser() - allowed = settings.formats or enc.TARGETS[settings.target] + # `destination` is the folder; `going_to` is the kind of place the image is + # headed. Naming both of them the same thing is how this got confusing in + # the first place. + out_dir = Path(args.output).expanduser() + allowed = settings.formats or enc.usable(going_to.formats) + match = f"{target:g}" if metric.name == "ssimulacra2" else f"{target:g} ({metric.name})" print(f"source {source}") - print(f"destination {destination}") - print(f"preset {args.preset} (max {max_dim or 'unlimited'}px, " - f"{metric.name} >= {target:g})") - print(f"candidates {', '.join(allowed)}") + print(f"writing to {out_dir}") + print(f"going to {going_to.name} - {going_to.label.lower()}") + size = f"up to {max_dim}px" if max_dim else "never resized" + print(f" {size}, visual match at least {match}") + if renamed: + print(f" ('{renamed}' is the old name for this; both work)") + print(f"formats {', '.join(allowed)}") missing = [k for k, v in enc.capabilities().items() if not v] if missing: print(f"note not installed: {', '.join(missing)} " @@ -179,7 +220,7 @@ def main(argv=None) -> int: print() results = compress_tree( - source, destination, settings, + source, out_dir, settings, recursive=not args.no_recursive, workers=args.workers, on_result=lambda r: print(describe(r, args.verbose), flush=True), diff --git a/imgcompress/core.py b/imgcompress/core.py index 5683550..7bfc440 100644 --- a/imgcompress/core.py +++ b/imgcompress/core.py @@ -3,8 +3,9 @@ Strategy, in order of how much size it actually saves: 1. Cap the pixel dimensions. A 6000px export that renders at 1200px is mostly - wasted bytes, and for Figma specifically the dimensions drive canvas memory - more than the byte count does. + wasted bytes, and inside a design tool the dimensions drive canvas memory + more than the byte count does. How large is a property of the destination + - see `destinations.py`. 2. Strip metadata (EXIF, ICC, XMP). 3. Run a **bake-off**: encode the image as JPEG *and* as palette PNG *and* as lossless PNG, binary-searching each one for the lowest quality that still @@ -28,6 +29,7 @@ from PIL import Image, ImageOps +from . import destinations as dest from . import encoders as enc from .quality import Metric, get_metric @@ -36,15 +38,12 @@ SUPPORTED_SUFFIXES = {".jpg", ".jpeg", ".png", ".webp", ".bmp", ".tif", ".tiff", ".gif"} -# Figma rescales anything above this on import, destructively and with no -# control over the resampling. Better to do it ourselves with Lanczos. -FIGMA_MAX_DIMENSION = 4096 - @dataclass class Settings: - target: str = "figma" - """figma | web | lossless - which output formats are allowed.""" + target: str = dest.DEFAULT + """Where the image is going - see `destinations.py`. Decides which output + formats are allowed and whether a dimension cap is enforced.""" max_dimension: int = 2560 """Longest edge in pixels. 0 disables resizing.""" @@ -124,8 +123,12 @@ def _normalise(img: Image.Image, settings: Settings) -> tuple: resized_to = None limit = settings.max_dimension or 0 - if settings.target == "figma": - limit = min(limit, FIGMA_MAX_DIMENSION) if limit else FIGMA_MAX_DIMENSION + # Some destinations enforce a ceiling regardless of what was asked for - + # design tools rescale above 4096px themselves, destructively, so the + # choice is between our Lanczos and theirs. + cap = dest.get(settings.target).hard_cap if dest.exists(settings.target) else 0 + if cap: + limit = min(limit, cap) if limit else cap if limit and max(img.size) > limit: scale = limit / float(max(img.size)) @@ -192,7 +195,11 @@ def probe(index: int) -> float: def _candidate_names(settings: Settings, has_alpha: bool) -> list[str]: - names = settings.formats or enc.TARGETS[settings.target] + names = settings.formats or dest.formats_for(settings.target) + # A destination names the formats it *wants*; this machine decides which of + # them it can write. The two are not the same list - the table offers AVIF + # everywhere the browser engine does, and most Pillow builds cannot make one. + names = enc.usable(names) if has_alpha: names = [n for n in names if enc.ALL[n].supports_alpha] return names diff --git a/imgcompress/destinations.py b/imgcompress/destinations.py new file mode 100644 index 0000000..5b4de36 --- /dev/null +++ b/imgcompress/destinations.py @@ -0,0 +1,169 @@ +"""Where the image is going. + +A destination is the one question a person can answer without knowing anything +about compression: where will this image end up? Everything the engine needs +follows from the answer - which formats it may write, how large the frame may +be, and how close the result has to look. + +This replaces two older ideas that overlapped and were both named after the +wrong thing. `--preset` used to set size and quality; `--target` used to set the +format list; and the default for both was `figma`, which capped every image at +4096px and refused WebP for a reason that applies to design tools and nobody +else. Someone compressing a photograph for their website silently got no WebP +and was never told why. One list, named after destinations, is the fix. + +This table is the single source of truth for the Python side. `web/worker.js`, +`web/app.js` and `imgcompress/webui/app.html` carry the same entries with the +same numbers; if you change one, change all four. `tests/test_compress.py` has +a test per destination so the Python side cannot drift on its own. +""" + +from __future__ import annotations + +from dataclasses import dataclass + +# Everything the bake-off knows how to write. A destination that lists a format +# this machine has no encoder for simply drops it - see `Encoder.available`. +EVERY_FORMAT = ("jpeg", "png8", "png", "webp", "webp-lossless", "avif") + +# Formats that design tools, office suites and document editors store as they +# were given them. Figma's own docs accept WebP, but its plugin API only knows +# PNG/JPEG/GIF and the standing community answer is that a WebP dropped on the +# canvas is decoded and re-encoded as PNG. If that is right, handing one of +# these tools a beautifully compressed 40 KB WebP gets you a multi-megabyte PNG +# inside the saved file. The downside is severe and the upside is a few +# percent, so this list stays conservative on purpose. +STORED_AS_GIVEN = ("jpeg", "png8", "png") + + +@dataclass(frozen=True) +class Destination: + name: str + label: str + """What a person calls this place.""" + + formats: tuple + max_dimension: int + """Longest edge in pixels. 0 never resizes.""" + + ss2_target: float + ssim_target: float + help: str + + hard_cap: int = 0 + """A limit the destination enforces even when asked for more. 0 means none.""" + + hidden: bool = False + """Kept working for scripts written against an older version, not offered.""" + + +DESTINATIONS = { + d.name: d + for d in ( + Destination( + name="web", + label="Website or app", + formats=EVERY_FORMAT, + max_dimension=2560, + ss2_target=90.0, + ssim_target=0.97, + help="Smallest possible files using modern formats. " + "Best for anything that loads in a browser.", + ), + Destination( + name="documents", + label="Design tool or document", + formats=STORED_AS_GIVEN, + # Design tools rescale destructively above this on import, with no + # control over the resampling, so the cap is enforced even when the + # caller asks for more - better Lanczos here than whatever they do. + max_dimension=4096, + hard_cap=4096, + ss2_target=90.0, + ssim_target=0.97, + help="Only formats these tools store as-is. " + "Prevents files getting bigger when you import them.", + ), + Destination( + name="email", + label="Email or chat", + formats=STORED_AS_GIVEN, + max_dimension=1920, + ss2_target=88.0, + ssim_target=0.965, + help="Small enough to attach, and opens everywhere.", + ), + Destination( + name="thumbnail", + label="Thumbnail or avatar", + formats=EVERY_FORMAT, + max_dimension=512, + ss2_target=85.0, + ssim_target=0.95, + help="For small display sizes - profile pictures, list icons, previews.", + ), + Destination( + name="original", + label="Keep full quality", + # Lossless is preferred by arithmetic rather than by rule: at a + # minimum visual match of 95 with no resizing, a lossy encode has + # to be both smaller and near-perfect to beat a lossless one, which + # on the content people reach for this with it rarely is. + formats=EVERY_FORMAT, + max_dimension=0, + ss2_target=95.0, + ssim_target=0.99, + help="No resizing, highest fidelity. For print and originals.", + ), + Destination( + name="lossless", + label="Pixel-perfect only", + formats=("png", "webp-lossless"), + max_dimension=2560, + ss2_target=90.0, + ssim_target=0.97, + help="Nothing but pixel-exact output.", + hidden=True, + ), + ) +} + +# Older names, kept working so existing scripts do not break. Not offered +# anywhere a person can see them. +ALIASES = { + "figma": "documents", + "archive": "original", +} + +DEFAULT = "web" + + +def resolve(name: str) -> str: + """Canonical destination name, following aliases. Unknown names pass through + so the caller can raise its own error with its own wording.""" + return ALIASES.get(name, name) + + +def get(name: str) -> Destination: + canonical = resolve(name) + try: + return DESTINATIONS[canonical] + except KeyError: + raise KeyError(f"unknown destination: {name}") from None + + +def exists(name: str) -> bool: + return resolve(name) in DESTINATIONS + + +def formats_for(name: str) -> list: + return list(get(name).formats) + + +def visible() -> list: + """The destinations a person is offered, in the order they are offered.""" + return [d for d in DESTINATIONS.values() if not d.hidden] + + +def names() -> list: + return [d.name for d in visible()] diff --git a/imgcompress/encoders.py b/imgcompress/encoders.py index 8a3fd6b..0c303bd 100644 --- a/imgcompress/encoders.py +++ b/imgcompress/encoders.py @@ -52,6 +52,7 @@ # cost at most one more probe. JPEG_QUALITY = [40, 50, 58, 65, 70, 74, 78, 82, 85, 88, 90, 92, 94, 96, 97, 98, 99] WEBP_QUALITY = [40, 50, 58, 65, 70, 75, 80, 84, 87, 90, 92, 94, 96, 98] +AVIF_QUALITY = [30, 38, 45, 52, 58, 64, 70, 76, 82, 88, 93, 96] def _zopfli_png(data: bytes, enabled: bool = True) -> bytes: @@ -203,30 +204,44 @@ def encode(self, img: Image.Image, level: int, fast: bool = False) -> bytes: return buf.getvalue() +class AvifEncoder(Encoder): + """AVIF, where Pillow was built with one. + + Pillow gained native AVIF support in 11.3, but only where the wheel was + built against libavif - which most Windows wheels are not, and the plugin + (`pillow-avif-plugin`) is a separate install. The browser engine has had + AVIF since the WASM codec tier landed, so the destination table lists it + either way and this reports honestly whether this machine can write one. + A destination that offers a format nobody here can encode simply loses it, + the same way `png8` falls back when libimagequant is missing. + """ + + name = "avif" + extension = ".avif" + supports_alpha = True + levels = AVIF_QUALITY + + def available(self) -> bool: + return "AVIF" in Image.SAVE + + def encode(self, img: Image.Image, level: int, fast: bool = False) -> bytes: + buf = io.BytesIO() + img.save(buf, "AVIF", quality=level, speed=8 if fast else 4) + return buf.getvalue() + + ALL = { "jpeg": JpegEncoder, "png8": Png8Encoder, "png": PngEncoder, "webp": WebpEncoder, "webp-lossless": WebpLosslessEncoder, -} - -# Which candidates each target is allowed to emit. -# -# figma: Figma's own docs say uploads are accepted as JPG, PNG, HEIC, WebP, GIF -# and TIFF - but its plugin API only knows PNG/JPEG/GIF, and the standing -# community answer is that WebP gets transcoded to PNG on import. If that -# is right, shipping WebP to Figma turns a small file into a large PNG. -# The downside is bad and the upside is small, so this target sticks to -# JPEG and PNG. -TARGETS = { - "figma": ["jpeg", "png8", "png"], - "web": ["jpeg", "png8", "png", "webp", "webp-lossless"], - "lossless": ["png", "webp-lossless"], + "avif": AvifEncoder, } def build(names, zopfli: bool = True, background=(255, 255, 255)) -> list[Encoder]: + """Instantiate the named encoders, dropping any this machine cannot run.""" out = [] for name in names: cls = ALL[name] @@ -236,6 +251,16 @@ def build(names, zopfli: bool = True, background=(255, 255, 255)) -> list[Encode return out +def usable(names) -> list: + """Of `names`, the ones that exist and this machine can actually write. + + Which formats a destination *offers* and which it can *emit here* are + different questions, and conflating them is how a destination table that + lists AVIF turns into a KeyError on a machine without an AVIF encoder. + """ + return [n for n in names if n in ALL and ALL[n](zopfli=False).available()] + + def capabilities() -> dict: return { "imagequant (pngquant engine)": HAVE_IMAGEQUANT, diff --git a/imgcompress/server.py b/imgcompress/server.py index 635a6ab..198e747 100644 --- a/imgcompress/server.py +++ b/imgcompress/server.py @@ -28,6 +28,7 @@ from PIL import Image from . import __version__ +from . import destinations as dest from . import encoders as enc from .core import ( SUPPORTED_SUFFIXES, @@ -100,9 +101,9 @@ def __init__(self, workers: int = 0): self.results: dict[str, CompressionResult] = {} self.previews: dict[str, bytes] = {} self.settings = { - "target": "figma", + "target": dest.DEFAULT, "quality_target": 90.0 if HAVE_SSIMULACRA2 else 0.97, - "max_dimension": 2560, + "max_dimension": dest.get(dest.DEFAULT).max_dimension, "metric": "ssimulacra2" if HAVE_SSIMULACRA2 else "ssim", "fast": False, "keep_metadata": False, @@ -144,6 +145,19 @@ def snapshot(self) -> dict: "version": __version__, "items": items, "settings": dict(self.settings), + # The interface builds its destination list from this rather + # than carrying its own copy. One table, no drift. + # + # `formats` is what this machine can actually write, not what + # the destination would like to - a tooltip promising AVIF on a + # Pillow built without libavif is a promise the engine cannot + # keep, and the person would only find out by its absence. + "destinations": [ + {"name": d.name, "label": d.label, "help": d.help, + "formats": enc.usable(d.formats), "max_dimension": d.max_dimension, + "quality_target": d.ss2_target if HAVE_SSIMULACRA2 else d.ssim_target} + for d in dest.visible() + ], "watch_folder": self.watch_folder, "last_folder": self.last_folder, "engines": {**enc.capabilities(), "ssimulacra2 (perceptual metric)": HAVE_SSIMULACRA2}, @@ -224,8 +238,13 @@ def settings_for(self, item: Item) -> Settings: merged = dict(self.settings) merged.update(item.override or {}) formats = merged.pop("formats", None) or None + # An older session's saved target may be a pre-2.7 name; resolve it + # rather than letting `figma` reach the engine as an unknown place. + going_to = dest.resolve(merged.get("target") or dest.DEFAULT) + if not dest.exists(going_to): + going_to = dest.DEFAULT return Settings( - target=merged.get("target", "figma"), + target=going_to, max_dimension=int(merged.get("max_dimension", 2560)), metric=merged.get("metric", ""), quality_target=float(merged["quality_target"]) if merged.get("quality_target") is not None else None, diff --git a/imgcompress/webui/app.html b/imgcompress/webui/app.html index 4613af4..a4c8338 100644 --- a/imgcompress/webui/app.html +++ b/imgcompress/webui/app.html @@ -427,12 +427,12 @@
- - + + +
@@ -627,6 +627,9 @@

Candidates tried

function render(next) { const firstPaint = state.rev === -1; state = next; + // Options before values: setting .value against an empty select is silently + // a no-op, and the control would sit blank until something else touched it. + renderDestinations(state.destinations); if (!localSettings) applySettingsToControls(state.settings); renderQueue(); @@ -835,8 +838,31 @@

Candidates tried

} /* ------------------------------- settings -------------------------------- */ + +/* Pre-2.7 names, so a session saved by an older build still selects something. + The server resolves these too; this only keeps the control from going blank + in the moment before the first snapshot lands. */ +const OLD_DESTINATION_NAMES = { figma: "documents", archive: "original" }; +let destinationsRendered = false; + +function renderDestinations(list) { + if (destinationsRendered || !list || !list.length) return; + const sel = $("target"); + sel.innerHTML = ""; + for (const d of list) { + const opt = document.createElement("option"); + opt.value = d.name; + opt.textContent = d.label; + opt.title = `${d.help} (${d.formats.join(", ")}; ` + + `${d.max_dimension ? "up to " + d.max_dimension + "px" : "never resized"})`; + sel.appendChild(opt); + } + destinationsRendered = true; +} + function applySettingsToControls(s) { - $("target").value = s.target || "figma"; + const name = OLD_DESTINATION_NAMES[s.target] || s.target || "web"; + if ($("target").querySelector(`option[value="${name}"]`)) $("target").value = name; const isSsim = s.metric === "ssim"; const q = $("quality"); q.min = isSsim ? 80 : 60; q.max = isSsim ? 100 : 99; @@ -864,7 +890,22 @@

Candidates tried

} /* -------------------------------- events --------------------------------- */ -$("target").addEventListener("change", pushSettings); +/* Picking a destination is picking all three of its numbers. Leaving the size + and quality where the last destination left them would make "Thumbnail" + mean nothing but a shorter format list, and the person would have to know + to go and change two more controls for it to do what it says. Both remain + editable afterwards - this sets a starting point, it does not lock it. */ +$("target").addEventListener("change", () => { + const d = (state.destinations || []).find((x) => x.name === $("target").value); + if (d) { + $("maxdim").value = d.max_dimension; + const q = $("quality"); + q.value = state.settings.metric === "ssim" + ? Math.round(d.quality_target * 100) : d.quality_target; + $("quality-out").textContent = q.value; + } + pushSettings(); +}); $("maxdim").addEventListener("change", pushSettings); $("quality").addEventListener("input", () => { $("quality-out").textContent = $("quality").value; }); $("quality").addEventListener("change", pushSettings); diff --git a/tests/BENCHMARK.md b/tests/BENCHMARK.md index 21e7ef6..7286bb6 100644 --- a/tests/BENCHMARK.md +++ b/tests/BENCHMARK.md @@ -17,8 +17,8 @@ Source 1.9 MB; normalised reference 5.4 MB. | WebP q75 (a common default) | webp | q75 | 22.4 KB | -94% | 75.8 | 0.9498 | **no** | | JPEG q75 (a common default) | jpeg | q75 | 79.4 KB | -78% | 80.1 | 0.9539 | **no** | | JPEG q85 (a common default) | jpeg | q85 | 178.3 KB | -51% | 84.9 | 0.9588 | **no** | -| imgcompress web (Figma target) **←** | jpeg | measured floor | 362.4 KB | best | 90.4 | 0.9657 | yes | -| imgcompress web (Web target) | jpeg | measured floor | 362.4 KB | +0% | 90.4 | 0.9657 | yes | +| imgcompress web (documents) **←** | jpeg | measured floor | 362.4 KB | best | 90.4 | 0.9657 | yes | +| imgcompress web (web) | jpeg | measured floor | 362.4 KB | +0% | 90.4 | 0.9657 | yes | | JPEG 4:2:0 only | jpeg | q94 | 517.4 KB | +43% | 90.5 | 0.9700 | yes | | imgcompress desktop | jpeg | measured floor | 543.8 KB | +50% | 91.4 | 0.9700 | yes | | mozjpeg 4:4:4 only | jpeg | q94 | 543.8 KB | +50% | 91.4 | 0.9700 | yes | @@ -31,11 +31,11 @@ Source 3.2 KB; normalised reference 17.9 KB. | Strategy | Format | Setting | Size | vs best | SSIMULACRA 2 | SSIM p5 | Clears floor | | --- | --- | --- | --- | --- | --- | --- | --- | | imgcompress desktop **←** | webp-lossless | measured floor | 438 B | best | 100.0 | 1.0000 | yes | -| imgcompress web (Web target) | webp | measured floor | 450 B | +3% | 100.0 | 1.0000 | yes | +| imgcompress web (web) | webp | measured floor | 450 B | +3% | 100.0 | 1.0000 | yes | | AVIF q50 (a common default) | avif | q50 | 1.3 KB | +197% | 85.8 | 0.9955 | **no** | | WebP q75 (a common default) | webp | q75 | 2.5 KB | +490% | 74.7 | 0.9876 | **no** | | PNG lossless + zopfli | png | lossless | 2.7 KB | +539% | 100.0 | 1.0000 | yes | -| imgcompress web (Figma target) | png | measured floor | 2.8 KB | +553% | 100.0 | 1.0000 | yes | +| imgcompress web (documents) | png | measured floor | 2.8 KB | +553% | 100.0 | 1.0000 | yes | | JPEG q75 (a common default) | jpeg | q75 | 6.0 KB | +1299% | 77.2 | 0.9940 | **no** | | JPEG q85 (a common default) | jpeg | q85 | 9.6 KB | +2154% | 83.0 | 0.9958 | **no** | @@ -45,11 +45,11 @@ Source 10.0 KB; normalised reference 38.4 KB. | Strategy | Format | Setting | Size | vs best | SSIMULACRA 2 | SSIM p5 | Clears floor | | --- | --- | --- | --- | --- | --- | --- | --- | -| imgcompress web (Web target) **←** | webp | measured floor | 2.9 KB | best | 100.0 | 1.0000 | yes | +| imgcompress web (web) **←** | webp | measured floor | 2.9 KB | best | 100.0 | 1.0000 | yes | | imgcompress desktop | webp-lossless | measured floor | 3.1 KB | +10% | 100.0 | 1.0000 | yes | | pngquant + zopfli | png8 | 8 colours | 3.9 KB | +36% | 100.0 | 1.0000 | yes | | PNG lossless + zopfli | png | lossless | 3.9 KB | +36% | 100.0 | 1.0000 | yes | -| imgcompress web (Figma target) | png | measured floor | 4.1 KB | +45% | 100.0 | 1.0000 | yes | +| imgcompress web (documents) | png | measured floor | 4.1 KB | +45% | 100.0 | 1.0000 | yes | | AVIF q50 (a common default) | avif | q50 | 7.1 KB | +148% | 81.0 | 0.9997 | **no** | | WebP q75 (a common default) | webp | q75 | 11.1 KB | +289% | 80.8 | 0.9962 | **no** | @@ -65,8 +65,8 @@ Source 1.7 MB; normalised reference 2.7 MB. | JPEG q85 (a common default) | jpeg | q85 | 82.7 KB | -81% | 74.2 | 0.9352 | **no** | | imgcompress desktop **←** | jpeg | measured floor | 439.3 KB | best | 90.7 | 0.9662 | yes | | mozjpeg 4:4:4 only | jpeg | q96 | 439.3 KB | +0% | 90.7 | 0.9662 | yes | -| imgcompress web (Figma target) | jpeg | measured floor | 450.1 KB | +2% | 91.1 | 0.9711 | yes | -| imgcompress web (Web target) | jpeg | measured floor | 450.1 KB | +2% | 91.1 | 0.9711 | yes | +| imgcompress web (documents) | jpeg | measured floor | 450.1 KB | +2% | 91.1 | 0.9711 | yes | +| imgcompress web (web) | jpeg | measured floor | 450.1 KB | +2% | 91.1 | 0.9711 | yes | | PNG lossless + zopfli | png | lossless | 1.5 MB | +249% | 100.0 | 1.0000 | yes | ## screenshot_retina.png — 2560x1600 @@ -75,11 +75,11 @@ Source 16.5 KB; normalised reference 121.1 KB. | Strategy | Format | Setting | Size | vs best | SSIMULACRA 2 | SSIM p5 | Clears floor | | --- | --- | --- | --- | --- | --- | --- | --- | -| imgcompress web (Web target) **←** | webp | measured floor | 1.1 KB | best | 100.0 | 1.0000 | yes | +| imgcompress web (web) **←** | webp | measured floor | 1.1 KB | best | 100.0 | 1.0000 | yes | | imgcompress desktop | webp-lossless | measured floor | 1.1 KB | +0% | 100.0 | 1.0000 | yes | | AVIF only | avif | q45 | 2.5 KB | +130% | 91.6 | 0.9999 | yes | | AVIF q50 (a common default) | avif | q50 | 2.5 KB | +131% | 92.3 | 1.0000 | yes | -| imgcompress web (Figma target) | png | measured floor | 4.2 KB | +284% | 100.0 | 1.0000 | yes | +| imgcompress web (documents) | png | measured floor | 4.2 KB | +284% | 100.0 | 1.0000 | yes | | pngquant + zopfli | png8 | 8 colours | 4.6 KB | +322% | 100.0 | 1.0000 | yes | | PNG lossless + zopfli | png | lossless | 4.6 KB | +322% | 100.0 | 1.0000 | yes | | WebP q75 (a common default) | webp | q75 | 15.1 KB | +1294% | 88.3 | 0.9758 | **no** | @@ -98,11 +98,11 @@ Source 29.3 KB; normalised reference 55.1 KB. | imgcompress desktop **←** | png8 | measured floor | 6.8 KB | best | 93.9 | 0.9983 | yes | | pngquant + zopfli | png8 | 16 colours | 6.8 KB | +0% | 93.9 | 0.9983 | yes | | AVIF q50 (a common default) | avif | q50 | 7.0 KB | +4% | 87.8 | 0.9969 | **no** | -| imgcompress web (Web target) | webp | measured floor | 9.3 KB | +37% | 100.0 | 1.0000 | yes | +| imgcompress web (web) | webp | measured floor | 9.3 KB | +37% | 100.0 | 1.0000 | yes | | AVIF only | avif | q88 | 11.2 KB | +66% | 90.2 | 1.0000 | yes | | WebP q75 (a common default) | webp | q75 | 12.2 KB | +79% | 83.1 | 0.9370 | **no** | | PNG lossless + zopfli | png | lossless | 21.5 KB | +217% | 100.0 | 1.0000 | yes | -| imgcompress web (Figma target) | png | measured floor | 23.4 KB | +246% | 100.0 | 1.0000 | yes | +| imgcompress web (documents) | png | measured floor | 23.4 KB | +246% | 100.0 | 1.0000 | yes | | JPEG q75 (a common default) | jpeg | q75 | 31.4 KB | +363% | 78.8 | 0.9760 | **no** | | JPEG q85 (a common default) | jpeg | q85 | 35.4 KB | +422% | 83.4 | 0.9807 | **no** | | mozjpeg 4:4:4 only | jpeg | q92 | 48.2 KB | +612% | 91.5 | 0.9939 | yes | diff --git a/tests/bench_vs_alternatives.py b/tests/bench_vs_alternatives.py index 02598d2..ee7b06a 100644 --- a/tests/bench_vs_alternatives.py +++ b/tests/bench_vs_alternatives.py @@ -301,8 +301,8 @@ def main() -> int: ref_bytes = ref_path.stat().st_size web: dict[str, bytes] = {} - for target_dir, label in (("figma", "imgcompress web (Figma target)"), - ("web", "imgcompress web (Web target)")): + for target_dir, label in (("documents", "imgcompress web (documents)"), + ("web", "imgcompress web (web)")): d = web_root / target_dir if not d.is_dir(): continue diff --git a/tests/bench_web_out.mjs b/tests/bench_web_out.mjs index 1f2684d..7c17a3d 100644 --- a/tests/bench_web_out.mjs +++ b/tests/bench_web_out.mjs @@ -74,7 +74,7 @@ const browser = await puppeteer.launch({ executablePath: CHROME, headless: true, protocolTimeout: 3_600_000, }); try { - for (const target of ["figma", "web"]) { + for (const target of ["documents", "web"]) { const dir = path.join(OUT, target); rmSync(dir, { recursive: true, force: true }); mkdirSync(dir, { recursive: true }); diff --git a/tests/bench_web_out/figma/camera_12mp.jpg b/tests/bench_web_out/documents/camera_12mp.jpg similarity index 100% rename from tests/bench_web_out/figma/camera_12mp.jpg rename to tests/bench_web_out/documents/camera_12mp.jpg diff --git a/tests/bench_web_out/figma/gradient.png b/tests/bench_web_out/documents/gradient.png similarity index 100% rename from tests/bench_web_out/figma/gradient.png rename to tests/bench_web_out/documents/gradient.png diff --git a/tests/bench_web_out/figma/logo_alpha.png b/tests/bench_web_out/documents/logo_alpha.png similarity index 100% rename from tests/bench_web_out/figma/logo_alpha.png rename to tests/bench_web_out/documents/logo_alpha.png diff --git a/tests/bench_web_out/figma/photo.jpg b/tests/bench_web_out/documents/photo.jpg similarity index 100% rename from tests/bench_web_out/figma/photo.jpg rename to tests/bench_web_out/documents/photo.jpg diff --git a/tests/bench_web_out/figma/screenshot_retina.png b/tests/bench_web_out/documents/screenshot_retina.png similarity index 100% rename from tests/bench_web_out/figma/screenshot_retina.png rename to tests/bench_web_out/documents/screenshot_retina.png diff --git a/tests/bench_web_out/figma/ui_text.png b/tests/bench_web_out/documents/ui_text.png similarity index 100% rename from tests/bench_web_out/figma/ui_text.png rename to tests/bench_web_out/documents/ui_text.png diff --git a/tests/test_compress.py b/tests/test_compress.py index deadb7b..f149138 100644 --- a/tests/test_compress.py +++ b/tests/test_compress.py @@ -11,6 +11,7 @@ from PIL import Image, ImageDraw # noqa: E402 from imgcompress import Settings, compress_file, compress_tree # noqa: E402 +from imgcompress import destinations as dest # noqa: E402 from imgcompress import encoders as enc # noqa: E402 from imgcompress.quality import ( # noqa: E402 HAVE_SSIMULACRA2, @@ -96,10 +97,85 @@ def test_png8_respects_palette_size(self): self.assertEqual(out.mode, "P") self.assertLessEqual(len(out.getcolors(maxcolors=1024)), 32) - def test_figma_target_never_offers_webp(self): - self.assertNotIn("webp", enc.TARGETS["figma"]) - self.assertNotIn("webp-lossless", enc.TARGETS["figma"]) - self.assertIn("webp", enc.TARGETS["web"]) + def test_every_named_format_has_an_encoder(self): + """A destination may only offer formats the engine knows how to write. + + `available()` decides whether this machine can actually run one; this + is the earlier question, and getting it wrong is a KeyError at the + moment somebody's image is being compressed. + """ + for d in dest.DESTINATIONS.values(): + for name in d.formats: + self.assertIn(name, enc.ALL, f"{d.name} offers unknown format {name}") + + +class DestinationTests(unittest.TestCase): + """The table is a promise about where an image is going. Pin all of it. + + These same five entries are duplicated in `web/worker.js`, `web/app.js` and + the desktop UI, which cannot be checked from here - but the Python side is + the reference, so at least it cannot drift on its own. + """ + + EXPECTED = { + # name: (formats, max_dimension, hard_cap, ss2) + "web": (("jpeg", "png8", "png", "webp", "webp-lossless", "avif"), + 2560, 0, 90.0), + "documents": (("jpeg", "png8", "png"), 4096, 4096, 90.0), + "email": (("jpeg", "png8", "png"), 1920, 0, 88.0), + "thumbnail": (("jpeg", "png8", "png", "webp", "webp-lossless", "avif"), + 512, 0, 85.0), + "original": (("jpeg", "png8", "png", "webp", "webp-lossless", "avif"), + 0, 0, 95.0), + } + + def test_every_destination_matches_the_brief(self): + for name, (formats, max_dim, cap, ss2) in self.EXPECTED.items(): + with self.subTest(destination=name): + d = dest.get(name) + self.assertEqual(d.formats, formats) + self.assertEqual(d.max_dimension, max_dim) + self.assertEqual(d.hard_cap, cap) + self.assertEqual(d.ss2_target, ss2) + + def test_the_five_are_the_ones_offered(self): + self.assertEqual(dest.names(), list(self.EXPECTED)) + + def test_the_default_is_the_web(self): + """Not a design tool. The old default silently refused WebP to everyone.""" + self.assertEqual(dest.DEFAULT, "web") + self.assertEqual(Settings().target, "web") + self.assertIn("webp", dest.formats_for(Settings().target)) + + def test_documents_never_offers_webp_or_avif(self): + formats = dest.formats_for("documents") + for lossy_modern in ("webp", "webp-lossless", "avif"): + self.assertNotIn(lossy_modern, formats) + + def test_documents_is_capped_at_4096(self): + self.assertEqual(dest.get("documents").hard_cap, 4096) + self.assertEqual(dest.get("documents").max_dimension, 4096) + + def test_only_documents_enforces_a_hard_cap(self): + capped = [d.name for d in dest.DESTINATIONS.values() if d.hard_cap] + self.assertEqual(capped, ["documents"]) + + def test_old_names_still_resolve(self): + """Scripts written against 2.6 keep working.""" + self.assertEqual(dest.resolve("figma"), "documents") + self.assertEqual(dest.resolve("archive"), "original") + self.assertEqual(dest.get("figma").formats, dest.get("documents").formats) + + def test_unknown_destination_is_rejected_not_guessed(self): + self.assertFalse(dest.exists("nowhere")) + with self.assertRaises(KeyError): + dest.get("nowhere") + + def test_hidden_destinations_are_reachable_but_not_offered(self): + self.assertIn("lossless", dest.DESTINATIONS) + self.assertNotIn("lossless", dest.names()) + for name in dest.formats_for("lossless"): + self.assertTrue(enc.ALL[name].lossless, f"{name} is not pixel-exact") class CompressTests(unittest.TestCase): @@ -138,13 +214,36 @@ def test_resize_caps_longest_edge(self): with Image.open(res.output) as out: self.assertEqual(max(out.size), 1000) - def test_figma_target_caps_at_4096_even_when_unlimited(self): + def test_documents_caps_at_4096_even_when_unlimited(self): + """Design tools rescale above this destructively, so asking for more is + not a request the destination can honour.""" path = self.src / "huge.png" sample((5000, 1200)).save(path) - res = compress_file(path, self.dst, Settings(max_dimension=0, **FAST)) + res = compress_file(path, self.dst, + Settings(target="documents", max_dimension=0, **FAST)) with Image.open(res.output) as out: self.assertLessEqual(max(out.size), 4096) + def test_the_cap_belongs_to_documents_and_not_to_everything(self): + """`original` means what it says. The 4096 ceiling was a Figma fact that + used to apply to the default and therefore to everyone.""" + path = self.src / "huge.png" + sample((5000, 1200)).save(path) + res = compress_file(path, self.dst, + Settings(target="original", max_dimension=0, **FAST)) + with Image.open(res.output) as out: + self.assertEqual(max(out.size), 5000) + + def test_documents_ships_no_webp_even_on_artwork_that_would_win_with_it(self): + path = self.src / "alpha.png" + img = Image.new("RGBA", (400, 400), (0, 0, 0, 0)) + ImageDraw.Draw(img).ellipse([40, 40, 360, 360], fill=(255, 0, 0, 255)) + img.save(path) + res = compress_file(path, self.dst, Settings(target="documents", **FAST)) + tried = {c[0] for c in res.candidates} + self.assertFalse(tried & {"webp", "webp-lossless", "avif"}) + self.assertIn(res.output.suffix, (".png", ".jpg")) + def test_transparency_survives(self): path = self.src / "alpha.png" img = Image.new("RGBA", (400, 400), (0, 0, 0, 0)) diff --git a/tests/web/README.md b/tests/web/README.md index 4a37d14..44737be 100644 --- a/tests/web/README.md +++ b/tests/web/README.md @@ -27,7 +27,7 @@ corrupt files fail gracefully; the console stays clean under the strict CSP. ``` node tests/web/setup_bench.mjs # once: builds bench/ from committed fixtures -node tests/web/bench.mjs mylabel # figma target, gated on snap-figma.json +node tests/web/bench.mjs mylabel # documents, gated on snap-documents.json BENCH_TARGET=web node tests/web/bench.mjs mylabel node tests/web/make_batch.mjs # builds batch/ (the 4 fixtures × 6) BENCH_DIR=batch SNAP=batch node tests/web/bench.mjs mylabel diff --git a/tests/web/bench.mjs b/tests/web/bench.mjs index 37d6528..63fdd4a 100644 --- a/tests/web/bench.mjs +++ b/tests/web/bench.mjs @@ -6,8 +6,8 @@ * may not move the results. * * node tests/web/setup_bench.mjs # once, to build bench/ - * node tests/web/bench.mjs