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 `` 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..0219e2b 100644 --- a/README.md +++ b/README.md @@ -5,10 +5,14 @@ **Image compression that proves it didn't ruin your image.** -Most compressors ask you to pick a quality number and hope. This one encodes -each image several different ways, decodes every candidate back, scores it -against the original with a real perceptual metric, and keeps the smallest file -that still clears the quality floor you set. Then it shows you the evidence. +Most compressors ask you to pick a quality number and hope. This one saves each +image several different ways, opens every version back up, compares it to your +original, and keeps the smallest one that still looks close enough. Then it +shows you the evidence. + +Put plainly: 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. Typical result on design assets: **70–90% smaller**, at a measured visually-lossless quality level. @@ -22,16 +26,16 @@ imgcompress photos/ # or the command line ``` Or skip the install: **[imgcompress-app.vercel.app](https://imgcompress-app.vercel.app)** -runs the same bake-off entirely in your browser — including SSIMULACRA 2 +runs the same comparison entirely in your browser — including SSIMULACRA 2 itself, ported to JavaScript and validated against the reference implementation. Nothing is uploaded; images never leave your device. [tests/BENCHMARK.md](tests/BENCHMARK.md) holds a reproducible head-to-head against single-format pipelines and fixed-quality defaults, every strategy -searched to the same SSIMULACRA 2 ≥ 90 floor. On that corpus imgcompress is +searched to the same visual match of 90 or better. On that corpus imgcompress is the smallest or tied-smallest passing file on every image; on the 12 MP photograph the browser version ships 362 KB where searched single-format -JPEG needs 517–544 KB — and every fixed-quality default fails the floor +JPEG needs 517–544 KB — and every fixed-quality default misses the target outright. The one caveat is spelled out there too: on one hard palette image the desktop's optional libimagequant quantizer beats the browser quantizer by ~2.5 KB. @@ -41,7 +45,7 @@ by ~2.5 KB. ## Two ideas, both load-bearing **1. Quality is measured, not guessed.** -Every candidate encode is decoded and scored with +Every version is opened back up and compared to the original with [SSIMULACRA 2](https://github.com/cloudinary/ssimulacra2) — the metric the image-compression community converged on, which correlates with human judgement at r≈0.88 versus SSIM's ≈0.76, and unlike SSIM can actually see chroma damage. @@ -49,10 +53,10 @@ The encoder quality setting is *found* by binary search, not assumed. Flat UI artwork survives a very low setting; a noisy photograph automatically gets a high one. -**2. The format is a bake-off, not an assumption.** -Each image is encoded as JPEG *and* palette PNG *and* lossless PNG (plus WebP if -you allow it), each searched independently, and the smallest passing result -wins. The winner is genuinely content-dependent: +**2. The format is a comparison, not an assumption.** +Each image is saved as JPEG *and* palette PNG *and* lossless PNG (plus WebP if +you allow it), each searched separately, and the smallest one that still looks +close enough wins. The winner is genuinely content-dependent: | Image | jpeg | png8 | png | webp | webp-lossless | winner | | --- | --- | --- | --- | --- | --- | --- | @@ -82,8 +86,8 @@ score. - **Split comparison** — drag the divider, or press Space to flip between original and compressed. Zoom to 100%, 200%, 400% and pan around. This is the point of the whole thing: you can *check*. -- **Candidates panel** — see every encoding that was tried and why the winner - won, then override the format or quality for that one image. +- **Versions panel** — see every version that was tried, why each one lost in a + single sentence, and switch to any of them instantly. - **Nothing is written until you press Save.** Review the whole batch, then save it or throw it away. - **Watch a folder** — point it at your Figma export folder and it compresses @@ -107,27 +111,45 @@ 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 | 2560px, hard ceiling 4096px | 90 | +| `email` | Attachments and chat | JPEG / PNG only | 1920px | 88 | +| `thumbnail` | Avatars, list icons, previews | all | 512px | 80 | +| `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 (100 = indistinguishable) | | `--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 -SSIMULACRA 2 runs to 100. The author's published scale: +The visual match runs to 100, where 100 means indistinguishable: | Value | Feels like | | --- | --- | @@ -139,30 +161,38 @@ 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 -more work than the encoder is. +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. + +`documents` carries two size numbers, doing two different jobs. **2560px** is +the everyday downscale, the same as `web` — memory pressure in these tools comes +from pixel dimensions more than from bytes, and no codec recovers what a 6000px +export wastes when it renders at 1200px. **4096px** is a ceiling, not a setting: +it clamps even an explicit `-m 8000`, because anything above it is downscaled +destructively on import with no control over the resampling, so the choice is +between our Lanczos and theirs. Asking for more is not refused, just quietly +brought down — the intent is reasonable, the destination simply cannot carry +it. + +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. --- @@ -197,8 +227,8 @@ with weaker built-ins. 1. **Caps the pixel dimensions.** The single biggest win; no codec recovers the bytes wasted on a 6000px export that renders at 1200px. 2. **Strips metadata** — EXIF, camera junk, colour profiles, XMP blobs. -3. **Runs the bake-off**, binary-searching each candidate format for the lowest - quality that still clears the perceptual floor. +3. **Runs the comparison**, searching each format for the smallest setting that + still looks close enough to the original. 4. **Keeps the smallest winner**, and never writes a file bigger than the source. Transparency is preserved, and scored against both a dark and a light backdrop @@ -218,7 +248,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 +259,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/docs/PACKAGING.md b/docs/PACKAGING.md new file mode 100644 index 0000000..9f15ccb --- /dev/null +++ b/docs/PACKAGING.md @@ -0,0 +1,255 @@ +# Packaging the desktop application + +This explains why the build is shaped the way it is. For the commands, see +[packaging/README.md](../packaging/README.md). + +The pip path is unchanged and stays unchanged. `pip install "imgcompress[full,app]"` +is the developer's install, the thing CI tests on three operating systems and two +Python versions, and the only supported way to work on the code. What is added +here is a second, parallel way to *ship* it, for the person the README is written +for: a designer on Windows who does not have Python and should not have to care. + +--- + +## The one fact that shapes everything else + +Every optional engine is imported like this +(`imgcompress/encoders.py`, `imgcompress/quality.py`): + +```python +try: + import zopfli as _zopfli + HAVE_ZOPFLI = True +except Exception: + HAVE_ZOPFLI = False +``` + +That guard is correct and should stay. It is what lets a plain +`pip install imgcompress` work on a machine with no wheels for anything, and +[CONTRIBUTING.md](../CONTRIBUTING.md) requires it of any new engine. + +But it means a frozen build that cannot load an extension module **does not +crash**. It starts, reports the engine inactive, and compresses every image with +weaker built-ins for the rest of the application's life. The palette quantizer +falls back to Pillow's, which on a UI screenshot reached a visual match of 87 in +a *larger* file than libimagequant's 90. PNGs come out about 10% bigger with no +zopfli. Nothing is reported anywhere, because from the code's point of view +nothing went wrong. + +There is no user-visible symptom. There is no crash log. There is only a product +that is quietly worse than the one that was tested. + +## So `--check` is a release gate, not a diagnostic + +`imgcompress --check` prints one line per engine and then `return 0` +(`imgcompress/cli.py`). The zero is right for a diagnostic — a machine without +zopfli is not in an error state — and useless for a release, so +`.github/workflows/release.yml` runs the frozen binary, captures the output, and +parses it. Any `[ ]` fails the build. + +This is not a theoretical risk. Removing one line from the spec and rebuilding +produced this, from a real Windows x64 bundle: + +``` +engines + [x] imagequant (pngquant engine) + [ ] zopfli (png recompression) + [x] mozjpeg (lossless jpeg pass) + [x] ssimulacra2 (perceptual metric) +``` + +Exit status: 0. That build would have shipped, installed, launched, compressed +images, and made every PNG about a tenth larger than it should be. + +The parser asserts two things, not one: that it understood the report at all, and +that all four engines are named in it. A regex that can match nothing and still +succeed is the third entry in the table of checks-that-checked-nothing in +CONTRIBUTING.md, and a `[ ]` scan over an empty string finds no problems. + +A second gate compresses `tests/bench_corpus` with the frozen binary. `--check` +only proves four modules imported; this proves the application works, and it is +the only step that starts a process pool. The comment in +`imgcompress/__init__.py` explains why that matters: a frozen build without +`multiprocessing.freeze_support()` re-launches itself once per worker, and no +single-image test can show it, because `compress_tree` takes a single-process +path when there is one job. + +--- + +## The four things that had to be collected by hand + +### `_cffi_backend`, for imagequant and mozjpeg + +Both are cffi *out-of-line API* modules. The Python side says +`from ._libimagequant import lib, ffi`, and the real `import _cffi_backend` +happens inside the compiled extension, in C, where PyInstaller's bytecode scanner +cannot see it. Neither package ships a PyInstaller hook, and PyInstaller has no +hook for cffi either. + +On Windows this has been working by accident for as long as anyone has tried it: +`pywebview` pulls in `pythonnet`, `pythonnet` imports `cffi`, `cffi` imports +`_cffi_backend`, and the module gets collected for a completely unrelated reason. +On macOS pywebview uses pyobjc and never touches cffi. Two of the four engines +would have died on macOS only — the platform where nobody would have thought to +look, because the Windows build was fine. + +Fixed by naming `_cffi_backend` in `hiddenimports`. + +### `zopflipy.libs`, for zopfli + +zopflipy's Windows wheel is repaired with delvewheel, so `zopfli/__init__.py` +opens with a generated patch: + +```python +if os.path.isdir(libs_dir := os.path.abspath( + os.path.join(os.path.dirname(__file__), os.pardir, 'zopflipy.libs'))): + os.add_dll_directory(libs_dir) +``` + +Frozen, that directory is not at that relative path. `os.path.isdir` returns +False, the patch silently does nothing, and `_zopfli.pyd` cannot resolve the +MSVCP140 DLL the wheel vendors under a hash-suffixed name. On a developer machine +with the Visual C++ redistributable installed it still fails, because the +extension's import table names `msvcp140-a4c2229b….dll` and `System32` has no +such file. + +What made this worth chasing rather than assuming: PyInstaller 6.22 *does* notice +the directory. It records `os.add_dll_directory` calls made while importing and +uses them as extra search paths, so it finds the DLL. It then places it in +`_internal/numpy.libs/`, because numpy vendors a DLL with the same name and +PyInstaller preserves the first directory structure it settled on. So the file is +in the bundle, `zopflipy.libs/` does not exist, the guard no-ops, and zopfli is +inactive — which is exactly the report quoted above. + +Fixed with PyInstaller's own `collect_delvewheel_libs_directory("zopfli", +"zopflipy.libs")`, which puts the DLLs where the package's own patch looks for +them. Only zopflipy needs it: imagequant and mozjpeg were not delvewheel-repaired, +and numpy and scipy are handled by hooks that ship with PyInstaller. + +### `scipy.ndimage`, for the quality metric + +`ssimulacra2` is pure Python and does `from scipy import ndimage` at module level. +It is reachable by the scanner, so it is named in `hiddenimports` for a different +reason: to make the cost visible in the file that pays it. See below. + +### pywebview, which came free + +`pywebview` registers a `pyinstaller40` entry point and ships a hook that collects +`webview/lib` and `webview/js`. Its backend selection uses real `import` statements +inside functions (`webview/guilib.py`), which PyInstaller's scanner does walk, so +the EdgeChromium and Cocoa backends are found without help. `pythonnet` ships a +hook directory too. Nothing to do, which is worth writing down so nobody adds +hidden imports for it later on the assumption that dynamic dispatch must need +them. + +--- + +## onedir, not onefile + +A onefile build is a self-extracting archive: every launch unpacks the whole +payload — around 150 MB — to a temporary directory before the application starts. +For a tool somebody opens to compress six images, that is a multi-second pause +every single time, for nothing. It is also the known-bad shape for macOS +notarisation, because what Apple signs and what actually executes are different +files. + +The application is already onedir-safe: `imgcompress/server.py` resolves its +assets as `Path(__file__).resolve().parent / "webui"`, which lands inside +`_internal/imgcompress/` in a onedir bundle, exactly where `collect_data_files` +puts them. Nothing in the application needed changing to be freezable, which is a +credit to it and not an accident — the `freeze_support()` call in +`imgcompress/__init__.py` was already there, with a comment about the fork bomb +it prevents. + +## Three builds, and why not fewer + +Verified against PyPI metadata for cp313: + +| Engine | macOS universal2 | macOS arm64 | macOS x86_64 | Windows x64 | Windows arm64 | +| --- | --- | --- | --- | --- | --- | +| imagequant 1.1.5 | yes | yes | yes | yes | **yes** | +| zopflipy 1.13 | **only** | no | no | yes | **no** | +| mozjpeg 1.3.2 | **no** | yes | yes | yes | **no** | +| ssimulacra2 0.3.0 | pure Python, `py3-none-any` | | | | | + +Read the two bold columns: + +- **A universal2 macOS build is impossible.** zopflipy ships *only* universal2 and + mozjpeg ships *no* universal2. One fat build would need somebody to `lipo` two + single-architecture mozjpeg extensions together by hand, per release. So macOS + is built twice, on its own runner each time, and each build gets the + architecture-specific mozjpeg wheel and one half of the fat zopfli wheel. +- **A Windows arm64 build can never be green.** Neither zopflipy nor mozjpeg + publishes a win_arm64 wheel, so two of the four engines would be inactive and + the gate would reject it — correctly. Windows on ARM runs the x64 build under + emulation, which is slower but complete. Shipping a native arm64 build that is + quietly worse than the emulated one would be the wrong trade. + +Python is pinned to 3.13 for the same kind of reason: it is the version with cp313 +wheels for all four engines and the version the working environment uses. A +release is the worst possible place for a new interpreter's first outing. + +## What scipy costs + +Measured in a clean Windows x64 environment with only the declared dependencies: + +| | Installed | In the bundle | +| --- | --- | --- | +| scipy + `scipy.libs` | 134 MB | 68 MB | +| everything else | 79 MB | 86 MB | +| **total** | **213 MB** | **154 MB** | + +scipy is 63% of the dependency install and 44% of what people download. It is +pulled in for **one call**: `ndimage.gaussian_filter`, in `ssimulacra2`'s own +implementation, invoked from the downsampling step of the metric. + +That is 68 MB of shipped bytes for one Gaussian blur. + +Three things could be done about it, and none of them are being done here: + +1. **Replace the call.** A separable Gaussian blur is a few lines of numpy, and + numpy is already a hard dependency. The problem is that the call is inside the + `ssimulacra2` package, not this one, so the fix is either a patch upstream or a + vendored copy of a metric implementation — and this project's central claim is + that its numbers match the reference implementation. Vendoring the reference + in order to shrink a download is the wrong side of that trade unless the + substitute blur is proven identical to the last decimal, on the whole + validation corpus, in both directions. +2. **Prune scipy in the spec.** Excluding `scipy.stats`, `scipy.sparse` and the + rest would recover most of the 68 MB. It is also exactly the kind of change + whose failure mode is an `ImportError` on somebody else's machine, months + later, on a code path the gate never touches. If anyone tries it, the gate to + add first is one that imports every module the metric touches, in the frozen + bundle, before the size is celebrated. +3. **Ship two builds**, one with the reference metric and one without. This + halves the download and doubles the number of things that can be wrong, and it + would mean shipping a build whose quality numbers are not comparable to the + ones in the README. Not worth it. + +Written down here so the next person does not have to rediscover that 68 MB +traces back to one line, and does not delete it in an afternoon without a gate +under it. + +## What the gate does not cover + +Being explicit about this, because a gate whose limits are unstated gets trusted +for more than it does. + +- **AVIF.** `AvifEncoder.available()` reports whether this Pillow can write AVIF, + but `capabilities()` does not include it, so `--check` never mentions it and the + gate cannot see it. A release that silently loses the AVIF encoder would be + green. Every destination that offers AVIF also offers WebP and JPEG, so the + consequence is a lost format rather than a failure — but it is unmeasured. +- **The window.** Nothing in CI opens `imgcompress-gui`. The gate exercises the + console command, which shares all of the compression code but none of the + pywebview path. A build where the window fails to open falls back to the + browser, prints a line saying so, and would pass every check here. +- **Signing.** Unsigned artifacts are the default and are named `-unsigned`. + See [packaging/README.md](../packaging/README.md) for what the owner has to buy + and why neither half can be automated from a repository secret. +- **`webui/favicon.svg` is not in the wheel.** `pyproject.toml` lists + `webui/*.html`, `webui/*.css` and `webui/fonts/*.woff2` as package data and no + `*.svg`, so the icon `app.html` links is missing from a pip install and + therefore from the bundle. Pre-existing, and only visible as a 404 and a + default tab icon. Noted here because building from the installed distribution + rather than the source tree is what made it visible at all. diff --git a/docs/figma-plugin-spike.md b/docs/figma-plugin-spike.md new file mode 100644 index 0000000..3801b31 --- /dev/null +++ b/docs/figma-plugin-spike.md @@ -0,0 +1,316 @@ +# The Figma plugin spike + +Roadmap phase 6.3. A fixed amount of time spent on one question: + +> Can the WebAssembly codecs load inside Figma's plugin sandbox? + +If yes, compress-on-export is a better distribution story than any install — the +tool arrives where the images already are, and nobody has to find a folder. If +no, we spent a spike and nothing else changes. + +**This is a spike.** Nothing in the shipping product depends on it. No CI job +runs it, no test imports it, and the probe under `spike/figma-probe/` is not part +of either interface. The honest outcome may be "not yet", and that outcome is +worth the same hour as a yes. + +--- + +## The verdict, up front + +**Technically yes, with one real risk left; and the product idea is not the one +we started with.** + +Three things came out of this that matter more than the wasm question: + +1. **Wasm will run.** It runs in the plugin's UI iframe, which is a normal + browser realm with `'unsafe-eval'`. There is no doubt left about compiling and + instantiating a module — the probe confirms it in the real sandbox in seconds. +2. **"Compress on export" cannot mean what it sounds like.** There is no export + hook in the plugin API. A plugin cannot sit behind Figma's Export panel. It + can only own its own export flow. +3. **This is two products, not one**, because `figma.createImage` takes PNG, JPEG + and GIF and nothing else. JPEG and PNG can go back into the document and + shrink the `.fig` file. WebP and AVIF can only ever leave as a download. + +The one open risk is memory, and it is not a small one. See +[The decisive unknown](#the-decisive-unknown-memory). + +--- + +## What is settled, and how firmly + +The distinction matters more than usual here, because a spike that quietly mixes +documentation with forum posts produces a plan nobody can audit later. + +### Documented by Figma + +| Claim | Consequence for us | +| --- | --- | +| WebAssembly runs in the plugin UI iframe | The codecs live in `ui.html`, never in the main plugin thread | +| The iframe CSP is `script-src 'unsafe-inline' 'unsafe-eval' figma.com` | `'unsafe-eval'` is what permits `WebAssembly.compile` / `instantiate` | +| Web Workers can be created from `blob:` and `data:` URLs | The port keeps its worker; this was broken and fixed in **Version 1 Update 76, August 2023** | +| `Uint8Array` is the one binary type that crosses `figma.ui.postMessage` | It is also exactly what `exportAsync` returns and `createImage` accepts, so no conversion layer is needed | +| `figma.on()` covers selection, page, document, drop and run | There is no export event. None. | +| `ExportSettingsImage` has no quality or compression parameter | This is precisely the gap this product fills | +| `figma.createImage` accepts PNG, JPEG and GIF only | WebP and AVIF cannot return to the document | +| The manifest takes one UI HTML file and one main JS file | Everything is inlined, or fetched from an absolute allow-listed URL | + +### Measured in this repository + +Codec payload, as the files sit in `web/vendor/` today: + +| File | Bytes | Share | +| --- | --- | --- | +| `avif_enc.wasm` | 3,485,872 | 82.1% | +| `webp_enc_simd.wasm` | 345,584 | 8.1% | +| `mozjpeg_enc.wasm` | 251,524 | 5.9% | +| `squoosh_oxipng_bg.wasm` | 164,172 | 3.9% | +| **total** | **4,247,152** | | + +Base64 costs a third on top, so a fully inlined single `ui.html` lands near +**6 MB**: 5,662,872 characters of encoded wasm, plus 124,957 bytes of codec glue, +plus `worker.js` (62,824), `ss2.js` (13,141) and `destinations.js` (2,902), plus +the plugin's own interface. + +Drop libaom and the same build is **about 1.2 MB**: 1,015,040 characters of +base64 for the three small codecs, and the same glue and worker on top. That one +decision is 82% of the payload. + +### Reported by developers, not documented + +| Claim | How to treat it | +| --- | --- | +| The plugin publish size ceiling is ~15 MB | **User-reported. Not in Figma's docs.** A 6 MB bundle is comfortable against it and a 1.2 MB bundle is not close to it, so nothing in the plan below leans on this number being exact. | +| A shipped wasm plugin has been observed hitting `RuntimeError: memory access out of bounds` inside Figma | Enough to take the memory question seriously. Not enough to predict where our wall is. | + +--- + +## Two facts that change what the product is + +### There is no export hook + +`figma.on()` has no export event, no before-export event, no +after-export event. A plugin cannot intercept the native Export panel and it +cannot post-process the panel's output. `exportAsync` is the only export path a +plugin controls. + +So **"compress on export" has to mean the plugin owns its own export flow** — its +own button, its own settings, its own download — not that it decorates Figma's. +That is a worse story than "your existing export just gets smaller", and it is +the story that is available. + +The probe turns this from a reading of the docs into something observed: it calls +`figma.on()` with `"export"`, `"beforeexport"` and `"exportcomplete"` alongside +two names that do exist, and prints whatever comes back. Run against a stubbed +sandbox the shape is already clear: + +``` +[main] figma.on("run"): accepted +[main] figma.on("selectionchange"): accepted +[main] figma.on("export"): refused - Unknown event type +``` + +A refusal from the real sandbox is the evidence. The two accepted names are the +control that makes a refusal mean something. + +### `figma.createImage` takes PNG, JPEG and GIF + +This splits the work cleanly in half, and the halves have different value. + +**Product A — shrink the document.** Read an image fill's own bytes with +`Image.getBytesAsync()`, compress them with mozjpeg or oxipng, hand the result to +`figma.createImage()` and re-point the fill. The `.fig` file gets smaller, every +export from it gets smaller, and the whole team downstream benefits without +installing anything. Constrained to JPEG and PNG — which is *the same constraint +the `documents` destination already lives under*, for the same reason. The +restriction we ship is a fact about the tool, and here it is again. + +**Product B — export to a download.** `exportAsync` a node, compress to WebP or +AVIF, and hand the bytes to the iframe to save. Modern formats, best byte counts, +and the result can never come back into the document. + +Two audiences, two flows, two sets of formats. Building both at once is how this +gets confusing; see [Sequencing](#sequencing). + +There is a bonus hiding in Product A. `Image.getBytesAsync()` is also the one +call that settles the unverified claim `GUIDE.md` hangs the whole `documents` +format policy on: import a WebP into Figma and read its bytes back. Bytes +starting `RIFF` mean Figma stored the original and the policy can be revisited. +Bytes starting with a PNG signature mean the policy is right and now proven. The +probe as built does **not** do this — it needs a document with an imported WebP in +it — but the scaffold is the vehicle for it, and it is a one-node addition. + +--- + +## The iframe is a null origin + +`window.origin` in the plugin iframe is literally `null`. That is not a detail, +it is a constraint that shapes every file: + +- **No relative `fetch`.** `fetch("vendor/avif_enc.wasm")` has nothing to resolve + against. +- **No relative `importScripts`.** Same reason. +- **The manifest takes one HTML file and one JS file.** There is no second file + to point at. + +So every byte is either inlined into `ui.html`, or fetched from an absolute +`https` URL on a domain listed in `networkAccess.allowedDomains`. And that server +**must** send `Access-Control-Allow-Origin: *` — a specific-origin value can +never match a `null` origin, so the usual careful CORS configuration is the one +that will not work. + +There is a second-order cost worth naming now: a lazy-fetched codec means the +plugin no longer runs offline, and it means the plugin's privacy story acquires a +footnote. The web app's claim is that images never leave the device. A plugin that +downloads a codec still never uploads an image, but it does make a network +request, and that is a sentence somebody has to write honestly on a listing page. + +--- + +## What the port would cost + +Small, and localised. `web/worker.js` already has the exact shape needed: it +loads codecs lazily, degrades to `false` when one is unavailable, and never +touches the network. Three relative-URL patterns have to change and nothing else +does. + +| Where | Today | In a plugin | +| --- | --- | --- | +| `web/worker.js:148-168` — `loadCodec()` | ``importScripts(`vendor/${script}`)`` and ``fetch(`vendor/${wasmFile}`)`` | `importScripts(blobUrl)` for the glue, base64 for the wasm | +| `web/worker.js:26` | `importScripts("ss2.js")` | inlined into the worker source | +| `web/worker.js:48` | `importScripts("destinations.js")` | inlined into the worker source | +| `web/app.js:286` | `new Worker("worker.js")` | `new Worker(URL.createObjectURL(blob))` | + +The probe checks the two mechanics this table depends on: that a worker spawns +from a `blob:` URL at all, and that a blob URL minted on the page can be pulled +into that worker with `importScripts` — which is the direct replacement for +`loadCodec`'s first line. + +`loadCodec`'s existing `try`/`catch`, which sets `CODECS[name] = false` and warns, +is already the right behaviour for a plugin where a codec might be absent for a +new reason. Nothing about the metric, the search, the ladders or the destination +policy changes. `ss2.js` is pure JavaScript and does not care where it runs. + +--- + +## The decisive unknown: memory + +Everything above is either documented or arithmetic. This is the part only a real +run answers, and it is the part that decides whether this ships. + +The plugin iframe would be asked to hold, at once: + +- a ~6 MB HTML parse (or ~1.2 MB, which is the argument for the smaller build) +- up to four instantiated wasm modules, each with its own linear memory +- full-resolution RGBA buffers for the image being worked on +- multi-megabyte `Uint8Array`s **copied, not transferred**, across the plugin + bridge — there is no transfer list on `figma.ui.postMessage`, so a 16 MB buffer + going one way means 16 MB allocated again on the other side + +— inside a browser tab that is already holding the user's Figma document. + +**The question is not "can wasm run".** It is: *does running out of memory kill +just the plugin, or the user's whole Figma tab, with unsaved work in it.* Those +two outcomes are separated by a support burden we would deserve. A crash that +loses somebody's afternoon is not a bug you fix in a patch release; it is a +reason not to have shipped. + +`RuntimeError: memory access out of bounds` has been observed in a shipped wasm +plugin inside Figma, so the failure mode is real rather than theoretical. What is +unknown is where our wall is and what happens when we hit it. + +--- + +## The probe + +`spike/figma-probe/` is the smallest plugin that answers the capability questions +from inside the real iframe. Its README covers running it and reading the output. +What it reports: + +- `typeof WebAssembly`, in both the main sandbox and the iframe — they are + different realms and only one of them matters +- whether a `blob:` URL worker spawns with the manifest asking for + `allowedDomains: ["none"]`, and whether a `data:` URL worker does +- whether `OffscreenCanvas` and `createImageBitmap` exist inside that worker, and + a real encode/decode/read-pixels round trip through them +- whether wasm SIMD is detected — without it, `webp_enc_simd.wasm` is 345,584 + bytes that buy nothing +- a successful `WebAssembly.instantiate` from base64, including a call across the + boundary and a memory grow +- wall-clock timing for round-tripping 1 MB, 4 MB and 16 MB `Uint8Array`s through + `figma.ui.postMessage`, with the bytes checked for damage on return +- `performance.memory.jsHeapSizeLimit`, which is the renderer's limit and + therefore shared with the document — an upper bound on headroom, never a budget + +It deliberately inlines a hand-written 52-byte wasm module rather than a real +codec. Instantiation either works in that sandbox or it does not; 3.5 MB of +libaom would turn a capability check into a download test and tell us nothing +extra. Throughput is a separate measurement that needs the real codecs to mean +anything. + +**What the probe does not answer:** the memory question above. It measures the +bridge and the headroom, which is the evidence you need *before* deciding to +spend a day on a real port — but the wall only shows up with four real codecs and +a real 12 MP image, and finding it is the next spike, not this one. + +--- + +## Recommendation + +**Build Product A first, with the three small codecs inlined. Leave AVIF out of +version one entirely.** + +The reasoning: + +- **761,280 bytes of wasm, about 1.0 MB base64.** That covers in-place JPEG and + PNG fill compression (Product A, complete) plus WebP export-to-download + (Product B's best format). It is a fifth of the full payload and it fits + comfortably under any reported ceiling. +- **libaom is 82% of the payload for a format `createImage` cannot accept back.** + AVIF can only ever be a download. Paying 3.5 MB of parse and instantiation cost + in every session, for the flow that benefits the document least, is the wrong + first trade. Lazy-fetch it later, from an allow-listed absolute URL with a + wildcard CORS header, once there is evidence anyone wants it. +- **Product A is the better story anyway.** "Your Figma file gets smaller, and so + does every export anybody takes from it" beats "here is a second export + button". How much smaller is a number we do not have yet and should not + invent — it is one of the first things a working port would measure. + +And one measurement to take before writing any encoder code: +`canvas.toBlob('image/webp', q)` is natively available in the iframe — the probe +confirms it and prints the byte count. If the browser's own WebP encoder is +within a few percent of libwebp at matched quality, we should not pay 345,584 +bytes to ship libwebp at all, and the small build drops to 415,696 bytes of wasm. +Measure it against `webp_enc_simd.wasm` on the existing fixture corpus before +deciding. The probe also checks `image/avif`, which is expected to be absent — +worth confirming rather than assuming. + +## Sequencing + +1. **Run the probe in Figma.** Half an hour. Paste the report into this document. + If wasm or blob workers are absent, stop here and the spike is finished. +2. **Measure native WebP against libwebp** on `tests/fixtures/`, at matched + quality. Decides whether the payload is 761,280 or 415,696 bytes. +3. **Settle the `GUIDE.md` WebP claim** with `Image.getBytesAsync()` on an + imported WebP. Costs one node and one line, and it either confirms the + `documents` format policy or unblocks a change to it. Do this while the + scaffold is warm regardless of what happens next. +4. **Port the worker** behind the three small codecs, and find the memory wall on + purpose: a 12 MP image, all three codecs instantiated, and watch what dies. + This is the go/no-go, and it must be run on a machine with a real document + open, not an empty file. +5. **Product A only** for version one. `getBytesAsync` in, `createImage` out, + undo-safe, one selection at a time before any batch flow. +6. **Product B, WebP only**, once A is stable. +7. **AVIF, lazy-fetched**, only if asked for. + +## If the answer is no + +Then the write-up above is the deliverable, `spike/figma-probe/` stays as a +record of what was asked and how, and the install-based distribution story is +unchanged. Nothing was built on top of this. That is the point of doing it as a +spike and the reason it was scoped to a fixed amount of time. + +The two facts about the export hook and about `createImage` are worth keeping +either way: they are already the reason `--for documents` refuses WebP, and now +they are written down somewhere other than a comment. diff --git a/imgcompress/__init__.py b/imgcompress/__init__.py index dfc301b..6ccc064 100644 --- a/imgcompress/__init__.py +++ b/imgcompress/__init__.py @@ -1,7 +1,29 @@ """imgcompress - quality-targeted image compression for design assets.""" +import multiprocessing as _multiprocessing + from .core import CompressionResult, Settings, compress, compress_file, compress_tree, write_result +# Must run before anything creates a process pool, and it belongs here rather +# than in one entry point because there are three ways in: the `imgcompress` +# command, the `imgcompress-gui` command, and `import imgcompress` from +# somebody else's script. +# +# `compress_tree` uses a ProcessPoolExecutor, and under the spawn start method - +# always on Windows, the default on macOS since 3.8 - each worker re-executes +# the program in order to import the module it needs. In a normal install that +# re-execution is a fresh `python`, and harmless. In a frozen bundle there is no +# python to re-execute: the child runs the application's own executable again, +# which starts a whole new imgcompress, which opens a pool of its own. A folder +# of images becomes a fork bomb. +# +# It stayed hidden because `compress_tree` takes a single-process path when there +# is only one job, so every one-image smoke test passes. Only a real folder shows +# it, and only in a build nobody had made yet. +# +# A no-op on any non-frozen interpreter, and cheap enough not to guard. +_multiprocessing.freeze_support() + __all__ = [ "CompressionResult", "Settings", @@ -10,4 +32,4 @@ "compress_tree", "write_result", ] -__version__ = "2.6.0" +__version__ = "2.7.0" diff --git a/imgcompress/cli.py b/imgcompress/cli.py index 1180ed5..acb4f9c 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) @@ -45,55 +38,86 @@ def describe(res: CompressionResult, verbose: bool = False) -> str: bits.append(f"q{res.level}") if res.score is not None: fmt = "{:.1f}" if res.metric == "ssimulacra2" else "{:.4f}" - bits.append(f"{res.metric} " + fmt.format(res.score)) + label = "visual match" if res.metric == "ssimulacra2" else res.metric + bits.append(f"{label} " + fmt.format(res.score)) line = f" ok {name} " + " ".join(bits) if verbose and res.candidates: losers = " ".join(f"{c}={human(s)}" for c, s, _ in sorted(res.candidates, key=lambda x: x[1])) - line += f"\n candidates: {losers}" + line += f"\n versions tried: {losers}" for warning in res.warnings: line += f"\n ! {warning}" 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 +129,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 +166,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 +190,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 +200,29 @@ 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()}") + # What will actually happen, not what was asked for. Printing the request + # meant `-m 8000 --for documents` advertised "up to 8000px" and produced + # 4096 - a dimension changing without saying so, which is the whole defect + # this destination work exists to remove, just moved onto the override path. + effective = dest.effective_limit(going_to.name, max_dim) + size = f"up to {effective}px" if effective else "never resized" + print(f" {size}, visual match at least {match}") + if effective != (max_dim or 0): + print(f" (asked for {max_dim or 'no limit'}; {going_to.name} clamps at " + f"{going_to.hard_cap}px because these tools rescale above it " + f"destructively on import)") + 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 +230,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..c110781 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.""" @@ -123,9 +122,11 @@ def _normalise(img: Image.Image, settings: Settings) -> tuple: original_size = img.size 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. The rule lives in one place so + # that what the CLI prints and what the engine does cannot disagree. + limit = dest.effective_limit(settings.target, settings.max_dimension) if limit and max(img.size) > limit: scale = limit / float(max(img.size)) @@ -192,7 +193,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 @@ -235,7 +240,7 @@ def compress(source: Path, settings: Settings) -> CompressionResult: result.suffix = source.suffix result.new_bytes = result.original_bytes result.skipped = True - result.note = "animated - passed through unchanged" + result.note = "animated - left exactly as it is" return result img, original_size, resized_to = _normalise(opened, settings) @@ -246,7 +251,8 @@ def compress(source: Path, settings: Settings) -> CompressionResult: has_alpha = _has_alpha(img) names = _candidate_names(settings, has_alpha) if not names: - result.error = "no candidate format can carry this image" + result.error = ("No format available here can hold this image. " + "Allow more formats, or choose a different destination.") return result # The smallest candidate that clears the floor wins. A candidate that @@ -261,7 +267,9 @@ def compress(source: Path, settings: Settings) -> CompressionResult: try: found = _search_one(img, encoder, metric, target, settings.fast) except Exception as exc: # a broken candidate must not kill the file - result.warnings.append(f"{encoder.name} failed: {type(exc).__name__}") + result.warnings.append( + f"{encoder.name} could not be written for this image, so it was " + f"left out of the comparison ({type(exc).__name__})") continue if not found: continue @@ -276,13 +284,16 @@ def compress(source: Path, settings: Settings) -> CompressionResult: best = best_passing or best_failing if best is None: - result.error = "no candidate produced usable output" + result.error = ("None of the formats could be written for this image. " + "It may be damaged; try re-exporting it.") return result data, level, score, encoder = best if score < target: + label = "visual match" if metric.name == "ssimulacra2" else metric.name result.warnings.append( - f"could not reach {metric.name} {target:g}; best was {score:.1f}" + f"could not reach a {label} of {target:g}; the closest was {score:.1f}. " + f"Lower the target, or keep the original." ) # Never ship a bigger file. The one exception is a caller who *forced* a @@ -296,7 +307,7 @@ def compress(source: Path, settings: Settings) -> CompressionResult: result.suffix = source.suffix result.new_bytes = result.original_bytes result.skipped = True - result.note = "already well compressed - passed through unchanged" + result.note = "already smaller than anything we could make - left as it is" result.fmt = encoder.name return result diff --git a/imgcompress/destinations.py b/imgcompress/destinations.py new file mode 100644 index 0000000..352da0f --- /dev/null +++ b/imgcompress/destinations.py @@ -0,0 +1,199 @@ +"""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 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, + # Two numbers doing two different jobs, and collapsing them into + # one is a real bug this file shipped with for exactly one commit. + # + # 2560 is the everyday downscale, the same as `web`, and it is + # where most of the saving on a design asset actually comes from - + # no codec recovers the bytes wasted on a 6000px export that + # renders at 1200px. + # + # 4096 is a safety clamp, not a setting. Design tools rescale + # above it destructively on import with no control over the + # resampling, so an explicit `-m 8000` is quietly brought down to + # 4096 rather than honoured or rejected: the intent is fine, the + # destination simply cannot carry it. + max_dimension=2560, + 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, + # 512 covers a 2x display at 256px, which is the change that can + # be argued for. The quality target stays at the 80 it has always + # been: artefacts are *less* visible at a smaller size, so if + # anything it could fall, and moving it up was a second change + # with no reason behind it. Nothing in the history records why the + # original 800px was chosen - it arrived in the initial import. + ss2_target=80.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 effective_limit(name: str, requested: int) -> int: + """The longest edge that will actually be produced. 0 means no resizing. + + The clamp rule lives here and nowhere else. It is already restated in + `worker.js`, and the moment a third copy appeared in the CLI - purely to + print an accurate number - the header started advertising `up to 8000px` + for a run that produced 4096. One function, two callers. + """ + limit = requested or 0 + cap = get(name).hard_cap if exists(name) else 0 + if cap: + limit = min(limit, cap) if limit else cap + return limit + + +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..28a3805 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, @@ -49,6 +50,18 @@ # Characters Windows refuses in filenames, plus control characters. _BAD_FILENAME = re.compile(r'[<>:"/\\|?*\x00-\x1f]') +# Content types for the files served out of `webui/`. Stated rather than looked +# up because `mimetypes` has no entry for woff2 on a stock Windows Python, and +# a face delivered as application/octet-stream is a font the browser may refuse +# - which would show up as the app silently falling back to a system typeface. +STATIC_TYPES = { + ".css": "text/css; charset=utf-8", + ".js": "text/javascript; charset=utf-8", + ".woff2": "font/woff2", + ".svg": "image/svg+xml", + ".png": "image/png", +} + # --------------------------------------------------------------------------- # # state @@ -100,9 +113,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 +157,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 +250,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, @@ -469,6 +500,22 @@ def _body(self) -> bytes | None: return None return self.rfile.read(length) if length else b"" + def _serve_static(self, route: str): + """A file from `webui/`, or None if the path does not name one. + + The traversal guard is the `parents` test: `..` segments resolve out of + the directory, and a resolved path whose parents do not include WEBUI is + refused. Returning None rather than a 404 lets the caller fall through + to the authorised routes, so this cannot shadow them. + """ + candidate = (WEBUI / unquote(route[len("/webui/"):])).resolve() + if WEBUI.resolve() not in candidate.parents or not candidate.is_file(): + return None + mime = (STATIC_TYPES.get(candidate.suffix.lower()) + or mimetypes.guess_type(candidate.name)[0] + or "application/octet-stream") + return self._send(200, candidate.read_bytes(), mime) + def _json_body(self) -> dict: raw = self._body() if not raw: @@ -492,6 +539,26 @@ def do_GET(self): # noqa: N802 html = html.replace(b"__TOKEN__", self.token.encode("ascii")) return self._send(200, html, "text/html; charset=utf-8") + # The app's own stylesheets and faces, served before the token check. + # + # A or a url() inside a stylesheet cannot carry the query string + # the page was opened with, so gating these produced a 403 with a JSON + # body - which Chrome reports as "refused to apply style, MIME type + # application/json" and then renders the whole app in Times New Roman + # with no tokens at all. Every static gate stayed green through that, + # which is why verify_desktop.mjs now looks at the real page. + # + # Safe to exempt, and only this: these are static files shipped inside + # the package. They carry no user data, nothing about the images being + # compressed, and no session state. `_host_ok` still refuses any Host + # that is not loopback, so a hostile page cannot reach them from a + # domain of its own, and the token still gates every API route and every + # image endpoint below. + if route.startswith("/webui/"): + served = self._serve_static(route) + if served: + return served + if not self._authorised(query): return self._json({"error": "unauthorised"}, 403) @@ -516,12 +583,6 @@ def do_GET(self): # noqa: N802 "webp": "image/webp", "webp-lossless": "image/webp"}.get(fmt, "image/png") return self._send(200, data, mime) - if route.startswith("/webui/"): - candidate = (WEBUI / unquote(route[len("/webui/"):])).resolve() - if WEBUI.resolve() in candidate.parents and candidate.is_file(): - mime = mimetypes.guess_type(candidate.name)[0] or "application/octet-stream" - return self._send(200, candidate.read_bytes(), mime) - return self._json({"error": "not found"}, 404) def do_POST(self): # noqa: N802 diff --git a/imgcompress/webui/app.html b/imgcompress/webui/app.html index 4613af4..9d28d99 100644 --- a/imgcompress/webui/app.html +++ b/imgcompress/webui/app.html @@ -4,6 +4,13 @@ Image Compressor + + + + @@ -418,37 +562,19 @@
Image Compressor
-
- - -
- -
- - - 90 -
- -
- - - px -
- +
@@ -460,123 +586,209 @@ pip install -r requirements.txt -
- - - - -
-
-
-

Drop images to compress

-

Every image is encoded several ways and scored against the original. - You'll see the result before anything is written to disk.

- + +
+ +
+ + +
+
+
+ + Fit + +
+
+

+ + +
+ + +
+
+
+ + + + + +
+
+

Drop images to compress

+

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. Nothing is written until you save.

+ +
+
+ + + + + +
`; } else { - const winner = Math.min(...it.candidates.map((c) => c.bytes)); + /* The winner is the version that actually shipped, not the smallest one. + This used to badge `Math.min(bytes)`, which is wrong whenever the + smallest version missed the target - and it hid that version's score + behind the badge, so the screen said "winner" next to a file the engine + had rejected and gave you no way to see why. Exactly the bug core.py + fixed in the engine, repeated in the picture of it. */ + const target = it.override?.quality_target ?? state.settings.quality_target; + const shipped = it.fmt; + const smallest = Math.min(...it.candidates.map((c) => c.bytes)); + const shippedBytes = (it.candidates.find((c) => c.format === shipped) || {}).bytes; + + const scoreOf = (c) => (c.score == null ? "—" + : c.score >= 100 ? "identical" + : it.metric === "ssim" ? c.score.toFixed(3) + : `${Math.round(c.score)}/100`); + + // One plain sentence per version that lost, same as the web app. + const why = (c) => { + if (c.format === shipped) return "The smallest version that still looked close enough."; + if (c.bytes >= it.original_bytes) return "Bigger than your original, so it was discarded."; + if (c.score != null && c.score < target) { + return `Too different from the original — matched ${Math.round(c.score)} ` + + `against your target of ${Math.round(target)}.`; + } + const larger = shippedBytes ? Math.round(100 * (c.bytes - shippedBytes) / shippedBytes) : 0; + return larger > 0 + ? `Close enough to the original, but ${larger}% larger than the one chosen.` + : "Close enough to the original, but not the one chosen."; + }; + cands.innerHTML = [...it.candidates].sort((a, b) => a.bytes - b.bytes).map((c) => ` -
+
${escapeHtml(c.format)} ${human(c.bytes)} - ${ - c.bytes === winner ? "winner" : - (c.score >= 100 ? "lossless" : (it.metric === "ssim" ? c.score.toFixed(3) : c.score.toFixed(1)))} + ${escapeHtml(scoreOf(c))} + ${ + c.format === shipped ? "kept" + : c.bytes === smallest ? "smallest" : ""}
`).join(""); } $("ov-format").value = it.override?.formats?.[0] || ""; @@ -809,7 +1146,12 @@

Candidates tried

const n = t.unsaved || 0; const btn = $("save-btn"); btn.disabled = n === 0; - btn.textContent = n === 0 ? "Save" : `Save ${n} image${n === 1 ? "" : "s"}`; + btn.textContent = n === 0 ? "Save all" : `Save all ${n}`; + // One primary way out per screen: the list saves the batch, the comparison + // saves the one image you are looking at. + const one = $("save-one"); + if (one) one.disabled = !state.items.some( + (i) => i.id === selected && (i.status === "done" || i.status === "saved")); $("watch-btn").setAttribute("aria-pressed", state.watch_folder ? "true" : "false"); $("watch-btn").classList.toggle("on", !!state.watch_folder); $("watch-btn").textContent = state.watch_folder ? "Watching" : "Watch folder"; @@ -835,8 +1177,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 +1229,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); @@ -1022,8 +1402,36 @@

Candidates tried

poll(true); }); +/* Both Details buttons open the same drawer from the same edge; the close + button and Escape are the only ways it shuts. */ +$("insp-toggle").addEventListener("click", () => setPanel($("panel").hidden)); +$("list-details").addEventListener("click", () => setPanel($("panel").hidden)); +$("panel-close").addEventListener("click", () => setPanel(false)); + +/* Back to the list. With one image there is no list, so the button is hidden + rather than disabled - a control that cannot do anything should not be + offered. */ +$("back-btn").addEventListener("click", () => { + selected = null; + setPanel(false); + renderView(); +}); + +/* Saving one image is the same API call as saving the batch; the server writes + whatever is unsaved, so this saves the one you are looking at by being the + only one on screen. */ +$("save-one").addEventListener("click", () => $("save-btn").click()); + +$("done-again").addEventListener("click", async () => { + await api("/api/clear", {}); + selected = null; + setPanel(false); + poll(true); +}); + document.addEventListener("keydown", (e) => { if (e.target.matches("input, select, textarea")) return; + if (e.key === "Escape" && !$("panel").hidden) { e.preventDefault(); setPanel(false); return; } if ((e.metaKey || e.ctrlKey) && e.key === "s") { e.preventDefault(); $("save-btn").click(); } else if (e.key === "Delete" || e.key === "Backspace") { if (selected) { e.preventDefault(); $("remove-btn").click(); } diff --git a/imgcompress/webui/favicon.svg b/imgcompress/webui/favicon.svg new file mode 100644 index 0000000..b99cbb8 --- /dev/null +++ b/imgcompress/webui/favicon.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/imgcompress/webui/fonts.css b/imgcompress/webui/fonts.css new file mode 100644 index 0000000..91646c9 --- /dev/null +++ b/imgcompress/webui/fonts.css @@ -0,0 +1,93 @@ +/* COPIED from web/fonts.css by tools/sync_webui_assets.py - DO NOT EDIT. + * Edit the file in web/ and re-run the tool; CI fails on a stale copy. */ +/* --------------------------------------------------------------------------- + Self-hosted webfaces for the HeyOz type tokens. + + The token values name 'Bricolage Grotesque', 'Geist' and 'Geist Mono' as + their first families. Upstream fetches those from Google Fonts by , + which this app cannot do: it would be a third-party request, and "nothing + ever leaves your device" is the product's headline promise, enforced by a + `default-src 'none'` CSP. So the files are served from this origin instead — + same typography, still zero external connections. + + All six faces are **variable**, requested from Google Fonts at `wght@400..600` + rather than the full 200..800 / 100..900 ranges. That is not only a size + saving: this app never renders above semibold, so the heavier masters would + be bytes that can never be drawn. 191 KB for the set. + + Latin and latin-ext only. Each face keeps the exact `unicode-range` Google + ships it with, so a glyph outside the subset falls through to the next family + in the token stack rather than rendering a tofu box. + + `font-display: swap` — text paints immediately in the fallback and swaps when + the face arrives. `size-adjust` is deliberately absent: these are the real + families the tokens name, so there is no metric mismatch to compensate for. + --------------------------------------------------------------------------- */ + +/* ------------------------------ display / heading ------------------------- */ +@font-face { + font-family: 'Bricolage Grotesque'; + font-style: normal; + font-weight: 400 600; + font-stretch: 100%; + font-display: swap; + src: url('fonts/bricolage-grotesque-latin.woff2') format('woff2'); + unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, + U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, + U+2212, U+2215, U+FEFF, U+FFFD; +} +@font-face { + font-family: 'Bricolage Grotesque'; + font-style: normal; + font-weight: 400 600; + font-stretch: 100%; + font-display: swap; + src: url('fonts/bricolage-grotesque-latin-ext.woff2') format('woff2'); + unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, + U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, + U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF; +} + +/* -------------------------------- body / label ---------------------------- */ +@font-face { + font-family: 'Geist'; + font-style: normal; + font-weight: 400 600; + font-display: swap; + src: url('fonts/geist-latin.woff2') format('woff2'); + unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, + U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, + U+2212, U+2215, U+FEFF, U+FFFD; +} +@font-face { + font-family: 'Geist'; + font-style: normal; + font-weight: 400 600; + font-display: swap; + src: url('fonts/geist-latin-ext.woff2') format('woff2'); + unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, + U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, + U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF; +} + +/* ---------------------------------- mono ---------------------------------- */ +@font-face { + font-family: 'Geist Mono'; + font-style: normal; + font-weight: 400 600; + font-display: swap; + src: url('fonts/geist-mono-latin.woff2') format('woff2'); + unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, + U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, + U+2212, U+2215, U+FEFF, U+FFFD; +} +@font-face { + font-family: 'Geist Mono'; + font-style: normal; + font-weight: 400 600; + font-display: swap; + src: url('fonts/geist-mono-latin-ext.woff2') format('woff2'); + unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, + U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, + U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF; +} diff --git a/imgcompress/webui/fonts/bricolage-grotesque-latin-ext.woff2 b/imgcompress/webui/fonts/bricolage-grotesque-latin-ext.woff2 new file mode 100644 index 0000000..6e8caa8 Binary files /dev/null and b/imgcompress/webui/fonts/bricolage-grotesque-latin-ext.woff2 differ diff --git a/imgcompress/webui/fonts/bricolage-grotesque-latin.woff2 b/imgcompress/webui/fonts/bricolage-grotesque-latin.woff2 new file mode 100644 index 0000000..fcc4eb1 Binary files /dev/null and b/imgcompress/webui/fonts/bricolage-grotesque-latin.woff2 differ diff --git a/imgcompress/webui/fonts/geist-latin-ext.woff2 b/imgcompress/webui/fonts/geist-latin-ext.woff2 new file mode 100644 index 0000000..ba90e20 Binary files /dev/null and b/imgcompress/webui/fonts/geist-latin-ext.woff2 differ diff --git a/imgcompress/webui/fonts/geist-latin.woff2 b/imgcompress/webui/fonts/geist-latin.woff2 new file mode 100644 index 0000000..991445d Binary files /dev/null and b/imgcompress/webui/fonts/geist-latin.woff2 differ diff --git a/imgcompress/webui/fonts/geist-mono-latin-ext.woff2 b/imgcompress/webui/fonts/geist-mono-latin-ext.woff2 new file mode 100644 index 0000000..ea22fd9 Binary files /dev/null and b/imgcompress/webui/fonts/geist-mono-latin-ext.woff2 differ diff --git a/imgcompress/webui/fonts/geist-mono-latin.woff2 b/imgcompress/webui/fonts/geist-mono-latin.woff2 new file mode 100644 index 0000000..0750682 Binary files /dev/null and b/imgcompress/webui/fonts/geist-mono-latin.woff2 differ diff --git a/imgcompress/webui/heyoz-tokens.css b/imgcompress/webui/heyoz-tokens.css new file mode 100644 index 0000000..be2bd4e --- /dev/null +++ b/imgcompress/webui/heyoz-tokens.css @@ -0,0 +1,1142 @@ +/* COPIED from web/heyoz-tokens.css by tools/sync_webui_assets.py - DO NOT EDIT. + * Edit the file in web/ and re-run the tool; CI fails on a stale copy. */ +/* GENERATED by build/build.mjs — do not edit. + * HeyOz design tokens. Import once, before Tailwind. + * Light is the default; add class="dark" (or data-theme="dark") to flip. + */ + +@layer base { + :root { + color-scheme: light; + + /* Reserve the classic scrollbar's track permanently. + color-scheme above decides what the native scrollbar LOOKS like; this decides + whether it takes up room, and the two belong together. + + A classic scrollbar is 15px of real layout width. Any overlay that locks the + page — every dialog, sheet, menu and command palette there will ever be — turns + the page's scrollbar off, the scrollport gains those 15px back, and the whole + document reflows sideways under the thing that just opened. Measured on this + showcase before this line existed: layout width 1265 → 1280, header right edge + and sidebar both moving 14.86px, on every single dialog open. + + Reserving the gutter up front means removing the scrollbar changes nothing, and + it fixes the class of bug rather than one instance: a component does not have to + know about scrollbars, and the next overlay someone writes inherits the fix + instead of re-earning the bug. The alternative — measuring the width in JS and + padding the body — compensates the document and NOT position: fixed elements, + which is how a compensated app ends up with a sticky header that still jumps. + + The cost is 15px of reserved gutter on a page short enough not to scroll, and + only where scrollbars are classic: overlay scrollbars (macOS, iOS, Android, and + headless Chromium) reserve nothing, so this is a Windows-and-Linux-desktop + trade. A permanently steady 15px beats an intermittent 15px jump. + + Browsers without support — Safari below 18.2 — fall back inside .oz-scroll-lock + below. */ + scrollbar-gutter: stable; + + /* ---- foundations, motion, typography (mode-independent) ---- */ + --oz-space-1: 4px; + --oz-space-2: 6px; + --oz-space-3: 8px; + --oz-space-4: 12px; + --oz-space-5: 16px; + --oz-space-6: 20px; + --oz-space-7: 24px; + --oz-space-8: 28px; + --oz-space-9: 32px; + --oz-space-10: 36px; + --oz-space-11: 40px; + --oz-space-12: 48px; + --oz-space-13: 56px; + --oz-space-14: 64px; + --oz-space-15: 72px; + --oz-space-16: 80px; + --oz-space-17: 96px; + --oz-space-18: 120px; + --oz-radius-1: 2px; + --oz-radius-2: 4px; + --oz-radius-3: 6px; + --oz-radius-4: 8px; + --oz-radius-5: 10px; + --oz-radius-6: 12px; + --oz-radius-7: 14px; + --oz-radius-8: 16px; + --oz-radius-9: 20px; + --oz-radius-10: 24px; + --oz-radius-11: 32px; + --oz-radius-12: 40px; + --oz-radius-full: 1000px; + --oz-stroke-1: 0.5px; + --oz-stroke-2: 1px; + --oz-stroke-3: 1.5px; + --oz-stroke-4: 2px; + --oz-stroke-5: 2.5px; + --oz-stroke-6: 4px; + --oz-focus-ring-width: 2px; + --oz-focus-ring-offset: 2px; + --oz-target-min: 44px; + --oz-target-comfortable: 48px; + --oz-icon-sm: 16px; + --oz-icon-md: 20px; + --oz-icon-lg: 24px; + --oz-icon-xl: 32px; + --oz-icon-stroke: 2px; + --oz-layer-base: 0; + --oz-layer-dropdown: 1000; + --oz-layer-sticky: 1100; + --oz-layer-overlay: 1200; + --oz-layer-modal: 1300; + --oz-layer-popover: 1400; + --oz-layer-toast: 1500; + --oz-layer-tooltip: 1600; + --oz-bp-sm: 480px; + --oz-bp-md: 768px; + --oz-bp-lg: 1024px; + --oz-bp-xl: 1280px; + --oz-container-sm: 640px; + --oz-container-md: 768px; + --oz-container-lg: 1024px; + --oz-container-xl: 1280px; + --oz-container-gutter: 24px; + --oz-container-measure: 65ch; + --oz-spring-effects-fast: linear(0, 0.024, 0.0823, 0.1594, 0.2448, 0.3315, 0.4153, 0.4934, 0.5646, 0.6283, 0.6845, 0.7335, 0.7758, 0.8122, 0.8432, 0.8695, 0.8916, 0.9102, 0.9258, 0.9388, 0.9496, 0.9586, 0.9661, 0.9722, 0.9772, 0.9814, 0.9848, 0.9876, 0.9899, 0.9918, 0.9933, 0.9946, 0.9956, 0.9964, 0.9971, 0.9977, 0.9981, 0.9985, 0.9988, 1); + --oz-spring-effects-fast-ms: 120ms; + --oz-spring-effects-default: linear(0, 0.024, 0.0823, 0.1594, 0.2448, 0.3315, 0.4153, 0.4934, 0.5646, 0.6283, 0.6845, 0.7335, 0.7758, 0.8122, 0.8432, 0.8695, 0.8916, 0.9102, 0.9258, 0.9388, 0.9496, 0.9586, 0.9661, 0.9722, 0.9772, 0.9814, 0.9848, 0.9876, 0.9899, 0.9918, 0.9933, 0.9946, 0.9956, 0.9964, 0.9971, 0.9977, 0.9981, 0.9985, 0.9988, 1); + --oz-spring-effects-default-ms: 180ms; + --oz-spring-effects-slow: linear(0, 0.024, 0.0823, 0.1594, 0.2448, 0.3315, 0.4153, 0.4934, 0.5646, 0.6283, 0.6845, 0.7335, 0.7758, 0.8122, 0.8432, 0.8695, 0.8916, 0.9102, 0.9258, 0.9388, 0.9496, 0.9586, 0.9661, 0.9722, 0.9772, 0.9814, 0.9848, 0.9876, 0.9899, 0.9918, 0.9933, 0.9946, 0.9956, 0.9964, 0.9971, 0.9977, 0.9981, 0.9985, 0.9988, 1); + --oz-spring-effects-slow-ms: 280ms; + --oz-spring-spatial-fast: linear(0, 0.0218, 0.0765, 0.1513, 0.2366, 0.3254, 0.413, 0.4962, 0.573, 0.6424, 0.7039, 0.7575, 0.8037, 0.8429, 0.8758, 0.9032, 0.9256, 0.9438, 0.9584, 0.9699, 0.979, 0.9859, 0.9912, 0.9951, 0.998, 1, 1.0014, 1.0022, 1.0027, 1.0029, 1.003, 1.0029, 1.0027, 1.0024, 1.0022, 1.0019, 1.0017, 1.0014, 1.0012, 1); + --oz-spring-spatial-fast-ms: 190ms; + --oz-spring-spatial-default: linear(0, 0.0217, 0.0767, 0.1527, 0.2402, 0.3321, 0.4233, 0.5104, 0.591, 0.6637, 0.7281, 0.784, 0.8317, 0.8718, 0.9049, 0.9318, 0.9534, 0.9702, 0.9832, 0.9929, 1, 1.0049, 1.0081, 1.01, 1.0109, 1.0111, 1.0108, 1.0101, 1.0092, 1.0083, 1.0072, 1.0062, 1.0053, 1.0044, 1.0036, 1.0029, 1.0023, 1.0018, 1.0014, 1); + --oz-spring-spatial-default-ms: 340ms; + --oz-spring-spatial-slow: linear(0, 0.0202, 0.0724, 0.1453, 0.2305, 0.3211, 0.4122, 0.5001, 0.5824, 0.6574, 0.7244, 0.7831, 0.8335, 0.876, 0.9113, 0.9401, 0.963, 0.9809, 0.9944, 1.0043, 1.0113, 1.0159, 1.0185, 1.0198, 1.0199, 1.0192, 1.0179, 1.0164, 1.0146, 1.0128, 1.011, 1.0093, 1.0077, 1.0063, 1.005, 1.0039, 1.003, 1.0022, 1.0015, 1); + --oz-spring-spatial-slow-ms: 480ms; + --oz-spring-expressive: linear(0, 0.031, 0.1102, 0.2197, 0.3445, 0.4731, 0.5968, 0.7098, 0.8083, 0.8907, 0.9566, 1.0067, 1.0424, 1.0657, 1.0787, 1.0834, 1.0819, 1.076, 1.0673, 1.0571, 1.0463, 1.0359, 1.0262, 1.0177, 1.0105, 1.0047, 1.0002, 0.997, 0.9949, 0.9936, 0.9931, 0.9931, 0.9935, 0.9942, 0.995, 0.9959, 0.9968, 0.9976, 0.9984, 1); + --oz-spring-expressive-ms: 520ms; + --oz-motion-spatial-scale: 1; + --oz-duration-instant: 0ms; + --oz-duration-fast: 150ms; + --oz-duration-base: 250ms; + --oz-duration-slow: 420ms; + --oz-duration-slower: 720ms; + --oz-duration-ambient: 1500ms; + --oz-ease-entrance: cubic-bezier(0.34, 1.56, 0.64, 1); + --oz-ease-exit: cubic-bezier(0.4, 0, 1, 1); + --oz-ease-standard: cubic-bezier(0.4, 0, 0.2, 1); + --oz-ease-linear: linear; + --oz-font-display: 'Bricolage Grotesque', ui-sans-serif, system-ui, -apple-system, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif; + --oz-font-heading: 'Bricolage Grotesque', ui-sans-serif, system-ui, -apple-system, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif; + --oz-font-body: 'Geist', 'Inter', ui-sans-serif, system-ui, -apple-system, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif; + --oz-font-label: 'Geist', 'Inter', ui-sans-serif, system-ui, -apple-system, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif; + --oz-font-mono: 'Geist Mono', ui-monospace, SFMono-Regular, 'SF Mono', Menlo, Consolas, 'Liberation Mono', monospace; + --oz-weight-regular: 400; + --oz-weight-medium: 500; + --oz-weight-semibold: 600; + --oz-weight-bold: 700; + --oz-weight-extrabold: 800; + --oz-text-display-lg: clamp(40px, calc(40px + (64 - 40) * (100vw - 360px) / 880), 64px); + --oz-text-display-md: clamp(34px, calc(34px + (52 - 34) * (100vw - 360px) / 880), 52px); + --oz-text-display-sm: clamp(28px, calc(28px + (40 - 28) * (100vw - 360px) / 880), 40px); + --oz-text-heading-xl: clamp(26px, calc(26px + (36 - 26) * (100vw - 360px) / 880), 36px); + --oz-text-heading-lg: clamp(24px, calc(24px + (30 - 24) * (100vw - 360px) / 880), 30px); + --oz-text-heading-md: clamp(20px, calc(20px + (24 - 20) * (100vw - 360px) / 880), 24px); + --oz-text-heading-sm: 20px; + --oz-text-heading-xs: 18px; + --oz-text-body-lg: 18px; + --oz-text-body-md: 16px; + --oz-text-body-sm: 14px; + --oz-text-body-xs: 12px; + --oz-text-label-md: 14px; + --oz-text-label-sm: 12px; + --oz-text-label-xs: 10px; + --oz-leading-display-lg: 1.0625; + --oz-leading-display-md: 1.0769; + --oz-leading-display-sm: 1.1; + --oz-leading-heading-xl: 1.1111; + --oz-leading-heading-lg: 1.2; + --oz-leading-heading-md: 1.3333; + --oz-leading-heading-sm: 1.4; + --oz-leading-heading-xs: 1.3333; + --oz-leading-body-lg: 1.5556; + --oz-leading-body-md: 1.5; + --oz-leading-body-sm: 1.4286; + --oz-leading-body-xs: 1.3333; + --oz-leading-label-md: 1.1429; + --oz-leading-label-sm: 1.3333; + --oz-leading-label-xs: 1.2; + --oz-tracking-display-lg: -0.02em; + --oz-tracking-display-md: -0.02em; + --oz-tracking-display-sm: -0.015em; + --oz-tracking-heading-xl: -0.015em; + --oz-tracking-heading-lg: -0.01em; + --oz-tracking-heading-md: -0.01em; + --oz-tracking-heading-sm: -0.005em; + --oz-tracking-heading-xs: 0em; + --oz-tracking-body-lg: 0em; + --oz-tracking-body-md: 0em; + --oz-tracking-body-sm: 0em; + --oz-tracking-body-xs: 0.005em; + --oz-tracking-label-md: 0.005em; + --oz-tracking-label-sm: 0.01em; + --oz-tracking-label-xs: 0.02em; + --oz-default-weight-display: 800; + --oz-default-weight-heading: 600; + --oz-default-weight-body: 400; + --oz-default-weight-label: 500; + + /* ---- semantic: light ---- */ + --oz-color-background: #FFFFFF; + --oz-color-surface-primary: #F7F5F4; + --oz-color-surface-primary-variant: #FFFFFF; + --oz-color-surface-secondary: #EFEDEC; + --oz-color-surface-secondary-variant: #F7F5F4; + --oz-color-surface-tertiary: #DCDAD9; + --oz-color-surface-tertiary-variant: #EFEDEC; + --oz-color-surface-elevated: #FFFFFF; + --oz-color-surface-overlay: #FFFFFF; + --oz-color-surface-inverse: #070605; + --oz-color-surface-fixed: #FFFFFF; + --oz-color-surface-brand: #FFECE7; + --oz-color-surface-success: #E7F5EB; + --oz-color-surface-warning: #FDF0DE; + --oz-color-surface-critical: #FFEAEC; + --oz-color-surface-info: #E7F0FF; + --oz-color-surface-brand-flat: #FFECE7; + --oz-color-surface-success-flat: #E7F5EB; + --oz-color-surface-warning-flat: #FDF0DE; + --oz-color-surface-critical-flat: #FFEAEC; + --oz-color-surface-info-flat: #E7F0FF; + --oz-color-fill-primary: #F7F5F4; + --oz-color-fill-primary-hover: #EFEDEC; + --oz-color-fill-primary-active: #E3E1E0; + --oz-color-fill-primary-disabled: #F7F5F480; + --oz-color-fill-primary-variant: #FFFFFF; + --oz-color-fill-primary-variant-hover: #F7F5F4; + --oz-color-fill-primary-variant-active: #EFEDEC; + --oz-color-fill-primary-variant-disabled: #FFFFFF80; + --oz-color-fill-secondary: #EFEDEC; + --oz-color-fill-secondary-hover: #E3E1E0; + --oz-color-fill-secondary-active: #D5D3D2; + --oz-color-fill-secondary-disabled: #EFEDEC80; + --oz-color-fill-secondary-variant: #F7F5F4; + --oz-color-fill-secondary-variant-hover: #EFEDEC; + --oz-color-fill-secondary-variant-active: #E3E1E0; + --oz-color-fill-secondary-variant-disabled: #F7F5F480; + --oz-color-fill-tertiary: #E3E1E0; + --oz-color-fill-tertiary-hover: #D5D3D2; + --oz-color-fill-tertiary-active: #C2C0BF; + --oz-color-fill-tertiary-disabled: #E3E1E080; + --oz-color-fill-tertiary-variant: #EFEDEC; + --oz-color-fill-tertiary-variant-hover: #E3E1E0; + --oz-color-fill-tertiary-variant-active: #D5D3D2; + --oz-color-fill-tertiary-variant-disabled: #EFEDEC80; + --oz-color-fill-elevated: #FFFFFF; + --oz-color-fill-elevated-hover: #F7F5F4; + --oz-color-fill-elevated-active: #EFEDEC; + --oz-color-fill-elevated-disabled: #FFFFFF80; + --oz-color-fill-inverse: #070605; + --oz-color-fill-inverse-hover: #0E0C0B; + --oz-color-fill-inverse-active: #151312; + --oz-color-fill-inverse-disabled: #07060580; + --oz-color-fill-brand: #FF3D01; + --oz-color-fill-brand-hover: #D53100; + --oz-color-fill-brand-active: #A92500; + --oz-color-fill-brand-disabled: #E9E7E6; + --oz-color-fill-success: #1D9156; + --oz-color-fill-success-hover: #037944; + --oz-color-fill-success-active: #006035; + --oz-color-fill-success-disabled: #E9E7E6; + --oz-color-fill-warning: #A36E07; + --oz-color-fill-warning-hover: #865900; + --oz-color-fill-warning-active: #6B4600; + --oz-color-fill-warning-disabled: #E9E7E6; + --oz-color-fill-critical: #E63C65; + --oz-color-fill-critical-hover: #C52450; + --oz-color-fill-critical-active: #9D183E; + --oz-color-fill-critical-disabled: #E9E7E6; + --oz-color-fill-info: #2C74EA; + --oz-color-fill-info-hover: #195DCA; + --oz-color-fill-info-active: #1049A4; + --oz-color-fill-info-disabled: #E9E7E6; + --oz-color-fill-brand-secondary: #FC664526; + --oz-color-fill-brand-secondary-hover: #FC66454D; + --oz-color-fill-brand-secondary-active: #FF3D014D; + --oz-color-fill-brand-secondary-disabled: #FC664514; + --oz-color-fill-success-secondary: #4DA97226; + --oz-color-fill-success-secondary-hover: #4DA9724D; + --oz-color-fill-success-secondary-active: #1D91564D; + --oz-color-fill-success-secondary-disabled: #4DA97214; + --oz-color-fill-warning-secondary: #BF8B3926; + --oz-color-fill-warning-secondary-hover: #BF8B394D; + --oz-color-fill-warning-secondary-active: #A36E074D; + --oz-color-fill-warning-secondary-disabled: #BF8B3914; + --oz-color-fill-critical-secondary: #F5617D26; + --oz-color-fill-critical-secondary-hover: #F5617D4D; + --oz-color-fill-critical-secondary-active: #E63C654D; + --oz-color-fill-critical-secondary-disabled: #F5617D14; + --oz-color-fill-info-secondary: #5292FD26; + --oz-color-fill-info-secondary-hover: #5292FD4D; + --oz-color-fill-info-secondary-active: #2C74EA4D; + --oz-color-fill-info-secondary-disabled: #5292FD14; + --oz-color-fill-selected: #FC664526; + --oz-color-fill-selected-hover: #FC66454D; + --oz-color-fill-selected-active: #FF3D014D; + --oz-color-fill-selected-disabled: #FC664514; + --oz-color-fill-fixed: #FFFFFF; + --oz-color-fill-fixed-disabled: #FFFFFF80; + --oz-color-border-primary: #E3E1E0; + --oz-color-border-primary-hover: #D5D3D2; + --oz-color-border-primary-disabled: #E3E1E080; + --oz-color-border-secondary: #CCC9C8; + --oz-color-border-secondary-hover: #C2C0BF; + --oz-color-border-secondary-disabled: #CCC9C880; + --oz-color-border-tertiary: #C2C0BF; + --oz-color-border-tertiary-hover: #A9A7A6; + --oz-color-border-tertiary-disabled: #C2C0BF80; + --oz-color-border-elevated: #E3E1E0; + --oz-color-border-elevated-hover: #D5D3D2; + --oz-color-border-elevated-disabled: #E3E1E080; + --oz-color-border-inverse: #070605; + --oz-color-border-inverse-hover: #0E0C0B; + --oz-color-border-inverse-disabled: #07060580; + --oz-color-border-brand: #FF3D01; + --oz-color-border-brand-hover: #D53100; + --oz-color-border-brand-disabled: #FF3D0180; + --oz-color-border-success: #1D9156; + --oz-color-border-success-hover: #037944; + --oz-color-border-success-disabled: #1D915680; + --oz-color-border-warning: #A36E07; + --oz-color-border-warning-hover: #865900; + --oz-color-border-warning-disabled: #A36E0780; + --oz-color-border-critical: #E63C65; + --oz-color-border-critical-hover: #C52450; + --oz-color-border-critical-disabled: #E63C6580; + --oz-color-border-info: #2C74EA; + --oz-color-border-info-hover: #195DCA; + --oz-color-border-info-disabled: #2C74EA80; + --oz-color-border-focus: #BF2B00; + --oz-color-border-focus-inverse: #FFFFFF; + --oz-color-border-brand-secondary: #FC66454D; + --oz-color-border-selected: #D53100; + --oz-color-content-primary: #070605; + --oz-color-content-secondary: #2E2C2B; + --oz-color-content-tertiary: #5F5D5C; + --oz-color-content-placeholder: #5F5D5C; + --oz-color-content-primary-disabled: #07060580; + --oz-color-content-secondary-disabled: #2E2C2B80; + --oz-color-content-tertiary-disabled: #5F5D5C80; + --oz-color-content-link: #A92500; + --oz-color-content-link-hover: #7F1900; + --oz-color-content-link-visited: #5F4599; + --oz-color-content-selected: #A92500; + --oz-color-content-inverse-primary: #EFEDEC; + --oz-color-content-inverse-secondary: #C2C0BF; + --oz-color-content-inverse-primary-disabled: #EFEDEC80; + --oz-color-content-inverse-secondary-disabled: #C2C0BF80; + --oz-color-content-fixed-primary: #070605; + --oz-color-content-fixed-inverse: #FFFFFF; + --oz-color-content-fixed-primary-disabled: #07060580; + --oz-color-content-fixed-inverse-disabled: #FFFFFF80; + --oz-color-content-brand: #A92500; + --oz-color-content-brand-hover: #7F1900; + --oz-color-content-brand-active: #571002; + --oz-color-content-brand-disabled: #A9250080; + --oz-color-content-success: #006035; + --oz-color-content-success-hover: #004725; + --oz-color-content-success-active: #013118; + --oz-color-content-success-disabled: #00603580; + --oz-color-content-warning: #6B4600; + --oz-color-content-warning-hover: #4F3300; + --oz-color-content-warning-active: #362200; + --oz-color-content-warning-disabled: #6B460080; + --oz-color-content-critical: #9D183E; + --oz-color-content-critical-hover: #75112D; + --oz-color-content-critical-active: #520C1E; + --oz-color-content-critical-disabled: #9D183E80; + --oz-color-content-info: #1049A4; + --oz-color-content-info-hover: #09367C; + --oz-color-content-info-active: #062457; + --oz-color-content-info-disabled: #1049A480; + --oz-color-content-brand-inverse: #FC6645; + --oz-color-content-success-inverse: #4DA972; + --oz-color-content-warning-inverse: #BF8B39; + --oz-color-content-critical-inverse: #F5617D; + --oz-color-content-info-inverse: #5292FD; + --oz-color-content-on-brand: #FFFFFF; + --oz-color-content-on-success: #FFFFFF; + --oz-color-content-on-warning: #FFFFFF; + --oz-color-content-on-critical: #FFFFFF; + --oz-color-content-on-info: #FFFFFF; + --oz-color-content-on-inverse: #F7F5F4; + --oz-color-content-on-brand-disabled: #7C7A78; + --oz-color-content-on-success-disabled: #7C7A78; + --oz-color-content-on-warning-disabled: #7C7A78; + --oz-color-content-on-critical-disabled: #7C7A78; + --oz-color-content-on-info-disabled: #7C7A78; + --oz-color-chart-1: #FE542D; + --oz-color-chart-2: #005C94; + --oz-color-chart-3: #007F7F; + --oz-color-chart-4: #483376; + --oz-color-chart-5: #A97F00; + --oz-color-sidebar-background: #EFEDEC; + --oz-color-sidebar-border: #E3E1E0; + --oz-color-sidebar-item-hover: #E9E7E6; + --oz-color-sidebar-item-active: #D5D3D2; + --oz-color-sidebar-item-selected: #FC664526; + --oz-color-sidebar-content: #070605; + --oz-color-sidebar-content-muted: #2E2C2B; + --oz-color-sidebar-content-selected: #A92500; + --oz-color-gradient-mesh-1: #FF8A6F; + --oz-color-gradient-mesh-2: #D1C4FD; + --oz-color-gradient-mesh-3: #FFD8CE; + --oz-color-gradient-mesh-4: #FFB3A0; + --oz-color-gradient-mesh-base: #FFFFFF; + --oz-color-gradient-onboarding-1: #FF3D01; + --oz-color-gradient-onboarding-2: #C85993; + --oz-color-gradient-onboarding-3: #8E6FD8; + --oz-color-gradient-halo: #FF3D014D; + --oz-overlay-dimness: #07060566; + --oz-overlay-blur: 4px; + --oz-shadow-x-small: #A9A7A614; + --oz-shadow-small: #A9A7A61F; + --oz-shadow-medium: #A9A7A629; + --oz-shadow-large: #A9A7A633; + + /* ready-to-use box-shadow composites */ + --oz-elevation-x-small: 0 1px 2px 0 #A9A7A614; + --oz-elevation-small: 0 1px 3px 0 #A9A7A61F, 0 1px 2px -1px #A9A7A61F; + --oz-elevation-medium: 0 4px 6px -1px #A9A7A629, 0 2px 4px -2px #A9A7A629; + --oz-elevation-large: 0 10px 15px -3px #A9A7A633, 0 4px 6px -4px #A9A7A633; + } + + /* color-scheme is what tells the browser to render its OWN chrome dark: + scrollbars, pickers, form control defaults, spellcheck + underlines, the canvas behind an overscroll. The .light block declared it + and this one did not, so a dark-themed app kept light scrollbars and a + blinding white date picker. */ + .dark, + [data-theme='dark'] { + color-scheme: dark; + --oz-color-background: #070605; + --oz-color-surface-primary: #151312; + --oz-color-surface-primary-variant: #0E0C0B; + --oz-color-surface-secondary: #211F1D; + --oz-color-surface-secondary-variant: #151312; + --oz-color-surface-tertiary: #2E2C2B; + --oz-color-surface-tertiary-variant: #211F1D; + --oz-color-surface-elevated: #393735; + --oz-color-surface-overlay: #393735; + --oz-color-surface-inverse: #F7F5F4; + --oz-color-surface-fixed: #FFFFFF; + --oz-color-surface-brand: #FF3D0126; + --oz-color-surface-success: #1D915626; + --oz-color-surface-warning: #A36E0726; + --oz-color-surface-critical: #E63C6526; + --oz-color-surface-info: #2C74EA26; + --oz-color-surface-brand-flat: #571002; + --oz-color-surface-success-flat: #013118; + --oz-color-surface-warning-flat: #362200; + --oz-color-surface-critical-flat: #520C1E; + --oz-color-surface-info-flat: #062457; + --oz-color-fill-primary: #151312; + --oz-color-fill-primary-hover: #211F1D; + --oz-color-fill-primary-active: #2E2C2B; + --oz-color-fill-primary-disabled: #15131280; + --oz-color-fill-primary-variant: #0E0C0B; + --oz-color-fill-primary-variant-hover: #151312; + --oz-color-fill-primary-variant-active: #211F1D; + --oz-color-fill-primary-variant-disabled: #0E0C0B80; + --oz-color-fill-secondary: #211F1D; + --oz-color-fill-secondary-hover: #2E2C2B; + --oz-color-fill-secondary-active: #444241; + --oz-color-fill-secondary-disabled: #211F1D80; + --oz-color-fill-secondary-variant: #151312; + --oz-color-fill-secondary-variant-hover: #211F1D; + --oz-color-fill-secondary-variant-active: #2E2C2B; + --oz-color-fill-secondary-variant-disabled: #15131280; + --oz-color-fill-tertiary: #2E2C2B; + --oz-color-fill-tertiary-hover: #444241; + --oz-color-fill-tertiary-active: #5F5D5C; + --oz-color-fill-tertiary-disabled: #2E2C2B80; + --oz-color-fill-tertiary-variant: #211F1D; + --oz-color-fill-tertiary-variant-hover: #2E2C2B; + --oz-color-fill-tertiary-variant-active: #444241; + --oz-color-fill-tertiary-variant-disabled: #211F1D80; + --oz-color-fill-elevated: #211F1D; + --oz-color-fill-elevated-hover: #2E2C2B; + --oz-color-fill-elevated-active: #444241; + --oz-color-fill-elevated-disabled: #211F1D80; + --oz-color-fill-inverse: #F7F5F4; + --oz-color-fill-inverse-hover: #EFEDEC; + --oz-color-fill-inverse-active: #E3E1E0; + --oz-color-fill-inverse-disabled: #F7F5F480; + --oz-color-fill-brand: #FF3D01; + --oz-color-fill-brand-hover: #FE542D; + --oz-color-fill-brand-active: #FC6645; + --oz-color-fill-brand-disabled: #272524; + --oz-color-fill-success: #1D9156; + --oz-color-fill-success-hover: #389D64; + --oz-color-fill-success-active: #4DA972; + --oz-color-fill-success-disabled: #272524; + --oz-color-fill-warning: #A36E07; + --oz-color-fill-warning-hover: #B17C25; + --oz-color-fill-warning-active: #BF8B39; + --oz-color-fill-warning-disabled: #272524; + --oz-color-fill-critical: #E63C65; + --oz-color-fill-critical-hover: #EE5071; + --oz-color-fill-critical-active: #F5617D; + --oz-color-fill-critical-disabled: #272524; + --oz-color-fill-info: #2C74EA; + --oz-color-fill-info-hover: #4083F4; + --oz-color-fill-info-active: #5292FD; + --oz-color-fill-info-disabled: #272524; + --oz-color-fill-brand-secondary: #FF3D0126; + --oz-color-fill-brand-secondary-hover: #FF3D014D; + --oz-color-fill-brand-secondary-active: #FC66454D; + --oz-color-fill-brand-secondary-disabled: #FF3D0114; + --oz-color-fill-success-secondary: #1D915626; + --oz-color-fill-success-secondary-hover: #1D91564D; + --oz-color-fill-success-secondary-active: #4DA9724D; + --oz-color-fill-success-secondary-disabled: #1D915614; + --oz-color-fill-warning-secondary: #A36E0726; + --oz-color-fill-warning-secondary-hover: #A36E074D; + --oz-color-fill-warning-secondary-active: #BF8B394D; + --oz-color-fill-warning-secondary-disabled: #A36E0714; + --oz-color-fill-critical-secondary: #E63C6526; + --oz-color-fill-critical-secondary-hover: #E63C654D; + --oz-color-fill-critical-secondary-active: #F5617D4D; + --oz-color-fill-critical-secondary-disabled: #E63C6514; + --oz-color-fill-info-secondary: #2C74EA26; + --oz-color-fill-info-secondary-hover: #2C74EA4D; + --oz-color-fill-info-secondary-active: #5292FD4D; + --oz-color-fill-info-secondary-disabled: #2C74EA14; + --oz-color-fill-selected: #FF3D0126; + --oz-color-fill-selected-hover: #FF3D014D; + --oz-color-fill-selected-active: #FC66454D; + --oz-color-fill-selected-disabled: #FF3D0114; + --oz-color-fill-fixed: #FFFFFF; + --oz-color-fill-fixed-disabled: #FFFFFF80; + --oz-color-border-primary: #514F4E; + --oz-color-border-primary-hover: #5F5D5C; + --oz-color-border-primary-disabled: #514F4E80; + --oz-color-border-secondary: #5F5D5C; + --oz-color-border-secondary-hover: #7C7A78; + --oz-color-border-secondary-disabled: #5F5D5C80; + --oz-color-border-tertiary: #7C7A78; + --oz-color-border-tertiary-hover: #949290; + --oz-color-border-tertiary-disabled: #7C7A7880; + --oz-color-border-elevated: #444241; + --oz-color-border-elevated-hover: #5F5D5C; + --oz-color-border-elevated-disabled: #44424180; + --oz-color-border-inverse: #F7F5F4; + --oz-color-border-inverse-hover: #EFEDEC; + --oz-color-border-inverse-disabled: #F7F5F480; + --oz-color-border-brand: #FF3D01; + --oz-color-border-brand-hover: #FC6645; + --oz-color-border-brand-disabled: #FF3D0180; + --oz-color-border-success: #1D9156; + --oz-color-border-success-hover: #4DA972; + --oz-color-border-success-disabled: #1D915680; + --oz-color-border-warning: #A36E07; + --oz-color-border-warning-hover: #BF8B39; + --oz-color-border-warning-disabled: #A36E0780; + --oz-color-border-critical: #E63C65; + --oz-color-border-critical-hover: #F5617D; + --oz-color-border-critical-disabled: #E63C6580; + --oz-color-border-info: #2C74EA; + --oz-color-border-info-hover: #5292FD; + --oz-color-border-info-disabled: #2C74EA80; + --oz-color-border-focus: #FE785A; + --oz-color-border-focus-inverse: #070605; + --oz-color-border-brand-secondary: #FF3D014D; + --oz-color-border-selected: #FC6645; + --oz-color-content-primary: #EFEDEC; + --oz-color-content-secondary: #C2C0BF; + --oz-color-content-tertiary: #A9A7A6; + --oz-color-content-placeholder: #A9A7A6; + --oz-color-content-primary-disabled: #EFEDEC80; + --oz-color-content-secondary-disabled: #C2C0BF80; + --oz-color-content-tertiary-disabled: #A9A7A680; + --oz-color-content-link: #FC6645; + --oz-color-content-link-hover: #FF8A6F; + --oz-color-content-link-visited: #A58CE9; + --oz-color-content-selected: #FC6645; + --oz-color-content-inverse-primary: #070605; + --oz-color-content-inverse-secondary: #2E2C2B; + --oz-color-content-inverse-primary-disabled: #07060580; + --oz-color-content-inverse-secondary-disabled: #2E2C2B80; + --oz-color-content-fixed-primary: #070605; + --oz-color-content-fixed-inverse: #FFFFFF; + --oz-color-content-fixed-primary-disabled: #07060580; + --oz-color-content-fixed-inverse-disabled: #FFFFFF80; + --oz-color-content-brand: #FC6645; + --oz-color-content-brand-hover: #FF8A6F; + --oz-color-content-brand-active: #FFB3A0; + --oz-color-content-brand-disabled: #FC664580; + --oz-color-content-success: #4DA972; + --oz-color-content-success-hover: #7AC394; + --oz-color-content-success-active: #A5D8B6; + --oz-color-content-success-disabled: #4DA97280; + --oz-color-content-warning: #BF8B39; + --oz-color-content-warning-hover: #D9A75E; + --oz-color-content-warning-active: #EFC489; + --oz-color-content-warning-disabled: #BF8B3980; + --oz-color-content-critical: #F5617D; + --oz-color-content-critical-hover: #FE8597; + --oz-color-content-critical-active: #FFB0B9; + --oz-color-content-critical-disabled: #F5617D80; + --oz-color-content-info: #5292FD; + --oz-color-content-info-hover: #7DAEFF; + --oz-color-content-info-active: #A8C9FF; + --oz-color-content-info-disabled: #5292FD80; + --oz-color-content-brand-inverse: #A92500; + --oz-color-content-success-inverse: #006035; + --oz-color-content-warning-inverse: #6B4600; + --oz-color-content-critical-inverse: #9D183E; + --oz-color-content-info-inverse: #1049A4; + --oz-color-content-on-brand: #FFFFFF; + --oz-color-content-on-success: #FFFFFF; + --oz-color-content-on-warning: #FFFFFF; + --oz-color-content-on-critical: #FFFFFF; + --oz-color-content-on-info: #FFFFFF; + --oz-color-content-on-inverse: #070605; + --oz-color-content-on-brand-disabled: #949290; + --oz-color-content-on-success-disabled: #949290; + --oz-color-content-on-warning-disabled: #949290; + --oz-color-content-on-critical-disabled: #949290; + --oz-color-content-on-info-disabled: #949290; + --oz-color-chart-1: #FC6645; + --oz-color-chart-2: #048CDC; + --oz-color-chart-3: #4FCDCD; + --oz-color-chart-4: #7758BB; + --oz-color-chart-5: #E6CA92; + --oz-color-sidebar-background: #0E0C0B; + --oz-color-sidebar-border: #211F1D; + --oz-color-sidebar-item-hover: #110F0E; + --oz-color-sidebar-item-active: #2E2C2B; + --oz-color-sidebar-item-selected: #FF3D0126; + --oz-color-sidebar-content: #EFEDEC; + --oz-color-sidebar-content-muted: #C2C0BF; + --oz-color-sidebar-content-selected: #FC6645; + --oz-color-gradient-mesh-1: #D53100; + --oz-color-gradient-mesh-2: #7758BB; + --oz-color-gradient-mesh-3: #7F1900; + --oz-color-gradient-mesh-4: #FF3D01; + --oz-color-gradient-mesh-base: #151312; + --oz-color-gradient-onboarding-1: #A92500; + --oz-color-gradient-onboarding-2: #8B3262; + --oz-color-gradient-onboarding-3: #5F4599; + --oz-color-gradient-halo: #FC66454D; + --oz-overlay-dimness: #00000099; + --oz-overlay-blur: 4px; + --oz-shadow-x-small: #00000073; + --oz-shadow-small: #00000099; + --oz-shadow-medium: #000000BF; + --oz-shadow-large: #000000E6; + + /* ready-to-use box-shadow composites */ + --oz-elevation-x-small: 0 1px 2px 0 #00000073; + --oz-elevation-small: 0 1px 3px 0 #00000099, 0 1px 2px -1px #00000099; + --oz-elevation-medium: 0 4px 6px -1px #000000BF, 0 2px 4px -2px #000000BF; + --oz-elevation-large: 0 10px 15px -3px #000000E6, 0 4px 6px -4px #000000E6; + } + + /* Scoped light island inside a dark app. Replaces .force-light, which was a + hand-maintained third copy of the theme and had already drifted. */ + .light, + [data-theme='light'] { + color-scheme: light; + --oz-color-background: #FFFFFF; + --oz-color-surface-primary: #F7F5F4; + --oz-color-surface-primary-variant: #FFFFFF; + --oz-color-surface-secondary: #EFEDEC; + --oz-color-surface-secondary-variant: #F7F5F4; + --oz-color-surface-tertiary: #DCDAD9; + --oz-color-surface-tertiary-variant: #EFEDEC; + --oz-color-surface-elevated: #FFFFFF; + --oz-color-surface-overlay: #FFFFFF; + --oz-color-surface-inverse: #070605; + --oz-color-surface-fixed: #FFFFFF; + --oz-color-surface-brand: #FFECE7; + --oz-color-surface-success: #E7F5EB; + --oz-color-surface-warning: #FDF0DE; + --oz-color-surface-critical: #FFEAEC; + --oz-color-surface-info: #E7F0FF; + --oz-color-surface-brand-flat: #FFECE7; + --oz-color-surface-success-flat: #E7F5EB; + --oz-color-surface-warning-flat: #FDF0DE; + --oz-color-surface-critical-flat: #FFEAEC; + --oz-color-surface-info-flat: #E7F0FF; + --oz-color-fill-primary: #F7F5F4; + --oz-color-fill-primary-hover: #EFEDEC; + --oz-color-fill-primary-active: #E3E1E0; + --oz-color-fill-primary-disabled: #F7F5F480; + --oz-color-fill-primary-variant: #FFFFFF; + --oz-color-fill-primary-variant-hover: #F7F5F4; + --oz-color-fill-primary-variant-active: #EFEDEC; + --oz-color-fill-primary-variant-disabled: #FFFFFF80; + --oz-color-fill-secondary: #EFEDEC; + --oz-color-fill-secondary-hover: #E3E1E0; + --oz-color-fill-secondary-active: #D5D3D2; + --oz-color-fill-secondary-disabled: #EFEDEC80; + --oz-color-fill-secondary-variant: #F7F5F4; + --oz-color-fill-secondary-variant-hover: #EFEDEC; + --oz-color-fill-secondary-variant-active: #E3E1E0; + --oz-color-fill-secondary-variant-disabled: #F7F5F480; + --oz-color-fill-tertiary: #E3E1E0; + --oz-color-fill-tertiary-hover: #D5D3D2; + --oz-color-fill-tertiary-active: #C2C0BF; + --oz-color-fill-tertiary-disabled: #E3E1E080; + --oz-color-fill-tertiary-variant: #EFEDEC; + --oz-color-fill-tertiary-variant-hover: #E3E1E0; + --oz-color-fill-tertiary-variant-active: #D5D3D2; + --oz-color-fill-tertiary-variant-disabled: #EFEDEC80; + --oz-color-fill-elevated: #FFFFFF; + --oz-color-fill-elevated-hover: #F7F5F4; + --oz-color-fill-elevated-active: #EFEDEC; + --oz-color-fill-elevated-disabled: #FFFFFF80; + --oz-color-fill-inverse: #070605; + --oz-color-fill-inverse-hover: #0E0C0B; + --oz-color-fill-inverse-active: #151312; + --oz-color-fill-inverse-disabled: #07060580; + --oz-color-fill-brand: #FF3D01; + --oz-color-fill-brand-hover: #D53100; + --oz-color-fill-brand-active: #A92500; + --oz-color-fill-brand-disabled: #E9E7E6; + --oz-color-fill-success: #1D9156; + --oz-color-fill-success-hover: #037944; + --oz-color-fill-success-active: #006035; + --oz-color-fill-success-disabled: #E9E7E6; + --oz-color-fill-warning: #A36E07; + --oz-color-fill-warning-hover: #865900; + --oz-color-fill-warning-active: #6B4600; + --oz-color-fill-warning-disabled: #E9E7E6; + --oz-color-fill-critical: #E63C65; + --oz-color-fill-critical-hover: #C52450; + --oz-color-fill-critical-active: #9D183E; + --oz-color-fill-critical-disabled: #E9E7E6; + --oz-color-fill-info: #2C74EA; + --oz-color-fill-info-hover: #195DCA; + --oz-color-fill-info-active: #1049A4; + --oz-color-fill-info-disabled: #E9E7E6; + --oz-color-fill-brand-secondary: #FC664526; + --oz-color-fill-brand-secondary-hover: #FC66454D; + --oz-color-fill-brand-secondary-active: #FF3D014D; + --oz-color-fill-brand-secondary-disabled: #FC664514; + --oz-color-fill-success-secondary: #4DA97226; + --oz-color-fill-success-secondary-hover: #4DA9724D; + --oz-color-fill-success-secondary-active: #1D91564D; + --oz-color-fill-success-secondary-disabled: #4DA97214; + --oz-color-fill-warning-secondary: #BF8B3926; + --oz-color-fill-warning-secondary-hover: #BF8B394D; + --oz-color-fill-warning-secondary-active: #A36E074D; + --oz-color-fill-warning-secondary-disabled: #BF8B3914; + --oz-color-fill-critical-secondary: #F5617D26; + --oz-color-fill-critical-secondary-hover: #F5617D4D; + --oz-color-fill-critical-secondary-active: #E63C654D; + --oz-color-fill-critical-secondary-disabled: #F5617D14; + --oz-color-fill-info-secondary: #5292FD26; + --oz-color-fill-info-secondary-hover: #5292FD4D; + --oz-color-fill-info-secondary-active: #2C74EA4D; + --oz-color-fill-info-secondary-disabled: #5292FD14; + --oz-color-fill-selected: #FC664526; + --oz-color-fill-selected-hover: #FC66454D; + --oz-color-fill-selected-active: #FF3D014D; + --oz-color-fill-selected-disabled: #FC664514; + --oz-color-fill-fixed: #FFFFFF; + --oz-color-fill-fixed-disabled: #FFFFFF80; + --oz-color-border-primary: #E3E1E0; + --oz-color-border-primary-hover: #D5D3D2; + --oz-color-border-primary-disabled: #E3E1E080; + --oz-color-border-secondary: #CCC9C8; + --oz-color-border-secondary-hover: #C2C0BF; + --oz-color-border-secondary-disabled: #CCC9C880; + --oz-color-border-tertiary: #C2C0BF; + --oz-color-border-tertiary-hover: #A9A7A6; + --oz-color-border-tertiary-disabled: #C2C0BF80; + --oz-color-border-elevated: #E3E1E0; + --oz-color-border-elevated-hover: #D5D3D2; + --oz-color-border-elevated-disabled: #E3E1E080; + --oz-color-border-inverse: #070605; + --oz-color-border-inverse-hover: #0E0C0B; + --oz-color-border-inverse-disabled: #07060580; + --oz-color-border-brand: #FF3D01; + --oz-color-border-brand-hover: #D53100; + --oz-color-border-brand-disabled: #FF3D0180; + --oz-color-border-success: #1D9156; + --oz-color-border-success-hover: #037944; + --oz-color-border-success-disabled: #1D915680; + --oz-color-border-warning: #A36E07; + --oz-color-border-warning-hover: #865900; + --oz-color-border-warning-disabled: #A36E0780; + --oz-color-border-critical: #E63C65; + --oz-color-border-critical-hover: #C52450; + --oz-color-border-critical-disabled: #E63C6580; + --oz-color-border-info: #2C74EA; + --oz-color-border-info-hover: #195DCA; + --oz-color-border-info-disabled: #2C74EA80; + --oz-color-border-focus: #BF2B00; + --oz-color-border-focus-inverse: #FFFFFF; + --oz-color-border-brand-secondary: #FC66454D; + --oz-color-border-selected: #D53100; + --oz-color-content-primary: #070605; + --oz-color-content-secondary: #2E2C2B; + --oz-color-content-tertiary: #5F5D5C; + --oz-color-content-placeholder: #5F5D5C; + --oz-color-content-primary-disabled: #07060580; + --oz-color-content-secondary-disabled: #2E2C2B80; + --oz-color-content-tertiary-disabled: #5F5D5C80; + --oz-color-content-link: #A92500; + --oz-color-content-link-hover: #7F1900; + --oz-color-content-link-visited: #5F4599; + --oz-color-content-selected: #A92500; + --oz-color-content-inverse-primary: #EFEDEC; + --oz-color-content-inverse-secondary: #C2C0BF; + --oz-color-content-inverse-primary-disabled: #EFEDEC80; + --oz-color-content-inverse-secondary-disabled: #C2C0BF80; + --oz-color-content-fixed-primary: #070605; + --oz-color-content-fixed-inverse: #FFFFFF; + --oz-color-content-fixed-primary-disabled: #07060580; + --oz-color-content-fixed-inverse-disabled: #FFFFFF80; + --oz-color-content-brand: #A92500; + --oz-color-content-brand-hover: #7F1900; + --oz-color-content-brand-active: #571002; + --oz-color-content-brand-disabled: #A9250080; + --oz-color-content-success: #006035; + --oz-color-content-success-hover: #004725; + --oz-color-content-success-active: #013118; + --oz-color-content-success-disabled: #00603580; + --oz-color-content-warning: #6B4600; + --oz-color-content-warning-hover: #4F3300; + --oz-color-content-warning-active: #362200; + --oz-color-content-warning-disabled: #6B460080; + --oz-color-content-critical: #9D183E; + --oz-color-content-critical-hover: #75112D; + --oz-color-content-critical-active: #520C1E; + --oz-color-content-critical-disabled: #9D183E80; + --oz-color-content-info: #1049A4; + --oz-color-content-info-hover: #09367C; + --oz-color-content-info-active: #062457; + --oz-color-content-info-disabled: #1049A480; + --oz-color-content-brand-inverse: #FC6645; + --oz-color-content-success-inverse: #4DA972; + --oz-color-content-warning-inverse: #BF8B39; + --oz-color-content-critical-inverse: #F5617D; + --oz-color-content-info-inverse: #5292FD; + --oz-color-content-on-brand: #FFFFFF; + --oz-color-content-on-success: #FFFFFF; + --oz-color-content-on-warning: #FFFFFF; + --oz-color-content-on-critical: #FFFFFF; + --oz-color-content-on-info: #FFFFFF; + --oz-color-content-on-inverse: #F7F5F4; + --oz-color-content-on-brand-disabled: #7C7A78; + --oz-color-content-on-success-disabled: #7C7A78; + --oz-color-content-on-warning-disabled: #7C7A78; + --oz-color-content-on-critical-disabled: #7C7A78; + --oz-color-content-on-info-disabled: #7C7A78; + --oz-color-chart-1: #FE542D; + --oz-color-chart-2: #005C94; + --oz-color-chart-3: #007F7F; + --oz-color-chart-4: #483376; + --oz-color-chart-5: #A97F00; + --oz-color-sidebar-background: #EFEDEC; + --oz-color-sidebar-border: #E3E1E0; + --oz-color-sidebar-item-hover: #E9E7E6; + --oz-color-sidebar-item-active: #D5D3D2; + --oz-color-sidebar-item-selected: #FC664526; + --oz-color-sidebar-content: #070605; + --oz-color-sidebar-content-muted: #2E2C2B; + --oz-color-sidebar-content-selected: #A92500; + --oz-color-gradient-mesh-1: #FF8A6F; + --oz-color-gradient-mesh-2: #D1C4FD; + --oz-color-gradient-mesh-3: #FFD8CE; + --oz-color-gradient-mesh-4: #FFB3A0; + --oz-color-gradient-mesh-base: #FFFFFF; + --oz-color-gradient-onboarding-1: #FF3D01; + --oz-color-gradient-onboarding-2: #C85993; + --oz-color-gradient-onboarding-3: #8E6FD8; + --oz-color-gradient-halo: #FF3D014D; + --oz-overlay-dimness: #07060566; + --oz-overlay-blur: 4px; + --oz-shadow-x-small: #A9A7A614; + --oz-shadow-small: #A9A7A61F; + --oz-shadow-medium: #A9A7A629; + --oz-shadow-large: #A9A7A633; + + /* ready-to-use box-shadow composites */ + --oz-elevation-x-small: 0 1px 2px 0 #A9A7A614; + --oz-elevation-small: 0 1px 3px 0 #A9A7A61F, 0 1px 2px -1px #A9A7A61F; + --oz-elevation-medium: 0 4px 6px -1px #A9A7A629, 0 2px 4px -2px #A9A7A629; + --oz-elevation-large: 0 10px 15px -3px #A9A7A633, 0 4px 6px -4px #A9A7A633; + } + + /* Someone asked their operating system for less movement. Honour it here, in the + layer that defines the movement — not in whichever app happens to remember. See + reducedMotionBlock() in build/build.mjs for why this is not `animation: none`. + + INSIDE `@layer base`, and after the mode blocks, and both halves of that are + load-bearing. This block shipped after the closing brace instead, on the + reasoning that unlayered CSS outranks layered CSS — which is true of real + cascade layers and false here. Tailwind treats `@layer base` as its own + directive rather than as CSS: it hoists the contents to wherever + `@tailwind base` sits and emits no `@layer` at-rule at all. So in the compiled + sheet there were no layers to reason about, this block sat at byte 95, the + `:root` that sets the multiplier to 1 sat at byte 8849, the two had identical + specificity — and later won. + + The effect was total: the multiplier measured 1 with the preference on, so every + `.oz-enter-*` kept its full travel, every press-scale kept its squash, and the + spring remap never applied either, so spatial curves kept their overshoot. + Nothing was visibly broken, which is why it survived — the only symptom was that + a preference did nothing. + + Now the relative order is authored order within one layer, which Tailwind + preserves. Do not move it back out. `verify-classes.mjs` measures the byte + offsets in the compiled stylesheet, because this is a property of the output and + no assertion about the input can see it. */ + @media (prefers-reduced-motion: reduce) { + /* The island selectors are not redundant with `:root`. A scoped `.dark` island + re-declares every value including the multiplier, and for an inherited custom + property the nearer element wins outright — specificity never enters into it — + so an island would silently re-enable motion inside itself. */ + :root, + .dark, + [data-theme='dark'], + .light, + [data-theme='light'] { + /* 1. All spatial travel collapses; fades are untouched. */ + --oz-motion-spatial-scale: 0; + + /* 2. Spatial springs lose their overshoot, keeping their speed. */ + --oz-spring-spatial-fast: var(--oz-spring-effects-fast); + --oz-spring-spatial-default: var(--oz-spring-effects-default); + --oz-spring-spatial-slow: var(--oz-spring-effects-slow); + --oz-spring-expressive: var(--oz-spring-effects-slow); + } + + /* 3. Ambient loops stop. `.oz-ambient` is the opt-in marker for a decorative + loop; the attribute selector catches anything driving one from the token + directly. Not a universal selector — a functional transition is not the + thing being objected to. `!important` is what carries this past the + `.oz-ambient` in @layer utilities, which is emitted after this one. */ + .oz-ambient, + [style*='--oz-duration-ambient'] { + animation: none !important; + } + + /* 4. Programmatic smooth scroll is large-viewport travel. */ + html { + scroll-behavior: auto !important; + } + } +} + +/* Type steps. Weight is deliberately NOT baked in — every step accepts every + weight via --oz-weight-* or Tailwind's font-* utilities. */ +@layer utilities { +.oz-text-display-lg { + font-family: var(--oz-font-display); + font-size: var(--oz-text-display-lg); + line-height: var(--oz-leading-display-lg); + letter-spacing: var(--oz-tracking-display-lg); +} +.oz-text-display-md { + font-family: var(--oz-font-display); + font-size: var(--oz-text-display-md); + line-height: var(--oz-leading-display-md); + letter-spacing: var(--oz-tracking-display-md); +} +.oz-text-display-sm { + font-family: var(--oz-font-display); + font-size: var(--oz-text-display-sm); + line-height: var(--oz-leading-display-sm); + letter-spacing: var(--oz-tracking-display-sm); +} +.oz-text-heading-xl { + font-family: var(--oz-font-heading); + font-size: var(--oz-text-heading-xl); + line-height: var(--oz-leading-heading-xl); + letter-spacing: var(--oz-tracking-heading-xl); +} +.oz-text-heading-lg { + font-family: var(--oz-font-heading); + font-size: var(--oz-text-heading-lg); + line-height: var(--oz-leading-heading-lg); + letter-spacing: var(--oz-tracking-heading-lg); +} +.oz-text-heading-md { + font-family: var(--oz-font-heading); + font-size: var(--oz-text-heading-md); + line-height: var(--oz-leading-heading-md); + letter-spacing: var(--oz-tracking-heading-md); +} +.oz-text-heading-sm { + font-family: var(--oz-font-heading); + font-size: var(--oz-text-heading-sm); + line-height: var(--oz-leading-heading-sm); + letter-spacing: var(--oz-tracking-heading-sm); +} +.oz-text-heading-xs { + font-family: var(--oz-font-heading); + font-size: var(--oz-text-heading-xs); + line-height: var(--oz-leading-heading-xs); + letter-spacing: var(--oz-tracking-heading-xs); +} +.oz-text-body-lg { + font-family: var(--oz-font-body); + font-size: var(--oz-text-body-lg); + line-height: var(--oz-leading-body-lg); + letter-spacing: var(--oz-tracking-body-lg); +} +.oz-text-body-md { + font-family: var(--oz-font-body); + font-size: var(--oz-text-body-md); + line-height: var(--oz-leading-body-md); + letter-spacing: var(--oz-tracking-body-md); +} +.oz-text-body-sm { + font-family: var(--oz-font-body); + font-size: var(--oz-text-body-sm); + line-height: var(--oz-leading-body-sm); + letter-spacing: var(--oz-tracking-body-sm); +} +.oz-text-body-xs { + font-family: var(--oz-font-body); + font-size: var(--oz-text-body-xs); + line-height: var(--oz-leading-body-xs); + letter-spacing: var(--oz-tracking-body-xs); +} +.oz-text-label-md { + font-family: var(--oz-font-label); + font-size: var(--oz-text-label-md); + line-height: var(--oz-leading-label-md); + letter-spacing: var(--oz-tracking-label-md); +} +.oz-text-label-sm { + font-family: var(--oz-font-label); + font-size: var(--oz-text-label-sm); + line-height: var(--oz-leading-label-sm); + letter-spacing: var(--oz-tracking-label-sm); +} +.oz-text-label-xs { + font-family: var(--oz-font-label); + font-size: var(--oz-text-label-xs); + line-height: var(--oz-leading-label-xs); + letter-spacing: var(--oz-tracking-label-xs); +} + +/* Every visual property, and nothing that triggers layout. */ +.oz-transition-visual { + transition-property: color, background-color, border-color, outline-color, fill, + stroke, opacity, box-shadow, transform, filter; +} + +/* Depth alone, for a surface that lifts without changing colour. */ +.oz-transition-depth { + transition-property: box-shadow, transform; +} + +@keyframes oz-enter-fade { + from { opacity: 0; transform: none; } + to { opacity: 1; transform: none; } +} + +@keyframes oz-enter-rise { + from { opacity: 0; transform: translateY(calc(6px * var(--oz-motion-spatial-scale))); } + to { opacity: 1; transform: none; } +} + +@keyframes oz-enter-pop { + from { opacity: 0; transform: scale(calc(1 - 0.04 * var(--oz-motion-spatial-scale))); } + to { opacity: 1; transform: none; } +} + +@keyframes oz-enter-hero { + from { opacity: 0; transform: translateY(calc(10px * var(--oz-motion-spatial-scale))) scale(calc(1 - 0.02 * var(--oz-motion-spatial-scale))); } + to { opacity: 1; transform: none; } +} + +@keyframes oz-pulse { + 0%, 100% { opacity: 1; } + 50% { opacity: 0.45; } +} + +/* Opacity only. The safe default, and the only one that is identical under reduced motion. */ +.oz-enter-fade { + animation: oz-enter-fade var(--oz-spring-effects-default-ms) var(--oz-spring-effects-default) both; +} + +/* Content arriving in place: a row, a card, a result. */ +.oz-enter-rise { + animation: oz-enter-rise var(--oz-spring-spatial-default-ms) var(--oz-spring-spatial-default) both; +} + +/* Something that appeared because you acted: a popover, a menu, a toast. */ +.oz-enter-pop { + animation: oz-enter-pop var(--oz-spring-spatial-fast-ms) var(--oz-spring-spatial-fast) both; +} + +/* One per screen. The moment worth noticing. */ +.oz-enter-hero { + animation: oz-enter-hero var(--oz-spring-expressive-ms) var(--oz-spring-expressive) both; +} + +/* The one loop in the system. Not a spring — a pulse has no target to settle + toward, so it keeps the ambient duration and a symmetric curve. Carries the + .oz-ambient marker that the reduced-motion block switches off, because an endless + decorative loop is the clearest case of what that preference is asking about. */ +.oz-ambient { + animation: oz-pulse var(--oz-duration-ambient) var(--oz-ease-standard) infinite; +} + + /* Stops the page behind an overlay from scrolling. Toggle it on the root + element; see useScrollLock in the showcase for the reference consumer. + + No padding compensation here, and that is the point — :root reserves the + scrollbar gutter permanently, so taking the scrollbar away costs no width and + nothing moves. */ + .oz-scroll-lock { + overflow: hidden; + + /* And the gutter has to be painted, because an overlay's scrim cannot reach it. + position: fixed resolves against the initial containing block, which excludes + reserved gutters, so `inset: 0` stops short of the window edge by exactly the + scrollbar's width. Left alone that strip keeps the page background — measured + here as an undimmed 15px band down the right of a dimmed page, which is a + smaller version of the artefact the gutter was reserved to remove. + + Two background layers rather than one translucent colour: a semi-transparent + background on the root composites against the browser's default canvas, not + against the page, so it would come out grey in dark mode. A gradient of one + colour is the standard way to get a paint layer that composites over + background-color, and the pair reproduces exactly what the scrim is — dimness + over the page. + + This also takes over from background propagation. With no background of its + own the root propagates 's to the canvas; declaring one here stops that + for as long as the lock is held, which is correct — body still paints its own + box, so only the gutter changes. */ + background-color: var(--oz-color-background); + background-image: linear-gradient( + var(--oz-overlay-dimness), + var(--oz-overlay-dimness) + ); + } + + /* Safari below 18.2 has no scrollbar-gutter, so the gutter cannot be reserved and + the width has to be given back by hand. --oz-scrollbar-width is measured by + the locking code BEFORE the class lands, because measuring afterwards reads the + scrollbar that has already gone. It falls back to 0px, so a browser with support + and a browser with overlay scrollbars both pay exactly nothing. + + Scoped in @supports rather than applied unconditionally because the two fixes are + not additive: reserving the gutter AND padding for it would move the page 15px + the other way. */ + @supports not (scrollbar-gutter: stable) { + .oz-scroll-lock { + padding-right: var(--oz-scrollbar-width, 0px); + } + } +} diff --git a/packaging/README.md b/packaging/README.md new file mode 100644 index 0000000..79d84aa --- /dev/null +++ b/packaging/README.md @@ -0,0 +1,160 @@ +# Packaging + +This directory builds the downloadable application: a self-contained folder with +its own Python, Pillow, numpy, scipy and all four optional engines, so somebody +who has never installed Python can double-click an installer and get the same +results a developer gets. + +It changes nothing about the developer path. `pip install -e ".[full,app]"` is +still the way to work on this, still the thing CI tests, and still what +[CONTRIBUTING.md](../CONTRIBUTING.md) describes. Nothing in here is imported by +`imgcompress` and nothing in `imgcompress` knows it exists. + +For *why* it is built this way — the arch matrix, onedir, the engines that fail +silently — read [docs/PACKAGING.md](../docs/PACKAGING.md). This file is the +instructions. + +## What comes out + +| Platform | Artifact | Contains | +| --- | --- | --- | +| Windows x64 | `imgcompress--windows-x64[-unsigned]-setup.exe` | per-user installer, Start-menu entry, optional desktop shortcut | +| macOS arm64 | `imgcompress--macos-arm64[-unsigned].dmg` | `Image Compressor.app`, drag to Applications | +| macOS x86_64 | `imgcompress--macos-x86_64[-unsigned].dmg` | the same, for Intel Macs | + +Every artifact holds two programs. `imgcompress-gui` is the window; `imgcompress` +is the console command, and it is the one CI interrogates. On Windows both sit in +the install directory. On macOS the console command is inside the bundle at +`Image Compressor.app/Contents/MacOS/imgcompress`. + +Until signing is sorted out (see below) `-unsigned` appears in every filename. +That is deliberate and it is load-bearing: an unsigned installer should not be +able to acquire a filename that suggests otherwise. The word is added or omitted +once, before anything is named, from whether the signing credentials exist — so +the name and the signature cannot disagree. + +## Building it yourself + +You need Python 3.13 — not 3.12, not 3.14. That is the version with wheels for +all four engines and the version the release builds with. + +```bash +python -m pip install ".[full,app]" pyinstaller +pyinstaller --clean --noconfirm packaging/imgcompress.spec +``` + +Note the missing `-e`. The spec builds from the installed distribution rather +than from the working tree, and it stops with an error if the package is not +installed. Building from the tree would hide a mistake in the package-data list +in `pyproject.toml` until somebody pip-installed a release. + +Then check the result the same way CI does: + +```bash +# Windows +dist/imgcompress/imgcompress.exe --check +dist/imgcompress/imgcompress.exe tests/bench_corpus -o /tmp/out + +# macOS +"dist/Image Compressor.app/Contents/MacOS/imgcompress" --check +"dist/Image Compressor.app/Contents/MacOS/imgcompress" tests/bench_corpus -o /tmp/out +``` + +`--check` must print `[x]` on all four lines. Anything else means an engine did +not make it in, the application is quietly compressing worse than it should, and +the build is not shippable. A build takes a couple of minutes; compressing the +corpus takes about six, because that is a real quality search over a 12 MP +photograph. + +## What the release workflow does + +`.github/workflows/release.yml` runs on a `v*` tag and, per platform: installs, +builds, then **gates**. It reads the output of `imgcompress --check` from the +frozen binary and fails the release if any engine is inactive; compresses the +benchmark corpus with the frozen binary and fails if the file count is wrong; +and confirms the architecture is the one the filename claims. Only then does it +wrap the build into an installer, and it publishes to a **draft** release that a +person has to look at and promote. + +The gate parses the report rather than trusting the exit status, because +`--check` returns 0 whether or not everything is present. That is the correct +behaviour for a diagnostic and useless for a gate. + +## What only the owner can do + +Signing cannot be automated from this repository. Both halves need paid accounts +and credentials that belong to a person, and neither can be faked. The workflow +therefore builds and gates unsigned artifacts, with the signing steps present, +skipped unless the credentials exist, and marked in the file as never having run. + +### Windows + +Since June 2023 the CA/Browser Forum has required the private key for an OV +code-signing certificate to live on FIPS 140-2 Level 2 (or Common Criteria +EAL4+) hardware. A `.pfx` file in a repository secret is no longer a thing that +exists. The realistic routes, all of which need the owner's identity documents +and a company or sole-trader registration: + +| Route | Roughly | Notes | +| --- | --- | --- | +| Azure Trusted Signing | a few dollars a month plus per-signature | cheapest; needs an Azure subscription and a verified identity. Uses `signtool` with Microsoft's key-store library, not the command in the workflow | +| Certificate in Azure Key Vault (HSM-backed) | certificate cost plus Key Vault | what the workflow is currently written against, via `AzureSignTool` | +| DigiCert KeyLocker / SSL.com eSigner | a few hundred a year | cloud signing service with its own CLI | +| A physical token (YubiKey / eToken) | a few hundred a year | cannot be used from CI at all; signing becomes a manual step on the owner's machine | + +Secrets the current step expects: `AZURE_TENANT_ID`, `AZURE_CLIENT_ID`, +`AZURE_CLIENT_SECRET`, `AZURE_KEY_VAULT_URL`, `AZURE_KEY_VAULT_CERTIFICATE`. +`AZURE_CLIENT_ID` is the one whose presence flips the build into signed mode, so +add it last. + +Note what signing does and does not buy. A brand-new OV certificate has no +SmartScreen reputation, so the first few hundred downloads may still show a +warning; reputation accrues per certificate and per publisher. Only an EV +certificate starts with reputation, and it costs several times more. + +### macOS + +Two separate things, both needed: + +1. **A Developer ID Application certificate**, which requires an Apple Developer + Program membership (99 USD a year). This is what `codesign` uses. A free + Apple ID cannot issue one. +2. **Notarisation** — uploading the signed artifact to Apple, who scan it and + return a ticket that gets stapled into the disk image. Without it, Gatekeeper + on a machine that has never seen the app refuses to open it, and the + right-click-Open trick has been getting steadily harder to find since + macOS 15. + +Secrets the current steps expect: `MACOS_CERTIFICATE_P12` (the exported +certificate and key, base64), `MACOS_CERTIFICATE_PASSWORD`, +`MACOS_SIGNING_IDENTITY` (for example `Developer ID Application: Name (TEAMID)`), +`APPLE_ID`, `APPLE_APP_PASSWORD` (an app-specific password, not the account +password) and `APPLE_TEAM_ID`. `MACOS_CERTIFICATE_P12` is the one that flips the +build into signed mode. + +Expect to debug the first notarisation. A PyInstaller bundle is hundreds of +individual Mach-O files and Apple checks all of them. If the rejection mentions +executable memory or JIT, write an entitlements plist and point the build at it +with `IMGCOMPRESS_ENTITLEMENTS` — the spec already reads that variable and +passes it through — rather than dropping the hardened runtime, which would make +notarisation impossible instead of merely annoying. + +## Things to check first when it breaks + +- **The job never starts.** Runner labels for Intel macOS have changed before and + will change again. `macos-15-intel` is the current one; if it has been retired, + that is the line to fix, and dropping the x86_64 build is a product decision, + not a build fix. +- **`--check` reports one engine missing.** Nothing about the application is + broken; one shared library did not get collected. `docs/PACKAGING.md` has the + known causes, one per engine. +- **The build is enormous.** `dist/imgcompress` measured 154 MB on Windows x64 in + an environment holding only the declared dependencies; the installer that wraps + it is compressed and smaller again. The same spec, built from a general-purpose + Python install that also had torch, transformers and a few machine-learning + libraries in it, came out at 1.5 GB — PyInstaller collects what it can reach, + and hooks fire for packages you did not know were installed. Build in a fresh + virtual environment. The release does. +- **`AppId` in the generated Inno Setup script.** Never change it. It is how + Windows recognises one version as an upgrade of another; a new value turns + every future release into a second, parallel installation. diff --git a/packaging/imgcompress.spec b/packaging/imgcompress.spec new file mode 100644 index 0000000..7a019c7 --- /dev/null +++ b/packaging/imgcompress.spec @@ -0,0 +1,327 @@ +# -*- mode: python -*- +"""PyInstaller spec for the desktop build. + +One onedir bundle, two executables: `imgcompress`, the console command, and +`imgcompress-gui`, the windowed app that the installer puts in the Start menu or +in /Applications. They share one copy of Python, Pillow, numpy, scipy and the +four optional engines, which is the entire reason they live in the same bundle - +the payload is around 250 MB and building it twice would double every download. + +Read docs/PACKAGING.md before changing anything here. Every collection rule +below exists because of a specific engine that goes *quiet* rather than crashing +when it cannot load, and a build that compresses images with weaker built-ins +looks exactly like a working one. That is also why `imgcompress --check` is a +release gate in .github/workflows/release.yml rather than a diagnostic anyone is +expected to read. + +Build it from an installed package, not from the source tree: + + python -m pip install ".[full,app]" pyinstaller + pyinstaller --clean --noconfirm packaging/imgcompress.spec +""" + +# PyInstaller execs this file with Analysis, PYZ, EXE, COLLECT, BUNDLE, SPECPATH +# and workpath already bound in the namespace, so to a linter reading it as an +# ordinary module they are all undefined names. Silenced once here rather than +# eight times inline. +# ruff: noqa: F821 + +import os +import sys +from pathlib import Path + +from PyInstaller.utils.hooks import ( + collect_data_files, + collect_delvewheel_libs_directory, +) + +REPO = Path(SPECPATH).resolve().parent + +IS_MACOS = sys.platform == "darwin" + +# The name people see. gui.py already titles the window this, so the .app, the +# Start menu entry and the window agree without anybody retyping the string. +APP_NAME = "Image Compressor" +BUNDLE_ID = "com.heyoz.imgcompress" + +# One 512px source for both platforms. PyInstaller converts it to .ico or .icns +# with Pillow, which is a build dependency anyway. The obvious-looking +# alternative, web/favicon.ico, is 16x16 only - it would give the installed +# application a blurred smudge everywhere Windows asks for 48px or 256px. +ICON = REPO / "web" / "icon-512.png" + +# Signing is handled outside PyInstaller on Windows and can be handled either +# way on macOS; see packaging/README.md. When the identity is absent these stay +# None and PyInstaller falls back to the ad-hoc signature that arm64 macOS +# requires just to execute, which is not the same thing as a signed app. +CODESIGN_IDENTITY = os.environ.get("IMGCOMPRESS_CODESIGN_IDENTITY") or None +ENTITLEMENTS_FILE = os.environ.get("IMGCOMPRESS_ENTITLEMENTS") or None + + +# --------------------------------------------------------------------------- # +# entry scripts +# --------------------------------------------------------------------------- # + +# Neither imgcompress/cli.py nor imgcompress/gui.py can be handed to Analysis +# directly: PyInstaller runs the entry script as `__main__`, and both files +# start with relative imports (`from . import __version__`), which fail outside +# their package. So the two three-line shims are generated into the build +# directory instead of being committed. They are build output, not source - +# there is nothing in them to review or to keep in step with anything, and a +# committed copy would be one more file that can drift. +# +# `import imgcompress` is what runs multiprocessing.freeze_support() - see the +# comment in imgcompress/__init__.py, which describes what a frozen build does +# to a ProcessPoolExecutor without it. Both shims reach it on their first line. + +CLI_ENTRY_NAME = "imgcompress_cli_entry" +GUI_ENTRY_NAME = "imgcompress_gui_entry" + + +def _write_entry_shim(name, module): + """Write a shim that calls `module.main()` and return its path.""" + directory = Path(workpath) / "entry" + directory.mkdir(parents=True, exist_ok=True) + path = directory / f"{name}.py" + path.write_text( + "import sys\n" + f"from {module} import main\n" + "sys.exit(main())\n", + encoding="utf-8", + ) + return str(path) + + +cli_entry = _write_entry_shim(CLI_ENTRY_NAME, "imgcompress.cli") +gui_entry = _write_entry_shim(GUI_ENTRY_NAME, "imgcompress.gui") + + +# --------------------------------------------------------------------------- # +# what has to be collected by hand +# --------------------------------------------------------------------------- # + +# The desktop UI: one HTML file, the design system copied from web/, and the +# faces. server.py resolves them as Path(__file__).resolve().parent / "webui", +# which in a onedir build lands inside _internal/imgcompress/ - exactly where +# collect_data_files puts them. (This is also why onefile is not an option; see +# docs/PACKAGING.md.) Collected wholesale rather than by extension so that +# adding an icon or a face to webui/ never needs an edit here. +datas = collect_data_files("imgcompress") + +binaries = [] + +# zopflipy's wheel is delvewheel-repaired: zopfli/__init__.py runs +# os.add_dll_directory(/zopflipy.libs), guarded by os.path.isdir. In a +# frozen build that directory is not at that relative path, so the guard quietly +# does nothing, and _zopfli.pyd then cannot resolve the vendored MSVCP140 DLL. +# The result is not a crash: encoders.py catches the ImportError, sets +# HAVE_ZOPFLI = False, and every PNG ships about 10% larger than it should. It +# loads on any developer machine that has the VC++ redistributable and fails on +# a clean Windows install, which is the machine this whole exercise is for. +# +# So the directory is collected under its own name, one level above the package, +# where the patch already looks for it. PyInstaller's own numpy hook solves the +# same problem by dumping the DLLs into the bundle root and relying on the +# bootloader having put that on the search path; putting them where the package +# itself looks does not depend on bootloader behaviour, and keeps working if a +# later zopflipy version vendors a different set of libraries. +# +# A no-op off Windows, so it needs no guard. Only zopflipy needs this - neither +# imagequant nor mozjpeg-lossless-optimization was delvewheel-repaired, and +# numpy and scipy are handled by hooks that ship with PyInstaller. +datas, binaries = collect_delvewheel_libs_directory( + "zopfli", "zopflipy.libs", datas=datas, binaries=binaries +) + +hiddenimports = [ + # imagequant and mozjpeg-lossless-optimization are cffi out-of-line API + # modules: the Python side does `from ._libimagequant import lib, ffi`, and + # the real `import _cffi_backend` happens inside the compiled extension, + # where PyInstaller's bytecode scan cannot see it. Neither package ships a + # PyInstaller hook, and PyInstaller has none for cffi either. + # + # On Windows this has been working by accident: pywebview pulls in pythonnet, + # pythonnet imports cffi, and cffi imports _cffi_backend, so the module gets + # collected for an unrelated reason. On macOS pywebview uses pyobjc and never + # touches cffi - so without this line both engines die silently there, the + # palette quantizer falls back to Pillow's (which scored 87 against + # libimagequant's 90 in a *larger* file) and the mozjpeg pass disappears. + "_cffi_backend", + # The four engines are all imported inside `try: ... except Exception:` in + # encoders.py and quality.py, so a build that fails to collect one produces + # a working application rather than an error. Naming them here does not by + # itself make the build fail - PyInstaller only warns about a hidden import + # it cannot find - which is precisely why the release workflow runs + # `imgcompress --check` against the built binary and reads the answer. + "imagequant", + "mozjpeg_lossless_optimization", + "zopfli", + "ssimulacra2", + # ssimulacra2 is pure Python and reaches scipy through `from scipy import + # ndimage` for a single gaussian_filter call. Stated explicitly because that + # one import is what makes 134 MB of scipy a hard requirement of the metric, + # and someone reading this list should see the cost rather than discover it. + "scipy.ndimage", +] + +excludes = [ + # GitHub's runner images carry more than a developer's laptop does, and + # pywebview's backend selection imports whichever of these it finds. On + # Windows it uses EdgeChromium via pythonnet and on macOS it uses pyobjc; + # a Qt or GTK binding that happens to be installed on the runner would be + # collected for a backend the shipped app never selects. + "PyQt5", + "PyQt6", + "PySide2", + "PySide6", + "gi", + # Nothing here draws with Tk. Pillow's own hook already excludes it, but + # this build also pulls in scipy and pywebview, and the exclusion should + # not depend on which package happened to be analysed first. + "tkinter", +] + +a = Analysis( + [cli_entry, gui_entry], + binaries=binaries, + datas=datas, + hiddenimports=hiddenimports, + excludes=excludes, + noarchive=False, +) + +# Analysis returns the runtime hooks and the entry scripts in one list, and each +# executable has to be given exactly one entry script plus all of the hooks. If +# the filter below silently matched nothing, both executables would embed both +# entry scripts and run them in sequence: `imgcompress --check` would print the +# engine report and then open an application window. That is a defect you find +# by launching the artifact, not by reading a build log, so the names are +# asserted here instead. +ENTRY_NAMES = {CLI_ENTRY_NAME, GUI_ENTRY_NAME} +_analysed = {name for name, _path, _kind in a.scripts} +_missing = sorted(ENTRY_NAMES - _analysed) +if _missing: + raise SystemExit( + "imgcompress.spec: PyInstaller did not name the entry scripts as " + f"expected - {_missing} not found in {sorted(_analysed)}. The two " + "executables are separated by matching those names, so this build " + "would have produced two identical programs. Fix the filter, do not " + "delete the check." + ) + +# The application's own modules have to be in the archive, and PyInstaller only +# *warns* when they are not: the build succeeds, and both executables die on +# their second line with ModuleNotFoundError: No module named 'imgcompress.cli'. +# That is what happens when the spec is run without the package installed - +# `collect_data_files` finds it through the working directory and copies webui/ +# in, while the module graph, which searches the spec's own directory, does not +# find a thing. Building against the installed distribution rather than the +# source tree is deliberate: it means a missing entry in the package-data list +# in pyproject.toml fails a release instead of shipping an app with no interface. +REQUIRED_MODULES = ("imgcompress", "imgcompress.cli", "imgcompress.gui", "imgcompress.server") +_collected = {name for name, _path, _kind in a.pure} +_absent = [name for name in REQUIRED_MODULES if name not in _collected] +if _absent: + raise SystemExit( + f"imgcompress.spec: {_absent} did not make it into the archive. " + "Install the package into the environment you are building from:\n" + ' python -m pip install ".[full,app]"' + ) + + +def _scripts_for(entry_name): + """The runtime hooks plus one entry script, in the order Analysis gave.""" + return [item for item in a.scripts if item[0] not in ENTRY_NAMES or item[0] == entry_name] + + +pyz = PYZ(a.pure) + +# strip=False on purpose: stripping a Mach-O binary invalidates the code +# signature that has to survive notarisation. upx=False on purpose too - a +# UPX-compressed DLL cannot be signature-verified and reliably trips antivirus +# heuristics, which is the opposite of what signing the installer is for. +_exe_common = { + "exclude_binaries": True, + "debug": False, + "bootloader_ignore_signals": False, + "strip": False, + "upx": False, + "disable_windowed_traceback": False, + "argv_emulation": False, + # Never "universal2". zopflipy ships a universal2 macOS wheel but + # mozjpeg-lossless-optimization ships x86_64 and arm64 separately and no + # universal2 at all, so a fat build would need a hand-lipo'd engine that + # nobody publishes. None follows the interpreter, and the release workflow + # builds arm64 and x86_64 on their own runners. + "target_arch": None, + "codesign_identity": CODESIGN_IDENTITY, + "entitlements_file": ENTITLEMENTS_FILE, + "icon": str(ICON), +} + +cli_exe = EXE( + pyz, + _scripts_for(CLI_ENTRY_NAME), + [], + name="imgcompress", + # A console executable, and not only so people can read the output: a + # windowed build has no stdout on Windows, so `imgcompress --check | ...` + # would hand the release gate an empty string to parse and pass. + console=True, + **_exe_common, +) + +gui_exe = EXE( + pyz, + _scripts_for(GUI_ENTRY_NAME), + [], + name="imgcompress-gui", + console=False, + **_exe_common, +) + +# The windowed executable is passed last on purpose. COLLECT copies `console` +# from the last EXE it is handed and BUNDLE inherits it from the COLLECT, and a +# BUNDLE that thinks it is a console app writes LSBackgroundOnly=True into +# Info.plist - an .app that launches with no window and no dock icon, which is +# a bug you only find by shipping it. The Info.plist keys below say the same +# thing a second time, explicitly, because one ordering-dependent line is a +# thin thing to hang the app's ability to appear on. +coll = COLLECT( + cli_exe, + gui_exe, + a.binaries, + a.datas, + strip=False, + upx=False, + name="imgcompress", +) + +if IS_MACOS: + # BUNDLE takes the app's main executable from the first EXECUTABLE entry it + # sees, and COLLECT sorts its own contents alphabetically - so the windowed + # executable is handed over directly rather than left to depend on how two + # filenames happen to sort. The console `imgcompress` still ships inside the + # bundle, at Contents/MacOS/imgcompress, which is what the release gate runs + # and what somebody can symlink onto their PATH. + app = BUNDLE( + gui_exe, + coll, + name=f"{APP_NAME}.app", + icon=str(ICON), + bundle_identifier=BUNDLE_ID, + version=os.environ.get("IMGCOMPRESS_VERSION", "0.0.0"), + info_plist={ + "CFBundleName": APP_NAME, + "CFBundleDisplayName": APP_NAME, + "LSBackgroundOnly": False, + "NSHighResolutionCapable": True, + # The app is a compressor with a local server on 127.0.0.1. It has + # no reason to reach the internet and saying so keeps the hardened + # runtime honest. + "NSAppTransportSecurity": {"NSAllowsLocalNetworking": True}, + # Apple silicon only ships 11.0 and later, and the x86_64 build has + # no reason to promise anything older than the SDK it was made with. + "LSMinimumSystemVersion": "11.0", + }, + ) diff --git a/pyproject.toml b/pyproject.toml index cda4e0b..600c7bd 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "imgcompress" -version = "2.6.0" +version = "2.7.0" description = "Quality-targeted image compression. Measures perceptual quality instead of guessing it, and picks the best format per image." readme = "README.md" requires-python = ">=3.9" @@ -62,7 +62,11 @@ Changelog = "https://github.com/SyedSaribSultan/imgcompress/blob/main/CHANGELOG. packages = ["imgcompress"] [tool.setuptools.package-data] -imgcompress = ["webui/*.html"] +# The desktop app is one HTML file plus the design system it shares with the +# web app: the token layer, the face declarations and the faces themselves. +# They are copied in by tools/sync_webui_assets.py and must ship, because the +# app has to render correctly from a pip install with no network at all. +imgcompress = ["webui/*.html", "webui/*.css", "webui/fonts/*.woff2"] [tool.ruff] line-length = 100 diff --git a/spike/figma-probe/README.md b/spike/figma-probe/README.md new file mode 100644 index 0000000..5e69c94 --- /dev/null +++ b/spike/figma-probe/README.md @@ -0,0 +1,145 @@ +# figma-probe + +The smallest plugin that answers, from inside Figma's real plugin iframe, whether +this project's WebAssembly codecs could live there. + +Read [`docs/figma-plugin-spike.md`](../../docs/figma-plugin-spike.md) first. It +holds the question, what was already settled without running anything, and the +recommendation. This directory is just the instrument. + +**This is not part of the product.** Nothing imports it, no test covers it, CI +does not know it exists, and it does not read or modify your document. Four files, +no build step, no dependencies. + +## Running it + +You need the Figma **desktop** app; plugin development does not work in the +browser. + +1. Menu → **Plugins** → **Development** → **Import plugin from manifest...** +2. Pick `spike/figma-probe/manifest.json`. +3. Open any design file — an empty one is fine — and run **imgcompress wasm + probe** from Plugins → Development. +4. Wait for `probe finished`, then press **Copy report**. + +The report also goes to the developer console (Plugins → Development → **Open +console**), which is the copy still there if the plugin window dies partway +through. + +If Figma refuses the manifest, the likely culprit is the `id` field: it is a +placeholder here, because Figma assigns real ids. Create a throwaway plugin +through Figma's own **New plugin** flow and copy the `id` it writes into this +manifest, or copy these four fields into the manifest it generated. + +## Reading the report + +Every line is tagged with where it was measured, because the three places have +genuinely different capabilities and conflating them is the mistake this whole +spike is guarding against. + +| Tag | Where | What to expect | +| --- | --- | --- | +| `[main]` | Figma's plugin sandbox | Not a browser. No `Worker`, no `fetch`, no `OffscreenCanvas`. `WebAssembly` may well be absent here and that is fine — it is not where the codecs would go. | +| `[ui]` | the plugin's UI iframe | A real browser realm at a `null` origin. This is where the codecs would live, so this is the block that decides the spike. | +| `[worker]` | a worker spawned from a `blob:` URL | The port's actual home. `blob:` workers were broken in Figma until Version 1 Update 76, August 2023. | +| `[bridge]` | `figma.ui.postMessage` | Buffers copied between the two realms, timed, and checked for damage on arrival. | + +### What a good result looks like + +``` +[ui] typeof WebAssembly: object +[ui] window.origin: null +[ui] wasm SIMD: detected +[ui] instantiate from base64: ok, add(2,3)=5 +[ui] wasm memory grow: 65536 B -> 131072 B +[worker] blob:: spawned +[worker] blob:: importScripts(blob: URL): ok, the glue ran +[worker] blob:: instantiate from base64: ok, add(2,3)=5 +[worker] blob:: createImageBitmap: ok, 8x8 +[worker] blob:: getImageData first pixel: 10,20,30,255 (as drawn) +[bridge] 16777216 B: intact, 84 ms, 190.2 MiB/s round trip +``` + +`window.origin: null` is not a fault — it is the confirmation that relative +`fetch` and relative `importScripts` are off the table, which is the finding that +shapes the port. + +### The lines that decide things + +- **`typeof WebAssembly` under `[ui]`.** If this is `undefined`, the spike is over + and the answer is no. +- **`[worker] blob:: spawned`.** If it is missing, or you see `onerror` or `no + answer within 8000 ms`, the codecs cannot go in a worker and would have to run + on the iframe's main thread — which means every encode freezes the plugin + window. Check whether `data::` spawned, because that is the second door. +- **`wasm SIMD`.** Not detected means `webp_enc_simd.wasm` is 345,584 bytes that + buy nothing, and the glue will fall back. +- **`canvas.toBlob(image/webp)`.** If this reports a real `image/webp` blob, the + browser already has a WebP encoder and libwebp may not be worth shipping. + Measure before assuming. A line saying it *fell back to* `image/png` means + there is no encoder — `toBlob` is allowed to hand back a format you did not ask + for, which is why the returned MIME type is what gets printed rather than a + yes. +- **The `[bridge]` ladder at 16777216 B.** This is the number the spike exists + for. `no reply within 20 s` means the plugin is wedged at that size. If Figma + itself dies here, *that is the result* — write down which size, and whether the + tab went with it. + +Absences are printed, never skipped. A missing capability produces a line saying +so; a silent gap in the report means the probe stopped, and the last line printed +tells you where. + +## What it deliberately does not do + +- **No real codec is inlined.** The wasm module is 52 hand-assembled bytes + exporting `add(i32,i32)` and a memory. Instantiation either works in that + sandbox or it does not, and 3,485,872 bytes of libaom would turn a capability + check into a download test. Throughput is a separate measurement that needs the + real encoders to mean anything. +- **It does not touch your document.** No node is read, created or changed. +- **It does not answer the memory question.** Four real codecs plus a 12 MP RGBA + buffer is the thing that might take the tab down, and that needs a real port. + This measures the bridge and prints the renderer's heap limit, which is the + evidence you want before spending a day on one. + +## Extending it + +- **Push the bridge harder:** `ECHO_SIZES` at the top of `code.js`. Adding + `67108864` is a one-line change and a fair question. +- **Settle the `GUIDE.md` WebP claim:** import a WebP into the file, select it, + and call `Image.getBytesAsync()` on the fill's image. Bytes starting `RIFF` + mean Figma stored the original; a PNG signature means it transcoded. That single + reading is the hinge the `documents` format policy hangs on, and it needs + `documentAccess` and a selection, which is why it is not in the probe as built. + +## Two things that look like sloppiness and are not + +**No design system.** Every other surface here renders from +`web/heyoz-tokens.css`, and the standing rule is that nothing hand-types a +colour, a corner radius or a duration. `ui.html` cannot import that stylesheet: +the manifest takes exactly one UI HTML file and the iframe has a `null` origin, so +there is no relative URL to load it from. Hand-copying values out of the token +layer is the exact drift that rule exists to prevent, so this page declares no +colours of its own and borrows Figma's `--figma-color-*` variables instead. If +those are ever absent, the declarations fall back to the browser default and a +diagnostic textarea is still perfectly readable. + +**Everything printed is ASCII.** The report gets pasted into terminals and commit +messages, and a middot arriving as a replacement character on a Windows console +defeats the point of writing it down. + +## How this was checked without Figma + +Figma is the only place the answers are real, but the page was driven in headless +Chrome over `http` with a stubbed bridge before anyone wasted a plugin import on a +typo. That run confirms the page runs clean, every branch reports something, and +the report stays ASCII. It cannot confirm anything about the `null` origin or the +plugin CSP. + +It was also broken on purpose, four ways, to check it says so: a corrupted base64 +module, a CSP that forbids `blob:` workers, `OffscreenCanvas` deleted inside the +worker, and `WebAssembly` deleted outright. Each produced a legible red line +rather than silence. `code.js`'s buffer check was run against buffers that came +back truncated, mangled, as a plain object, as an `Array`, and as nothing at all. +A probe that only ever prints good news is indistinguishable from one that prints +nothing. diff --git a/spike/figma-probe/code.js b/spike/figma-probe/code.js new file mode 100644 index 0000000..ec29837 --- /dev/null +++ b/spike/figma-probe/code.js @@ -0,0 +1,269 @@ +/* The main-thread half of the Figma capability probe. See + * docs/figma-plugin-spike.md for the question this exists to answer and + * spike/figma-probe/README.md for how to run it. + * + * Nothing in the shipping product imports this file, no test runs it, and CI + * does not know it exists. It is a spike: it either comes back with numbers + * that make a Figma plugin worth building, or it comes back with numbers that + * say not yet, and either answer is worth the hour. + * + * This file runs in Figma's plugin sandbox, which is not the browser realm. + * There is no window, no DOM, no fetch, no Worker and - the whole reason the + * codecs would have to live in ui.html instead - no WebAssembly guaranteed. + * The code below deliberately stays on plain ES2017 with no optional chaining, + * because the sandbox is a separate engine from the iframe's and has lagged the + * browser on syntax before. + */ + +"use strict"; + +/* Three sizes, because one number tells you nothing about where the wall is. + * A megabyte is a small PNG export, 4 MB is a full-resolution RGBA frame of a + * modest artboard, and 16 MB is roughly one 2000x2000 image's pixel buffer - + * the size the port would actually push across this bridge. If the plugin dies + * partway up this ladder, that death is the finding, which is why every line is + * printed the moment it is measured rather than collected into a final report. */ +var ECHO_SIZES = [1048576, 4194304, 16777216]; + +/* Figma's sandbox has no performance.now() in every version, and the copies we + * are timing take milliseconds to hundreds of milliseconds, so Date.now()'s + * resolution is enough. The one thing this rules out is comparing our clock to + * the iframe's - the two realms have different time origins, so the UI reports + * how long it held the buffer as its own number and we never subtract it. */ +function now() { + if (typeof performance !== "undefined" && performance && performance.now) { + return performance.now(); + } + return Date.now(); +} + +var lines = []; + +/* Messages posted before the iframe has installed its own window.onmessage are + * simply dropped - this bridge does not queue - and the sandbox checks below all + * run synchronously the moment showUI returns. So every line is held until the + * iframe says hello, then flushed in order. Without this the copyable report was + * missing exactly the lines about the sandbox, while the console had them, which + * is the sort of gap that gets read as "the sandbox has no WebAssembly". */ +var uiReady = false; +var backlog = []; + +// Printed to the developer console AND pushed to the UI, so the report is +// copyable from the plugin window without opening devtools. +function say(tag, text) { + var line = "[" + tag + "] " + text; + lines.push(line); + console.log(line); + if (uiReady) { + figma.ui.postMessage({ type: "line", text: line }); + } else { + backlog.push(line); + } +} + +function flushBacklog() { + uiReady = true; + for (var i = 0; i < backlog.length; i++) { + figma.ui.postMessage({ type: "line", text: backlog[i] }); + } + backlog = []; +} + +// The UI's own findings arrive already rendered in its textarea, so echoing +// them back would print everything twice. The console still wants them. +function note(tag, text) { + var line = "[" + tag + "] " + text; + lines.push(line); + console.log(line); +} + +function describe(e) { + if (!e) return "no error object"; + if (e.message) return e.message; + return String(e); +} + +/* ------------------------------------------------------------------------- * + * what the sandbox itself can do + * ------------------------------------------------------------------------- */ + +function probeSandbox() { + say("main", "figma.editorType: " + figma.editorType); + say("main", "figma.apiVersion: " + + (typeof figma.apiVersion === "undefined" ? "absent" : figma.apiVersion)); + // typeof on a name that was never declared is the one read that cannot throw, + // which is why every absence below is checked this way rather than with a + // try/catch around the identifier itself. + say("main", "typeof WebAssembly: " + typeof WebAssembly); + say("main", "typeof Worker: " + typeof Worker); + say("main", "typeof fetch: " + typeof fetch); + say("main", "typeof OffscreenCanvas: " + typeof OffscreenCanvas); + say("main", "typeof figma.createImage: " + typeof figma.createImage); + say("main", "typeof figma.createImageAsync: " + typeof figma.createImageAsync); +} + +/* The write-up claims there is no export hook, on the strength of Figma's + * documented event list. This turns that reading into something observed: ask + * figma.on() for each name and print whatever it says back. A name that is + * accepted proves the call works, which is what makes a rejection mean + * something. Be careful reading "documentchange" style refusals - under + * dynamic-page document access some real events are refused for a reason that + * has nothing to do with whether they exist, so the message is printed verbatim + * rather than summarised into a yes or a no. */ +function probeEventNames() { + var names = ["run", "selectionchange", "export", "beforeexport", "exportcomplete"]; + var noop = function () {}; + for (var i = 0; i < names.length; i++) { + try { + figma.on(names[i], noop); + say("main", 'figma.on("' + names[i] + '"): accepted'); + try { figma.off(names[i], noop); } catch (e) { /* nothing to undo */ } + } catch (e) { + say("main", 'figma.on("' + names[i] + '"): refused - ' + describe(e)); + } + } +} + +/* ------------------------------------------------------------------------- * + * the bridge + * ------------------------------------------------------------------------- */ + +/* Uint8Array is the one binary type that crosses figma.ui.postMessage, and it + * is copied rather than transferred - there is no transfer list on this bridge. + * So a round trip of N bytes allocates N bytes twice more, on top of the + * original, inside a tab that is already holding the user's document. That cost + * is the thing this whole probe exists to measure. */ +function makeBuffer(size) { + var bytes = new Uint8Array(size); + bytes.fill(0xa5); + // A ramp in the first 256 bytes catches a buffer that came back as a plain + // object with numeric keys, or with its element type quietly widened. + for (var i = 0; i < 256 && i < size; i++) bytes[i] = i & 0xff; + bytes[size - 1] = 0x5a; + if (size > 2) bytes[Math.floor(size / 2)] = 0xc3; + return bytes; +} + +function checkBuffer(bytes, size) { + if (!bytes) return "nothing came back"; + if (!(bytes instanceof Uint8Array)) { + return "came back as " + Object.prototype.toString.call(bytes) + ", not a Uint8Array"; + } + if (bytes.length !== size) return "came back " + bytes.length + " B, sent " + size + " B"; + // Guarded because the README invites editing ECHO_SIZES: a size under 256 has + // no ramp to check, and reading past the end would report damage that is not + // there, which is the worst thing a diagnostic can do. + if (size >= 256 && (bytes[0] !== 0 || bytes[255] !== 255)) { + return "the leading ramp came back changed"; + } + if (bytes[size - 1] !== 0x5a) return "the last byte is " + bytes[size - 1] + ", expected 90"; + if (bytes[Math.floor(size / 2)] !== 0xc3) return "the middle byte changed"; + return null; +} + +var pendingEcho = null; + +function echoOnce(size) { + return new Promise(function (resolve) { + var settled = false; + var timer = setTimeout(function () { + if (settled) return; + settled = true; + pendingEcho = null; + resolve(null); + }, 20000); + + pendingEcho = function (msg) { + if (settled) return; + settled = true; + clearTimeout(timer); + resolve(msg); + }; + + var t0 = now(); + var bytes = makeBuffer(size); + var allocMs = now() - t0; + say("bridge", size + " B allocated in " + Math.round(allocMs) + " ms"); + + figma.ui.postMessage({ type: "echo", size: size, bytes: bytes }); + }); +} + +function rate(size, ms) { + if (!ms || ms <= 0) return "too fast to time at this clock's resolution"; + var mib = size / 1048576; + return (Math.round((mib / (ms / 1000)) * 10) / 10) + " MiB/s round trip"; +} + +async function runBridgeLadder() { + for (var i = 0; i < ECHO_SIZES.length; i++) { + var size = ECHO_SIZES[i]; + var start = now(); + var reply = await echoOnce(size); + var elapsed = now() - start; + + if (!reply) { + say("bridge", size + " B: no reply within 20 s - treat the plugin as wedged at this size"); + break; + } + var problem = checkBuffer(reply.bytes, size); + if (problem) { + say("bridge", size + " B: " + problem); + } else { + say("bridge", size + " B: intact, " + Math.round(elapsed) + " ms, " + rate(size, elapsed)); + } + if (typeof reply.uiHoldMs === "number") { + say("bridge", size + " B: the iframe held it " + Math.round(reply.uiHoldMs) + + " ms (its own clock, not comparable to ours)"); + } + } + say("main", "probe finished - copy the report before closing the plugin"); + figma.notify("imgcompress probe finished. Copy the report before closing."); +} + +/* ------------------------------------------------------------------------- * + * wiring + * ------------------------------------------------------------------------- */ + +figma.showUI(__html__, { width: 560, height: 620, themeColors: true }); + +figma.ui.onmessage = function (msg) { + if (!msg || typeof msg !== "object") return; + + if (msg.type === "ui-ready") { + flushBacklog(); + return; + } + if (msg.type === "ui-line") { + note("ui", msg.text); + return; + } + if (msg.type === "echo-back") { + if (pendingEcho) pendingEcho(msg); + return; + } + if (msg.type === "ui-done") { + startLadderOnce("the iframe finished its own checks"); + return; + } +}; + +var ladderStarted = false; +function startLadderOnce(why) { + if (ladderStarted) return; + ladderStarted = true; + say("bridge", "starting the buffer round trip: " + why); + runBridgeLadder(); +} + +probeSandbox(); +probeEventNames(); +say("main", "waiting for the iframe to report"); + +/* If the iframe dies during its own checks it will never send ui-done, and a + * probe that hangs silently is worse than one that reports a gap. Fifteen + * seconds is long enough for four wasm instantiations and two worker spawns on + * a slow machine. */ +setTimeout(function () { + startLadderOnce("the iframe never reported done - the gap above is itself a finding"); +}, 15000); diff --git a/spike/figma-probe/manifest.json b/spike/figma-probe/manifest.json new file mode 100644 index 0000000..e7389f3 --- /dev/null +++ b/spike/figma-probe/manifest.json @@ -0,0 +1,12 @@ +{ + "name": "imgcompress wasm probe", + "id": "1000000000000000001", + "api": "1.0.0", + "main": "code.js", + "ui": "ui.html", + "editorType": ["figma"], + "documentAccess": "dynamic-page", + "networkAccess": { + "allowedDomains": ["none"] + } +} diff --git a/spike/figma-probe/ui.html b/spike/figma-probe/ui.html new file mode 100644 index 0000000..7b50fe0 --- /dev/null +++ b/spike/figma-probe/ui.html @@ -0,0 +1,487 @@ + +imgcompress wasm probe + + +

Reading what this iframe can actually do. Copy the report when it finishes.

+ +
+ + +
+ + 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..9361d35 100644 --- a/tests/bench_vs_alternatives.py +++ b/tests/bench_vs_alternatives.py @@ -29,6 +29,7 @@ import argparse import io +import json import sys from dataclasses import dataclass from pathlib import Path @@ -267,6 +268,11 @@ def main() -> int: ap.add_argument("--corpus", default="tests/bench_corpus") ap.add_argument("--web", default="tests/bench_web_out") ap.add_argument("--out", default="tests/BENCHMARK.md") + # The same run, also as data. The markdown is for a reader; the JSON is so + # the public comparison page can be generated from the measurements instead + # of transcribed from them - a hand-copied table is a table that will + # eventually disagree with the benchmark it claims to report. + ap.add_argument("--json", default="tests/benchmark.json") args = ap.parse_args() corpus = Path(args.corpus) @@ -281,6 +287,7 @@ def main() -> int: web_root = Path(args.web) lines: list[str] = [] + data: dict = {"floor": FLOOR, "images": []} lines.append("# Head-to-head, at matched perceptual quality\n") lines.append(f"Every strategy below is searched for the **smallest file that still scores " f"SSIMULACRA 2 >= {FLOOR:g}** against the same normalised source — the metric the " @@ -301,8 +308,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 @@ -344,8 +351,31 @@ def main() -> int: lines.append(f"| {r.strategy}{star} | {r.fmt} | {r.setting} | {human(len(r.data))} " f"| {rel} | {s2:.1f} | {s:.4f} | {mark} |") + data["images"].append({ + "name": src.name, + "width": ref.size[0], + "height": ref.size[1], + "sourceBytes": orig_bytes, + "referenceBytes": ref_bytes, + "rows": [ + { + "strategy": r.strategy, + "format": r.fmt, + "setting": r.setting, + "searched": r.searched, + "bytes": len(r.data), + "ss2": round(s2, 1), + "ssim": round(s, 4), + "clearsFloor": bool(s2 >= FLOOR), + "winner": bool(winner and r is winner[0]), + } + for r, s2, s in sorted(rows, key=lambda x: len(x[0].data)) + ], + }) + Path(args.out).write_text("\n".join(lines) + "\n", encoding="utf-8") - print(f"\nwritten to {args.out}") + Path(args.json).write_text(json.dumps(data, indent=1) + "\n", encoding="utf-8") + print(f"\nwritten to {args.out} and {args.json}") return 0 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/benchmark.json b/tests/benchmark.json new file mode 100644 index 0000000..fdecd2c --- /dev/null +++ b/tests/benchmark.json @@ -0,0 +1,708 @@ +{ + "floor": 90.0, + "images": [ + { + "name": "camera_12mp.jpg", + "width": 2560, + "height": 1920, + "sourceBytes": 1992520, + "referenceBytes": 5650700, + "rows": [ + { + "strategy": "AVIF q50 (a common default)", + "format": "avif", + "setting": "q50", + "searched": false, + "bytes": 12596, + "ss2": 79.8, + "ssim": 0.9521, + "clearsFloor": false, + "winner": false + }, + { + "strategy": "WebP q75 (a common default)", + "format": "webp", + "setting": "q75", + "searched": false, + "bytes": 22970, + "ss2": 75.8, + "ssim": 0.9498, + "clearsFloor": false, + "winner": false + }, + { + "strategy": "JPEG q75 (a common default)", + "format": "jpeg", + "setting": "q75", + "searched": false, + "bytes": 81282, + "ss2": 80.1, + "ssim": 0.9539, + "clearsFloor": false, + "winner": false + }, + { + "strategy": "JPEG q85 (a common default)", + "format": "jpeg", + "setting": "q85", + "searched": false, + "bytes": 182619, + "ss2": 84.9, + "ssim": 0.9588, + "clearsFloor": false, + "winner": false + }, + { + "strategy": "imgcompress web (documents)", + "format": "jpeg", + "setting": "measured floor", + "searched": true, + "bytes": 371100, + "ss2": 90.4, + "ssim": 0.9657, + "clearsFloor": true, + "winner": true + }, + { + "strategy": "imgcompress web (web)", + "format": "jpeg", + "setting": "measured floor", + "searched": true, + "bytes": 371100, + "ss2": 90.4, + "ssim": 0.9657, + "clearsFloor": true, + "winner": false + }, + { + "strategy": "JPEG 4:2:0 only", + "format": "jpeg", + "setting": "q94", + "searched": true, + "bytes": 529809, + "ss2": 90.5, + "ssim": 0.97, + "clearsFloor": true, + "winner": false + }, + { + "strategy": "imgcompress desktop", + "format": "jpeg", + "setting": "measured floor", + "searched": true, + "bytes": 556852, + "ss2": 91.4, + "ssim": 0.97, + "clearsFloor": true, + "winner": false + }, + { + "strategy": "mozjpeg 4:4:4 only", + "format": "jpeg", + "setting": "q94", + "searched": true, + "bytes": 556852, + "ss2": 91.4, + "ssim": 0.97, + "clearsFloor": true, + "winner": false + }, + { + "strategy": "PNG lossless + zopfli", + "format": "png", + "setting": "lossless", + "searched": true, + "bytes": 2677021, + "ss2": 100.0, + "ssim": 1.0, + "clearsFloor": true, + "winner": false + } + ] + }, + { + "name": "gradient.png", + "width": 1000, + "height": 600, + "sourceBytes": 3313, + "referenceBytes": 18375, + "rows": [ + { + "strategy": "imgcompress desktop", + "format": "webp-lossless", + "setting": "measured floor", + "searched": true, + "bytes": 438, + "ss2": 100.0, + "ssim": 1.0, + "clearsFloor": true, + "winner": true + }, + { + "strategy": "imgcompress web (web)", + "format": "webp", + "setting": "measured floor", + "searched": true, + "bytes": 450, + "ss2": 100.0, + "ssim": 1.0, + "clearsFloor": true, + "winner": false + }, + { + "strategy": "AVIF q50 (a common default)", + "format": "avif", + "setting": "q50", + "searched": false, + "bytes": 1302, + "ss2": 85.8, + "ssim": 0.9955, + "clearsFloor": false, + "winner": false + }, + { + "strategy": "WebP q75 (a common default)", + "format": "webp", + "setting": "q75", + "searched": false, + "bytes": 2584, + "ss2": 74.7, + "ssim": 0.9876, + "clearsFloor": false, + "winner": false + }, + { + "strategy": "PNG lossless + zopfli", + "format": "png", + "setting": "lossless", + "searched": true, + "bytes": 2798, + "ss2": 100.0, + "ssim": 1.0, + "clearsFloor": true, + "winner": false + }, + { + "strategy": "imgcompress web (documents)", + "format": "png", + "setting": "measured floor", + "searched": true, + "bytes": 2862, + "ss2": 100.0, + "ssim": 1.0, + "clearsFloor": true, + "winner": false + }, + { + "strategy": "JPEG q75 (a common default)", + "format": "jpeg", + "setting": "q75", + "searched": false, + "bytes": 6128, + "ss2": 77.2, + "ssim": 0.994, + "clearsFloor": false, + "winner": false + }, + { + "strategy": "JPEG q85 (a common default)", + "format": "jpeg", + "setting": "q85", + "searched": false, + "bytes": 9873, + "ss2": 83.0, + "ssim": 0.9958, + "clearsFloor": false, + "winner": false + } + ] + }, + { + "name": "logo_alpha.png", + "width": 900, + "height": 900, + "sourceBytes": 10200, + "referenceBytes": 39276, + "rows": [ + { + "strategy": "imgcompress web (web)", + "format": "webp", + "setting": "measured floor", + "searched": true, + "bytes": 2922, + "ss2": 100.0, + "ssim": 1.0, + "clearsFloor": true, + "winner": true + }, + { + "strategy": "imgcompress desktop", + "format": "webp-lossless", + "setting": "measured floor", + "searched": true, + "bytes": 3218, + "ss2": 100.0, + "ssim": 1.0, + "clearsFloor": true, + "winner": false + }, + { + "strategy": "pngquant + zopfli", + "format": "png8", + "setting": "8 colours", + "searched": true, + "bytes": 3965, + "ss2": 100.0, + "ssim": 1.0, + "clearsFloor": true, + "winner": false + }, + { + "strategy": "PNG lossless + zopfli", + "format": "png", + "setting": "lossless", + "searched": true, + "bytes": 3965, + "ss2": 100.0, + "ssim": 1.0, + "clearsFloor": true, + "winner": false + }, + { + "strategy": "imgcompress web (documents)", + "format": "png", + "setting": "measured floor", + "searched": true, + "bytes": 4244, + "ss2": 100.0, + "ssim": 1.0, + "clearsFloor": true, + "winner": false + }, + { + "strategy": "AVIF q50 (a common default)", + "format": "avif", + "setting": "q50", + "searched": false, + "bytes": 7261, + "ss2": 81.0, + "ssim": 0.9997, + "clearsFloor": false, + "winner": false + }, + { + "strategy": "WebP q75 (a common default)", + "format": "webp", + "setting": "q75", + "searched": false, + "bytes": 11378, + "ss2": 80.8, + "ssim": 0.9962, + "clearsFloor": false, + "winner": false + } + ] + }, + { + "name": "photo.png", + "width": 1280, + "height": 820, + "sourceBytes": 1742416, + "referenceBytes": 2787761, + "rows": [ + { + "strategy": "AVIF q50 (a common default)", + "format": "avif", + "setting": "q50", + "searched": false, + "bytes": 11307, + "ss2": 70.5, + "ssim": 0.9227, + "clearsFloor": false, + "winner": false + }, + { + "strategy": "WebP q75 (a common default)", + "format": "webp", + "setting": "q75", + "searched": false, + "bytes": 16280, + "ss2": 61.8, + "ssim": 0.9142, + "clearsFloor": false, + "winner": false + }, + { + "strategy": "JPEG q75 (a common default)", + "format": "jpeg", + "setting": "q75", + "searched": false, + "bytes": 49719, + "ss2": 69.8, + "ssim": 0.9269, + "clearsFloor": false, + "winner": false + }, + { + "strategy": "JPEG q85 (a common default)", + "format": "jpeg", + "setting": "q85", + "searched": false, + "bytes": 84654, + "ss2": 74.2, + "ssim": 0.9352, + "clearsFloor": false, + "winner": false + }, + { + "strategy": "imgcompress desktop", + "format": "jpeg", + "setting": "measured floor", + "searched": true, + "bytes": 449799, + "ss2": 90.7, + "ssim": 0.9662, + "clearsFloor": true, + "winner": true + }, + { + "strategy": "mozjpeg 4:4:4 only", + "format": "jpeg", + "setting": "q96", + "searched": true, + "bytes": 449799, + "ss2": 90.7, + "ssim": 0.9662, + "clearsFloor": true, + "winner": false + }, + { + "strategy": "imgcompress web (documents)", + "format": "jpeg", + "setting": "measured floor", + "searched": true, + "bytes": 460900, + "ss2": 91.1, + "ssim": 0.9711, + "clearsFloor": true, + "winner": false + }, + { + "strategy": "imgcompress web (web)", + "format": "jpeg", + "setting": "measured floor", + "searched": true, + "bytes": 460900, + "ss2": 91.1, + "ssim": 0.9711, + "clearsFloor": true, + "winner": false + }, + { + "strategy": "PNG lossless + zopfli", + "format": "png", + "setting": "lossless", + "searched": true, + "bytes": 1567771, + "ss2": 100.0, + "ssim": 1.0, + "clearsFloor": true, + "winner": false + } + ] + }, + { + "name": "screenshot_retina.png", + "width": 2560, + "height": 1600, + "sourceBytes": 16885, + "referenceBytes": 123966, + "rows": [ + { + "strategy": "imgcompress web (web)", + "format": "webp", + "setting": "measured floor", + "searched": true, + "bytes": 1108, + "ss2": 100.0, + "ssim": 1.0, + "clearsFloor": true, + "winner": true + }, + { + "strategy": "imgcompress desktop", + "format": "webp-lossless", + "setting": "measured floor", + "searched": true, + "bytes": 1108, + "ss2": 100.0, + "ssim": 1.0, + "clearsFloor": true, + "winner": false + }, + { + "strategy": "AVIF only", + "format": "avif", + "setting": "q45", + "searched": true, + "bytes": 2543, + "ss2": 91.6, + "ssim": 0.9999, + "clearsFloor": true, + "winner": false + }, + { + "strategy": "AVIF q50 (a common default)", + "format": "avif", + "setting": "q50", + "searched": false, + "bytes": 2559, + "ss2": 92.3, + "ssim": 1.0, + "clearsFloor": true, + "winner": false + }, + { + "strategy": "imgcompress web (documents)", + "format": "png", + "setting": "measured floor", + "searched": true, + "bytes": 4253, + "ss2": 100.0, + "ssim": 1.0, + "clearsFloor": true, + "winner": false + }, + { + "strategy": "pngquant + zopfli", + "format": "png8", + "setting": "8 colours", + "searched": true, + "bytes": 4679, + "ss2": 100.0, + "ssim": 1.0, + "clearsFloor": true, + "winner": false + }, + { + "strategy": "PNG lossless + zopfli", + "format": "png", + "setting": "lossless", + "searched": true, + "bytes": 4679, + "ss2": 100.0, + "ssim": 1.0, + "clearsFloor": true, + "winner": false + }, + { + "strategy": "WebP q75 (a common default)", + "format": "webp", + "setting": "q75", + "searched": false, + "bytes": 15444, + "ss2": 88.3, + "ssim": 0.9758, + "clearsFloor": false, + "winner": false + }, + { + "strategy": "WebP only", + "format": "webp", + "setting": "q84", + "searched": true, + "bytes": 18038, + "ss2": 90.4, + "ssim": 0.9916, + "clearsFloor": true, + "winner": false + }, + { + "strategy": "JPEG q75 (a common default)", + "format": "jpeg", + "setting": "q75", + "searched": false, + "bytes": 96840, + "ss2": 88.1, + "ssim": 0.9842, + "clearsFloor": false, + "winner": false + }, + { + "strategy": "mozjpeg 4:4:4 only", + "format": "jpeg", + "setting": "q82", + "searched": true, + "bytes": 100160, + "ss2": 92.2, + "ssim": 0.9889, + "clearsFloor": true, + "winner": false + }, + { + "strategy": "JPEG 4:2:0 only", + "format": "jpeg", + "setting": "q85", + "searched": true, + "bytes": 112257, + "ss2": 90.3, + "ssim": 0.9855, + "clearsFloor": true, + "winner": false + }, + { + "strategy": "JPEG q85 (a common default)", + "format": "jpeg", + "setting": "q85", + "searched": false, + "bytes": 112257, + "ss2": 90.3, + "ssim": 0.9855, + "clearsFloor": true, + "winner": false + } + ] + }, + { + "name": "ui_text.png", + "width": 1280, + "height": 820, + "sourceBytes": 30045, + "referenceBytes": 56468, + "rows": [ + { + "strategy": "imgcompress desktop", + "format": "png8", + "setting": "measured floor", + "searched": true, + "bytes": 6937, + "ss2": 93.9, + "ssim": 0.9983, + "clearsFloor": true, + "winner": true + }, + { + "strategy": "pngquant + zopfli", + "format": "png8", + "setting": "16 colours", + "searched": true, + "bytes": 6937, + "ss2": 93.9, + "ssim": 0.9983, + "clearsFloor": true, + "winner": false + }, + { + "strategy": "AVIF q50 (a common default)", + "format": "avif", + "setting": "q50", + "searched": false, + "bytes": 7182, + "ss2": 87.8, + "ssim": 0.9969, + "clearsFloor": false, + "winner": false + }, + { + "strategy": "imgcompress web (web)", + "format": "webp", + "setting": "measured floor", + "searched": true, + "bytes": 9530, + "ss2": 100.0, + "ssim": 1.0, + "clearsFloor": true, + "winner": false + }, + { + "strategy": "AVIF only", + "format": "avif", + "setting": "q88", + "searched": true, + "bytes": 11492, + "ss2": 90.2, + "ssim": 1.0, + "clearsFloor": true, + "winner": false + }, + { + "strategy": "WebP q75 (a common default)", + "format": "webp", + "setting": "q75", + "searched": false, + "bytes": 12448, + "ss2": 83.1, + "ssim": 0.937, + "clearsFloor": false, + "winner": false + }, + { + "strategy": "PNG lossless + zopfli", + "format": "png", + "setting": "lossless", + "searched": true, + "bytes": 21989, + "ss2": 100.0, + "ssim": 1.0, + "clearsFloor": true, + "winner": false + }, + { + "strategy": "imgcompress web (documents)", + "format": "png", + "setting": "measured floor", + "searched": true, + "bytes": 24003, + "ss2": 100.0, + "ssim": 1.0, + "clearsFloor": true, + "winner": false + }, + { + "strategy": "JPEG q75 (a common default)", + "format": "jpeg", + "setting": "q75", + "searched": false, + "bytes": 32139, + "ss2": 78.8, + "ssim": 0.976, + "clearsFloor": false, + "winner": false + }, + { + "strategy": "JPEG q85 (a common default)", + "format": "jpeg", + "setting": "q85", + "searched": false, + "bytes": 36223, + "ss2": 83.4, + "ssim": 0.9807, + "clearsFloor": false, + "winner": false + }, + { + "strategy": "mozjpeg 4:4:4 only", + "format": "jpeg", + "setting": "q92", + "searched": true, + "bytes": 49387, + "ss2": 91.5, + "ssim": 0.9939, + "clearsFloor": true, + "winner": false + }, + { + "strategy": "JPEG 4:2:0 only", + "format": "jpeg", + "setting": "q98", + "searched": true, + "bytes": 61902, + "ss2": 90.2, + "ssim": 0.9997, + "clearsFloor": true, + "winner": false + } + ] + } + ] +} diff --git a/tests/test_compress.py b/tests/test_compress.py index deadb7b..a22d89d 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,96 @@ 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), + # 2560 is the everyday downscale; 4096 is a clamp that only fires when + # somebody explicitly asks for more. Two numbers, two jobs. + "documents": (("jpeg", "png8", "png"), 2560, 4096, 90.0), + "email": (("jpeg", "png8", "png"), 1920, 0, 88.0), + "thumbnail": (("jpeg", "png8", "png", "webp", "webp-lossless", "avif"), + 512, 0, 80.0), + "original": (("jpeg", "png8", "png", "webp", "webp-lossless", "avif"), + 0, 0, 95.0), + } + + def test_documents_downscales_to_2560_by_default(self): + """The clamp is not the setting. Defaulting to the ceiling would ship + roughly 2.5x the pixels on every design asset.""" + self.assertEqual(dest.get("documents").max_dimension, 2560) + self.assertEqual(dest.get("documents").max_dimension, + dest.get("web").max_dimension) + + 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): + """The ceiling, which is a different number from the default.""" + self.assertEqual(dest.get("documents").hard_cap, 4096) + self.assertNotEqual(dest.get("documents").max_dimension, + dest.get("documents").hard_cap) + + 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 +225,107 @@ 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_documents_clamps_an_explicit_oversized_request(self): + """`-m 8000` is the only way to reach the ceiling now that the default + sits at 2560, so this is the branch that would otherwise go untested. + + It must *clamp*, not refuse. The person's intent is perfectly + reasonable; the destination simply cannot carry it, and turning that + into an error would make them go and find a number the tool already + knows. + """ + path = self.src / "huge.png" + sample((5000, 1200)).save(path) + res = compress_file(path, self.dst, + Settings(target="documents", max_dimension=8000, **FAST)) + self.assertEqual(res.error, "") + with Image.open(res.output) as out: + self.assertLessEqual(max(out.size), 4096) + self.assertEqual(max(out.size), 4096) + + def test_the_clamp_is_reported_not_applied_in_silence(self): + """A dimension that changes without saying so is the defect the whole + destination rework exists to remove. It must not survive on the + override path just because the override path is rarer. + + `effective_limit` is the one place the rule lives, so the CLI header + and the engine cannot disagree - which they did: the header advertised + `up to 8000px` for a run that produced 4096. + """ + from imgcompress import destinations as d + self.assertEqual(d.effective_limit("documents", 8000), 4096) + self.assertEqual(d.effective_limit("documents", 800), 800) + self.assertEqual(d.effective_limit("documents", 0), 4096) + # Only documents clamps; asking web for 8000 gets 8000. + self.assertEqual(d.effective_limit("web", 8000), 8000) + self.assertEqual(d.effective_limit("original", 0), 0) + + def test_the_engine_uses_the_same_rule_the_cli_prints(self): + from imgcompress import destinations as d + path = self.src / "huge.png" + sample((5000, 1200)).save(path) + for name, asked in (("documents", 8000), ("documents", 800), ("web", 3000)): + with self.subTest(destination=name, asked=asked): + res = compress_file(path, self.dst / f"{name}{asked}", + Settings(target=name, max_dimension=asked, **FAST)) + expected = d.effective_limit(name, asked) + with Image.open(res.output) as out: + # 5000px source, so any limit at or below it must bite. + self.assertEqual(max(out.size), min(expected, 5000)) + + def test_the_clamp_does_not_inflate_a_smaller_request(self): + """A ceiling only ever lowers. Asking for 800 must give 800.""" + path = self.src / "huge.png" + sample((5000, 1200)).save(path) + res = compress_file(path, self.dst, + Settings(target="documents", max_dimension=800, **FAST)) + with Image.open(res.output) as out: + self.assertEqual(max(out.size), 800) + + def test_documents_downscale_matches_web_by_default(self): + """The everyday behaviour of the two destinations differs in format + policy, not in how many pixels survive.""" + path = self.src / "huge.png" + sample((5000, 1200)).save(path) + sizes = {} + for name in ("web", "documents"): + res = compress_file(path, self.dst / name, + Settings(target=name, **FAST)) + with Image.open(res.output) as out: + sizes[name] = out.size + self.assertEqual(sizes["web"], sizes["documents"]) + self.assertEqual(max(sizes["documents"]), 2560) + + 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)) @@ -235,5 +416,43 @@ def test_forcing_a_format_is_respected(self): self.assertEqual(res.output.suffix, ".jpg") +class FrozenBundleSafety(unittest.TestCase): + """The pool must not be able to re-launch the application. + + `compress_tree` uses a ProcessPoolExecutor. Under the spawn start method - + always on Windows, the default on macOS - each worker re-executes the + program to import the module it needs. Frozen, there is no python to + re-execute: the child runs the app's own executable again, starts a whole + new imgcompress, and opens a pool of its own. A folder of images becomes a + fork bomb. + + It hid because `compress_tree` takes a single-process path when there is one + job, so every one-image smoke test passed. These two assertions are cheap + and they are the only thing standing between a build and that. + """ + + def test_freeze_support_is_called_at_import(self): + source = (Path(__file__).resolve().parent.parent + / "imgcompress" / "__init__.py").read_text(encoding="utf-8") + self.assertIn("freeze_support()", source, + "multiprocessing.freeze_support() is gone from the package " + "__init__; a frozen build will fork-bomb on a folder") + + def test_a_real_pool_still_runs_more_than_one_job(self): + """The path the guard protects has to keep working, or the guard is + protecting nothing.""" + tmp = tempfile.TemporaryDirectory() + self.addCleanup(tmp.cleanup) + root = Path(tmp.name) + src = root / "many" + src.mkdir() + for i in range(3): + sample((240, 200)).save(src / f"a{i}.png") + results = compress_tree(src, root / "out", Settings(**FAST), workers=3) + self.assertEqual(len(results), 3) + self.assertTrue(all(r.error == "" for r in results), + [r.error for r in results]) + + if __name__ == "__main__": unittest.main() diff --git a/tests/test_corpus_guard.py b/tests/test_corpus_guard.py new file mode 100644 index 0000000..88f4649 --- /dev/null +++ b/tests/test_corpus_guard.py @@ -0,0 +1,142 @@ +"""Tests for the thing that checks the checker. + +`tests/web/check_ss2_corpus.py` exists because `ss2_validate.mjs` prints +VALIDATED just as happily over 48 vectors as over 60, so a failed AVIF plugin +install would have shown a green tick with AVIF parity untested forever. + +That guard was itself only ever verified by hand - which is precisely the +posture `ss2_validate.mjs` was in before it was wired into CI, and the reason +this whole thread exists. So it gets tests, and they include watching it fail: +a guard nobody has seen go red is a guess about whether it measures anything. + +It also had a real bug found by hand and fixed: argparse's `action="append"` +adds to a list default rather than replacing it, so `--require-codec jpeg` +meant "jpeg *and* the three defaults" and the exclusion path had never run. +That case is pinned below. +""" + +from __future__ import annotations + +import contextlib +import io +import json +import sys +import tempfile +import unittest +from pathlib import Path + +ROOT = Path(__file__).resolve().parent.parent +sys.path.insert(0, str(ROOT / "tests" / "web")) + +import check_ss2_corpus # noqa: E402 + + +def _vectors(n_jpeg=2, n_webp=1, n_avif=1): + out = [] + for kind, count in (("jpeg", n_jpeg), ("webp", n_webp), ("avif", n_avif)): + for i in range(count): + out.append({"ref": "src", "dist": f"src-{kind}{i}", "w": 8, "h": 8, + "score": 90.0}) + return out + + +class CorpusGuard(unittest.TestCase): + def setUp(self): + # addCleanup rather than enterContext: the latter arrived in 3.11 and + # this package supports 3.9, which the version matrix caught. + tmp = tempfile.TemporaryDirectory() + self.addCleanup(tmp.cleanup) + self.tmp = Path(tmp.name) + self.vectors_path = self.tmp / "vectors.json" + # The module resolves the path at import time; point it at a temp file. + self._real = check_ss2_corpus.VECTORS + check_ss2_corpus.VECTORS = self.vectors_path + + def tearDown(self): + check_ss2_corpus.VECTORS = self._real + + def write(self, vectors): + self.vectors_path.write_text(json.dumps(vectors), encoding="utf-8") + + def run_guard(self, *args): + """Returns (exit_code, stdout+stderr).""" + out, err = io.StringIO(), io.StringIO() + with contextlib.redirect_stdout(out), contextlib.redirect_stderr(err): + code = check_ss2_corpus.main(list(args)) + return code, out.getvalue() + err.getvalue() + + # -- it passes when it should ------------------------------------------ # + + def test_a_complete_corpus_passes(self): + self.write(_vectors()) + code, said = self.run_guard("--expect", "4") + self.assertEqual(code, 0, said) + self.assertIn("complete", said) + + # -- and fails when it should ------------------------------------------ # + + def test_a_short_corpus_fails(self): + """The failure the guard was written for: AVIF silently absent.""" + self.write(_vectors(n_avif=0)) + code, said = self.run_guard("--expect", "4") + self.assertEqual(code, 1) + self.assertIn("expected 4", said) + self.assertIn("avif", said) + + def test_the_right_count_with_a_missing_codec_still_fails(self): + """Count alone is not enough - a corpus can be the right size and still + have lost a whole codec.""" + self.write(_vectors(n_jpeg=3, n_avif=0)) + code, said = self.run_guard("--expect", "4") + self.assertEqual(code, 1) + self.assertIn("avif", said) + self.assertNotIn("expected 4", said) + + def test_a_long_corpus_fails_too(self): + """Not just a minimum. An unexpected extra means the corpus changed and + nobody updated the number.""" + self.write(_vectors(n_jpeg=5)) + code, _ = self.run_guard("--expect", "4") + self.assertEqual(code, 1) + + def test_missing_vectors_file_fails_rather_than_passing_vacuously(self): + code, said = self.run_guard("--expect", "4") + self.assertEqual(code, 2) + self.assertIn("run make_ss2_vectors", said) + + # -- the argparse bug --------------------------------------------------- # + + def test_require_codec_replaces_the_defaults_it_does_not_extend_them(self): + """`action="append"` appends to a list default. With `default=[...]`, + `--require-codec jpeg` silently meant "jpeg and avif and webp too", so + the narrowing path never actually narrowed.""" + self.write(_vectors(n_avif=0)) + code, said = self.run_guard("--expect", "3", "--require-codec", "jpeg", + "--require-codec", "webp") + self.assertEqual(code, 0, f"avif was still required despite being excluded: {said}") + + def test_the_default_requirement_is_all_three(self): + self.write(_vectors(n_webp=0, n_avif=0)) + code, said = self.run_guard("--expect", "2") + self.assertEqual(code, 1) + self.assertIn("webp", said) + self.assertIn("avif", said) + + +class TheRealCorpusScriptIsWiredUp(unittest.TestCase): + def test_ci_runs_it_with_an_explicit_count(self): + """A guard nothing calls is decoration.""" + ci = (ROOT / ".github" / "workflows" / "ci.yml").read_text(encoding="utf-8") + self.assertIn("check_ss2_corpus.py", ci) + self.assertIn("--expect", ci) + + def test_ci_does_not_tolerate_a_failed_avif_install(self): + """`continue-on-error` on that step is what made the whole corpus + optional in the first place.""" + ci = (ROOT / ".github" / "workflows" / "ci.yml").read_text(encoding="utf-8") + block = ci.split("pillow-avif-plugin")[0].rsplit("- name:", 1)[-1] + self.assertNotIn("continue-on-error", block) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_design_system.py b/tests/test_design_system.py new file mode 100644 index 0000000..9742467 --- /dev/null +++ b/tests/test_design_system.py @@ -0,0 +1,258 @@ +"""One design system, and the tooling that keeps it that way. + +Two interfaces ship in this repo. The browser app reads the token layer from +`web/`; the desktop app reads a copy of the same files, produced by +`tools/sync_webui_assets.py` and committed so a pip install needs no build step +and no network. Before that existed, the desktop app carried its own palette, +its own corners and its own two transition shorthands - a second visual +identity for the same product, and the half of it that no gate could see. + +What is checked here is everything reachable without a browser. The rest - +that the stylesheets are actually served, with types a browser accepts, and +that the tokens resolve on the real page - needs Chrome and lives in +`tests/web/verify_desktop.mjs`. Both halves matter: the static checks proved +the desktop app *referenced* the token layer for a while during which every +request for it came back 403 and the app rendered in Times New Roman. +""" + +from __future__ import annotations + +import re +import sys +import unittest +from pathlib import Path + +ROOT = Path(__file__).resolve().parent.parent +sys.path.insert(0, str(ROOT)) + +from tools import sync_webui_assets as sync # noqa: E402 + +WEB = ROOT / "web" +WEBUI = ROOT / "imgcompress" / "webui" +DESKTOP = WEBUI / "app.html" + + +def _read(path: Path) -> str: + with path.open("r", encoding="utf-8", newline="") as fh: + return fh.read() + + +def _desktop_css() -> str: + match = re.search(r"", _read(DESKTOP), re.S) + assert match, "could not find the