diff --git a/.gitattributes b/.gitattributes
new file mode 100644
index 0000000..5de00da
--- /dev/null
+++ b/.gitattributes
@@ -0,0 +1,23 @@
+# Let git decide what is text, and check text out with LF everywhere.
+#
+# This repo is written on Windows with core.autocrlf=true, which rewrites line
+# endings on checkout. That is harmless for files people edit and not harmless
+# for a file a tool regenerates and compares: `tools/gen_destinations.py`
+# emits LF, a CRLF working copy differs from it on every single line, and
+# `--check` reports "out of date" over a diff that looks empty because the only
+# difference is invisible. Pinning the whole tree removes the class of problem
+# rather than the one instance.
+* text=auto eol=lf
+
+# Binary, so git must not touch them at all. Corrupting a byte here would break
+# the snapshot gates in a way that looks like an engine regression.
+*.png binary
+*.jpg binary
+*.jpeg binary
+*.webp binary
+*.avif binary
+*.gif binary
+*.ico binary
+*.woff2 binary
+*.wasm binary
+*.rgb binary
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 340a44c..1d24bf4 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -52,6 +52,71 @@ 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
+ # NOT allowed to fail. This job runs on Linux, where AVIF is available,
+ # and the graceful skip that make_ss2_vectors.py performs on a Pillow
+ # without libavif is meant for a developer's Windows laptop - not here.
+ # If it were tolerated here, an install failure would drop twelve AVIF
+ # pairs, print VALIDATED, and show the same green tick with AVIF parity
+ # untested from then on. A weaker check that looks identical to a strong
+ # one is the thing this whole job exists to prevent.
+ 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 corpus must be the full one
+ # The count is asserted, not merely printed. Reporting a shortfall only
+ # helps somebody who reads a passing job's logs, which nobody does.
+ run: python tests/web/check_ss2_corpus.py --expect 60
+ - name: The JS port must match them
+ run: node tests/web/ss2_validate.mjs
+
+ generated:
+ # web/ has no build step and should not grow one, so the browser's
+ # destination table is generated from the Python reference and committed
+ # like source. Regenerating here and failing on a diff is what makes the
+ # committed file trustworthy: there is no copy to drift, only a file that
+ # is either current or a red build.
+ name: generated files are current
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v4
+ - uses: actions/setup-python@v5
+ with:
+ python-version: "3.12"
+ - run: python tools/gen_destinations.py --check
+ # The desktop app's copy of the design system. Same reasoning: the file
+ # people edit is in web/, the copy is committed so a pip install needs no
+ # build, and a stale copy is a red build rather than two visual identities.
+ - run: python tools/sync_webui_assets.py --check
+ # The comparison page. Generated from tests/benchmark.json so the page
+ # cannot claim one thing while the measurement says another.
+ - run: python tools/gen_compare_page.py --check
+
lint:
runs-on: ubuntu-latest
steps:
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
new file mode 100644
index 0000000..5f69da0
--- /dev/null
+++ b/.github/workflows/release.yml
@@ -0,0 +1,474 @@
+name: Release
+
+# Builds the desktop application for the three platforms it can honestly be
+# built for, and refuses to publish one where any engine went quiet. See
+# docs/PACKAGING.md for why each of those two halves is shaped the way it is.
+
+on:
+ push:
+ tags: ["v*"]
+ workflow_dispatch:
+
+permissions:
+ contents: read
+
+concurrency:
+ group: release-${{ github.ref }}
+ cancel-in-progress: false
+
+jobs:
+ build:
+ name: ${{ matrix.label }}
+ runs-on: ${{ matrix.runner }}
+ # A release build compresses the benchmark corpus with the frozen binary,
+ # which means a real zopfli pass and a real SSIMULACRA 2 search over a 12 MP
+ # photograph. That took six minutes on a developer machine. The generous
+ # limit is also the thing that catches a frozen build re-launching itself
+ # instead of starting a worker - see the freeze_support() comment in
+ # imgcompress/__init__.py.
+ timeout-minutes: 60
+ strategy:
+ fail-fast: false
+ matrix:
+ include:
+ # Windows x64 and nothing else on Windows: neither zopflipy nor
+ # mozjpeg-lossless-optimization publishes a win_arm64 wheel, so a
+ # native arm64 build would report two of the four engines inactive and
+ # the gate below would reject it - correctly. Windows on ARM runs this
+ # x64 build under emulation, slower but complete.
+ - label: windows-x64
+ runner: windows-latest
+ expect_arch: AMD64
+ # macOS is built twice rather than once as a universal2 binary.
+ # mozjpeg-lossless-optimization ships x86_64 and arm64 wheels and no
+ # universal2 wheel at all, so a fat build would need an engine nobody
+ # publishes. Runner labels for Intel macOS are the most likely line in
+ # this file to rot; if the job cannot start, that is what to look at.
+ - label: macos-arm64
+ runner: macos-latest
+ expect_arch: arm64
+ - label: macos-x86_64
+ runner: macos-15-intel
+ expect_arch: x86_64
+
+ steps:
+ - uses: actions/checkout@v4
+
+ - uses: actions/setup-python@v5
+ with:
+ # Pinned. cp313 wheels exist for all four optional engines, and this
+ # is the interpreter the working environment uses; a release should
+ # not be the place where a new Python version gets its first outing.
+ python-version: "3.13"
+
+ - name: Install the application and the build tool
+ shell: bash
+ run: |
+ python -m pip install --upgrade pip
+ # Not an editable install. Building from the installed distribution
+ # means a missing entry in the package-data list in pyproject.toml
+ # fails here instead of shipping an application with no interface.
+ python -m pip install ".[full,app]" pyinstaller
+
+ - name: Read the version, and hold the tag to it
+ shell: bash
+ run: |
+ version=$(python -c "import imgcompress; print(imgcompress.__version__)")
+ echo "IMGCOMPRESS_VERSION=$version" >> "$GITHUB_ENV"
+ echo "version is $version"
+ if [ "${GITHUB_REF_TYPE}" = "tag" ]; then
+ tag="${GITHUB_REF_NAME#v}"
+ if [ "$tag" != "$version" ]; then
+ echo "Tag ${GITHUB_REF_NAME} does not match imgcompress $version."
+ echo "A release whose filenames disagree with the tag is worse than no release."
+ exit 1
+ fi
+ fi
+
+ - name: Decide whether this build can be signed
+ # Signing has to be decided before anything is named, so that an
+ # unsigned artifact cannot end up wearing a filename that implies
+ # otherwise. If the credentials are present, the signing steps run and a
+ # failure there fails the job - the one outcome that must never happen
+ # is quietly shipping an unsigned file under a signed name.
+ shell: bash
+ env:
+ MACOS_CERTIFICATE_P12: ${{ secrets.MACOS_CERTIFICATE_P12 }}
+ WINDOWS_SIGNING_CLIENT_ID: ${{ secrets.AZURE_CLIENT_ID }}
+ run: |
+ signed=false
+ if [ "$RUNNER_OS" = "macOS" ] && [ -n "$MACOS_CERTIFICATE_P12" ]; then
+ signed=true
+ fi
+ if [ "$RUNNER_OS" = "Windows" ] && [ -n "$WINDOWS_SIGNING_CLIENT_ID" ]; then
+ signed=true
+ fi
+ echo "SIGNED=$signed" >> "$GITHUB_ENV"
+ if [ "$signed" = "true" ]; then
+ echo "Signing credentials found; artifacts will be signed."
+ else
+ echo "No signing credentials; artifacts will be marked unsigned."
+ fi
+
+ - name: Build
+ shell: bash
+ run: pyinstaller --clean --noconfirm packaging/imgcompress.spec
+
+ - name: Locate the console command inside the build
+ shell: bash
+ run: |
+ if [ "$RUNNER_OS" = "Windows" ]; then
+ echo "IMGCOMPRESS_BIN=dist/imgcompress/imgcompress.exe" >> "$GITHUB_ENV"
+ else
+ echo "IMGCOMPRESS_BIN=dist/Image Compressor.app/Contents/MacOS/imgcompress" >> "$GITHUB_ENV"
+ fi
+
+ # ------------------------------------------------------------------- #
+ # the gate
+ # ------------------------------------------------------------------- #
+
+ - name: Every engine must be active in the built application
+ # This is the release gate, not a diagnostic. Every optional engine is
+ # imported inside `try: ... except Exception:` (imgcompress/encoders.py,
+ # imgcompress/quality.py), so a bundle that cannot load one does not
+ # crash - it reports the engine inactive and compresses with weaker
+ # built-ins. `--check` itself exits 0 either way, which is why the
+ # output is parsed rather than the exit status trusted.
+ #
+ # Observed: a build without the zopflipy.libs collection in the spec
+ # printed "[ ] zopfli (png recompression)" and exited 0. Every PNG in
+ # that build would have shipped about 10% larger, and nothing anywhere
+ # would have said so.
+ shell: bash
+ run: |
+ "$IMGCOMPRESS_BIN" --check | tee check.txt
+ python - <<'PY'
+ import pathlib
+ import re
+ import sys
+
+ report = pathlib.Path("check.txt").read_text(encoding="utf-8", errors="replace")
+ rows = re.findall(r"^\s*\[( |x)\]\s+(.+?)\s*$", report, re.M)
+
+ # A parser that can match nothing and pass is one of the four checks
+ # CONTRIBUTING.md lists as having been green while checking nothing.
+ # Both halves are asserted: that the report was understood at all, and
+ # that every engine named in it is present and active.
+ if not rows:
+ sys.exit(
+ "Could not find a single engine line in the output of --check. "
+ "Either the build is broken or report_capabilities() changed "
+ "its format; fix whichever it is, do not relax this."
+ )
+
+ wanted = ("imagequant", "zopfli", "mozjpeg", "ssimulacra2")
+ labels = [label for _mark, label in rows]
+ unreported = [name for name in wanted if not any(name in label for label in labels)]
+ inactive = [label for mark, label in rows if mark != "x"]
+
+ if unreported:
+ sys.exit(
+ f"--check no longer reports on {unreported}. An engine that is "
+ f"not reported cannot be gated. Reported: {labels}"
+ )
+ if inactive:
+ sys.exit(
+ f"These engines are inactive in the build: {inactive}. The "
+ "application would run and quietly produce larger files. See "
+ "docs/PACKAGING.md for what usually causes each one."
+ )
+ print(f"All {len(rows)} engines active.")
+ PY
+
+ - name: The built application must compress a real folder
+ # --check only proves the engines imported. This proves the application
+ # works, and it is the only step that exercises the process pool: a
+ # frozen build without multiprocessing.freeze_support() re-launches
+ # itself once per worker, and a single-image test never shows it because
+ # compress_tree takes a single-process path for one job.
+ #
+ # Paths here and below stay inside the workspace rather than using
+ # RUNNER_TEMP, which on a Windows runner is a backslash path that these
+ # `shell: bash` steps would have to keep converting.
+ shell: bash
+ run: |
+ expected=$(find tests/bench_corpus -type f | wc -l)
+ "$IMGCOMPRESS_BIN" tests/bench_corpus -o smoke-out
+ produced=$(find smoke-out -type f | wc -l)
+ echo "in $expected, out $produced"
+ if [ "$produced" -ne "$expected" ]; then
+ echo "The built application wrote $produced files for $expected images."
+ exit 1
+ fi
+
+ - name: The artifact must be for the architecture it claims
+ # Finding out which architecture a release was built for by reading the
+ # runner label is how a universal2 or arm64-Windows build gets published
+ # under a name nobody can install.
+ shell: bash
+ run: |
+ if [ "$RUNNER_OS" = "macOS" ]; then
+ got=$(lipo -archs "$IMGCOMPRESS_BIN")
+ else
+ got=$(python -c "import platform; print(platform.machine())")
+ fi
+ echo "expected ${{ matrix.expect_arch }}, got $got"
+ if [ "$got" != "${{ matrix.expect_arch }}" ]; then
+ echo "Architecture mismatch on runner ${{ matrix.runner }}."
+ exit 1
+ fi
+
+ # ------------------------------------------------------------------- #
+ # signing the payload
+ # ------------------------------------------------------------------- #
+
+ - name: Sign the macOS application
+ # NEVER RUN. There is no Apple Developer ID in this repository, so these
+ # commands have never executed and should be treated as a starting point
+ # rather than a working recipe. They are the standard sequence; expect to
+ # debug the keychain and the hardened runtime the first time through.
+ # packaging/README.md says what has to be bought and created first.
+ if: ${{ runner.os == 'macOS' && env.SIGNED == 'true' }}
+ shell: bash
+ env:
+ MACOS_CERTIFICATE_P12: ${{ secrets.MACOS_CERTIFICATE_P12 }}
+ MACOS_CERTIFICATE_PASSWORD: ${{ secrets.MACOS_CERTIFICATE_PASSWORD }}
+ MACOS_SIGNING_IDENTITY: ${{ secrets.MACOS_SIGNING_IDENTITY }}
+ run: |
+ keychain="$RUNNER_TEMP/release.keychain-db"
+ password=$(python -c "import secrets; print(secrets.token_urlsafe(24))")
+ security create-keychain -p "$password" "$keychain"
+ security set-keychain-settings -lut 3600 "$keychain"
+ security unlock-keychain -p "$password" "$keychain"
+ echo "$MACOS_CERTIFICATE_P12" | base64 --decode > "$RUNNER_TEMP/certificate.p12"
+ security import "$RUNNER_TEMP/certificate.p12" -k "$keychain" \
+ -P "$MACOS_CERTIFICATE_PASSWORD" -T /usr/bin/codesign
+ security set-key-partition-list -S apple-tool:,apple:,codesign: \
+ -s -k "$password" "$keychain"
+ security list-keychains -d user -s "$keychain"
+ rm -f "$RUNNER_TEMP/certificate.p12"
+ # --deep is needed because the bundle carries every Python extension
+ # module as its own Mach-O file. --options runtime is what makes the
+ # app eligible for notarisation. If notarisation later complains about
+ # executable memory, add an entitlements plist and point the spec at
+ # it with IMGCOMPRESS_ENTITLEMENTS rather than dropping the hardened
+ # runtime.
+ codesign --force --deep --timestamp --options runtime \
+ --sign "$MACOS_SIGNING_IDENTITY" "dist/Image Compressor.app"
+ codesign --verify --deep --strict --verbose=2 "dist/Image Compressor.app"
+
+ - name: Sign the Windows executables
+ # NEVER RUN. Since June 2023 an OV code-signing key has to live on
+ # FIPS 140-2 Level 2 hardware, so there is no .pfx that can go in a
+ # repository secret and no way to finish this without the owner's own
+ # signing account. What is written here is one of the possible routes -
+ # a key held in Azure Key Vault, driven by AzureSignTool, which is what
+ # the secret names below describe. packaging/README.md lists the
+ # alternatives and what each one costs.
+ if: ${{ runner.os == 'Windows' && env.SIGNED == 'true' }}
+ shell: pwsh
+ env:
+ AZURE_TENANT_ID: ${{ secrets.AZURE_TENANT_ID }}
+ AZURE_CLIENT_ID: ${{ secrets.AZURE_CLIENT_ID }}
+ AZURE_CLIENT_SECRET: ${{ secrets.AZURE_CLIENT_SECRET }}
+ AZURE_KEY_VAULT_URL: ${{ secrets.AZURE_KEY_VAULT_URL }}
+ AZURE_KEY_VAULT_CERTIFICATE: ${{ secrets.AZURE_KEY_VAULT_CERTIFICATE }}
+ run: |
+ dotnet tool install --global AzureSignTool
+ # Both executables, not just the windowed one. The console command is
+ # what people are told to run to check their install, and an unsigned
+ # binary next to a signed one is the kind of detail an enterprise
+ # deployment tool notices and a human does not.
+ AzureSignTool sign `
+ --azure-key-vault-url "$env:AZURE_KEY_VAULT_URL" `
+ --azure-key-vault-tenant-id "$env:AZURE_TENANT_ID" `
+ --azure-key-vault-client-id "$env:AZURE_CLIENT_ID" `
+ --azure-key-vault-client-secret "$env:AZURE_CLIENT_SECRET" `
+ --azure-key-vault-certificate "$env:AZURE_KEY_VAULT_CERTIFICATE" `
+ --timestamp-rfc3161 "http://timestamp.digicert.com" `
+ --file-digest sha256 `
+ "dist\imgcompress\imgcompress.exe" "dist\imgcompress\imgcompress-gui.exe"
+
+ # ------------------------------------------------------------------- #
+ # wrapping it up for a human
+ # ------------------------------------------------------------------- #
+
+ - name: Name the artifact
+ shell: bash
+ run: |
+ suffix=""
+ if [ "$SIGNED" != "true" ]; then
+ suffix="-unsigned"
+ fi
+ echo "ARTIFACT_NAME=imgcompress-${IMGCOMPRESS_VERSION}-${{ matrix.label }}${suffix}" \
+ >> "$GITHUB_ENV"
+
+ - name: Build the Windows installer
+ if: runner.os == 'Windows'
+ shell: bash
+ run: |
+ # Installed explicitly and then called by absolute path. Chocolatey
+ # edits the machine PATH, which the already-running job does not
+ # re-read, so `iscc` would not be found in this step however the
+ # runner image happens to be provisioned. ArchitecturesAllowed below
+ # needs Inno Setup 6.3 or newer, which is the other reason not to rely
+ # on whatever version the image ships.
+ choco install innosetup --no-progress -y
+ ISCC="/c/Program Files (x86)/Inno Setup 6/ISCC.exe"
+ test -x "$ISCC" || { echo "Inno Setup did not install at $ISCC"; exit 1; }
+ mkdir -p installer
+ # Written here rather than committed because every value in it either
+ # comes from the build (the version, the output name) or is a
+ # restatement of the layout the spec produced. AppId is the exception:
+ # it is how Windows recognises one version of this program as an
+ # upgrade of another, so it must never change.
+ cat > imgcompress.iss <<'ISS'
+ [Setup]
+ AppId={{4D2B8C1A-96F1-4C7E-9A5D-2E7B1F0A6C33}
+ AppName=Image Compressor
+ AppVersion={#AppVersion}
+ AppPublisher=HeyOz
+ AppPublisherURL=https://github.com/SyedSaribSultan/imgcompress
+ DefaultDirName={autopf}\Image Compressor
+ DefaultGroupName=Image Compressor
+ UninstallDisplayIcon={app}\imgcompress-gui.exe
+ OutputDir={#OutDir}
+ OutputBaseFilename={#OutName}
+ Compression=lzma2/max
+ SolidCompression=yes
+ ArchitecturesAllowed=x64compatible
+ ArchitecturesInstallIn64BitMode=x64compatible
+ ; Installs per-user so there is no elevation prompt. An unsigned
+ ; installer asking for administrator rights is the single most
+ ; alarming thing this project could put in front of a designer.
+ PrivilegesRequired=lowest
+ WizardStyle=modern
+
+ [Files]
+ Source: "{#Payload}\*"; DestDir: "{app}"; Flags: recursesubdirs createallsubdirs ignoreversion
+
+ [Icons]
+ Name: "{group}\Image Compressor"; Filename: "{app}\imgcompress-gui.exe"
+ Name: "{autodesktop}\Image Compressor"; Filename: "{app}\imgcompress-gui.exe"; Tasks: desktopicon
+
+ [Tasks]
+ Name: "desktopicon"; Description: "Create a desktop shortcut"; GroupDescription: "Shortcuts:"
+
+ [Run]
+ Filename: "{app}\imgcompress-gui.exe"; Description: "Open Image Compressor"; Flags: nowait postinstall skipifsilent
+ ISS
+ "$ISCC" \
+ "/DAppVersion=$IMGCOMPRESS_VERSION" \
+ "/DOutDir=$(cygpath -w "$PWD/installer")" \
+ "/DOutName=${ARTIFACT_NAME}-setup" \
+ "/DPayload=$(cygpath -w "$PWD/dist/imgcompress")" \
+ "$(cygpath -w "$PWD/imgcompress.iss")"
+ ls -la installer
+
+ - name: Build the macOS disk image
+ if: runner.os == 'macOS'
+ shell: bash
+ run: |
+ mkdir -p installer dmg-staging
+ cp -R "dist/Image Compressor.app" dmg-staging/
+ ln -s /Applications dmg-staging/Applications
+ hdiutil create \
+ -volname "Image Compressor" \
+ -srcfolder dmg-staging \
+ -ov -format UDZO \
+ "installer/${ARTIFACT_NAME}.dmg"
+
+ - name: Notarise and staple the disk image
+ # NEVER RUN, for the same reason as the signing step above. notarytool
+ # replaced altool; the wait is not optional, because a disk image that
+ # has been submitted but not stapled still shows the user a warning.
+ if: ${{ runner.os == 'macOS' && env.SIGNED == 'true' }}
+ shell: bash
+ env:
+ APPLE_ID: ${{ secrets.APPLE_ID }}
+ APPLE_APP_PASSWORD: ${{ secrets.APPLE_APP_PASSWORD }}
+ APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
+ run: |
+ dmg="installer/${ARTIFACT_NAME}.dmg"
+ xcrun notarytool submit "$dmg" \
+ --apple-id "$APPLE_ID" \
+ --password "$APPLE_APP_PASSWORD" \
+ --team-id "$APPLE_TEAM_ID" \
+ --wait
+ xcrun stapler staple "$dmg"
+ spctl --assess --type open --context context:primary-signature -vv "$dmg"
+
+ - name: Sign the Windows installer
+ # NEVER RUN. The installer is signed after Inno Setup writes it, because
+ # signing the payload does not sign the wrapper, and SmartScreen judges
+ # the wrapper.
+ if: ${{ runner.os == 'Windows' && env.SIGNED == 'true' }}
+ shell: pwsh
+ env:
+ AZURE_TENANT_ID: ${{ secrets.AZURE_TENANT_ID }}
+ AZURE_CLIENT_ID: ${{ secrets.AZURE_CLIENT_ID }}
+ AZURE_CLIENT_SECRET: ${{ secrets.AZURE_CLIENT_SECRET }}
+ AZURE_KEY_VAULT_URL: ${{ secrets.AZURE_KEY_VAULT_URL }}
+ AZURE_KEY_VAULT_CERTIFICATE: ${{ secrets.AZURE_KEY_VAULT_CERTIFICATE }}
+ run: |
+ AzureSignTool sign `
+ --azure-key-vault-url "$env:AZURE_KEY_VAULT_URL" `
+ --azure-key-vault-tenant-id "$env:AZURE_TENANT_ID" `
+ --azure-key-vault-client-id "$env:AZURE_CLIENT_ID" `
+ --azure-key-vault-client-secret "$env:AZURE_CLIENT_SECRET" `
+ --azure-key-vault-certificate "$env:AZURE_KEY_VAULT_CERTIFICATE" `
+ --timestamp-rfc3161 "http://timestamp.digicert.com" `
+ --file-digest sha256 `
+ "installer\$env:ARTIFACT_NAME-setup.exe"
+
+ - name: Record what the build actually contains
+ # A release with three artifacts and no record of what was in them is a
+ # release nobody can debug six months later.
+ shell: bash
+ run: |
+ {
+ echo "### ${{ matrix.label }}"
+ echo
+ echo "- version: \`$IMGCOMPRESS_VERSION\`"
+ echo "- runner: \`${{ matrix.runner }}\`, architecture \`${{ matrix.expect_arch }}\`"
+ echo "- signed: \`$SIGNED\`"
+ echo
+ echo '```'
+ cat check.txt
+ echo '```'
+ } >> "$GITHUB_STEP_SUMMARY"
+
+ - uses: actions/upload-artifact@v4
+ with:
+ name: ${{ env.ARTIFACT_NAME }}
+ path: installer/*
+ if-no-files-found: error
+
+ publish:
+ name: draft the release
+ needs: build
+ if: github.ref_type == 'tag'
+ runs-on: ubuntu-latest
+ permissions:
+ contents: write
+ steps:
+ - uses: actions/download-artifact@v4
+ with:
+ path: artifacts
+ merge-multiple: true
+
+ - name: Draft the release
+ # Always a draft, never published automatically. While signing is
+ # unsolved these files are named `-unsigned`, and the person who decides
+ # to hand an unsigned installer to somebody should be a person, not a
+ # workflow.
+ env:
+ GH_TOKEN: ${{ github.token }}
+ run: |
+ ls -la artifacts
+ gh release create "${GITHUB_REF_NAME}" \
+ --repo "${GITHUB_REPOSITORY}" \
+ --title "imgcompress ${GITHUB_REF_NAME}" \
+ --draft \
+ --generate-notes \
+ artifacts/*
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 2e5e80d..6cb76f9 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -3,6 +3,280 @@
All notable changes to this project are documented here.
This project follows [Semantic Versioning](https://semver.org/).
+## [2.7.0] - 2026-08-08
+
+### Changed
+- **One design system, and the desktop app is inside it.** There were two
+ interfaces and they looked like two products. `web/` rendered from a token
+ layer with an automated gate; the desktop app had its own palette baked into
+ the file — its own greys, its own brass, its own three corner radii, its own
+ two transition shorthands and its own system-font stack — and nothing checked
+ any of it. That is the real answer to "how do I get consistency": not a
+ component library, but one interface sitting outside the gate.
+
+ The token layer and the self-hosted faces are now copied into
+ `imgcompress/webui/` by `tools/sync_webui_assets.py` and committed, the same
+ pattern as `web/destinations.js`: no build step, and CI fails on a stale copy.
+ The desktop app's private palette is gone — every colour, corner, face and
+ spring comes from the shared tokens, and it shares the browser app's
+ `--app-*` alias names so the two are one product rather than two that happen
+ to share a name.
+- **Motion is enforced, not just available.** The token layer already shipped a
+ closed set (`--oz-duration-*`, `--oz-ease-*`, and the `--oz-spring-*` pairs);
+ what was missing was anything rejecting a value from outside it.
+ `verify_tokens.mjs` now fails on a hand-typed duration or easing curve,
+ `transition: all`, and any transition of a layout property.
+- **Three progress bars stopped animating `width`.** The batch hairline, the
+ per-row hairline and the version-chip meter all transitioned `width`, which
+ makes the browser recompute layout on every frame of every bar. They now
+ scale a `transform`, which is composited and cannot reflow anything. The
+ fraction arrives as a unitless `--p` instead of a percentage.
+- **`prefers-reduced-motion` is handled once**, in the token layer, for both
+ interfaces. The desktop app's own blanket `transition-duration: .01ms
+ !important` is gone: the shared version collapses spatial travel and takes
+ the overshoot off the springs while leaving fades alone, and a fade is often
+ the thing carrying the meaning.
+
+### Fixed
+- **The desktop app labelled a rejected version as the winner.** Its versions
+ list badged `Math.min(bytes)` — the smallest candidate — rather than the one
+ that actually shipped, and hid that candidate's score behind the badge. On a
+ real photograph it read `webp 229.6 KB WINNER` while the file it wrote was
+ `webp-lossless` at 344.1 KB, with no way to see that WebP had scored 87
+ against a target of 90. This is precisely the bug `core.py` fixed in the
+ engine, reappearing in the picture of it. The badge now follows the shipped
+ format, every version shows how close it came, and each one carries the same
+ one-sentence reason the browser app gained in the vocabulary pass.
+- **The desktop app was one 403 away from rendering in Times New Roman.** A
+ `` and a `url()` inside a stylesheet cannot carry the query string the
+ page was opened with, so the token check refused the app's own stylesheets and
+ Chrome dropped them for having a JSON MIME type. Static assets under
+ `/webui/` are now served before the token check — they are files shipped in
+ the package with no user data in them, the loopback-Host check still applies,
+ and the token still gates every API route and every image. Found by the new
+ runtime gate on its first run; every static check was green throughout.
+- Faces are served as `font/woff2`. `mimetypes` has no woff2 entry on a stock
+ Windows Python, so they went out as `application/octet-stream`.
+- The desktop app has the product's icon. Without one linked the browser asked
+ for `/favicon.ico`, which answered 403 — one console error on every launch,
+ saying nothing useful.
+- `Now` became `New size` in the browser app's result panel — a vocabulary-pass
+ miss, caught by looking at a screenshot rather than at the code.
+
+### Added
+- **`tests/web/verify_desktop.mjs`** — the desktop app in real Chrome: the
+ shared stylesheets arrive with a CSS type, the faces arrive as `font/woff2`,
+ the tokens resolve to real values, six faces register, nothing renders above
+ 600, the private palette is undefined, and no request leaves the machine. The
+ static gate can only prove the app *references* the token layer; this proves
+ the browser receives it.
+- **`tests/test_design_system.py`** — 22 tests covering everything reachable
+ without a browser: the copies are current, the copy tool fails on an edited,
+ missing or CRLF copy, the face URLs are rewritten for `/webui/` while the
+ source is left alone, the desktop app declares no palette of its own, and
+ neither app layer transitions a layout property.
+- **`probe_a11y.mjs` and `probe_mobile.mjs` can now fail.** Both printed
+ measurements and exited 0 whatever they said, which made them reports rather
+ than tests — running them and seeing no errors carried almost no information,
+ and it blocked Phase 4, whose criteria they are supposed to enforce.
+ `probe_mobile` now measures at **375px**, not 390.
+- `tests/web/shoot_both.mjs`, which screenshots both interfaces in both themes,
+ so "recognisably the same product" is something you can look at.
+
+- **The interface speaks English.** Eleven invented words for three ideas meant
+ it was possible to look at this product and not know what it was telling you.
+ One concept now gets one word everywhere a person can see it — the browser
+ app, the desktop app, the command line, every error message and the README:
+
+ | Was | Is |
+ | --- | --- |
+ | bake-off | the comparison |
+ | candidate | version |
+ | floor / quality floor | your target / minimum visual match |
+ | passes, still passes | close enough to the original |
+ | survives | wins |
+ | untouched | left exactly as it is |
+ | force a format | always use |
+ | redo just this image | try different settings |
+ | SSIMULACRA 2 82.8 | visual match 83 out of 100 |
+
+ The measure's real name moved into the details panel, where it belongs: which
+ measure produced the number is a fact about our implementation, and how close
+ the result came is the fact somebody is actually here for. The SSIM fallback
+ keeps its name, because that scale runs 0–1 and calling it the same thing
+ would mislead.
+- **What the tool does is described as a benefit, not as machinery.** "Every
+ image is encoded several different ways, scored against the original with a
+ perceptual metric, and only the smallest version that still passes survives"
+ became "every image comes out as small as it can go without you being able to
+ see the difference — and you get the side-by-side to check that for
+ yourself."
+- **Every version that lost now says why**, in one sentence: bigger than your
+ original, too different from it (with both numbers), lost too much colour
+ detail, or close enough but larger than the one chosen. A list of rejects
+ with no reasons showed the machinery working without saying anything. The
+ sentence for whichever version is on screen is shown under the row rather
+ than hidden in a tooltip.
+- **Error messages say what happened, then what to do next.** No apology, no
+ blame, no error code as the headline. "Error: unsupported format" became
+ "Those file types aren't supported yet. Try PNG, JPEG, WebP, AVIF, GIF, BMP
+ or TIFF."
+- **"How this was measured" is written for a person.** It explains that the
+ comparison looks at local contrast and detail the way eyes do rather than
+ counting pixel differences, and that 100 means indistinguishable — and it now
+ carries the fact that makes this tool beat the obvious alternative: colour is
+ never thrown away, because matching the same quality with colour detail
+ discarded needed setting 97 instead of 76, a file 3.8× larger.
+
+ Zero output bytes changed; both byte snapshots are identical.
+
+- **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
+ JPEG or PNG and nothing else, 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 | 2560px, ceiling 4096px | 90 |
+ | `email` | JPEG / PNG only | 1920px | 88 |
+ | `thumbnail` | all | 512px | 80 |
+ | `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."
+- **The browser's destination table is generated, not maintained.**
+ `tools/gen_destinations.py` writes `web/destinations.js` from
+ `imgcompress/destinations.py`; `worker.js` imports it, `index.html` loads it
+ before `app.js`, and the Format control's options are rendered from it rather
+ than typed into the markup. The generated file is committed, because `web/`
+ has no build step and should not grow one — CI regenerates it and fails on
+ any difference, so the commit is the check. Testing copies catches drift
+ afterwards; not having copies prevents it.
+- **A parity test for the destination table**, `tests/test_destination_parity.py`.
+ The table now exists in Python, in `worker.js`, in `app.js` and in the
+ markup, and nothing checked that they agreed — the same hazard `ss2.js` had
+ before the CI job above, and it bit immediately: `app.js` was already
+ claiming 4096px for `documents` and quality 85 for `thumbnail` while Python
+ said 2560 and 80, so every browser compression would have used numbers the
+ reference had already rejected, silently. Now that the copies are generated,
+ the test guards the generator instead: the committed file must be current,
+ and no consumer may hand-write a destination's name, frame size or format
+ list. It found one more copy while being written — `app.js` restated the
+ default destination's numbers in its initial state, where a stale value would
+ have been wrong for exactly the people arriving for the first time.
+- **`tests/web/check_ss2_corpus.py`**, wired into CI. `make_ss2_vectors.py`
+ skips AVIF where Pillow cannot write it, which is right on a Windows laptop
+ and wrong in CI: a failed plugin install would run 48 vectors instead of 60,
+ print VALIDATED, and show the same green tick with AVIF parity untested from
+ then on. The plugin install is no longer allowed to fail, and the vector
+ count and codec coverage are asserted rather than merely reported.
+- Tests pinning every destination's formats, size cap and minimum visual
+ match, that only `documents` enforces a ceiling, that an explicit `-m 8000`
+ is clamped to 4096 rather than refused, that a smaller request is never
+ inflated, 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. The Python suite goes from 24 tests to 64; the browser
+ suite from 72 assertions to 76.
+
+### 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`, at the real defaults — it takes the destination's
+ own frame rather than a pinned one, which it can do because `documents` and
+ `web` agree on 2560 and the format list is genuinely the only difference.
+- **`--preset thumbnail` changed** from 800px to 512px. The quality target
+ stays at 80. Nothing in the history records why 800 was chosen — it arrived
+ in the initial import — so 512 is the change that can be argued for and the
+ target was left alone: artefacts are *less* visible at a smaller size, so if
+ anything it could fall, and raising it would have been a second change with
+ no reason behind it.
+
+- **A rule, in CONTRIBUTING.md: every new gate must be observed failing.**
+ Four checks on this branch reported success while checking nothing — a
+ snapshot with a hand-pinned frame, an AVIF skip that still printed
+ `VALIDATED`, parser-based assertions that could match zero lines, and a
+ `diff` against a file the job had not written yet. Two were caught in review
+ and one by a file timestamp, which is not a process. Breaking a gate and
+ watching it go red costs a minute and is the only thing separating it from a
+ comment.
+- **`tests/test_corpus_guard.py`**, because `check_ss2_corpus.py` was itself
+ only verified by hand — the same posture `ss2_validate.mjs` was in before it
+ was wired into CI. Nine tests, including the argparse bug it shipped with:
+ `action="append"` adds to a list default rather than replacing it, so
+ `--require-codec jpeg` meant "jpeg *and* the three defaults" and the
+ narrowing path had never run.
+
+### Fixed since
+- **The clamp announces itself.** `-m 8000 --for documents` printed
+ `up to 8000px` and produced 4096 — a dimension changing without saying so,
+ which is the defect this whole rework exists to remove, surviving on the
+ override path because that path is rarer. The rule now lives in one function,
+ `destinations.effective_limit`, which both the engine and the CLI header
+ call, so they cannot disagree. The header states the real limit and, when it
+ differs from the request, says which destination clamped it and why.
+
+### A bug this branch introduced and then removed
+Recorded because the shape of it is worth remembering, not because it shipped.
+
+`documents` briefly carried **one** size number where the old `figma` preset
+had two. `figma` downscaled to 2560 and separately clamped at 4096 — the clamp
+being the thing that fires when somebody explicitly asks for more, which is why
+the original code described it as applying *regardless*. Collapsing them handed
+the ceiling over as the everyday setting, so every design-asset compression
+would have shipped roughly 2.5× the pixels it should, and downscaling saves
+more than the encoder does.
+
+`bench.mjs` caught the resulting byte change immediately, and it was
+misdiagnosed as a test-isolation problem: the fix applied was to pin the frame
+size so the comparison stayed clean. That was a correct testing instinct
+reached for at the wrong moment. It isolated the variable and certified a
+configuration no user would ever run — a green gate over a setting that does
+not exist, which is worse than a red one. The pin is gone and the two numbers
+are back to doing two jobs.
+
## [2.6.0] - 2026-08-07
### Changed
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index 55ff12e..c955a5f 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -29,6 +29,83 @@ A smaller file at a lower score isn't an improvement, it's a different setting.
And don't validate a change to the metric using that same metric — that's
circular, and it is exactly the mistake that made version 1 look fine.
+## Every new gate must be observed failing
+
+A test, check or CI job that has never been seen to go red is a guess about
+whether it measures anything. Before you open the PR: break the thing it
+watches, watch it fail, restore, and **say so in the commit message** — what
+you broke and what it said.
+
+This is not hypothetical bookkeeping. Four checks on one branch reported
+success while checking nothing:
+
+| The check | Why it was green | Caught by |
+| --- | --- | --- |
+| A byte-comparison snapshot | The frame size had been pinned by hand, so it certified a configuration no user would ever run | Review |
+| The AVIF corpus skip | A failed plugin install dropped 12 vectors and still printed `VALIDATED` | Review |
+| A parser-based parity test | Every regex could match nothing and pass | Writing this rule |
+| A `diff` against a regenerated file | The job had not written the file yet, so it compared it to itself | A file mtime |
+
+Two were found in review and one by luck. Watching a gate fail once costs a
+minute and is the only thing that distinguishes it from a comment.
+
+The same rule applies to guards *about* guards. `tests/test_corpus_guard.py`
+exists because `check_ss2_corpus.py` was itself only verified by hand.
+
+## Generated files
+
+Two things are generated from a source of truth and committed, because neither
+`web/` nor a pip install has a build step and neither should grow one:
+
+```bash
+python tools/gen_destinations.py --check # web/destinations.js
+python tools/sync_webui_assets.py --check # the desktop app's design system
+```
+
+Drop `--check` to rewrite them. **Never edit the outputs.** Change the source
+and re-run; CI runs both with `--check` and fails on a stale copy.
+
+| Output | Source |
+| --- | --- |
+| `web/destinations.js` | `imgcompress/destinations.py` |
+| `imgcompress/webui/heyoz-tokens.css` | `web/heyoz-tokens.css` |
+| `imgcompress/webui/fonts.css` + `fonts/` | `web/fonts.css` + `web/fonts/` |
+| `imgcompress/webui/favicon.svg` | `web/favicon.svg` |
+
+If you find yourself typing a destination's name, a frame size, a colour or a
+corner radius into a second file, that is the mistake these exist to prevent —
+the previous hand-written copy of the destination table drifted from its
+reference within an hour of being created.
+
+## One design system, and one set of motion values
+
+Both interfaces render from `web/heyoz-tokens.css`. The desktop app gets a
+committed copy of it; nothing in either app declares a colour, a corner or a
+duration of its own.
+
+```bash
+node tests/web/verify_tokens.mjs # static: both app layers, colour + motion
+node tests/web/verify_desktop.mjs # runtime: the desktop app in real Chrome
+node tests/web/shoot_both.mjs # screenshots, both apps, both themes
+```
+
+`verify_tokens.mjs` fails on a hand-typed colour, a hand-typed duration or
+easing curve, `transition: all`, and — the one that costs users something real
+— **any transition of a layout property**. `width`, `height`, `top`, `left`,
+`margin`, `padding` and `inset` all force the browser to recompute layout on
+every frame; `transform` and `opacity` are composited and cannot. Three
+progress bars in this app animated `width` before that rule existed.
+
+Use the values the system already ships: `--oz-duration-*`, `--oz-ease-*`, and
+the `--oz-spring-{effects,spatial}-{fast,default,slow}` pairs. Do not add a
+second motion vocabulary — `--oz-ease-exit` already exists, and redefining it
+would silently change every exit animation in the product.
+
+`prefers-reduced-motion` is handled once, in the token layer, for both
+interfaces. It collapses spatial travel and takes the overshoot off the springs
+while leaving fades alone, because a fade is often the thing carrying the
+meaning. Do not re-handle it per component or per app.
+
## Ground rules
- **Pip-installable dependencies only.** No shelling out to `cwebp`, `pngquant`
@@ -37,6 +114,9 @@ circular, and it is exactly the mistake that made version 1 look fine.
- **Optional engines must degrade, not crash.** Guard imports and fall back.
- **New behaviour needs a test**, especially the awkward cases: transparency,
CMYK, animated GIFs, corrupt files, extreme aspect ratios.
+- **Inherited values have no recorded reason.** The repository landed in a
+ single initial commit, so nothing before it has a documented rationale. If
+ you change one, write down why — you are the first person who can.
- Run `ruff check .` before opening a PR.
## Reporting a bug
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 `