diff --git a/.coderabbit.yaml b/.coderabbit.yaml
new file mode 100644
index 0000000000..2408084cf9
--- /dev/null
+++ b/.coderabbit.yaml
@@ -0,0 +1,6 @@
+# yaml-language-server: $schema=https://coderabbit.ai/integrations/schema.v2.json
+reviews:
+ auto_review:
+ # Dependabot bumps are checked by CI; skip CodeRabbit reviews on them.
+ ignore_usernames:
+ - "dependabot[bot]"
diff --git a/.config/nextest.toml b/.config/nextest.toml
new file mode 100644
index 0000000000..2a4cd2cb93
--- /dev/null
+++ b/.config/nextest.toml
@@ -0,0 +1,3 @@
+# Minimum cargo-nextest for this repository: GHSA-vc25-qg58-w57c is fixed in 0.9.150 and CI
+# installs exactly that version, so an older nextest refuses to run (exit 92 REQUIRED_VERSION_NOT_MET).
+nextest-version = "0.9.150"
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 388b44f032..94a4c545fe 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -1,152 +1,92 @@
name: CI
+# Quality gates for every push to main and every pull request (`cargo xtask ci`), through the
+# suite's shared gate workflow in craft-common-actions. Nightly fuzzing is in fuzz.yml.
on:
push:
branches: [main]
pull_request:
+ # `labeled` so that adding `full-ci` to an open PR starts a run with Windows in it.
+ types: [opened, synchronize, reopened, labeled]
workflow_dispatch:
- schedule:
- - cron: "17 3 * * *" # nightly fuzzing
-env:
- CARGO_TERM_COLOR: always
- RUST_BACKTRACE: 1
+# The token is only used to check out the code: no job writes to the repository.
+permissions:
+ contents: read
+# A newer push to a PR branch cancels its older run; every push to main runs to the end. Adding
+# any label but `full-ci` gets a group of its own, so it neither runs the gate nor cancels a run.
concurrency:
- group: ci-${{ github.ref }}
- cancel-in-progress: true
+ group: ci-${{ github.event_name == 'pull_request' && (github.event.action != 'labeled' || github.event.label.name == 'full-ci') && github.ref || github.run_id }}
+ cancel-in-progress: ${{ github.event_name == 'pull_request' }}
jobs:
- test:
- name: test (${{ matrix.os }})
- runs-on: ${{ matrix.os }}
- env:
- CARGO_PROFILE_DEV_DEBUG: line-tables-only
- CARGO_PROFILE_TEST_DEBUG: line-tables-only
- strategy:
- fail-fast: false
- matrix:
- # No macOS on PRs, main or nightly: the org's 5 macOS slots are kept for release builds,
- # and release.yml builds macOS (a failing release is fixed and rebuilt).
- os: [ubuntu-latest, windows-latest]
+ # The shared gate doesn't check where git dependencies come from; release.yml runs the same job.
+ git-sources:
+ if: github.event.action != 'labeled' || github.event.label.name == 'full-ci'
+ name: Git dependencies come from storytold
+ runs-on: ubuntu-latest
+ permissions:
+ contents: read
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
-
- # The optional craft-fonts build input (Japanese fonts), so the Japanese font tests run on
- # Linux; Windows tests the build without it. See craftrules standards/fonts.md.
- - name: Check out craft-fonts (Linux)
- if: runner.os == 'Linux'
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
- repository: storytold/craft-fonts
- ref: 3d9ab3a132ed0eff457e424226a435a901da30d3
- path: craft-fonts
-
- - name: Configure craft-fonts (Linux)
- if: runner.os == 'Linux'
- run: |
- echo "CRAFT_FONTS_DIR=$GITHUB_WORKSPACE/craft-fonts" >> "$GITHUB_ENV"
- echo "CRAFT_FONTS_REQUIRED=1" >> "$GITHUB_ENV"
-
- - name: Install Linux system dependencies (egui/winit/rfd) and the oracles
- if: runner.os == 'Linux'
- run: |
- sudo apt-get update
- sudo apt-get install -y --no-install-recommends \
- libxkbcommon-dev libwayland-dev libx11-dev libxrandr-dev libxi-dev \
- libgl1-mesa-dev libgtk-3-dev \
- qpdf poppler-utils
-
- # The independent oracles (crates/oracle) are only worth having when they run. With this
- # set, a missing one fails the job instead of printing a skip notice that reads as a pass.
- # Only the tools the job actually uses are installed above.
- - name: Require the oracles (Linux)
- if: runner.os == 'Linux'
- run: echo "PDFCRAFT_ORACLES_REQUIRED=1" >> "$GITHUB_ENV"
-
- - uses: dtolnay/rust-toolchain@686976e191b89faba57d3206551f0f330d8cb249 # stable
- with:
- toolchain: stable
- components: clippy, rustfmt
-
- - name: Require Windows executable resources
- if: runner.os == 'Windows'
- run: Add-Content -Path $env:GITHUB_ENV -Value 'PDFCRAFT_REQUIRE_WINRES=1'
+ persist-credentials: false
+ - name: Windows font notice regressions
shell: pwsh
+ run: ./packaging/windows/test-font-notices.ps1
+ - name: Git dependencies come from storytold
+ run: |
+ set -euo pipefail
+ test -f Cargo.lock || { echo "Cargo.lock is missing"; exit 1; }
+ # Source replacement, patches and path overrides in cargo config never show up
+ # in Cargo.lock. Parsed rather than grepped: TOML has many spellings of one key.
+ # Cargo still reads the legacy .cargo/config.
+ python3 - <<'PY'
+ import os, sys, tomllib
+ for path in (".cargo/config", ".cargo/config.toml"):
+ if not os.path.exists(path):
+ continue
+ config = tomllib.load(open(path, "rb"))
+ replaced = [name for name, source in config.get("source", {}).items()
+ if isinstance(source, dict) and "replace-with" in source]
+ if replaced or "patch" in config or "paths" in config:
+ print(f"{path} replaces, patches or overrides a source")
+ sys.exit(1)
+ PY
+ # Fails when Cargo.lock is out of date with the manifests.
+ cargo metadata --locked --format-version 1 > /dev/null
+ python3 - <<'PY'
+ import sys, tomllib
+ lock = tomllib.load(open("Cargo.lock", "rb"))
+ bad = sorted({p["source"] for p in lock.get("package", [])
+ if p.get("source", "").startswith("git+")
+ and not p["source"].startswith("git+https://github.com/storytold/")})
+ if bad:
+ print("Git dependencies outside github.com/storytold:", *bad, sep="\n")
+ sys.exit(1)
+ PY
- - uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2
-
- - name: Format
- if: runner.os == 'Linux'
- run: cargo fmt --all -- --check
-
- - name: Clippy
- run: cargo clippy --workspace --all-targets -- -D warnings
-
- - name: Test
- if: runner.os != 'Linux'
- run: cargo test --workspace
-
- - name: Test (with craft-fonts)
- if: runner.os == 'Linux'
- run: cargo test --workspace
-
- - name: Layering
- if: runner.os == 'Linux'
- run: cargo xtask layers
-
- - name: Assets (licences and attribution)
- if: runner.os == 'Linux'
- run: cargo xtask assets
-
- - name: CLI without the MCP server still builds
- if: runner.os == 'Linux'
- run: cargo check -p pdfcraft-cli --no-default-features
-
- wasm:
- name: wasm32 check
- runs-on: ubuntu-latest
- steps:
- - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- - uses: dtolnay/rust-toolchain@686976e191b89faba57d3206551f0f330d8cb249 # stable
- with:
- toolchain: stable
- targets: wasm32-unknown-unknown
- - uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2
- with:
- key: wasm32
- - name: Check library crates for wasm32
- run: cargo xtask wasm
-
- deny:
- name: dependency licences and advisories
- runs-on: ubuntu-latest
- steps:
- - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- - uses: EmbarkStudios/cargo-deny-action@v2
- with:
- command: check
-
- fuzz:
- name: fuzz (mutation, 15 min)
- if: github.event_name == 'schedule' || github.event_name == 'workflow_dispatch'
- runs-on: ubuntu-latest
- steps:
- - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- - uses: dtolnay/rust-toolchain@686976e191b89faba57d3206551f0f330d8cb249 # stable
- with:
- toolchain: stable
- - uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2
- with:
- key: fuzz
- - name: Fetch the pdf.js corpus (seeds)
- run: cargo xtask corpus
- - name: Fuzz
- run: cargo xtask fuzz --time 900 --seed ${{ github.run_number }}
- - name: Upload findings
- if: always()
- uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
- with:
- name: fuzz-findings
- path: fuzz-out/findings
- if-no-files-found: ignore
+ gate:
+ if: github.event.action != 'labeled' || github.event.label.name == 'full-ci'
+ # Tracks @main so suite-wide CI fixes reach every app without a PR in each repository.
+ uses: storytold/craft-common-actions/.github/workflows/gate.yml@main
+ with:
+ # Windows on main, manual runs and pull requests labelled `full-ci`. No macOS on PRs or main:
+ # the org's 5 macOS slots are kept for release builds, and release.yml builds macOS.
+ os: ${{ (github.event_name != 'pull_request' || contains(github.event.pull_request.labels.*.name, 'full-ci')) && '["ubuntu-24.04","windows-latest"]' || '["ubuntu-24.04"]' }}
+ # Same craft-fonts commit as release.yml and freebsd.yml.
+ craft-fonts-ref: b076417b5880bd4154bd22f00695b1d28c6bea83
+ # egui/winit/rfd, and the independent oracles (crates/oracle).
+ packages: >-
+ libxkbcommon-dev libwayland-dev libx11-dev libxrandr-dev libxi-dev libgl1-mesa-dev
+ libgtk-3-dev qpdf poppler-utils
+ # cargo-deny for the deny step of `cargo xtask ci`, which requires it under CI.
+ tools: cargo-nextest@0.9.150,cargo-deny@0.20.2
+ # A missing oracle fails the job instead of printing a skip notice that reads as a pass.
+ linux-env: PDFCRAFT_ORACLES_REQUIRED=1
+ windows-env: PDFCRAFT_REQUIRE_WINRES=1
+ # Windows checks what differs by platform: the build (with resources) and the tests.
+ windows-command: |
+ cargo clippy --workspace --all-targets --locked -- -D warnings
+ cargo test --workspace --locked
diff --git a/.github/workflows/freebsd.yml b/.github/workflows/freebsd.yml
index fba20b1cc7..268da70ca1 100644
--- a/.github/workflows/freebsd.yml
+++ b/.github/workflows/freebsd.yml
@@ -26,4 +26,4 @@ jobs:
# Intentionally tracked at @main (not SHA-pinned) so org-wide CI improvements, security fixes, and build optimizations propagate without PR churn across all craftapp repos.
uses: storytold/craft-common-actions/.github/workflows/freebsd.yml@main
with:
- craft-fonts-ref: 3d9ab3a132ed0eff457e424226a435a901da30d3
+ craft-fonts-ref: b076417b5880bd4154bd22f00695b1d28c6bea83
diff --git a/.github/workflows/fuzz.yml b/.github/workflows/fuzz.yml
new file mode 100644
index 0000000000..b7b1d409c8
--- /dev/null
+++ b/.github/workflows/fuzz.yml
@@ -0,0 +1,46 @@
+name: Fuzz
+
+# Mutation fuzzing of open/render/edit/save (`cargo xtask fuzz`), nightly and on demand. Findings
+# are uploaded as an artifact; each real one becomes a synthetic regression test.
+on:
+ schedule:
+ - cron: "17 3 * * *"
+ workflow_dispatch:
+
+# The token is only used to check out the code: no job writes to the repository.
+permissions:
+ contents: read
+
+env:
+ CARGO_TERM_COLOR: always
+ RUST_BACKTRACE: 1
+
+concurrency:
+ group: fuzz-${{ github.ref }}
+ cancel-in-progress: true
+
+jobs:
+ fuzz:
+ name: fuzz (mutation, 15 min)
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
+ with:
+ persist-credentials: false
+ - uses: dtolnay/rust-toolchain@686976e191b89faba57d3206551f0f330d8cb249 # stable
+ with:
+ toolchain: stable
+ - uses: Swatinem/rust-cache@d1527c8f67ef9af357290475d4af4b0fab8f12e0 # v2.9.3
+ with:
+ key: fuzz
+ - name: Fetch the pdf.js corpus (seeds)
+ run: cargo xtask corpus
+ - name: Fuzz
+ run: cargo xtask fuzz --time 900 --seed ${{ github.run_number }}
+ - name: Upload findings
+ if: always()
+ uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
+ with:
+ name: fuzz-findings
+ path: fuzz-out/findings
+ if-no-files-found: ignore
diff --git a/.github/workflows/nix.yml b/.github/workflows/nix.yml
index ce817da129..01cb9cd663 100644
--- a/.github/workflows/nix.yml
+++ b/.github/workflows/nix.yml
@@ -18,16 +18,6 @@ concurrency:
cancel-in-progress: true
jobs:
- eval:
- runs-on: ubuntu-latest
- timeout-minutes: 15
- steps:
- - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- - uses: cachix/install-nix-action@448cde739abec1eaf0646249e3d25bb8d9bc7702 # v31.11.2
- - name: nix flake check --no-build
- run: |
- if [ -f flake.nix ]; then
- nix flake check --no-build --all-systems
- else
- echo "No flake.nix present in this repository; skipping check."
- fi
+ nix:
+ # Intentionally tracked at @main (not SHA-pinned) so org-wide CI improvements, security fixes, and build optimizations propagate without PR churn across all craftapp repos.
+ uses: storytold/craft-common-actions/.github/workflows/nix.yml@main
diff --git a/.github/workflows/packaging-lint.yml b/.github/workflows/packaging-lint.yml
index f54b0538b9..ea61d84303 100644
--- a/.github/workflows/packaging-lint.yml
+++ b/.github/workflows/packaging-lint.yml
@@ -21,3 +21,27 @@ jobs:
lint:
# Intentionally tracked at @main (not SHA-pinned) so org-wide CI improvements, security fixes, and build optimizations propagate without PR churn across all craftapp repos.
uses: storytold/craft-common-actions/.github/workflows/packaging-lint.yml@main
+
+ windows-install-policy:
+ timeout-minutes: 10
+ name: Windows installation policy (no app build)
+ runs-on: windows-latest
+ steps:
+ - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
+ with:
+ persist-credentials: false
+ - name: Install the pinned packaging toolchain
+ shell: pwsh
+ run: |
+ $tools = Join-Path $env:RUNNER_TEMP 'craft-install-policy-wix'
+ dotnet tool install --tool-path $tools wix --version 5.0.2
+ if ($LASTEXITCODE -ne 0) { throw 'WiX installation failed.' }
+ $tools >> $env:GITHUB_PATH
+ $policy = Get-Content -Raw -LiteralPath packaging/windows/install-policy.json | ConvertFrom-Json
+ foreach ($extension in @($policy.extensions)) {
+ & (Join-Path $tools 'wix.exe') extension add -g "WixToolset.$extension.wixext/5.0.2"
+ if ($LASTEXITCODE -ne 0) { throw "WiX extension $extension installation failed." }
+ }
+ - name: Build and check non-executable installer fixtures
+ shell: pwsh
+ run: ./packaging/windows/test-install-policy.ps1 -OutputDirectory (Join-Path $env:RUNNER_TEMP 'craft-install-policy-tests')
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index c6ee0e1c31..a5c2ba7f7f 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -24,7 +24,7 @@ jobs:
with:
app-name: pdfcraft
app-title: PdfCraft
- craft-fonts-ref: 3d9ab3a132ed0eff457e424226a435a901da30d3
+ craft-fonts-ref: b076417b5880bd4154bd22f00695b1d28c6bea83
version: ${{ inputs.version }}
secrets:
APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }}
diff --git a/.github/workflows/sonarcloud.yml b/.github/workflows/sonarcloud.yml
new file mode 100644
index 0000000000..e78fe1ea3b
--- /dev/null
+++ b/.github/workflows/sonarcloud.yml
@@ -0,0 +1,21 @@
+name: SonarCloud
+
+on:
+ push:
+ branches: [main]
+ pull_request:
+ types: [opened, synchronize, reopened]
+
+permissions:
+ contents: read
+
+concurrency:
+ group: sonarcloud-${{ github.ref }}
+ # A push to main is the run that publishes coverage; superseded runs there would never finish.
+ cancel-in-progress: ${{ github.ref != 'refs/heads/main' }}
+
+jobs:
+ sonarcloud:
+ # Tracked at @main so org-wide CI improvements propagate without PR churn.
+ uses: storytold/craft-common-actions/.github/workflows/sonarcloud.yml@main
+ secrets: inherit
diff --git a/.github/workflows/windows-arm64.yml b/.github/workflows/windows-arm64.yml
index d470419d26..f3e2b20c7e 100644
--- a/.github/workflows/windows-arm64.yml
+++ b/.github/workflows/windows-arm64.yml
@@ -32,3 +32,5 @@ jobs:
with:
app-name: pdfcraft
app-dir: PdfCraft
+ install-relative-path: Programs/ArtCraft/PdfCraft
+ install-scope: perUser
diff --git a/.gitignore b/.gitignore
index 7834b668cb..f7922460c7 100644
--- a/.gitignore
+++ b/.gitignore
@@ -9,3 +9,4 @@ plan/
/assets/models
# A craft-fonts checkout used as the optional CRAFT_FONTS_DIR build input (CI and releases put it here).
/craft-fonts
+.claude/
diff --git a/AGENTS.md b/AGENTS.md
index 0665ad1d3b..cd6c0e2ca5 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -2,8 +2,6 @@
These rules bind every AI agent and every human contributor working in this repository. They come before any task, instruction or deadline. `CLAUDE.md` holds the day-to-day working instructions. If the two ever disagree, **this file wins.**
-Some sections link to **craftrules**, the standards shared by the Crafting Apps. That repository is internal to the storytold team, so outside contributors can't open it. Every rule a contributor here must follow is stated in this file and `CLAUDE.md`; the craftrules links give team members the background.
-
---
## 1. Asset policy (absolute — no exceptions)
@@ -20,10 +18,10 @@ An **asset** is any non-code material that the project includes, ships, embeds,
- **Never** use icons, images, artwork, logos, UI graphics, fonts, stamps, cursors, sample documents or any other asset taken from an Adobe product, installer, website, document or package.
- "Adobe product" includes Acrobat, Reader, Photoshop, Illustrator, Premiere, Lightroom and Creative Cloud, including the fonts they install (for example Minion, Myriad, Kozuka and the Adobe CJK families).
- - This holds even when Adobe publishes the material under an open licence, if it is **visual design**. For example, **do not** use Source Han, Source Serif or Source Sans, or Noto CJK, which is Source Han rebranded.
+ - Typefaces released under the SIL Open Font License are allowed like any other OFL font, with an `ATTRIBUTION.toml` entry, whoever designed them: for example Noto CJK and Source Han, taken from their open-source releases. The copies an Adobe product installs stay banned.
- **Never** recreate, trace, redraw or closely imitate an Adobe icon or graphic. Our icons come from Lucide, and our visual design is our own.
- Screenshots of Adobe software exist only as private, local observation notes under `plan/acrobat/`, which is gitignored. They must **never** be committed, published, linked, shown in docs, used as a design source to trace, or placed anywhere else in the repository or its outputs.
-- The only Adobe-authored material allowed is **non-visual technical data** that is openly licensed and required for PDF compatibility. Each item needs its own entry in `ATTRIBUTION.toml` with `adobe_data = true`, and the list below is closed:
+- Apart from those OFL typefaces, the only Adobe-authored material allowed is **non-visual technical data** that is openly licensed and required for PDF compatibility. Each item needs its own entry in `ATTRIBUTION.toml` with `adobe_data = true`, and the list below is closed:
- The CMap resources (BSD-3-Clause), bundled inside the `hayro-cmap` dependency.
- The standard-14 font metrics and encoding tables in the PDF specification. They are data, not typefaces. They are embedded as code in hayro, and as our own generated table in `crates/fonts/src/std14_metrics.rs` (approved by the owner, 2026-10-08; that file holds the data alone, so the code reading it can change without re-attesting the table), which is what generated appearances measure with and what survives hayro being retired.
@@ -54,23 +52,25 @@ Anything else is forbidden, including assets whose licence you cannot prove from
- path or URL, title, author;
- source (URL, with version or commit);
- SPDX licence, and the licence file in the repo;
- - SHA-256;
+ - SHA-256, except for translation catalogs (see below);
- how it is used.
+- **Translation catalogs** (`crates/ui-egui/src/i18n/*.tsv`, `kind = "translation"`) are project files under the project licence. Their entries carry no `sha256`: git history records every change and who made it. Every other kind (fonts, images, data) is hashed.
- **`ATTRIBUTION.md`** is generated from it (`cargo xtask assets --write`). Never edit it by hand.
- **`NOTICE`** keeps the licence notices that the licences require.
- **`cargo xtask assets`** runs in `cargo xtask ci`. It fails if:
- an asset file in the repository has no entry;
- - an entry's hash does not match;
+ - an entry's hash is missing or does not match (translation catalogs carry none);
- a licence is not on the allowlist;
- a licence file is missing;
- - an image, icon or font names Adobe as author or source.
+ - an image, icon or font names Adobe as author or source, except a font licensed under OFL-1.1 alone.
- **`cargo xtask demo-pdf`** downloads its fonts only from `ATTRIBUTION.toml` entries, pinned by SHA-256. Afterwards it **fails if the PDF embeds any font not in its allowlist**, which catches the browser silently falling back to a system font.
### 1.4 Fonts live in craft-fonts
-- Font assets shared by the Crafting Apps, including every Japanese font, live in [`storytold/craft-fonts`](https://github.com/storytold/craft-fonts), not here. **Never commit a font file to this repository**; adding a font means adding it to craft-fonts. (The Latin UI fonts already in `assets/fonts/` — Inter, JetBrains Mono, Dancing Script — stay.) Team members: the shared rules are in [craftrules `standards/fonts.md`](https://github.com/storytold/craftrules/blob/main/standards/fonts.md) (internal).
+- Font assets shared by the Crafting Apps, including every Japanese font, live in [`storytold/craft-fonts`](https://github.com/storytold/craft-fonts), not here. **Never commit a font file to this repository**; adding a font means adding it to craft-fonts. (The Latin UI fonts already in `assets/fonts/` — Inter, JetBrains Mono, Dancing Script — stay.)
- craft-fonts is an **optional build input**, never a Cargo dependency: `CRAFT_FONTS_DIR= cargo build` makes `crates/fonts/build.rs` embed its fonts as `pdfcraft_fonts::CRAFT_FONTS`. Without it PdfCraft builds, tests and runs, but has no Japanese face (Japanese UI text shows replacement boxes, and writing Japanese into a PDF returns a clear error). Release builds always set it (`.github/workflows/release.yml`), and `ATTRIBUTION.toml` lists it as a `[[build_input]]`.
- The desktop app may read one font **already installed on the user's machine** at runtime, as the last interface fallback for characters no embedded face has (`crates/ui-egui/src/system_fonts.rs`). That font is never embedded, shipped, committed or copied, and never appears in published screenshots (`PDFCRAFT_SYSTEM_FONTS=0`, which `cargo xtask screenshots` sets). It is not a way around §1.2: nothing from it may enter a PDF, a package or the repository.
+- The page viewer may also draw a CID font of an Adobe CJK collection (Japan1, GB1, CNS1, Korea1) that a PDF does **not embed** with one face already installed on the machine (`crates/render/src/system_font.rs`), **on screen only**. `RenderConfig::system_fonts` is off by default and is enabled only for on-screen viewer and preview rasters (`RenderConfig::on_screen`, used by the engine viewer, Combine thumbnails and accessibility figure previews), so exports, printing, OCR and the automation tools never use it. Like the interface font it is read at runtime and never embedded, shipped, committed or copied; Noto CJK and Source Han are never candidates (§1.1), nor is any Adobe family; and `PDFCRAFT_SYSTEM_FONTS=0` turns it off. It is not a way around §1.2 either: nothing drawn with it may be saved or printed.
### 1.5 Procedure for adding or changing an asset
@@ -91,7 +91,7 @@ If you find an asset that breaks these rules, stop and remove it from the reposi
- Behaviour comes only from public specifications (ISO 32000-2, the Arlington model), public documentation and black-box observation.
- Never copy GPL/AGPL code. Copyleft tools run only as external oracle processes.
- Observe Acrobat only with synthetic fixtures. Never capture personal data, account information or recent files.
-- Shared real-file test oracles (Photoshop-authored PSDs, etc.) live in [`storytold/photocraft-corpus`](https://github.com/storytold/photocraft-corpus), explained in [craftrules `standards/test-corpora.md`](https://github.com/storytold/craftrules/blob/main/standards/test-corpora.md) (internal). Never commit large binary fixtures to this repo; fetch them pinned by commit and sha256-verified, as PhotoCraft does with `cargo xtask corpus`.
+- Shared real-file test oracles (Photoshop-authored PSDs, etc.) live in [`storytold/photocraft-corpus`](https://github.com/storytold/photocraft-corpus). Never commit large binary fixtures to this repo; fetch them pinned by commit and sha256-verified, as PhotoCraft does with `cargo xtask corpus`.
## 3. Agent control (automation and MCP)
@@ -104,7 +104,7 @@ If you find an asset that breaks these rules, stop and remove it from the reposi
## 4. Never crash
-PdfCraft opens files from strangers, and people trust it with their work. A malformed PDF, a bad tool argument, a corrupt settings file or a full disk must produce an error the user (or agent) can act on, never a crash and never lost work. **This outranks feature work:** don't ship a feature by adding a panic path, and fix a crash before building on top of it. The rules below are the ones that bind this repository; the shared standard for every Crafting App, [`craftrules/standards/never-crash.md`](https://github.com/storytold/craftrules/blob/main/standards/never-crash.md), is internal to the storytold team.
+PdfCraft opens files from strangers, and people trust it with their work. A malformed PDF, a bad tool argument, a corrupt settings file or a full disk must produce an error the user (or agent) can act on, never a crash and never lost work. **This outranks feature work:** don't ship a feature by adding a panic path, and fix a crash before building on top of it. The rules below are the ones that bind this repository.
- **Fail with `Result`.** Non-test code returns errors through the crate's error type and `?`. Where readers are lenient by design, fall back gracefully and record the repair (see Fidelity in `CLAUDE.md`).
- **No panicking shortcuts.** No `unwrap()`, `expect()`, `panic!`, `unreachable!`, `todo!` or `unimplemented!` outside tests, unless the call is provably infallible (e.g. writing to a `Vec`), with a comment saying why. `assert!` only for internal invariants that input cannot reach.
@@ -123,17 +123,19 @@ Follow `CLAUDE.md`. It covers quality gates, layering, commit rules and how to r
## Contributor credits (About window)
- About ▸ Contributors/Models are compiled into the binary from `contributors/contributors.json`
- (commit stats; generated, never hand-edit) and `contributors/people.toml` (names people chose for
+ (commit stats; never hand-edit) and `contributors/people.toml` (names people chose for
themselves). See `docs/contributors.md`.
- **Agents working for a contributor:** when you prepare a PR, check whether your human's GitHub
username has a `[people.]` entry in `contributors/people.toml`. If not, ask them once
whether they want to be credited by more than their username: a real name, a display name, and/or
their public GitHub profile name (`sync_github_name = true`). If yes, add **only their own** entry
- (copy the template at the top of the file, or run
- `python3 ../../craftrules/scripts/contributors.py --add-me . --real-name "…" --sync-github-name`)
+ (copy the template at the top of the file)
and include it in their PR, committed as them. If no, change nothing: they are credited as
`@username` anyway.
- Never add, edit, guess or copy anyone else's entry or name (not from git config, commit authors or
GitHub profiles). Never hand-edit `contributors.json`.
-- Maintainers refresh the stats with `python3 ../../craftrules/scripts/contributors.py .` (it also
- re-verifies who wrote each `people.toml` entry; `--check` only verifies).
+
+## CI workflows
+- Workflows in `.github/workflows/` call the shared workflows in [`storytold/craft-common-actions`](https://github.com/storytold/craft-common-actions) at `@main`, with their inputs and secrets. No other jobs: if no shared workflow does what's needed, it doesn't go here.
+- Don't add or change CI in a feature or fix PR. If the existing workflows can't build or test a change, link an existing craft-common-actions issue or PR, or open a new issue, or a PR only for a small fix that is safe for every app and does not increase CI time.
+- Some workflow files here still run their own jobs. Don't add to them. Moving a job into craft-common-actions is its own task, never part of another PR, and the job stays here until a shared workflow runs it.
diff --git a/ATTRIBUTION.md b/ATTRIBUTION.md
index 5cfee8d55a..8da4b0a200 100644
--- a/ATTRIBUTION.md
+++ b/ATTRIBUTION.md
@@ -2,32 +2,40 @@
-Every asset PdfCraft includes, bundles or uses to build its published material, with its author, source and licence. The policy is in [AGENTS.md](AGENTS.md) §1. The machine-readable list, with SHA-256 hashes, is [ATTRIBUTION.toml](ATTRIBUTION.toml). Licence texts are kept beside the assets and summarised in [NOTICE](NOTICE).
+Every asset PdfCraft includes, bundles or uses to build its published material, with its author, source and licence. The policy is in [AGENTS.md](AGENTS.md) §1. The machine-readable list, with the SHA-256 of every asset but the translation catalogs, is [ATTRIBUTION.toml](ATTRIBUTION.toml). Licence texts are kept beside the assets and summarised in [NOTICE](NOTICE).
-## In this repository (272)
+## In this repository (289)
| Asset | Title | Author | Licence | Source | Used for |
|---|---|---|---|---|---|
-| `crates/ui-egui/src/i18n/el.tsv` | Greek interface translation catalog | Camthalion77 and contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft English UI labels | Greek UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs |
-| `crates/ui-egui/src/i18n/zh-hans.tsv` | Simplified Chinese interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources | Simplified Chinese UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs |
-| `crates/ui-egui/src/i18n/zh-hant.tsv` | Traditional Chinese (Taiwan) interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources | Traditional Chinese UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs |
-| `crates/ui-egui/src/i18n/cs.tsv` | Czech interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources | Czech UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs |
-| `crates/ui-egui/src/i18n/pt-br.tsv` | Brazilian Portuguese interface translation catalog | André Cardoso (andrecards), PdfCraft contributors | MIT OR Apache-2.0 | Clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources: the ~1,826-entry gist posted in issue #288 (https://gist.github.com/andrecards/ac388c25fba17312ce83207f8b7b467e), completed and reviewed by PdfCraft contributors | Brazilian Portuguese UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs |
-| `crates/ui-egui/src/i18n/es.tsv` | Spanish interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources | Spanish UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs |
-| `crates/ui-egui/src/i18n/fr.tsv` | French interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources | French UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs |
-| `crates/ui-egui/src/i18n/de.tsv` | German interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources | German UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs |
-| `crates/ui-egui/src/i18n/te.tsv` | Telugu interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources | Telugu UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs |
-| `crates/ui-egui/src/i18n/it.tsv` | Italian interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources | Italian UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs |
-| `crates/ui-egui/src/i18n/pl.tsv` | Polish interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources | Polish UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs |
-| `crates/ui-egui/src/i18n/ru.tsv` | Russian interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources | Russian UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs |
-| `crates/ui-egui/src/i18n/hu.tsv` | Hungarian interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources | Hungarian UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs |
-| `crates/ui-egui/src/i18n/bg.tsv` | Bulgarian interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources | Bulgarian UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs |
-| `crates/ui-egui/src/i18n/kk.tsv` | Kazakh interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of the English UI labels into Kazakh, no proprietary localisation resources | Kazakh UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs |
-| `crates/ui-egui/src/i18n/ja.tsv` | Japanese interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources | Japanese UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs |
-| `crates/ui-egui/src/i18n/uk.tsv` | Ukrainian interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources | Ukrainian UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs |
-| `crates/ui-egui/src/i18n/ar.tsv` | Arabic interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources | Arabic UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs |
-| `crates/ui-egui/src/i18n/tr.tsv` | Turkish interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources | Turkish UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs |
-| `crates/ui-egui/src/i18n/lt.tsv` | Lithuanian interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels and user-facing messages; original catalog reviewed by a native Lithuanian-speaking contributor; 2026-10-15 coverage additions are AI-assisted and not separately native-reviewed | Lithuanian UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs |
+| `crates/ui-egui/src/i18n/el.tsv` | Greek interface translation catalog | Camthalion77 and contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft English UI labels; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed | Greek UI strings, compiled in by crates/ui-egui/src/i18n.rs |
+| `crates/ui-egui/src/i18n/zh-hans.tsv` | Simplified Chinese interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed | Simplified Chinese UI strings, compiled in by crates/ui-egui/src/i18n.rs |
+| `crates/ui-egui/src/i18n/zh-hant.tsv` | Traditional Chinese (Taiwan) interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed | Traditional Chinese UI strings, compiled in by crates/ui-egui/src/i18n.rs |
+| `crates/ui-egui/src/i18n/cs.tsv` | Czech interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 signature-check and standard-identification wordings are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed | Czech UI strings, compiled in by crates/ui-egui/src/i18n.rs |
+| `crates/ui-egui/src/i18n/pt-br.tsv` | Brazilian Portuguese interface translation catalog | André Cardoso (andrecards), PdfCraft contributors | MIT OR Apache-2.0 | Clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources: the ~1,826-entry gist posted in issue #288 (https://gist.github.com/andrecards/ac388c25fba17312ce83207f8b7b467e), completed and reviewed by PdfCraft contributors; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 text-field-table limit messages are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed | Brazilian Portuguese UI strings, compiled in by crates/ui-egui/src/i18n.rs |
+| `crates/ui-egui/src/i18n/es.tsv` | Spanish interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; the 2026-10-20 coverage additions are AI-assisted and not separately native-reviewed; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 signature-check wordings are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed | Spanish UI strings, compiled in by crates/ui-egui/src/i18n.rs |
+| `crates/ui-egui/src/i18n/fr.tsv` | French interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed | French UI strings, compiled in by crates/ui-egui/src/i18n.rs |
+| `crates/ui-egui/src/i18n/de.tsv` | German interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed | German UI strings, compiled in by crates/ui-egui/src/i18n.rs |
+| `crates/ui-egui/src/i18n/lo.tsv` | Lao interface translation catalog | @bounkirdni-2025 | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft English UI labels, no proprietary localisation resources; 2026-10-22 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed | Lao UI strings, compiled in by crates/ui-egui/src/i18n.rs |
+| `crates/ui-egui/src/i18n/te.tsv` | Telugu interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed | Telugu UI strings, compiled in by crates/ui-egui/src/i18n.rs |
+| `crates/ui-egui/src/i18n/it.tsv` | Italian interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed | Italian UI strings, compiled in by crates/ui-egui/src/i18n.rs |
+| `crates/ui-egui/src/i18n/pl.tsv` | Polish interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 signature-check wordings are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed | Polish UI strings, compiled in by crates/ui-egui/src/i18n.rs |
+| `crates/ui-egui/src/i18n/ru.tsv` | Russian interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed | Russian UI strings, compiled in by crates/ui-egui/src/i18n.rs |
+| `crates/ui-egui/src/i18n/hu.tsv` | Hungarian interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed | Hungarian UI strings, compiled in by crates/ui-egui/src/i18n.rs |
+| `crates/ui-egui/src/i18n/bg.tsv` | Bulgarian interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed | Bulgarian UI strings, compiled in by crates/ui-egui/src/i18n.rs |
+| `crates/ui-egui/src/i18n/kk.tsv` | Kazakh interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of the English UI labels into Kazakh, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed | Kazakh UI strings, compiled in by crates/ui-egui/src/i18n.rs |
+| `crates/ui-egui/src/i18n/ja.tsv` | Japanese interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed | Japanese UI strings, compiled in by crates/ui-egui/src/i18n.rs |
+| `crates/ui-egui/src/i18n/uk.tsv` | Ukrainian interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed | Ukrainian UI strings, compiled in by crates/ui-egui/src/i18n.rs |
+| `crates/ui-egui/src/i18n/ar.tsv` | Arabic interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed | Arabic UI strings, compiled in by crates/ui-egui/src/i18n.rs |
+| `crates/ui-egui/src/i18n/ckb.tsv` | Central Kurdish (Sorani) interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-22 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed | Central Kurdish UI strings, compiled in by crates/ui-egui/src/i18n.rs |
+| `crates/ui-egui/src/i18n/fa.tsv` | Persian (Farsi) interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-25 initial catalog covering the current 2,776-entry key union, AI-assisted translations from English, not separately native-reviewed | Persian UI strings, compiled in by crates/ui-egui/src/i18n.rs |
+| `crates/ui-egui/src/i18n/tr.tsv` | Turkish interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed | Turkish UI strings, compiled in by crates/ui-egui/src/i18n.rs |
+| `crates/ui-egui/src/i18n/lt.tsv` | Lithuanian interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels and user-facing messages; original catalog reviewed by a native Lithuanian-speaking contributor; 2026-10-15 coverage additions are AI-assisted and not separately native-reviewed; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed | Lithuanian UI strings, compiled in by crates/ui-egui/src/i18n.rs |
+| `crates/ui-egui/src/i18n/hi.tsv` | Hindi interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-22 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 text-field-table labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed | Hindi UI strings, compiled in by crates/ui-egui/src/i18n.rs |
+| `crates/ui-egui/src/i18n/id.tsv` | Indonesian interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-22 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 text-field-table labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed | Indonesian UI strings, compiled in by crates/ui-egui/src/i18n.rs |
+| `crates/ui-egui/src/i18n/ko.tsv` | Korean interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-22 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 text-field-table labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed | Korean UI strings, compiled in by crates/ui-egui/src/i18n.rs |
+| `crates/ui-egui/src/i18n/ro.tsv` | Romanian interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels and user-facing messages; AI-assisted and not yet reviewed line by line by a native Romanian speaker; 2026-10-22 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 text-field-table labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed | Romanian UI strings, compiled in by crates/ui-egui/src/i18n.rs |
+| `crates/ui-egui/src/i18n/fi.tsv` | Finnish interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: AI-assisted (Claude Code) clean-room translations of PdfCraft's English UI labels and user-facing messages; not yet proofread by a native Finnish speaker; 2026-10-22 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 text-field-table labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 calculation-order, text-format, print, thumbnail, layer, comment-reshape and signature labels (previously English placeholders) are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed | Finnish UI strings, compiled in by crates/ui-egui/src/i18n.rs |
| `assets/icons/accessibility.svg` | Lucide icon "accessibility" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/accessibility.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
| `assets/icons/align-left.svg` | Lucide icon "align-left" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/align-left.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
| `assets/icons/align-center.svg` | Lucide icon "align-center" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/align-center.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
@@ -223,6 +231,7 @@ Every asset PdfCraft includes, bundles or uses to build its published material,
| `vendor/hayro-interpret/assets/FoxitSymbol.pfb` | FoxitSymbol (standard-14 substitute font) | Foxit Software Inc. / PDFium Authors | BSD-3-Clause | https://pdfium.googlesource.com/pdfium (via hayro-interpret 0.7.0 assets/) | Renders non-embedded standard-14 fonts (vendored hayro-interpret) |
| `vendor/hayro-interpret/assets/CGATS001Compat-v2-micro.icc` | CGATS001Compat v2 micro ICC profile | Clinton Ingram (saucecontrol/Compact-ICC-Profiles) | CC0-1.0 | https://github.com/saucecontrol/Compact-ICC-Profiles | Default CMYK colour conversion (vendored hayro-interpret) |
| `crates/fonts/src/std14_metrics.rs` | Standard-14 font advance widths and encodings (as Rust tables) | Adobe (Core14 AFM metrics, PDF specification encodings); tables generated by the PdfCraft contributors | MIT OR Apache-2.0 | ISO 32000-2 Annex D and the Adobe Core14 AFM metrics. Generated by joining the published WinAnsi, Symbol and ZapfDingbats encodings (code to glyph name) with the per-glyph advance widths, then spot-checked against the specification by hand in the tests of crates/fonts/src/std14.rs. Data only: the code that reads these tables lives in std14.rs, so that ordinary edits do not churn this file's hash. | Advance widths for the fourteen standard fonts, which a PDF may name without embedding. Non-visual data, not a typeface: the item AGENTS.md 1.1 permits, held first-party so generated appearance streams and the renderer agree. |
+| `vendor/hayro-cmap/assets/cmaps.brotli` | Adobe CMap resources (predefined CJK CMaps) | Adobe | BSD-3-Clause | https://github.com/adobe-type-tools/cmap-resources (via hayro-cmap 0.1.0 assets/) | Character-code mapping tables required to read CJK PDFs. Non-visual data, allowed by AGENTS.md §1.1 |
| `docs/brand/artcraft-logo-white.png` | ArtCraft brand: artcraft-logo-white | ArtCraft Team | LicenseRef-Storyteller-Trademark | ArtCraft Team brand assets (provided by the project owner, 2026-10-01; identical copies in storytold/photocraft) | ArtCraft branding next to the community links (About dialog, Help menu, home screen) and in docs. ArtCraft Team trademark, not open source; may be used only unmodified within PdfCraft; forks and modified versions must remove it. |
| `docs/brand/artcraft-logo-white.svg` | ArtCraft brand: artcraft-logo-white | ArtCraft Team | LicenseRef-Storyteller-Trademark | ArtCraft Team brand assets (provided by the project owner, 2026-10-01; identical copies in storytold/photocraft) | ArtCraft branding next to the community links (About dialog, Help menu, home screen) and in docs. ArtCraft Team trademark, not open source; may be used only unmodified within PdfCraft; forks and modified versions must remove it. |
| `docs/brand/artcraft-logo.png` | ArtCraft brand: artcraft-logo | ArtCraft Team | LicenseRef-Storyteller-Trademark | ArtCraft Team brand assets (provided by the project owner, 2026-10-01; identical copies in storytold/photocraft) | ArtCraft branding next to the community links (About dialog, Help menu, home screen) and in docs. ArtCraft Team trademark, not open source; may be used only unmodified within PdfCraft; forks and modified versions must remove it. |
@@ -231,22 +240,22 @@ Every asset PdfCraft includes, bundles or uses to build its published material,
| `docs/brand/artcraft-mark-black.svg` | ArtCraft brand: artcraft-mark-black | ArtCraft Team | LicenseRef-Storyteller-Trademark | ArtCraft Team brand assets (provided by the project owner, 2026-10-01; identical copies in storytold/photocraft) | ArtCraft branding next to the community links (About dialog, Help menu, home screen) and in docs. ArtCraft Team trademark, not open source; may be used only unmodified within PdfCraft; forks and modified versions must remove it. |
| `docs/brand/artcraft-mark.png` | ArtCraft brand: artcraft-mark | ArtCraft Team | LicenseRef-Storyteller-Trademark | ArtCraft Team brand assets (provided by the project owner, 2026-10-01; identical copies in storytold/photocraft) | ArtCraft branding next to the community links (About dialog, Help menu, home screen) and in docs. ArtCraft Team trademark, not open source; may be used only unmodified within PdfCraft; forks and modified versions must remove it. |
| `docs/brand/artcraft-mark.svg` | ArtCraft brand: artcraft-mark | ArtCraft Team | LicenseRef-Storyteller-Trademark | ArtCraft Team brand assets (provided by the project owner, 2026-10-01; identical copies in storytold/photocraft) | ArtCraft branding next to the community links (About dialog, Help menu, home screen) and in docs. ArtCraft Team trademark, not open source; may be used only unmodified within PdfCraft; forks and modified versions must remove it. |
-| `assets/app-icon/pdfcraft.svg` | PdfCraft app icon (master vector) | PdfCraft contributors (drawn by the project owner in ArtCraft; vectorised with craftrules vectorize_tile.py) | MIT OR Apache-2.0 | Contributor-original (see assets/app-icon/README.md) | Master artwork; every other app-icon file is rendered from it (packaging/icons.sh) |
-| `assets/app-icon/pdfcraft-small.svg` | PdfCraft app icon (light vector) | PdfCraft contributors (drawn by the project owner in ArtCraft; vectorised with craftrules vectorize_tile.py) | MIT OR Apache-2.0 | Contributor-original (see assets/app-icon/README.md) | README header and assets/app-icon/README.md |
-| `assets/app-icon/pdfcraft-1024.png` | PdfCraft app icon, 1024 px on Apple's icon grid | PdfCraft contributors (drawn by the project owner in ArtCraft; vectorised with craftrules vectorize_tile.py) | MIT OR Apache-2.0 | Contributor-original (see assets/app-icon/README.md) | Runtime window/Dock icon on macOS (apps/pdfcraft/src/main.rs) |
-| `assets/app-icon/pdfcraft.icns` | PdfCraft app icon (macOS .icns) | PdfCraft contributors (drawn by the project owner in ArtCraft; vectorised with craftrules vectorize_tile.py) | MIT OR Apache-2.0 | Contributor-original (see assets/app-icon/README.md) | macOS app bundle icon |
-| `assets/app-icon/pdfcraft.ico` | PdfCraft app icon (Windows .ico, 16-256 px) | PdfCraft contributors (drawn by the project owner in ArtCraft; vectorised with craftrules vectorize_tile.py) | MIT OR Apache-2.0 | Contributor-original (see assets/app-icon/README.md) | Embedded in pdfcraft.exe by apps/pdfcraft/build.rs (Explorer, taskbar, Start menu, Alt-Tab) |
+| `assets/app-icon/pdfcraft.svg` | PdfCraft app icon (master vector) | PdfCraft contributors (drawn by the project owner in ArtCraft) | MIT OR Apache-2.0 | Contributor-original (see assets/app-icon/README.md) | Master artwork; every other app-icon file is rendered from it (packaging/icons.sh) |
+| `assets/app-icon/pdfcraft-small.svg` | PdfCraft app icon (light vector) | PdfCraft contributors (drawn by the project owner in ArtCraft) | MIT OR Apache-2.0 | Contributor-original (see assets/app-icon/README.md) | README header and assets/app-icon/README.md |
+| `assets/app-icon/pdfcraft-1024.png` | PdfCraft app icon, 1024 px on Apple's icon grid | PdfCraft contributors (drawn by the project owner in ArtCraft) | MIT OR Apache-2.0 | Contributor-original (see assets/app-icon/README.md) | Runtime window/Dock icon on macOS (apps/pdfcraft/src/main.rs) |
+| `assets/app-icon/pdfcraft.icns` | PdfCraft app icon (macOS .icns) | PdfCraft contributors (drawn by the project owner in ArtCraft) | MIT OR Apache-2.0 | Contributor-original (see assets/app-icon/README.md) | macOS app bundle icon |
+| `assets/app-icon/pdfcraft.ico` | PdfCraft app icon (Windows .ico, 16-256 px) | PdfCraft contributors (drawn by the project owner in ArtCraft) | MIT OR Apache-2.0 | Contributor-original (see assets/app-icon/README.md) | Embedded in pdfcraft.exe by apps/pdfcraft/build.rs (Explorer, taskbar, Start menu, Alt-Tab) |
| `assets/app-icon/pdfcraft-document.svg` | PdfCraft PDF document icon (master vector) | Lucide Icons and Contributors (document outline); PdfCraft contributors (PDF badge, lettering and composition) | ISC AND (MIT OR Apache-2.0) | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/file-text.svg); contributor-original composition (assets/app-icon/README.md) | Master artwork for the Windows PDF file type icon; rendered by packaging/icons.sh, without fonts or external images |
| `assets/app-icon/pdfcraft-document.ico` | PdfCraft PDF document icon (Windows .ico, 16-256 px) | Lucide Icons and Contributors (document outline); PdfCraft contributors (PDF badge, lettering and composition) | ISC AND (MIT OR Apache-2.0) | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/file-text.svg); contributor-original composition (assets/app-icon/README.md) | Embedded in pdfcraft.exe as resource 2; the Windows installer uses it for PdfCraft.Document and Applications/pdfcraft.exe PDF file associations |
-| `assets/app-icon/hicolor/16x16/apps/ai.storyteller.pdfcraft.png` | PdfCraft app icon, 16 px | PdfCraft contributors (drawn by the project owner in ArtCraft; vectorised with craftrules vectorize_tile.py) | MIT OR Apache-2.0 | Contributor-original (see assets/app-icon/README.md) | Linux hicolor theme icon (packaging/linux/ai.storyteller.pdfcraft.desktop) |
-| `assets/app-icon/hicolor/24x24/apps/ai.storyteller.pdfcraft.png` | PdfCraft app icon, 24 px | PdfCraft contributors (drawn by the project owner in ArtCraft; vectorised with craftrules vectorize_tile.py) | MIT OR Apache-2.0 | Contributor-original (see assets/app-icon/README.md) | Linux hicolor theme icon (packaging/linux/ai.storyteller.pdfcraft.desktop) |
-| `assets/app-icon/hicolor/32x32/apps/ai.storyteller.pdfcraft.png` | PdfCraft app icon, 32 px | PdfCraft contributors (drawn by the project owner in ArtCraft; vectorised with craftrules vectorize_tile.py) | MIT OR Apache-2.0 | Contributor-original (see assets/app-icon/README.md) | Linux hicolor theme icon (packaging/linux/ai.storyteller.pdfcraft.desktop) |
-| `assets/app-icon/hicolor/48x48/apps/ai.storyteller.pdfcraft.png` | PdfCraft app icon, 48 px | PdfCraft contributors (drawn by the project owner in ArtCraft; vectorised with craftrules vectorize_tile.py) | MIT OR Apache-2.0 | Contributor-original (see assets/app-icon/README.md) | Linux hicolor theme icon (packaging/linux/ai.storyteller.pdfcraft.desktop) |
-| `assets/app-icon/hicolor/64x64/apps/ai.storyteller.pdfcraft.png` | PdfCraft app icon, 64 px | PdfCraft contributors (drawn by the project owner in ArtCraft; vectorised with craftrules vectorize_tile.py) | MIT OR Apache-2.0 | Contributor-original (see assets/app-icon/README.md) | Linux hicolor theme icon (packaging/linux/ai.storyteller.pdfcraft.desktop) |
-| `assets/app-icon/hicolor/128x128/apps/ai.storyteller.pdfcraft.png` | PdfCraft app icon, 128 px | PdfCraft contributors (drawn by the project owner in ArtCraft; vectorised with craftrules vectorize_tile.py) | MIT OR Apache-2.0 | Contributor-original (see assets/app-icon/README.md) | Linux hicolor theme icon (packaging/linux/ai.storyteller.pdfcraft.desktop) |
-| `assets/app-icon/hicolor/256x256/apps/ai.storyteller.pdfcraft.png` | PdfCraft app icon, 256 px | PdfCraft contributors (drawn by the project owner in ArtCraft; vectorised with craftrules vectorize_tile.py) | MIT OR Apache-2.0 | Contributor-original (see assets/app-icon/README.md) | Linux hicolor theme icon (packaging/linux/ai.storyteller.pdfcraft.desktop); runtime window icon on Windows and Linux (apps/pdfcraft/src/main.rs) |
-| `assets/app-icon/hicolor/512x512/apps/ai.storyteller.pdfcraft.png` | PdfCraft app icon, 512 px | PdfCraft contributors (drawn by the project owner in ArtCraft; vectorised with craftrules vectorize_tile.py) | MIT OR Apache-2.0 | Contributor-original (see assets/app-icon/README.md) | Linux hicolor theme icon (packaging/linux/ai.storyteller.pdfcraft.desktop) |
-| `assets/app-icon/hicolor/scalable/apps/ai.storyteller.pdfcraft.svg` | PdfCraft app icon (Linux scalable) | PdfCraft contributors (drawn by the project owner in ArtCraft; vectorised with craftrules vectorize_tile.py) | MIT OR Apache-2.0 | Contributor-original (see assets/app-icon/README.md) | Linux hicolor theme icon (packaging/linux/ai.storyteller.pdfcraft.desktop) |
+| `assets/app-icon/hicolor/16x16/apps/ai.storyteller.pdfcraft.png` | PdfCraft app icon, 16 px | PdfCraft contributors (drawn by the project owner in ArtCraft) | MIT OR Apache-2.0 | Contributor-original (see assets/app-icon/README.md) | Linux hicolor theme icon (packaging/linux/ai.storyteller.pdfcraft.desktop) |
+| `assets/app-icon/hicolor/24x24/apps/ai.storyteller.pdfcraft.png` | PdfCraft app icon, 24 px | PdfCraft contributors (drawn by the project owner in ArtCraft) | MIT OR Apache-2.0 | Contributor-original (see assets/app-icon/README.md) | Linux hicolor theme icon (packaging/linux/ai.storyteller.pdfcraft.desktop) |
+| `assets/app-icon/hicolor/32x32/apps/ai.storyteller.pdfcraft.png` | PdfCraft app icon, 32 px | PdfCraft contributors (drawn by the project owner in ArtCraft) | MIT OR Apache-2.0 | Contributor-original (see assets/app-icon/README.md) | Linux hicolor theme icon (packaging/linux/ai.storyteller.pdfcraft.desktop) |
+| `assets/app-icon/hicolor/48x48/apps/ai.storyteller.pdfcraft.png` | PdfCraft app icon, 48 px | PdfCraft contributors (drawn by the project owner in ArtCraft) | MIT OR Apache-2.0 | Contributor-original (see assets/app-icon/README.md) | Linux hicolor theme icon (packaging/linux/ai.storyteller.pdfcraft.desktop) |
+| `assets/app-icon/hicolor/64x64/apps/ai.storyteller.pdfcraft.png` | PdfCraft app icon, 64 px | PdfCraft contributors (drawn by the project owner in ArtCraft) | MIT OR Apache-2.0 | Contributor-original (see assets/app-icon/README.md) | Linux hicolor theme icon (packaging/linux/ai.storyteller.pdfcraft.desktop) |
+| `assets/app-icon/hicolor/128x128/apps/ai.storyteller.pdfcraft.png` | PdfCraft app icon, 128 px | PdfCraft contributors (drawn by the project owner in ArtCraft) | MIT OR Apache-2.0 | Contributor-original (see assets/app-icon/README.md) | Linux hicolor theme icon (packaging/linux/ai.storyteller.pdfcraft.desktop) |
+| `assets/app-icon/hicolor/256x256/apps/ai.storyteller.pdfcraft.png` | PdfCraft app icon, 256 px | PdfCraft contributors (drawn by the project owner in ArtCraft) | MIT OR Apache-2.0 | Contributor-original (see assets/app-icon/README.md) | Linux hicolor theme icon (packaging/linux/ai.storyteller.pdfcraft.desktop); runtime window icon on Windows and Linux (apps/pdfcraft/src/main.rs) |
+| `assets/app-icon/hicolor/512x512/apps/ai.storyteller.pdfcraft.png` | PdfCraft app icon, 512 px | PdfCraft contributors (drawn by the project owner in ArtCraft) | MIT OR Apache-2.0 | Contributor-original (see assets/app-icon/README.md) | Linux hicolor theme icon (packaging/linux/ai.storyteller.pdfcraft.desktop) |
+| `assets/app-icon/hicolor/scalable/apps/ai.storyteller.pdfcraft.svg` | PdfCraft app icon (Linux scalable) | PdfCraft contributors (drawn by the project owner in ArtCraft) | MIT OR Apache-2.0 | Contributor-original (see assets/app-icon/README.md) | Linux hicolor theme icon (packaging/linux/ai.storyteller.pdfcraft.desktop) |
| `docs/images/pdfcraft-bookmarks.png` | PdfCraft screenshot: Bookmarks | PdfCraft contributors (generated by `cargo xtask screenshots`) | MIT OR Apache-2.0 | this repository | README: navigation. Shows only PdfCraft UI, Lucide icons and the showcase PDF built from the [[fetched]] OFL fonts |
| `docs/images/pdfcraft-dark.png` | PdfCraft screenshot: Dark theme | PdfCraft contributors (generated by `cargo xtask screenshots`) | MIT OR Apache-2.0 | this repository | README: themes. Shows only PdfCraft UI, Lucide icons and the showcase PDF built from the [[fetched]] OFL fonts |
| `docs/images/pdfcraft-find.png` | PdfCraft screenshot: Find text | PdfCraft contributors (generated by `cargo xtask screenshots`) | MIT OR Apache-2.0 | this repository | README: search. Shows only PdfCraft UI, Lucide icons and the showcase PDF built from the [[fetched]] OFL fonts |
@@ -267,29 +276,37 @@ Every asset PdfCraft includes, bundles or uses to build its published material,
| `assets/icons/thumbs-down.svg` | Lucide icon "thumbs-down" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/thumbs-down.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
| `assets/icons/message-square-plus.svg` | Lucide icon "message-square-plus" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/message-square-plus.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
| `assets/icons/circle-x.svg` | Lucide icon "circle-x" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/circle-x.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
-| `crates/ui-egui/tests/data/form.pdf` | One-page AcroForm test fixture (text, check box, radio group, combo box) | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: hand-written PDF objects, no third-party content | Form-filling UI tests (crates/ui-egui/tests/forms.rs, commands.rs) |
+| `crates/ui-egui/tests/data/form.pdf` | One-page AcroForm test fixture (text, check box, radio group, combo box) | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: hand-written PDF objects, no third-party content | Form-filling UI tests (crates/ui-egui/tests/it/forms.rs, commands.rs) |
| `assets/icons/pentagon.svg` | Lucide icon "pentagon" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/pentagon.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
| `assets/icons/message-square-quote.svg` | Lucide icon "message-square-quote" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/message-square-quote.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
-| `crates/sign/tests/data/openssl-signed.pdf` | One-page PDF signed with OpenSSL CMS (adbe.pkcs7.detached) by the RSA test key | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: hand-written PDF objects; signature made with `openssl cms -sign` over its byte ranges (see crates/sign/tests/data/README.md) | Signature validation tests (crates/sign/tests/pdf.rs): a signature PdfCraft did not make |
+| `crates/sign/tests/data/openssl-signed.pdf` | One-page PDF signed with OpenSSL CMS (adbe.pkcs7.detached) by the RSA test key | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: hand-written PDF objects; signature made with `openssl cms -sign` over its byte ranges (see crates/sign/tests/data/README.md) | Signature validation tests (crates/sign/tests/it/pdf.rs): a signature PdfCraft did not make |
| `assets/icons/clipboard-paste.svg` | Lucide icon "clipboard-paste" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/clipboard-paste.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
| `assets/fonts/DancingScript.ttf` | Dancing Script (variable) | The Dancing Script Project Authors (Pablo Impallari) | OFL-1.1 | https://github.com/google/fonts/blob/9710da1eacb3be272583c3224dcb70f9da6eadbb/ofl/dancingscript/DancingScript%5Bwght%5D.ttf | Fill & Sign typed signatures and initials: the typed name's glyph outlines, drawn as filled paths (the font file itself is not embedded in PDFs) |
| `packaging/macos/dmg/background.svg` | PdfCraft macOS DMG window background | @XusBadia | MIT OR Apache-2.0 | Original work for this repository, derived from the PdfCraft app icon (assets/app-icon/pdfcraft.svg, referenced via an SVG image element, not copied) | Finder window of the macOS DMG; rendered to background.tiff by packaging/macos/dmg/generate.py |
| `packaging/macos/dmg/background.tiff` | PdfCraft macOS DMG window background (rendered) | @XusBadia | MIT OR Apache-2.0 | Rendered from packaging/macos/dmg/background.svg by packaging/macos/dmg/generate.py | 1x + 2x HiDPI 16-colour palette TIFF copied into the macOS DMG by packaging/macos/package.sh; includes the app icon and the free LittleCMS built-in sRGB profile |
-| `crates/sign/tests/data/x509-rsa-sha1.pdf` | One-page PDF with a legacy adbe.x509.rsa_sha1 signature by the RSA test key | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: hand-written PDF objects; signature made with `openssl dgst -sha1 -sign` over its byte ranges (see crates/sign/tests/data/README.md) | Signature validation tests (crates/sign/tests/pdf.rs): the legacy adbe.x509.rsa_sha1 format |
+| `crates/sign/tests/data/x509-rsa-sha1.pdf` | One-page PDF with a legacy adbe.x509.rsa_sha1 signature by the RSA test key | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: hand-written PDF objects; signature made with `openssl dgst -sha1 -sign` over its byte ranges (see crates/sign/tests/data/README.md) | Signature validation tests (crates/sign/tests/it/pdf.rs): the legacy adbe.x509.rsa_sha1 format |
| `assets/icons/monitor.svg` | Lucide icon "monitor" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/monitor.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
-| `crates/ui-egui/src/i18n/vi.tsv` | Vietnamese interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels; PhotoCraft and VectorCraft Vietnamese catalogs used only as translation memory when English source and semantic context matched; original catalog reviewed by a native Vietnamese-speaking contributor; 2026-10-15 coverage additions are AI-assisted and not separately native-reviewed | Vietnamese UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs |
-| `crates/ui-egui/src/i18n/sl.tsv` | Slovene interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources | Slovene UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs |
+| `crates/ui-egui/src/i18n/vi.tsv` | Vietnamese interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels; PhotoCraft and VectorCraft Vietnamese catalogs used only as translation memory when English source and semantic context matched; original catalog reviewed by a native Vietnamese-speaking contributor; 2026-10-15 coverage additions are AI-assisted and not separately native-reviewed; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 signature-check wordings are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed | Vietnamese UI strings, compiled in by crates/ui-egui/src/i18n.rs |
+| `crates/ui-egui/src/i18n/sl.tsv` | Slovene interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed | Slovene UI strings, compiled in by crates/ui-egui/src/i18n.rs |
| `vendor/vello_common/assets/probe.png` | vello_common probe reference image (rendered from probe.rgba) | Linebender vello contributors | MIT OR Apache-2.0 | vello_common 0.0.8 (crates.io), vendored in vendor/vello_common; regenerated by its vello_sparse_tests regenerate_probe_reference binary | Reference picture of the vendored vello_common probe module, for people only; the build reads probe.rgba, not this file |
+| `crates/ui-egui/src/i18n/nb.tsv` | Norwegian Bokmål interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-22 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed | Norwegian Bokmål UI strings, compiled in by crates/ui-egui/src/i18n.rs |
+| `assets/icons/superscript.svg` | Lucide icon "superscript" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/superscript.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
+| `assets/icons/subscript.svg` | Lucide icon "subscript" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/subscript.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
+| `assets/icons/pilcrow.svg` | Lucide icon "pilcrow" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/pilcrow.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
+| `assets/icons/move-horizontal.svg` | Lucide icon "move-horizontal" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/move-horizontal.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
+| `crates/ui-egui/src/i18n/sq.tsv` | Albanian interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-22 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 signature-appearance labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 text-field-table labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed | Albanian UI strings, compiled in by crates/ui-egui/src/i18n.rs |
+| `crates/ui-egui/src/i18n/th.tsv` | Thai interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-22 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed | Thai UI strings, compiled in by crates/ui-egui/src/i18n.rs |
+| `crates/ui-egui/src/i18n/ca.tsv` | Catalan interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, written from their meaning; no proprietary localisation resources; 2026-10-22 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 text-field-table labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed | Catalan UI strings, compiled in by crates/ui-egui/src/i18n.rs |
## Compiled in through dependencies (6)
| Crate | File | Title | Author | Licence | Source | Used for |
|---|---|---|---|---|---|---|
-| `epaint_default_fonts` 0.36.2 | `fonts/Ubuntu-Light.ttf` | Ubuntu Light | Canonical Ltd. / Dalton Maag | Ubuntu-font-1.0 | https://design.ubuntu.com/font | egui fallback fonts (eframe feature default_fonts) |
-| `epaint_default_fonts` 0.36.2 | `fonts/Hack-Regular.ttf` | Hack Regular | Source Foundry Authors (derived from Bitstream Vera / DejaVu) | MIT AND Bitstream-Vera | https://github.com/source-foundry/Hack | egui fallback fonts (eframe feature default_fonts) |
-| `epaint_default_fonts` 0.36.2 | `fonts/NotoEmoji-Regular.ttf` | Noto Emoji (monochrome) | Google Inc. | OFL-1.1 | https://github.com/googlefonts/noto-emoji | egui fallback fonts (eframe feature default_fonts) |
-| `epaint_default_fonts` 0.36.2 | `fonts/emoji-icon-font.ttf` | emoji-icon-font | John Slegers | MIT | https://github.com/jslegers/emoji-icon-font | egui fallback fonts (eframe feature default_fonts) |
-| `hayro-cmap` 0.1.0 | `assets/cmaps.brotli` | Adobe CMap resources (predefined CJK CMaps) (non-visual data, AGENTS.md §1.1) | Adobe | BSD-3-Clause | https://github.com/adobe-type-tools/cmap-resources (via hayro-cmap) | Character-code mapping tables required to read CJK PDFs. Non-visual data, allowed by AGENTS.md §1.1 |
+| `epaint_default_fonts` 0.37.0 | `fonts/Ubuntu-Light.ttf` | Ubuntu Light | Canonical Ltd. / Dalton Maag | Ubuntu-font-1.0 | https://design.ubuntu.com/font | egui fallback fonts (eframe feature default_fonts) |
+| `epaint_default_fonts` 0.37.0 | `fonts/Hack-Regular.ttf` | Hack Regular | Source Foundry Authors (derived from Bitstream Vera / DejaVu) | MIT AND Bitstream-Vera | https://github.com/source-foundry/Hack | egui fallback fonts (eframe feature default_fonts) |
+| `epaint_default_fonts` 0.37.0 | `fonts/NotoEmoji-Regular.ttf` | Noto Emoji (monochrome) | Google Inc. | OFL-1.1 | https://github.com/googlefonts/noto-emoji | egui monochrome fallback font (egui feature monochrome_emoji_fonts) |
+| `epaint_default_fonts` 0.37.0 | `fonts/emoji-icon-font.ttf` | emoji-icon-font | John Slegers | MIT | https://github.com/jslegers/emoji-icon-font | egui monochrome fallback font (egui feature monochrome_emoji_fonts) |
+| `epaint_default_fonts` 0.37.0 | `fonts/egui-icons.ttf` | egui icons (monochrome emoji-icon-font subset) | John Slegers | MIT | https://github.com/jslegers/emoji-icon-font | Small special-icon subset embedded by egui default_fonts; retained in FontDefinitions::default(). Provenance: https://github.com/storytold/egui/blob/6d518fc799681571963842f95b55a6b68431b15a/crates/epaint_default_fonts/fonts/egui-icons.txt; complete MIT copyright and permission text in NOTICE. |
| `hayro-interpret` 0.7.0 | `src/font/generated/metrics.rs` | Standard-14 font metrics and encodings (as Rust tables) (non-visual data, AGENTS.md §1.1) | Adobe (Core 14 AFM metrics, PDF specification encodings); tables by The Hayro Authors | MIT OR Apache-2.0 | ISO 32000-2 Annex D; Adobe Core14 AFM files (via vendored hayro-interpret) | Glyph widths and encodings for non-embedded standard fonts. Non-visual data, allowed by AGENTS.md §1.1 |
## Downloaded at build time (22)
@@ -318,8 +335,8 @@ Fonts are fetched by `cargo xtask demo-pdf` into `target/demo-fonts/`, OCR model
| `NotoColorEmoji-Regular.ttf` | Noto Color Emoji | Google Inc. | OFL-1.1 | https://github.com/google/fonts/tree/9710da1eacb3be272583c3224dcb70f9da6eadbb/ofl/notocoloremoji | Showcase PDF (cargo xtask demo-pdf) |
| `Inter[opsz,wght].ttf` | Inter (variable) | The Inter Project Authors (Rasmus Andersson) | OFL-1.1 | https://github.com/google/fonts/tree/9710da1eacb3be272583c3224dcb70f9da6eadbb/ofl/inter | Showcase PDF (cargo xtask demo-pdf) |
| `Inter-Italic[opsz,wght].ttf` | Inter Italic (variable) | The Inter Project Authors (Rasmus Andersson) | OFL-1.1 | https://github.com/google/fonts/tree/9710da1eacb3be272583c3224dcb70f9da6eadbb/ofl/inter | Showcase PDF (cargo xtask demo-pdf) |
-| `text-detection.rten` | Ocrs text detection model | Robert Knight (ocrs-models; trained on HierText, CC-BY-SA-4.0) | CC-BY-SA-4.0 | https://github.com/robertknight/ocrs-models (the models ocrs downloads; model card: https://huggingface.co/robertknight/ocrs, cc-by-sa-4.0) | Scan & OCR: finds words on page images (cargo xtask models) |
-| `text-recognition.rten` | Ocrs text recognition model | Robert Knight (ocrs-models; trained on HierText, CC-BY-SA-4.0) | CC-BY-SA-4.0 | https://github.com/robertknight/ocrs-models (the models ocrs downloads; model card: https://huggingface.co/robertknight/ocrs, cc-by-sa-4.0) | Scan & OCR: reads the words it finds (cargo xtask models) |
+| `text-detection.onnx` | Ocrs text detection model | Robert Knight (ocrs-models; trained on HierText, CC-BY-SA-4.0) | CC-BY-SA-4.0 | https://github.com/robertknight/ocrs/blob/0c826441362a701c55560fefc17bf356b6d1037e/ocrs/examples/download-models.sh (author-published ONNX models; model card: https://huggingface.co/robertknight/ocrs/blob/df0edd170279ab971b53e094c627255a87e1a503/README.md, cc-by-sa-4.0) | Scan & OCR: finds words on page images (cargo xtask models) |
+| `text-recognition.onnx` | Ocrs text recognition model | Robert Knight (ocrs-models; trained on HierText, CC-BY-SA-4.0) | CC-BY-SA-4.0 | https://github.com/robertknight/ocrs/blob/0c826441362a701c55560fefc17bf356b6d1037e/ocrs/examples/download-models.sh (author-published ONNX models; model card: https://huggingface.co/robertknight/ocrs/blob/df0edd170279ab971b53e094c627255a87e1a503/README.md, cc-by-sa-4.0) | Scan & OCR: reads the words it finds (cargo xtask models) |
## Optional build inputs (1)
@@ -327,4 +344,4 @@ Not in this repository and never downloaded by it: compiled in only when the bui
| Input | Option | Title | Author | Licence | Source | Attribution | Used for |
|---|---|---|---|---|---|---|---|
-| `craft-fonts` | `CRAFT_FONTS_DIR` | Japanese fonts: BIZ UDPGothic (Regular, Bold), Shippori Mincho, BIZ UDMincho; Arabic font: Noto Sans Arabic | The BIZ UDGothic and BIZ UDMincho Project Authors (Morisawa Inc.), The Shippori Mincho Project Authors (FONTDASU), The Noto Project Authors (Noto Sans Arabic) | OFL-1.1 | https://github.com/storytold/craft-fonts (pinned by commit in .github/workflows/release.yml) | https://github.com/storytold/craft-fonts/blob/main/ATTRIBUTION.md | Japanese fallback in the interface (BIZ UDPGothic first) and Japanese glyphs in edited PDF text (Shippori Mincho); Arabic interface text, file names and document titles (Noto Sans Arabic); the web build embeds only BIZ UDPGothic Regular and Noto Sans Arabic. The checkout also holds Noto Sans CJK SC, which crates/fonts/build.rs never embeds (AGENTS.md §1.1) |
+| `craft-fonts` | `CRAFT_FONTS_DIR` | Japanese fonts: BIZ UDPGothic (Regular, Bold), Shippori Mincho, BIZ UDMincho; native Simplified Chinese font: LXGW WenKai Lite; Arabic font: Noto Sans Arabic | The BIZ UDGothic and BIZ UDMincho Project Authors (Morisawa Inc.), The Shippori Mincho Project Authors (FONTDASU), LXGW and the original Klee Project Authors / Fontworks (LXGW WenKai Lite), The Noto Project Authors (Noto Sans Arabic) | OFL-1.1 | https://github.com/storytold/craft-fonts (pinned by commit in .github/workflows/release.yml and .github/workflows/ci.yml); LXGW WenKai Lite from https://github.com/lxgw/LxgwWenKai-Lite/releases/tag/v1.530 (commit b712c83d98acf977bacfb7fb010207802808bda9) | https://github.com/storytold/craft-fonts/blob/main/ATTRIBUTION.md | Japanese fallback in the interface (BIZ UDPGothic first) and Japanese glyphs in edited PDF text (Shippori Mincho); Simplified Chinese interface fallback on native builds only (LXGW WenKai Lite Regular, font SHA-256 f799a538a3355877e597a0ae04050e897932c9ae7689eb9227c2953c5dd549ea, OFL SHA-256 0333a85fe077b6849f8e417acc44e8bd879442ceb681e271acf93e2d57fd4e2e, read from fonts/simplified-chinese-manifest.txt and never embedded on wasm); Arabic interface text, file names and document titles (Noto Sans Arabic); the web build embeds BIZ UDPGothic Regular and the pinned small Arabic, Hebrew, Thai and Devanagari faces; the selected craft-fonts revision contains no Telugu face. Other non-Japanese CJK faces retain the general 4 MiB cap; the reviewed native-only LXGW fallback has a separate 16 MiB cap. Open-source OFL releases are permitted under current AGENTS.md §1.1 |
diff --git a/ATTRIBUTION.toml b/ATTRIBUTION.toml
index db3dd6124f..84f71f904e 100644
--- a/ATTRIBUTION.toml
+++ b/ATTRIBUTION.toml
@@ -2,17 +2,18 @@
path = "crates/ui-egui/src/i18n/el.tsv"
title = "Greek interface translation catalog"
author = "Camthalion77 and contributors"
-source = "Contributor-original: clean-room translations of PdfCraft English UI labels"
+source = "Contributor-original: clean-room translations of PdfCraft English UI labels; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed"
licence = "MIT OR Apache-2.0"
licence_file = "LICENSE-MIT"
kind = "translation"
-usage = "Greek UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs"
-sha256 = "6eee0b34db6529f26235bfc07fa1fdb9030b8e7b0b10ede271ca9242cb491919"
+usage = "Greek UI strings, compiled in by crates/ui-egui/src/i18n.rs"
# ATTRIBUTION.toml: one entry per asset (AGENTS.md §1). The source of truth for provenance and licences.
# ATTRIBUTION.md is generated from this file: `cargo xtask assets --write`. Checked by `cargo xtask assets` (part of `xtask ci`).
#
# [[asset]] a file in this repository (committed or vendored); `sha256` must match the file.
+# Translation catalogs (kind = "translation", crates/ui-egui/src/i18n/*.tsv) are project files
+# under the project licence and carry no `sha256`: git history records their provenance.
# [[bundled]] a file compiled into PdfCraft by a Cargo dependency (its crate version must match Cargo.lock).
# [[fetched]] downloaded at build time by xtask (never committed), pinned by `sha256`.
# [[build_input]] an optional build input outside this repository (e.g. craft-fonts via CRAFT_FONTS_DIR);
@@ -25,211 +26,270 @@ sha256 = "6eee0b34db6529f26235bfc07fa1fdb9030b8e7b0b10ede271ca9242cb491919"
path = "crates/ui-egui/src/i18n/zh-hans.tsv"
title = "Simplified Chinese interface translation catalog"
author = "PdfCraft contributors"
-source = "Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources"
+source = "Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed"
licence = "MIT OR Apache-2.0"
licence_file = "LICENSE-MIT"
kind = "translation"
-usage = "Simplified Chinese UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs"
-sha256 = "9870c0fb9f7198155be16adee7f45d36cb9a254af90b545a3ce5b04cbbbf24d4"
+usage = "Simplified Chinese UI strings, compiled in by crates/ui-egui/src/i18n.rs"
[[asset]]
path = "crates/ui-egui/src/i18n/zh-hant.tsv"
title = "Traditional Chinese (Taiwan) interface translation catalog"
author = "PdfCraft contributors"
-source = "Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources"
+source = "Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed"
licence = "MIT OR Apache-2.0"
licence_file = "LICENSE-MIT"
kind = "translation"
-usage = "Traditional Chinese UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs"
-sha256 = "fc31e40877ce115cfb7033c57dd6acabcae3725b249364387e3fe23e04d94e5e"
+usage = "Traditional Chinese UI strings, compiled in by crates/ui-egui/src/i18n.rs"
[[asset]]
path = "crates/ui-egui/src/i18n/cs.tsv"
title = "Czech interface translation catalog"
author = "PdfCraft contributors"
-source = "Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources"
+source = "Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 signature-check and standard-identification wordings are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed"
licence = "MIT OR Apache-2.0"
licence_file = "LICENSE-MIT"
kind = "translation"
-usage = "Czech UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs"
-sha256 = "5de19834f293efaea68cb6b7f51a71d320391f53345f56cfebcd73dab828b665"
+usage = "Czech UI strings, compiled in by crates/ui-egui/src/i18n.rs"
[[asset]]
path = "crates/ui-egui/src/i18n/pt-br.tsv"
title = "Brazilian Portuguese interface translation catalog"
author = "André Cardoso (andrecards), PdfCraft contributors"
-source = "Clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources: the ~1,826-entry gist posted in issue #288 (https://gist.github.com/andrecards/ac388c25fba17312ce83207f8b7b467e), completed and reviewed by PdfCraft contributors"
+source = "Clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources: the ~1,826-entry gist posted in issue #288 (https://gist.github.com/andrecards/ac388c25fba17312ce83207f8b7b467e), completed and reviewed by PdfCraft contributors; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 text-field-table limit messages are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed"
licence = "MIT OR Apache-2.0"
licence_file = "LICENSE-MIT"
kind = "translation"
-usage = "Brazilian Portuguese UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs"
-sha256 = "cef0d22341d0722f029c6543fc846fce164331b7ad8f693a6754b7567b9d9e95"
+usage = "Brazilian Portuguese UI strings, compiled in by crates/ui-egui/src/i18n.rs"
[[asset]]
path = "crates/ui-egui/src/i18n/es.tsv"
title = "Spanish interface translation catalog"
author = "PdfCraft contributors"
-source = "Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources"
+source = "Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; the 2026-10-20 coverage additions are AI-assisted and not separately native-reviewed; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 signature-check wordings are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed"
licence = "MIT OR Apache-2.0"
licence_file = "LICENSE-MIT"
kind = "translation"
-usage = "Spanish UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs"
-sha256 = "c0ca7761cbabd8096bc9af54cdf6b88b3ae0cd8210003db8b3356e2706e44fc8"
+usage = "Spanish UI strings, compiled in by crates/ui-egui/src/i18n.rs"
[[asset]]
path = "crates/ui-egui/src/i18n/fr.tsv"
title = "French interface translation catalog"
author = "PdfCraft contributors"
-source = "Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources"
+source = "Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed"
licence = "MIT OR Apache-2.0"
licence_file = "LICENSE-MIT"
kind = "translation"
-usage = "French UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs"
-sha256 = "a8afd51c3296f44b0446d6b06492b1348444b3e3c448a326ad213d18ce7af714"
+usage = "French UI strings, compiled in by crates/ui-egui/src/i18n.rs"
[[asset]]
path = "crates/ui-egui/src/i18n/de.tsv"
title = "German interface translation catalog"
author = "PdfCraft contributors"
-source = "Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources"
+source = "Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed"
licence = "MIT OR Apache-2.0"
licence_file = "LICENSE-MIT"
kind = "translation"
-usage = "German UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs"
-sha256 = "7676a2dfaa7d66e2e9ef608d2d9de7f863557dcecbef6d94bafa981097405e6d"
+usage = "German UI strings, compiled in by crates/ui-egui/src/i18n.rs"
+
+[[asset]]
+path = "crates/ui-egui/src/i18n/lo.tsv"
+title = "Lao interface translation catalog"
+author = "@bounkirdni-2025"
+source = "Contributor-original: clean-room translations of PdfCraft English UI labels, no proprietary localisation resources; 2026-10-22 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed"
+licence = "MIT OR Apache-2.0"
+licence_file = "LICENSE-MIT"
+kind = "translation"
+usage = "Lao UI strings, compiled in by crates/ui-egui/src/i18n.rs"
[[asset]]
path = "crates/ui-egui/src/i18n/te.tsv"
title = "Telugu interface translation catalog"
author = "PdfCraft contributors"
-source = "Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources"
+source = "Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed"
licence = "MIT OR Apache-2.0"
licence_file = "LICENSE-MIT"
kind = "translation"
-usage = "Telugu UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs"
-sha256 = "9a22996c0308ea5b20ec5e328504cd83cbb560147763faaf0e6987f974844c29"
+usage = "Telugu UI strings, compiled in by crates/ui-egui/src/i18n.rs"
[[asset]]
path = "crates/ui-egui/src/i18n/it.tsv"
title = "Italian interface translation catalog"
author = "PdfCraft contributors"
-source = "Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources"
+source = "Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed"
licence = "MIT OR Apache-2.0"
licence_file = "LICENSE-MIT"
kind = "translation"
-usage = "Italian UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs"
-sha256 = "eacc972d65a2d951e94e1a8cf944638e8328f73f6c05759de6059a425f159cd7"
+usage = "Italian UI strings, compiled in by crates/ui-egui/src/i18n.rs"
[[asset]]
path = "crates/ui-egui/src/i18n/pl.tsv"
title = "Polish interface translation catalog"
author = "PdfCraft contributors"
-source = "Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources"
+source = "Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 signature-check wordings are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed"
licence = "MIT OR Apache-2.0"
licence_file = "LICENSE-MIT"
kind = "translation"
-usage = "Polish UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs"
-sha256 = "dfa4aa540cc7d61fa22a27a949e6eec0f78dcc5b883d0e4e004d695e460b95aa"
+usage = "Polish UI strings, compiled in by crates/ui-egui/src/i18n.rs"
[[asset]]
path = "crates/ui-egui/src/i18n/ru.tsv"
title = "Russian interface translation catalog"
author = "PdfCraft contributors"
-source = "Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources"
+source = "Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed"
licence = "MIT OR Apache-2.0"
licence_file = "LICENSE-MIT"
kind = "translation"
-usage = "Russian UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs"
-sha256 = "27e02e7bb3d378bceca76988a9dceaa68d974f57cbee962954deca6184cf62aa"
+usage = "Russian UI strings, compiled in by crates/ui-egui/src/i18n.rs"
[[asset]]
path = "crates/ui-egui/src/i18n/hu.tsv"
title = "Hungarian interface translation catalog"
author = "PdfCraft contributors"
-source = "Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources"
+source = "Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed"
licence = "MIT OR Apache-2.0"
licence_file = "LICENSE-MIT"
kind = "translation"
-usage = "Hungarian UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs"
-sha256 = "9675edd36c5ae54d9893fd10c4325aab664941bf714c08fbfeef9134fa42056c"
+usage = "Hungarian UI strings, compiled in by crates/ui-egui/src/i18n.rs"
[[asset]]
path = "crates/ui-egui/src/i18n/bg.tsv"
title = "Bulgarian interface translation catalog"
author = "PdfCraft contributors"
-source = "Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources"
+source = "Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed"
licence = "MIT OR Apache-2.0"
licence_file = "LICENSE-MIT"
kind = "translation"
-usage = "Bulgarian UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs"
-sha256 = "ce18fc96f8516aa917e4a8bcfd290edec13a9800b3d1ee102e4b2a71b8467d2b"
+usage = "Bulgarian UI strings, compiled in by crates/ui-egui/src/i18n.rs"
[[asset]]
path = "crates/ui-egui/src/i18n/kk.tsv"
title = "Kazakh interface translation catalog"
author = "PdfCraft contributors"
-source = "Contributor-original: clean-room translations of the English UI labels into Kazakh, no proprietary localisation resources"
+source = "Contributor-original: clean-room translations of the English UI labels into Kazakh, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed"
licence = "MIT OR Apache-2.0"
licence_file = "LICENSE-MIT"
kind = "translation"
-usage = "Kazakh UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs"
-sha256 = "286a1e2e051748eb4bcee3906918dc78d0704f8a37bde22dc50d3376d5e75017"
+usage = "Kazakh UI strings, compiled in by crates/ui-egui/src/i18n.rs"
[[asset]]
path = "crates/ui-egui/src/i18n/ja.tsv"
title = "Japanese interface translation catalog"
author = "PdfCraft contributors"
-source = "Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources"
+source = "Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed"
licence = "MIT OR Apache-2.0"
licence_file = "LICENSE-MIT"
kind = "translation"
-usage = "Japanese UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs"
-sha256 = "77593b7497c67f4a79cc31fa43fdd6eae11601dd87c079a8e666b25cdd9117ba"
+usage = "Japanese UI strings, compiled in by crates/ui-egui/src/i18n.rs"
[[asset]]
path = "crates/ui-egui/src/i18n/uk.tsv"
title = "Ukrainian interface translation catalog"
author = "PdfCraft contributors"
-source = "Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources"
+source = "Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed"
licence = "MIT OR Apache-2.0"
licence_file = "LICENSE-MIT"
kind = "translation"
-usage = "Ukrainian UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs"
-sha256 = "d7d93a03f22ab380696f69b20b12104bf30e78c4440b683df63594af716894e2"
+usage = "Ukrainian UI strings, compiled in by crates/ui-egui/src/i18n.rs"
[[asset]]
path = "crates/ui-egui/src/i18n/ar.tsv"
title = "Arabic interface translation catalog"
author = "PdfCraft contributors"
-source = "Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources"
+source = "Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed"
licence = "MIT OR Apache-2.0"
licence_file = "LICENSE-MIT"
kind = "translation"
-usage = "Arabic UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs"
-sha256 = "889a0d0a9ea53ca0a818c2ba672c053c232d2cca140f8d63127353e6f36505fe"
+usage = "Arabic UI strings, compiled in by crates/ui-egui/src/i18n.rs"
+
+[[asset]]
+path = "crates/ui-egui/src/i18n/ckb.tsv"
+title = "Central Kurdish (Sorani) interface translation catalog"
+author = "PdfCraft contributors"
+source = "Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-22 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed"
+licence = "MIT OR Apache-2.0"
+licence_file = "LICENSE-MIT"
+kind = "translation"
+usage = "Central Kurdish UI strings, compiled in by crates/ui-egui/src/i18n.rs"
+
+[[asset]]
+path = "crates/ui-egui/src/i18n/fa.tsv"
+title = "Persian (Farsi) interface translation catalog"
+author = "PdfCraft contributors"
+source = "Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-25 initial catalog covering the current 2,776-entry key union, AI-assisted translations from English, not separately native-reviewed"
+licence = "MIT OR Apache-2.0"
+licence_file = "LICENSE-MIT"
+kind = "translation"
+usage = "Persian UI strings, compiled in by crates/ui-egui/src/i18n.rs"
[[asset]]
path = "crates/ui-egui/src/i18n/tr.tsv"
title = "Turkish interface translation catalog"
author = "PdfCraft contributors"
-source = "Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources"
+source = "Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed"
licence = "MIT OR Apache-2.0"
licence_file = "LICENSE-MIT"
kind = "translation"
-usage = "Turkish UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs"
-sha256 = "bae8b21651ae2a69c71c91b69ba79727254dbfb078c1be312a13383332934927"
+usage = "Turkish UI strings, compiled in by crates/ui-egui/src/i18n.rs"
[[asset]]
path = "crates/ui-egui/src/i18n/lt.tsv"
title = "Lithuanian interface translation catalog"
author = "PdfCraft contributors"
-source = "Contributor-original: clean-room translations of PdfCraft's English UI labels and user-facing messages; original catalog reviewed by a native Lithuanian-speaking contributor; 2026-10-15 coverage additions are AI-assisted and not separately native-reviewed"
+source = "Contributor-original: clean-room translations of PdfCraft's English UI labels and user-facing messages; original catalog reviewed by a native Lithuanian-speaking contributor; 2026-10-15 coverage additions are AI-assisted and not separately native-reviewed; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed"
licence = "MIT OR Apache-2.0"
licence_file = "LICENSE-MIT"
kind = "translation"
-usage = "Lithuanian UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs"
-sha256 = "7fa70f68a9fa24d0b1cac05fe3aaabaeaca9750c57416124bd317a4eda806d5a"
+usage = "Lithuanian UI strings, compiled in by crates/ui-egui/src/i18n.rs"
+[[asset]]
+path = "crates/ui-egui/src/i18n/hi.tsv"
+title = "Hindi interface translation catalog"
+author = "PdfCraft contributors"
+source = "Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-22 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 text-field-table labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed"
+licence = "MIT OR Apache-2.0"
+licence_file = "LICENSE-MIT"
+kind = "translation"
+usage = "Hindi UI strings, compiled in by crates/ui-egui/src/i18n.rs"
+[[asset]]
+path = "crates/ui-egui/src/i18n/id.tsv"
+title = "Indonesian interface translation catalog"
+author = "PdfCraft contributors"
+source = "Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-22 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 text-field-table labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed"
+licence = "MIT OR Apache-2.0"
+licence_file = "LICENSE-MIT"
+kind = "translation"
+usage = "Indonesian UI strings, compiled in by crates/ui-egui/src/i18n.rs"
+[[asset]]
+path = "crates/ui-egui/src/i18n/ko.tsv"
+title = "Korean interface translation catalog"
+author = "PdfCraft contributors"
+source = "Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-22 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 text-field-table labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed"
+licence = "MIT OR Apache-2.0"
+licence_file = "LICENSE-MIT"
+kind = "translation"
+usage = "Korean UI strings, compiled in by crates/ui-egui/src/i18n.rs"
+
+[[asset]]
+path = "crates/ui-egui/src/i18n/ro.tsv"
+title = "Romanian interface translation catalog"
+author = "PdfCraft contributors"
+source = "Contributor-original: clean-room translations of PdfCraft's English UI labels and user-facing messages; AI-assisted and not yet reviewed line by line by a native Romanian speaker; 2026-10-22 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 text-field-table labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed"
+licence = "MIT OR Apache-2.0"
+licence_file = "LICENSE-MIT"
+kind = "translation"
+usage = "Romanian UI strings, compiled in by crates/ui-egui/src/i18n.rs"
+
+
+[[asset]]
+path = "crates/ui-egui/src/i18n/fi.tsv"
+title = "Finnish interface translation catalog"
+author = "PdfCraft contributors"
+source = "Contributor-original: AI-assisted (Claude Code) clean-room translations of PdfCraft's English UI labels and user-facing messages; not yet proofread by a native Finnish speaker; 2026-10-22 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 text-field-table labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 calculation-order, text-format, print, thumbnail, layer, comment-reshape and signature labels (previously English placeholders) are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed"
+licence = "MIT OR Apache-2.0"
+licence_file = "LICENSE-MIT"
+kind = "translation"
+usage = "Finnish UI strings, compiled in by crates/ui-egui/src/i18n.rs"
[[asset]]
path = "assets/icons/accessibility.svg"
@@ -2379,7 +2439,7 @@ sha256 = "f225e0b044875b8fa6375b6b5ad935f0d99fcc1ad03ecf9f0baae2cedbfca4d2"
[[bundled]]
crate = "epaint_default_fonts"
-version = "0.36.2"
+version = "0.37.0"
path = "fonts/Ubuntu-Light.ttf"
title = "Ubuntu Light"
author = "Canonical Ltd. / Dalton Maag"
@@ -2391,7 +2451,7 @@ sha256 = "80307b8da7649aa4ee4d484b232140e3ce1ec0ca093073d3c53c8f5a5ced7a70"
[[bundled]]
crate = "epaint_default_fonts"
-version = "0.36.2"
+version = "0.37.0"
path = "fonts/Hack-Regular.ttf"
title = "Hack Regular"
author = "Source Foundry Authors (derived from Bitstream Vera / DejaVu)"
@@ -2403,36 +2463,47 @@ sha256 = "15f55cc0c85a2988d2b4b3a8cdb5d77fdfbaf319e1bb5309d725db9818fb7125"
[[bundled]]
crate = "epaint_default_fonts"
-version = "0.36.2"
+version = "0.37.0"
path = "fonts/NotoEmoji-Regular.ttf"
title = "Noto Emoji (monochrome)"
author = "Google Inc."
source = "https://github.com/googlefonts/noto-emoji"
licence = "OFL-1.1"
kind = "font"
-usage = "egui fallback fonts (eframe feature default_fonts)"
+usage = "egui monochrome fallback font (egui feature monochrome_emoji_fonts)"
sha256 = "415dc6290378574135b64c808dc640c1df7531973290c4970c51fdeb849cb0c5"
[[bundled]]
crate = "epaint_default_fonts"
-version = "0.36.2"
+version = "0.37.0"
path = "fonts/emoji-icon-font.ttf"
title = "emoji-icon-font"
author = "John Slegers"
source = "https://github.com/jslegers/emoji-icon-font"
licence = "MIT"
kind = "font"
-usage = "egui fallback fonts (eframe feature default_fonts)"
+usage = "egui monochrome fallback font (egui feature monochrome_emoji_fonts)"
sha256 = "f426bec371f484646d002717e2c555a95cd5145e635d83a48bbef83f3abf18ca"
[[bundled]]
-crate = "hayro-cmap"
-version = "0.1.0"
-path = "assets/cmaps.brotli"
+crate = "epaint_default_fonts"
+version = "0.37.0"
+path = "fonts/egui-icons.ttf"
+title = "egui icons (monochrome emoji-icon-font subset)"
+author = "John Slegers"
+source = "https://github.com/jslegers/emoji-icon-font"
+licence = "MIT"
+kind = "font"
+usage = "Small special-icon subset embedded by egui default_fonts; retained in FontDefinitions::default(). Provenance: https://github.com/storytold/egui/blob/6d518fc799681571963842f95b55a6b68431b15a/crates/epaint_default_fonts/fonts/egui-icons.txt; complete MIT copyright and permission text in NOTICE."
+sha256 = "122806555c73df7cef55aa1d945289ae27fbc4dbbd31abbb4204ee0cda829cc8"
+
+[[asset]]
+path = "vendor/hayro-cmap/assets/cmaps.brotli"
title = "Adobe CMap resources (predefined CJK CMaps)"
author = "Adobe"
-source = "https://github.com/adobe-type-tools/cmap-resources (via hayro-cmap)"
+source = "https://github.com/adobe-type-tools/cmap-resources (via hayro-cmap 0.1.0 assets/)"
licence = "BSD-3-Clause"
+licence_file = "vendor/hayro-cmap/assets/LICENSE.txt"
kind = "data"
adobe_data = true
usage = "Character-code mapping tables required to read CJK PDFs. Non-visual data, allowed by AGENTS.md §1.1"
@@ -2802,57 +2873,57 @@ sha256 = "f87af81b6fa411da8820eed2bfdcc54c0ee9cd453b4e868c64f56c69db142d0e"
[[asset]]
path = "assets/app-icon/pdfcraft.svg"
title = "PdfCraft app icon (master vector)"
-author = "PdfCraft contributors (drawn by the project owner in ArtCraft; vectorised with craftrules vectorize_tile.py)"
+author = "PdfCraft contributors (drawn by the project owner in ArtCraft)"
source = "Contributor-original (see assets/app-icon/README.md)"
licence = "MIT OR Apache-2.0"
licence_file = "assets/app-icon/LICENSE.txt"
kind = "icon"
usage = "Master artwork; every other app-icon file is rendered from it (packaging/icons.sh)"
-sha256 = "52b384a10a2a7fdd3145d9822972cfd568ef0cb7312549bd4399addd89d45a6d"
+sha256 = "27745c021830f82c033b0f15f2d8d9f8e099138743297887ff7e2c9e7ed069da"
[[asset]]
path = "assets/app-icon/pdfcraft-small.svg"
title = "PdfCraft app icon (light vector)"
-author = "PdfCraft contributors (drawn by the project owner in ArtCraft; vectorised with craftrules vectorize_tile.py)"
+author = "PdfCraft contributors (drawn by the project owner in ArtCraft)"
source = "Contributor-original (see assets/app-icon/README.md)"
licence = "MIT OR Apache-2.0"
licence_file = "assets/app-icon/LICENSE.txt"
kind = "icon"
usage = "README header and assets/app-icon/README.md"
-sha256 = "b036304c11ef5dcad36636d06ca0bf331b51e47650d25d3c916a0e1eabf544c7"
+sha256 = "b524df1a583f8034a5a23836955e4fb423e0149429d695e80d95e5056f408678"
[[asset]]
path = "assets/app-icon/pdfcraft-1024.png"
title = "PdfCraft app icon, 1024 px on Apple's icon grid"
-author = "PdfCraft contributors (drawn by the project owner in ArtCraft; vectorised with craftrules vectorize_tile.py)"
+author = "PdfCraft contributors (drawn by the project owner in ArtCraft)"
source = "Contributor-original (see assets/app-icon/README.md)"
licence = "MIT OR Apache-2.0"
licence_file = "assets/app-icon/LICENSE.txt"
kind = "icon"
usage = "Runtime window/Dock icon on macOS (apps/pdfcraft/src/main.rs)"
-sha256 = "da577d3f21fbcf5c739067787a9e683be2b064d76813b5d5fec9074a7a106538"
+sha256 = "68ac6730b8990d07503351d32eacea81e9ec171a7a6f09daf52d6555d8245c8a"
[[asset]]
path = "assets/app-icon/pdfcraft.icns"
title = "PdfCraft app icon (macOS .icns)"
-author = "PdfCraft contributors (drawn by the project owner in ArtCraft; vectorised with craftrules vectorize_tile.py)"
+author = "PdfCraft contributors (drawn by the project owner in ArtCraft)"
source = "Contributor-original (see assets/app-icon/README.md)"
licence = "MIT OR Apache-2.0"
licence_file = "assets/app-icon/LICENSE.txt"
kind = "icon"
usage = "macOS app bundle icon"
-sha256 = "2425148222873de241d267aba667a8b234ecdd0149f2ca8bb6e79443b743b1e8"
+sha256 = "fca7e7a0e34cc5c5ecae5bca97ee896aac68c4a34b088bc3a0b3271484f23974"
[[asset]]
path = "assets/app-icon/pdfcraft.ico"
title = "PdfCraft app icon (Windows .ico, 16-256 px)"
-author = "PdfCraft contributors (drawn by the project owner in ArtCraft; vectorised with craftrules vectorize_tile.py)"
+author = "PdfCraft contributors (drawn by the project owner in ArtCraft)"
source = "Contributor-original (see assets/app-icon/README.md)"
licence = "MIT OR Apache-2.0"
licence_file = "assets/app-icon/LICENSE.txt"
kind = "icon"
usage = "Embedded in pdfcraft.exe by apps/pdfcraft/build.rs (Explorer, taskbar, Start menu, Alt-Tab)"
-sha256 = "56daaaae9ae5389794909570885809adf5e1d9183bef2d9669ed2f9bba3ed6f5"
+sha256 = "709271d66e07710a3f785ea21b0c85c5228eaf8bebe5058bc0f71f9ef20749eb"
[[asset]]
path = "assets/app-icon/pdfcraft-document.svg"
@@ -2863,7 +2934,7 @@ licence = "ISC AND (MIT OR Apache-2.0)"
licence_file = "assets/app-icon/LICENSE-document.txt"
kind = "icon"
usage = "Master artwork for the Windows PDF file type icon; rendered by packaging/icons.sh, without fonts or external images"
-sha256 = "8da8278c2b89362be0a960879d453ff879d7544d6d58e47d5389574e8dfff77b"
+sha256 = "c129f1b538ce16e3c3fa0d89d3301038c51249ba5d61bfd98c46e69cfdf5c316"
[[asset]]
path = "assets/app-icon/pdfcraft-document.ico"
@@ -2874,106 +2945,106 @@ licence = "ISC AND (MIT OR Apache-2.0)"
licence_file = "assets/app-icon/LICENSE-document.txt"
kind = "icon"
usage = "Embedded in pdfcraft.exe as resource 2; the Windows installer uses it for PdfCraft.Document and Applications/pdfcraft.exe PDF file associations"
-sha256 = "ba3f9128b368fc6d17d376cf62dbdbc22455451c5c8c4779210d7a2a7409b9e6"
+sha256 = "910be5e0ecdc94ce00368077ca1bef449bd5d62a75516feeb37884296c6fac4d"
[[asset]]
path = "assets/app-icon/hicolor/16x16/apps/ai.storyteller.pdfcraft.png"
title = "PdfCraft app icon, 16 px"
-author = "PdfCraft contributors (drawn by the project owner in ArtCraft; vectorised with craftrules vectorize_tile.py)"
+author = "PdfCraft contributors (drawn by the project owner in ArtCraft)"
source = "Contributor-original (see assets/app-icon/README.md)"
licence = "MIT OR Apache-2.0"
licence_file = "assets/app-icon/LICENSE.txt"
kind = "icon"
usage = "Linux hicolor theme icon (packaging/linux/ai.storyteller.pdfcraft.desktop)"
-sha256 = "9b77095cbe75dcba6b74a1fdcd4064812850efaf7a12edebefc9ded5eabb49da"
+sha256 = "660d2aace45ec1a28bc7e3708eab71ffee5cb1203f57a6256f7bd7c727193567"
[[asset]]
path = "assets/app-icon/hicolor/24x24/apps/ai.storyteller.pdfcraft.png"
title = "PdfCraft app icon, 24 px"
-author = "PdfCraft contributors (drawn by the project owner in ArtCraft; vectorised with craftrules vectorize_tile.py)"
+author = "PdfCraft contributors (drawn by the project owner in ArtCraft)"
source = "Contributor-original (see assets/app-icon/README.md)"
licence = "MIT OR Apache-2.0"
licence_file = "assets/app-icon/LICENSE.txt"
kind = "icon"
usage = "Linux hicolor theme icon (packaging/linux/ai.storyteller.pdfcraft.desktop)"
-sha256 = "a4cd466687f6c5cb59eef3fc564bdba1c8f0d9e324754eeebab82f18823efae7"
+sha256 = "1989441bdcc8d1df0d73cfe7eb836e9211abaa0fd7fd6ec7e4634bfcd472995d"
[[asset]]
path = "assets/app-icon/hicolor/32x32/apps/ai.storyteller.pdfcraft.png"
title = "PdfCraft app icon, 32 px"
-author = "PdfCraft contributors (drawn by the project owner in ArtCraft; vectorised with craftrules vectorize_tile.py)"
+author = "PdfCraft contributors (drawn by the project owner in ArtCraft)"
source = "Contributor-original (see assets/app-icon/README.md)"
licence = "MIT OR Apache-2.0"
licence_file = "assets/app-icon/LICENSE.txt"
kind = "icon"
usage = "Linux hicolor theme icon (packaging/linux/ai.storyteller.pdfcraft.desktop)"
-sha256 = "e22279013a5ed5c02571e1e4e9bc4cef9aa0847fcb733a285b2916fabd16bf78"
+sha256 = "7fd1ac555f7f6b13ef3fba2696dfb86900cfcb7f7b669aece38590bf2306cf2e"
[[asset]]
path = "assets/app-icon/hicolor/48x48/apps/ai.storyteller.pdfcraft.png"
title = "PdfCraft app icon, 48 px"
-author = "PdfCraft contributors (drawn by the project owner in ArtCraft; vectorised with craftrules vectorize_tile.py)"
+author = "PdfCraft contributors (drawn by the project owner in ArtCraft)"
source = "Contributor-original (see assets/app-icon/README.md)"
licence = "MIT OR Apache-2.0"
licence_file = "assets/app-icon/LICENSE.txt"
kind = "icon"
usage = "Linux hicolor theme icon (packaging/linux/ai.storyteller.pdfcraft.desktop)"
-sha256 = "55daf2d5575f6c4e4c4975c9f1c74231178f1801963a8b19f68dfa9bc50cb22f"
+sha256 = "784ef3bd9f9e479cb2df22a0aee259a441a9c358390e646639c3a0ed79d41288"
[[asset]]
path = "assets/app-icon/hicolor/64x64/apps/ai.storyteller.pdfcraft.png"
title = "PdfCraft app icon, 64 px"
-author = "PdfCraft contributors (drawn by the project owner in ArtCraft; vectorised with craftrules vectorize_tile.py)"
+author = "PdfCraft contributors (drawn by the project owner in ArtCraft)"
source = "Contributor-original (see assets/app-icon/README.md)"
licence = "MIT OR Apache-2.0"
licence_file = "assets/app-icon/LICENSE.txt"
kind = "icon"
usage = "Linux hicolor theme icon (packaging/linux/ai.storyteller.pdfcraft.desktop)"
-sha256 = "2975c200f341e247b5fa18f568fb27d390e3fa01e9b2ad37acb48d0e1cc8a124"
+sha256 = "deb1a9f849549d3f0652fb6d3c30b6eb6ea54498eaea8c1007d901bbafe7067c"
[[asset]]
path = "assets/app-icon/hicolor/128x128/apps/ai.storyteller.pdfcraft.png"
title = "PdfCraft app icon, 128 px"
-author = "PdfCraft contributors (drawn by the project owner in ArtCraft; vectorised with craftrules vectorize_tile.py)"
+author = "PdfCraft contributors (drawn by the project owner in ArtCraft)"
source = "Contributor-original (see assets/app-icon/README.md)"
licence = "MIT OR Apache-2.0"
licence_file = "assets/app-icon/LICENSE.txt"
kind = "icon"
usage = "Linux hicolor theme icon (packaging/linux/ai.storyteller.pdfcraft.desktop)"
-sha256 = "6ef927ca252b40bc9b5d8843d84137334362f8aa1acbb1bf8c15a02affd25b2b"
+sha256 = "1933bbeaea290497facd328e917dafcc433d0c0caa9f1e207aa45ac1e430898c"
[[asset]]
path = "assets/app-icon/hicolor/256x256/apps/ai.storyteller.pdfcraft.png"
title = "PdfCraft app icon, 256 px"
-author = "PdfCraft contributors (drawn by the project owner in ArtCraft; vectorised with craftrules vectorize_tile.py)"
+author = "PdfCraft contributors (drawn by the project owner in ArtCraft)"
source = "Contributor-original (see assets/app-icon/README.md)"
licence = "MIT OR Apache-2.0"
licence_file = "assets/app-icon/LICENSE.txt"
kind = "icon"
usage = "Linux hicolor theme icon (packaging/linux/ai.storyteller.pdfcraft.desktop); runtime window icon on Windows and Linux (apps/pdfcraft/src/main.rs)"
-sha256 = "cde7ee8c5ee38f9fc40a429fd9e6d99a035bcd321701fce331bb13bac126614d"
+sha256 = "47094c09fb77eb35d465eee46445626a4d78c7ef50629cc7d04a602de3e144e9"
[[asset]]
path = "assets/app-icon/hicolor/512x512/apps/ai.storyteller.pdfcraft.png"
title = "PdfCraft app icon, 512 px"
-author = "PdfCraft contributors (drawn by the project owner in ArtCraft; vectorised with craftrules vectorize_tile.py)"
+author = "PdfCraft contributors (drawn by the project owner in ArtCraft)"
source = "Contributor-original (see assets/app-icon/README.md)"
licence = "MIT OR Apache-2.0"
licence_file = "assets/app-icon/LICENSE.txt"
kind = "icon"
usage = "Linux hicolor theme icon (packaging/linux/ai.storyteller.pdfcraft.desktop)"
-sha256 = "5ede733e93b96f4eb8d94299232cf2f9605fa19b0d913f95fe9828c316a2d066"
+sha256 = "dea8010a8b41011aba054d3bfb8b790da57bc958c60869f508e1bbd288d3f280"
[[asset]]
path = "assets/app-icon/hicolor/scalable/apps/ai.storyteller.pdfcraft.svg"
title = "PdfCraft app icon (Linux scalable)"
-author = "PdfCraft contributors (drawn by the project owner in ArtCraft; vectorised with craftrules vectorize_tile.py)"
+author = "PdfCraft contributors (drawn by the project owner in ArtCraft)"
source = "Contributor-original (see assets/app-icon/README.md)"
licence = "MIT OR Apache-2.0"
licence_file = "assets/app-icon/LICENSE.txt"
kind = "icon"
usage = "Linux hicolor theme icon (packaging/linux/ai.storyteller.pdfcraft.desktop)"
-sha256 = "52b384a10a2a7fdd3145d9822972cfd568ef0cb7312549bd4399addd89d45a6d"
+sha256 = "27745c021830f82c033b0f15f2d8d9f8e099138743297887ff7e2c9e7ed069da"
[[asset]]
path = "docs/images/pdfcraft-bookmarks.png"
@@ -3203,7 +3274,7 @@ source = "Contributor-original: hand-written PDF objects, no third-party content
licence = "MIT OR Apache-2.0"
licence_file = "LICENSE-MIT"
kind = "document"
-usage = "Form-filling UI tests (crates/ui-egui/tests/forms.rs, commands.rs)"
+usage = "Form-filling UI tests (crates/ui-egui/tests/it/forms.rs, commands.rs)"
sha256 = "c6f8b656e225101129aa36bd5467adc8dba16730915f19caca17f6a546d0b040"
[[asset]]
@@ -3236,7 +3307,7 @@ source = "Contributor-original: hand-written PDF objects; signature made with `o
licence = "MIT OR Apache-2.0"
licence_file = "LICENSE-MIT"
kind = "document"
-usage = "Signature validation tests (crates/sign/tests/pdf.rs): a signature PdfCraft did not make"
+usage = "Signature validation tests (crates/sign/tests/it/pdf.rs): a signature PdfCraft did not make"
sha256 = "8b91c3b4e39c4a31717a0dd0685bd7d0468671df32aee5b9818d44dc72c2bc3c"
[[asset]]
@@ -3270,7 +3341,7 @@ licence = "MIT OR Apache-2.0"
licence_file = "assets/app-icon/LICENSE.txt"
kind = "image"
usage = "Finder window of the macOS DMG; rendered to background.tiff by packaging/macos/dmg/generate.py"
-sha256 = "e8b9ea9d5e64b7b741c977543c1693825e77d5c4c579eeb674bf5dcd3764cd8a"
+sha256 = "182663e4d1c4fabbb7ab2e1f0d4e991a8e0859b4a8dee6934d3e25d77ea020af"
[[asset]]
@@ -3282,44 +3353,44 @@ licence = "MIT OR Apache-2.0"
licence_file = "assets/app-icon/LICENSE.txt"
kind = "image"
usage = "1x + 2x HiDPI 16-colour palette TIFF copied into the macOS DMG by packaging/macos/package.sh; includes the app icon and the free LittleCMS built-in sRGB profile"
-sha256 = "f46881724a6621f3686a612a79dbdce4f7dbe9e2025610e44c9a094341334c2c"
+sha256 = "bedf1b23707b9e34157440e83f65464ff314fa9f6b1d39c7a8930d1645c2f8be"
[[fetched]]
-file = "text-detection.rten"
-url = "https://ocrs-models.s3-accelerate.amazonaws.com/text-detection.rten"
+file = "text-detection.onnx"
+url = "https://ocrs-models.s3-accelerate.amazonaws.com/text-detection.onnx"
title = "Ocrs text detection model"
author = "Robert Knight (ocrs-models; trained on HierText, CC-BY-SA-4.0)"
-source = "https://github.com/robertknight/ocrs-models (the models ocrs downloads; model card: https://huggingface.co/robertknight/ocrs, cc-by-sa-4.0)"
+source = "https://github.com/robertknight/ocrs/blob/0c826441362a701c55560fefc17bf356b6d1037e/ocrs/examples/download-models.sh (author-published ONNX models; model card: https://huggingface.co/robertknight/ocrs/blob/df0edd170279ab971b53e094c627255a87e1a503/README.md, cc-by-sa-4.0)"
licence = "CC-BY-SA-4.0"
licence_url = "https://creativecommons.org/licenses/by-sa/4.0/legalcode.txt"
licence_sha256 = "28a9529c7d0bb4dc51f4bf5c116a3d16ef247a052f7591466768ddf563fd1cf5"
kind = "model"
usage = "Scan & OCR: finds words on page images (cargo xtask models)"
-sha256 = "f15cfb56bd02c4bf478a20343986504a1f01e1665c2b3a0ad66340f054b1b5ca"
+sha256 = "a917b23dbd9524b465df7e922641b3ff2981623df4ded5a0234004ef2fee7cfe"
[[fetched]]
-file = "text-recognition.rten"
-url = "https://ocrs-models.s3-accelerate.amazonaws.com/text-recognition.rten"
+file = "text-recognition.onnx"
+url = "https://ocrs-models.s3-accelerate.amazonaws.com/text-recognition.onnx"
title = "Ocrs text recognition model"
author = "Robert Knight (ocrs-models; trained on HierText, CC-BY-SA-4.0)"
-source = "https://github.com/robertknight/ocrs-models (the models ocrs downloads; model card: https://huggingface.co/robertknight/ocrs, cc-by-sa-4.0)"
+source = "https://github.com/robertknight/ocrs/blob/0c826441362a701c55560fefc17bf356b6d1037e/ocrs/examples/download-models.sh (author-published ONNX models; model card: https://huggingface.co/robertknight/ocrs/blob/df0edd170279ab971b53e094c627255a87e1a503/README.md, cc-by-sa-4.0)"
licence = "CC-BY-SA-4.0"
licence_url = "https://creativecommons.org/licenses/by-sa/4.0/legalcode.txt"
licence_sha256 = "28a9529c7d0bb4dc51f4bf5c116a3d16ef247a052f7591466768ddf563fd1cf5"
kind = "model"
usage = "Scan & OCR: reads the words it finds (cargo xtask models)"
-sha256 = "e484866d4cce403175bd8d00b128feb08ab42e208de30e42cd9889d8f1735a6e"
+sha256 = "86c145c2edb96c8caed5b1ebb8f44d706408922211451c309c625157dd6061c5"
[[build_input]]
name = "craft-fonts"
option = "CRAFT_FONTS_DIR"
-title = "Japanese fonts: BIZ UDPGothic (Regular, Bold), Shippori Mincho, BIZ UDMincho; Arabic font: Noto Sans Arabic"
-author = "The BIZ UDGothic and BIZ UDMincho Project Authors (Morisawa Inc.), The Shippori Mincho Project Authors (FONTDASU), The Noto Project Authors (Noto Sans Arabic)"
-source = "https://github.com/storytold/craft-fonts (pinned by commit in .github/workflows/release.yml)"
+title = "Japanese fonts: BIZ UDPGothic (Regular, Bold), Shippori Mincho, BIZ UDMincho; native Simplified Chinese font: LXGW WenKai Lite; Arabic font: Noto Sans Arabic"
+author = "The BIZ UDGothic and BIZ UDMincho Project Authors (Morisawa Inc.), The Shippori Mincho Project Authors (FONTDASU), LXGW and the original Klee Project Authors / Fontworks (LXGW WenKai Lite), The Noto Project Authors (Noto Sans Arabic)"
+source = "https://github.com/storytold/craft-fonts (pinned by commit in .github/workflows/release.yml and .github/workflows/ci.yml); LXGW WenKai Lite from https://github.com/lxgw/LxgwWenKai-Lite/releases/tag/v1.530 (commit b712c83d98acf977bacfb7fb010207802808bda9)"
licence = "OFL-1.1"
attribution = "https://github.com/storytold/craft-fonts/blob/main/ATTRIBUTION.md"
kind = "font"
-usage = "Japanese fallback in the interface (BIZ UDPGothic first) and Japanese glyphs in edited PDF text (Shippori Mincho); Arabic interface text, file names and document titles (Noto Sans Arabic); the web build embeds only BIZ UDPGothic Regular and Noto Sans Arabic. The checkout also holds Noto Sans CJK SC, which crates/fonts/build.rs never embeds (AGENTS.md §1.1)"
+usage = "Japanese fallback in the interface (BIZ UDPGothic first) and Japanese glyphs in edited PDF text (Shippori Mincho); Simplified Chinese interface fallback on native builds only (LXGW WenKai Lite Regular, font SHA-256 f799a538a3355877e597a0ae04050e897932c9ae7689eb9227c2953c5dd549ea, OFL SHA-256 0333a85fe077b6849f8e417acc44e8bd879442ceb681e271acf93e2d57fd4e2e, read from fonts/simplified-chinese-manifest.txt and never embedded on wasm); Arabic interface text, file names and document titles (Noto Sans Arabic); the web build embeds BIZ UDPGothic Regular and the pinned small Arabic, Hebrew, Thai and Devanagari faces; the selected craft-fonts revision contains no Telugu face. Other non-Japanese CJK faces retain the general 4 MiB cap; the reviewed native-only LXGW fallback has a separate 16 MiB cap. Open-source OFL releases are permitted under current AGENTS.md §1.1"
[[asset]]
path = "crates/sign/tests/data/x509-rsa-sha1.pdf"
@@ -3329,7 +3400,7 @@ source = "Contributor-original: hand-written PDF objects; signature made with `o
licence = "MIT OR Apache-2.0"
licence_file = "LICENSE-MIT"
kind = "document"
-usage = "Signature validation tests (crates/sign/tests/pdf.rs): the legacy adbe.x509.rsa_sha1 format"
+usage = "Signature validation tests (crates/sign/tests/it/pdf.rs): the legacy adbe.x509.rsa_sha1 format"
sha256 = "b77bfb21166f4bd043597e45a1ea28b9d9f4ba6df0142b3a0fc8b7f8e0fc111b"
[[asset]]
@@ -3347,23 +3418,21 @@ sha256 = "d1a443233345724859de8e4fb48968ba91e885e12ddbd710701548bd91b39428"
path = "crates/ui-egui/src/i18n/vi.tsv"
title = "Vietnamese interface translation catalog"
author = "PdfCraft contributors"
-source = "Contributor-original: clean-room translations of PdfCraft's English UI labels; PhotoCraft and VectorCraft Vietnamese catalogs used only as translation memory when English source and semantic context matched; original catalog reviewed by a native Vietnamese-speaking contributor; 2026-10-15 coverage additions are AI-assisted and not separately native-reviewed"
+source = "Contributor-original: clean-room translations of PdfCraft's English UI labels; PhotoCraft and VectorCraft Vietnamese catalogs used only as translation memory when English source and semantic context matched; original catalog reviewed by a native Vietnamese-speaking contributor; 2026-10-15 coverage additions are AI-assisted and not separately native-reviewed; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 signature-check wordings are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed"
licence = "MIT OR Apache-2.0"
licence_file = "LICENSE-MIT"
kind = "translation"
-usage = "Vietnamese UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs"
-sha256 = "95b5fc6d10f7bc8371688772741870448de716fa2dd9af62fdfe4e009d269b45"
+usage = "Vietnamese UI strings, compiled in by crates/ui-egui/src/i18n.rs"
[[asset]]
path = "crates/ui-egui/src/i18n/sl.tsv"
title = "Slovene interface translation catalog"
author = "PdfCraft contributors"
-source = "Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources"
+source = "Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed"
licence = "MIT OR Apache-2.0"
licence_file = "LICENSE-MIT"
kind = "translation"
-usage = "Slovene UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs"
-sha256 = "3ac2b113045b7e399dd6d9ddb7773e5b0874c8b43c2ba4cca5854598a2a7ba92"
+usage = "Slovene UI strings, compiled in by crates/ui-egui/src/i18n.rs"
[[asset]]
@@ -3376,3 +3445,87 @@ licence_file = "vendor/vello_common/LICENSE-MIT"
kind = "image"
usage = "Reference picture of the vendored vello_common probe module, for people only; the build reads probe.rgba, not this file"
sha256 = "78c59a0cfd2c829909d77006f94e34dac1ae5d1f90e7f10f72e84de541fef697"
+
+[[asset]]
+path = "crates/ui-egui/src/i18n/nb.tsv"
+title = "Norwegian Bokmål interface translation catalog"
+author = "PdfCraft contributors"
+source = "Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-22 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed"
+licence = "MIT OR Apache-2.0"
+licence_file = "LICENSE-MIT"
+kind = "translation"
+usage = "Norwegian Bokmål UI strings, compiled in by crates/ui-egui/src/i18n.rs"
+
+[[asset]]
+path = "assets/icons/superscript.svg"
+title = "Lucide icon \"superscript\""
+author = "Lucide Contributors (portions Cole Bemis, Feather)"
+source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/superscript.svg)"
+licence = "ISC"
+licence_file = "assets/icons/LICENSE-lucide.txt"
+kind = "icon"
+usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs"
+sha256 = "34af289365aaa19295f874e4fda3b56196e203785d6fe1d9c3c929ae290518a0"
+
+[[asset]]
+path = "assets/icons/subscript.svg"
+title = "Lucide icon \"subscript\""
+author = "Lucide Contributors (portions Cole Bemis, Feather)"
+source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/subscript.svg)"
+licence = "ISC"
+licence_file = "assets/icons/LICENSE-lucide.txt"
+kind = "icon"
+usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs"
+sha256 = "06462f57e87a14e6496da9216eee9906b83ded0ff99b70498efa37ae88a5cba4"
+
+[[asset]]
+path = "assets/icons/pilcrow.svg"
+title = "Lucide icon \"pilcrow\""
+author = "Lucide Contributors (portions Cole Bemis, Feather)"
+source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/pilcrow.svg)"
+licence = "ISC"
+licence_file = "assets/icons/LICENSE-lucide.txt"
+kind = "icon"
+usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs"
+sha256 = "d344d9f9222f6c1b698c0c82a753a2212d9bd7785f06c00cd6e4910fe2fee8f8"
+
+[[asset]]
+path = "assets/icons/move-horizontal.svg"
+title = "Lucide icon \"move-horizontal\""
+author = "Lucide Contributors (portions Cole Bemis, Feather)"
+source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/move-horizontal.svg)"
+licence = "ISC"
+licence_file = "assets/icons/LICENSE-lucide.txt"
+kind = "icon"
+usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs"
+sha256 = "864ab2f19254e6ac7240bc6a98323197332e8f714d4d3f94831f41309e5ef2f7"
+
+[[asset]]
+path = "crates/ui-egui/src/i18n/sq.tsv"
+title = "Albanian interface translation catalog"
+author = "PdfCraft contributors"
+source = "Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-22 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 signature-appearance labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 text-field-table labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed"
+licence = "MIT OR Apache-2.0"
+licence_file = "LICENSE-MIT"
+kind = "translation"
+usage = "Albanian UI strings, compiled in by crates/ui-egui/src/i18n.rs"
+
+[[asset]]
+path = "crates/ui-egui/src/i18n/th.tsv"
+title = "Thai interface translation catalog"
+author = "PdfCraft contributors"
+source = "Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-22 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed"
+licence = "MIT OR Apache-2.0"
+licence_file = "LICENSE-MIT"
+kind = "translation"
+usage = "Thai UI strings, compiled in by crates/ui-egui/src/i18n.rs"
+
+[[asset]]
+path = "crates/ui-egui/src/i18n/ca.tsv"
+title = "Catalan interface translation catalog"
+author = "PdfCraft contributors"
+source = "Contributor-original: clean-room translations of PdfCraft's English UI labels, written from their meaning; no proprietary localisation resources; 2026-10-22 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 text-field-table labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed"
+licence = "MIT OR Apache-2.0"
+licence_file = "LICENSE-MIT"
+kind = "translation"
+usage = "Catalan UI strings, compiled in by crates/ui-egui/src/i18n.rs"
diff --git a/CLAUDE.md b/CLAUDE.md
index 12779b1e35..5e9328f8d3 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -4,7 +4,7 @@ PdfCraft is a clean-room, open-source, Rust-native PDF application targeting Ado
## Start every session here
1. Read `plan/STATUS.md`: the current milestone, the next unchecked task and any blockers. `ROADMAP.md` is the one-page summary (stage, numbers, progress log) and `docs/roadmap.md` the milestone table; at the end of every session update the milestone row and add a line to the ROADMAP.md progress log.
- - Read `docs/gaps.md` (the ranked gaps: Acrobat interop, the open-issue backlog, the renderer and a fidelity harness, editing existing content, Pro workflows, 1.0 polish) and `docs/target-app-parity.md` (the numbers and how they are measured) before choosing work. Prefer the gaps over new P2/P3 features, and keep both documents true when things change (`docs/` progress docs follow craftrules `standards/progress-docs.md`: status line, revision history).
+ - Read `docs/gaps.md` (the ranked gaps: Acrobat interop, the open-issue backlog, the renderer and a fidelity harness, editing existing content, Pro workflows, 1.0 polish) and `docs/target-app-parity.md` (the numbers and how they are measured) before choosing work. Prefer the gaps over new P2/P3 features, and keep both documents true when things change (`docs/` progress docs carry a status line and a revision history).
- "Shipped" in `parity/` means "exists and tested", not "as good as Acrobat". Don't mark a feature shipped on a generic test, and say in its notes what is still missing.
2. Read that task in `plan/execution-plan.md` §3, the relevant section of `plan/architecture.md`, and the README of the crate you're touching.
3. Follow the **autonomous operation protocol** in `plan/execution-plan.md` §7 (orient → plan → implement + test → verify → record → commit). Don't stop to ask unless §7 lists the decision as the user's.
@@ -13,7 +13,7 @@ PdfCraft is a clean-room, open-source, Rust-native PDF application targeting Ado
## Non-negotiables
- **Assets: read `AGENTS.md` §1 before adding or showing any icon, image, font or document.** No assets from Adobe products, ever. Only openly licensed or contributor-original assets are allowed, each with an entry in `ATTRIBUTION.toml`. `cargo xtask assets` enforces this. `AGENTS.md` overrides this file.
-- **Fonts live in [storytold/craft-fonts](https://github.com/storytold/craft-fonts).** Never commit font files here (`AGENTS.md` §1.4; team members: [craftrules `standards/fonts.md`](https://github.com/storytold/craftrules/blob/main/standards/fonts.md), internal). It is the optional build input `CRAFT_FONTS_DIR`: `git clone https://github.com/storytold/craft-fonts ../craft-fonts && CRAFT_FONTS_DIR=../craft-fonts cargo test --workspace` embeds the Japanese fonts (UI fallback, Japanese text in edited PDFs) and runs their tests, which otherwise skip. Code using `pdfcraft_fonts::CRAFT_FONTS` must work when it is empty.
+- **Fonts live in [storytold/craft-fonts](https://github.com/storytold/craft-fonts).** Never commit font files here (`AGENTS.md` §1.4). It is the optional build input `CRAFT_FONTS_DIR`: `git clone https://github.com/storytold/craft-fonts ../craft-fonts && CRAFT_FONTS_DIR=../craft-fonts cargo test --workspace` embeds the Japanese fonts (UI fallback, Japanese text in edited PDFs) and runs their tests, which otherwise skip. Code using `pdfcraft_fonts::CRAFT_FONTS` must work when it is empty.
- **Clean-room.**
- Never read, disassemble or copy anything inside the Acrobat bundle (names and listings only). **Never open `Contents/Resources/JavaScripts/`.**
- Behaviour comes from public docs, specs (ISO 32000-2, the Arlington model) and black-box observation (`plan/acrobat/`).
@@ -21,6 +21,9 @@ PdfCraft is a clean-room, open-source, Rust-native PDF application targeting Ado
- See `plan/README.md` §Clean-room and `plan/adr/0001`.
- **Privacy.** When observing Acrobat, use synthetic fixtures only. Never capture the Home view, recent files or account info. Capture by window id (`plan/acrobat/tools/`). Never commit Acrobat outputs, corpus files or personal data.
- **Layering.** Nothing below L7 depends on egui/winit/eframe/rfd (`plan/architecture.md` §3). `cos`/`filters`/`crypt`/`arlington` stay standalone.
+- **Page input.** Page modules read `PagePointer` (`crates/ui-egui/src/page_pointer.rs`), never egui's raw pointer state (`hover_pos`, `interact_pos`, `latest_pos`, `press_origin`, button reads); `page_pointer::tests::page_modules_read_the_pointer_only_through_page_pointer` enforces it.
+- **Page cursor.** Page modules claim the cursor for what is under the pointer (`view.cursor.claim(Layer::…, icon)`, `crates/ui-egui/src/canvas/page_cursor.rs`), never set it (`set_cursor_icon`, `cursor_icon =`, `on_hover_cursor`); `document_area` sets it once, from the claim on the highest layer the active tool reaches. `page_pointer::tests::page_modules_claim_the_cursor_and_never_set_it` enforces it.
+- **Page presses.** A page module decides what of its own is under a point in one hit test, which returns a `Hit` (owner, layer, cursor, tip; `crates/ui-egui/src/canvas/page_router.rs`) and gives its cursor, its tip and its press. It acts on a press only when the router says the press is its own (`view.router.owner(page)`), never by testing the press point again or by asking another module whether it wants it. Escape cancels any press the pages hold (`cancel_on_escape`), and that press is ignored until the button is up. `page_modules_take_presses_from_the_router_without_repeating_hit_tests` guards the centralized hit calls and captured-owner handlers; runtime routing tests cover gesture behavior.
- **Fidelity.** The PDF object graph is the model. Preserve unknown data. Saves are incremental unless a full rewrite is required. Never silently drop or repair data without recording it.
- **Never panic.** Every PDF, script, MCP call, settings file and keystroke is untrusted input, and none of it may crash the app or lose the user's work. This outranks feature work. Return `Result` (the crate's error enum) and propagate with `?`, or fall back leniently and record it. In non-test code:
- No `unwrap`/`expect`/`panic!`/`unreachable!`/`todo!` unless it is provably infallible, with a comment saying why.
@@ -29,15 +32,18 @@ PdfCraft is a clean-room, open-source, Rust-native PDF application targeting Ado
- Cap allocations sized by input, and bound recursion with depth limits or seen-sets.
- Handle lock poisoning.
- No `unsafe` (`unsafe_code = "forbid"`).
- - Every crash fix gets a synthetic regression test. See `AGENTS.md` §4 (and, for team members, the internal `craftrules/standards/never-crash.md`).
+ - Every crash fix gets a synthetic regression test. See `AGENTS.md` §4.
- **Rust only** in the product and build (`xtask`). No handwritten JS/TS.
+- **Time goes through `chrono`** (workspace dependency). Never hand-roll UTC/calendar conversion (days-to-date algorithms, leap-year math); a crate owns only its output format string. The one exception is range extension: past the years chrono can represent, shift the date by whole 400-year Gregorian cycles (146,097 days, after which the calendar repeats), let chrono compute the month and day, and add the cycles back to the year (splitting seconds into hours, minutes and seconds is plain arithmetic).
- **Review every update as you make it, at four zooms.** Not an end-of-task pass — a per-update habit, so a defect never gets built on. **Clause:** checked arithmetic, `get()` not `[i]`, char-boundary slicing, the right error through `?`, `<` against `<=`, the 1-based/0-based seam, capped allocations. **Function:** is it total — name an input that panics; walk empty, zero, max, malformed, cyclic; is the return type honest. **File/module:** two sources of truth, dead or duplicated code, is the public surface still smallest and the doc comment still true. **Project:** layering, wasm, undo/redo, save fidelity, encryption and permissions, a matching `automation` tool or registry entry, and whether a `parity/` status or note just became untrue. Scale the zoom to the change, and fix what a level turns up before widening.
- Tests mirror the zooms: property → unit → crate integration → end-to-end.
- **Prove a test fails before the fix by actually reverting the fix.** A test nobody has seen fail is not evidence. This has caught two worthless tests in this repo: one confinement test passed with path resolution switched off entirely, because the tools failed on a nonexistent document id before reaching the path.
- **Rebasing is a review step, not a mechanical one.** Read every hunk you resolve, and the auto-merged hunks in the files you touched too — `main` moves fast enough that a file can change by hundreds of lines under a branch.
- **External PRs are held to safety, security and completeness before style.** Provenance first (no Adobe-derived asset, no committed font, no copied GPL/AGPL code, behaviour from the spec and not from reading another implementation), then no new panic path or `unsafe`, then `--root` confinement on every path including ones reached by nested dispatch, opt-in-only MCP and control channel, no new outbound network, then a headless `automation` tool, tests at the right zoom and an honest `parity/` entry. Provenance is the part a contributor cannot be expected to know, and the only part that cannot be undone after merge.
- **Quality gates** before every commit: `cargo fmt --check`, clippy with `-D warnings`, the tests, `cargo xtask layers`, `cargo xtask assets`, and the wasm check once `xtask ci` exists (M0).
+ - New workspace crates must include `[lints] workspace = true` in `Cargo.toml` to inherit `[workspace.lints]`. Production crate roots must declare the required crate-level lint attributes before modules or other crate items; crate-level `//!` documentation may precede them.
- **Scope builds and tests to the crates you touched** — `cargo clippy -p -j 2 --all-targets` and `cargo test -p -j 2`, under a `timeout`. A full `--workspace` build has locked up a development machine's CPU; it is CI's job and CI runs it on every PR. Run the workspace sweep locally only when you have a reason to and the machine is free.
+- **Check for existing work before starting a fix or feature.** Search the issues and PRs, open and closed, with `gh issue list -R storytold/pdfcraft --state all --search ""` and the same with `gh pr list` (try a few phrasings), and check whether a merged fix is already on `main` (`git merge-base --is-ancestor origin/main`). If one is, the report is likely from an older build: say so rather than fixing it again. If an open PR covers it, review or build on that PR instead of opening a competing one. If an issue exists, reference it in the PR (`Fixes #123`).
- **Commits and PRs:** `main` is the only integration branch and is **never pushed to directly**. Branch from an up-to-date `main` as `/` (`feat/`, `fix/`, `docs/`, `chore/`, `refactor/`), one task id per commit (e.g. `M1.4: xref stream reader`), commit only green states, and open a PR into `main` using `.github/pull_request_template.md`. Merge only when CI is green.
- **`main` moves fast enough that PRs rot.** It took 396 commits in the three days to 2026-10-12, and most open PRs are conflicting at any given time. Rebase your own branch the same day `main` moves under it, and ask the author to rebase theirs — never rewrite a contributor's branch. Conflict risk tracks how *contested* the files are, not how large the change is: tooling, tests and docs are nearly free, while `crates/fonts`, `ui-egui` and the i18n catalogues are where several people collide at once.
@@ -52,3 +58,8 @@ PdfCraft is a clean-room, open-source, Rust-native PDF application targeting Ado
## Current bootstrap debt (tracked in STATUS.md)
- `pdfcraft-render` renders through the `hayro` crate directly and inspects documents through `lopdf`. Both get replaced by `cos` / `model` / the DisplayList device (M1–M2, ADR-0004).
+
+## CI workflows
+- Workflows in `.github/workflows/` call the shared workflows in [`storytold/craft-common-actions`](https://github.com/storytold/craft-common-actions) at `@main`, with their inputs and secrets. No other jobs: if no shared workflow does what's needed, it doesn't go here.
+- Don't add or change CI in a feature or fix PR. If the existing workflows can't build or test a change, link an existing craft-common-actions issue or PR, or open a new issue, or a PR only for a small fix that is safe for every app and does not increase CI time.
+- Some workflow files here still run their own jobs. Don't add to them. Moving a job into craft-common-actions is its own task, never part of another PR, and the job stays here until a shared workflow runs it.
diff --git a/Cargo.lock b/Cargo.lock
index 53e0f097d5..fd186d995c 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -4,9 +4,9 @@ version = 4
[[package]]
name = "accesskit"
-version = "0.24.1"
+version = "0.25.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "d3b7f7f85a7e5f68090000ed7622545829afd484d210358702ae4cb97dd0c320"
+checksum = "ad442f58ee04714aaa0ba0a2768c1ea1935b29507bb22ddece8cbbc76db02932"
dependencies = [
"enumn",
"serde",
@@ -15,57 +15,51 @@ dependencies = [
[[package]]
name = "accesskit_atspi_common"
-version = "0.18.1"
+version = "0.21.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "1e8c61bee90b42a772d39d06a740207dc71a4e780004ace1db8d99fb1baaa954"
+checksum = "52c182f9c282ac9c5638d876d551d15e5f7d397ec263349a0c6a2b61595dd5e4"
dependencies = [
"accesskit",
- "accesskit_consumer 0.36.0",
+ "accesskit_consumer",
"atspi-common",
- "phf 0.13.1",
+ "phf 0.14.0",
"serde",
"zvariant",
]
[[package]]
name = "accesskit_consumer"
-version = "0.35.0"
+version = "0.39.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "53cf47daed85312e763fbf85ceca136e0d7abc68e0a7e12abe11f48172bc3b10"
+checksum = "8a45e2bb300bea6d5f33a82c3e051c3ddc6f41c209750f68a0acbe991a284344"
dependencies = [
"accesskit",
- "hashbrown 0.16.1",
+ "hashbrown 0.17.1",
]
[[package]]
-name = "accesskit_consumer"
-version = "0.36.0"
+name = "accesskit_ios"
+version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "25e0d7e25d06f4dc21d1774d67146e9e80d6789216cbd4d1e88185b0095dba60"
+checksum = "28e539d64b27fd4c14de8f78ada8f7c944c092f52f7e49fb63316507a57f6937"
dependencies = [
"accesskit",
- "hashbrown 0.16.1",
-]
-
-[[package]]
-name = "accesskit_consumer"
-version = "0.38.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "5d10a236f96f87d70732e44520046785431ef01d5bcd6b041317bfadd2f88245"
-dependencies = [
- "accesskit",
- "hashbrown 0.16.1",
+ "accesskit_consumer",
+ "hashbrown 0.17.1",
+ "objc2 0.5.2",
+ "objc2-foundation 0.2.2",
+ "objc2-ui-kit 0.2.2",
]
[[package]]
name = "accesskit_macos"
-version = "0.26.3"
+version = "0.27.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "ce02dc63b43f0c9296af9ac946312a2dc8814427d7a64d2d600971dac55b6076"
+checksum = "5701624bf6a11efb6d4c8e922bf44126ed96250e5faa9a7a0468c979635839a4"
dependencies = [
"accesskit",
- "accesskit_consumer 0.38.0",
- "hashbrown 0.16.1",
+ "accesskit_consumer",
+ "hashbrown 0.17.1",
"objc2 0.5.2",
"objc2-app-kit 0.2.2",
"objc2-foundation 0.2.2",
@@ -73,9 +67,9 @@ dependencies = [
[[package]]
name = "accesskit_unix"
-version = "0.21.1"
+version = "0.24.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "b016ca8db0ea0ea2ceff29a9d6240391492d960716aa471967c00e8cc8cb197c"
+checksum = "202f24df034a7476d07b7f74284de84f6d62aabd858dbe7ee9cad3b7ad6f8f9d"
dependencies = [
"accesskit",
"accesskit_atspi_common",
@@ -91,13 +85,13 @@ dependencies = [
[[package]]
name = "accesskit_windows"
-version = "0.32.1"
+version = "0.35.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "eff7009f1a532e917d66970a1e80c965140c6cfbbabbdde3d64e5431e6c78e21"
+checksum = "ce63f35d6bdcf59f26b76b3379063f738e6412cef46999cc772d46aa3de35adb"
dependencies = [
"accesskit",
- "accesskit_consumer 0.35.0",
- "hashbrown 0.16.1",
+ "accesskit_consumer",
+ "hashbrown 0.17.1",
"static_assertions",
"windows",
"windows-core",
@@ -105,11 +99,12 @@ dependencies = [
[[package]]
name = "accesskit_winit"
-version = "0.32.2"
+version = "0.34.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "1fe9a94394896352cc4660ca2288bd4ef883d83238853c038b44070c8f134313"
+checksum = "ee96dd150375c05912d8a473f98c2fb155fb3234fac88292d396548a2460fa77"
dependencies = [
"accesskit",
+ "accesskit_ios",
"accesskit_macos",
"accesskit_unix",
"accesskit_windows",
@@ -220,21 +215,22 @@ checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470"
[[package]]
name = "arboard"
-version = "3.6.1"
+version = "3.7.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "0348a1c054491f4bfe6ab86a7b6ab1e44e45d899005de92f58b3df180b36ddaf"
+checksum = "aeb9553e62312c8756a5979254fc6d7da8bbeb5986a1e17037fab932f7037917"
dependencies = [
"clipboard-win",
"image",
"log",
- "objc2 0.6.4",
+ "objc2 0.6.5",
"objc2-app-kit 0.3.2",
"objc2-core-foundation",
"objc2-core-graphics",
"objc2-foundation 0.3.2",
"parking_lot",
"percent-encoding",
- "windows-sys 0.60.2",
+ "windows-sys 0.61.2",
+ "wl-clipboard-rs",
"x11rb",
]
@@ -447,9 +443,9 @@ checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53"
[[package]]
name = "aws-lc-rs"
-version = "1.18.1"
+version = "1.18.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "b281d307588d634de920874890732659e2e7672f72b5e10e81badc1a8a83621e"
+checksum = "faac5829c2b74c28f830747e7818ccfb684261b5f48a1118b1e2a13d36dfab13"
dependencies = [
"aws-lc-sys",
"untrusted 0.7.1",
@@ -458,9 +454,9 @@ dependencies = [
[[package]]
name = "aws-lc-sys"
-version = "0.45.0"
+version = "0.46.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "9bff6c3b54fad79a2e60b8102caf565819711497c1f5f092f49508e2f5c31b27"
+checksum = "f1622d8446a2d4b2ce0c7eefc73dd43a99779028d5ee5c2dd8073a658ba8a2bc"
dependencies = [
"cc",
"cmake",
@@ -493,15 +489,6 @@ version = "1.8.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06"
-[[package]]
-name = "bit-set"
-version = "0.8.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "08807e080ed7f9d5433fa9b275196cfc35414f66a0c79d864dc51a0d825231a3"
-dependencies = [
- "bit-vec 0.8.0",
-]
-
[[package]]
name = "bit-set"
version = "0.10.0"
@@ -512,10 +499,13 @@ dependencies = [
]
[[package]]
-name = "bit-vec"
-version = "0.8.0"
+name = "bit-set"
+version = "0.11.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "5e764a1d40d510daf35e07be9eb06e75770908c27d411ee6c92109c9840eaaf7"
+checksum = "56d87354e4229f54a44f7bf2435906a4656dba36026ab6eaca629a2c436a691c"
+dependencies = [
+ "bit-vec 0.10.1",
+]
[[package]]
name = "bit-vec"
@@ -523,6 +513,16 @@ version = "0.9.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b71798fca2c1fe1086445a7258a4bc81e6e49dcd24c8d0dd9a1e57395b603f51"
+[[package]]
+name = "bit-vec"
+version = "0.10.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5727b15fa97d4f4fee0a3b7c3d550ed0269f54329207b86388de918604e31269"
+dependencies = [
+ "borsh",
+ "serde",
+]
+
[[package]]
name = "bitflags"
version = "1.3.2"
@@ -580,7 +580,7 @@ version = "0.6.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cdeb9d870516001442e364c5220d3574d2da8dc765554b4a617230d33fa58ef5"
dependencies = [
- "objc2 0.6.4",
+ "objc2 0.6.5",
]
[[package]]
@@ -649,7 +649,7 @@ dependencies = [
"oneshot",
"pastey",
"portable-atomic",
- "rand 0.10.3",
+ "rand",
"regress",
"rustc-hash 2.1.3",
"ryu-js",
@@ -740,6 +740,30 @@ dependencies = [
"static_assertions",
]
+[[package]]
+name = "borsh"
+version = "1.8.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "553c5d846a6ba5150c65e3b1b8ec073bcf1abc20f9b7220de384a4443ea4e20a"
+dependencies = [
+ "borsh-derive",
+ "bytes",
+ "cfg_aliases",
+]
+
+[[package]]
+name = "borsh-derive"
+version = "1.8.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "12cdfe656708a01f89b451a7d36466e6fe6c414de0aa18fc54f864f6f9ca9f56"
+dependencies = [
+ "once_cell",
+ "proc-macro-crate",
+ "proc-macro2",
+ "quote",
+ "syn 3.0.6",
+]
+
[[package]]
name = "bp256"
version = "0.14.0"
@@ -999,6 +1023,15 @@ dependencies = [
"x509-cert",
]
+[[package]]
+name = "cobs"
+version = "0.3.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0fa961b519f0b462e3a3b4a34b64d119eeaca1d59af726fe450bbba07a9fc0a1"
+dependencies = [
+ "thiserror 2.0.21",
+]
+
[[package]]
name = "codespan-reporting"
version = "0.13.1"
@@ -1145,15 +1178,68 @@ dependencies = [
[[package]]
name = "craft-docpath"
version = "0.1.0"
-source = "git+https://github.com/storytold/craft-libs?rev=82a24b006eefa036b43c09a67fb9a3fbd17ffe63#82a24b006eefa036b43c09a67fb9a3fbd17ffe63"
+source = "git+https://github.com/storytold/craft-libs?rev=c5a1cbfb5f9049f9270955caffb5bee5997aebf7#c5a1cbfb5f9049f9270955caffb5bee5997aebf7"
+
+[[package]]
+name = "craft-fonts-build"
+version = "0.1.0"
+source = "git+https://github.com/storytold/craft-libs?rev=c5a1cbfb5f9049f9270955caffb5bee5997aebf7#c5a1cbfb5f9049f9270955caffb5bee5997aebf7"
+
+[[package]]
+name = "craft-i18n"
+version = "0.1.0"
+source = "git+https://github.com/storytold/craft-libs?rev=c5a1cbfb5f9049f9270955caffb5bee5997aebf7#c5a1cbfb5f9049f9270955caffb5bee5997aebf7"
+dependencies = [
+ "fixed_decimal",
+ "icu_decimal",
+ "icu_locale_core",
+ "icu_locale_fallback",
+ "icu_normalizer",
+ "icu_plurals",
+ "icu_properties",
+ "icu_provider",
+ "icu_provider_adapters",
+ "icu_provider_blob",
+ "log",
+ "objc2-foundation 0.3.2",
+ "sys-locale",
+ "unicode-bidi",
+ "windows-registry",
+ "writeable",
+]
+
+[[package]]
+name = "craft-keymap"
+version = "0.1.0"
+source = "git+https://github.com/storytold/craft-libs?rev=c5a1cbfb5f9049f9270955caffb5bee5997aebf7#c5a1cbfb5f9049f9270955caffb5bee5997aebf7"
+dependencies = [
+ "egui",
+ "serde",
+ "serde_json",
+]
+
+[[package]]
+name = "craft-menu"
+version = "0.1.0"
+source = "git+https://github.com/storytold/craft-libs?rev=c5a1cbfb5f9049f9270955caffb5bee5997aebf7#c5a1cbfb5f9049f9270955caffb5bee5997aebf7"
+dependencies = [
+ "craft-keymap",
+ "craft-quit",
+ "egui",
+ "muda",
+ "objc2 0.6.5",
+ "objc2-app-kit 0.3.2",
+]
[[package]]
name = "craft-print"
version = "0.1.0"
-source = "git+https://github.com/storytold/craft-libs?rev=666c358065261a2816b376fa3bb10538dbf53040#666c358065261a2816b376fa3bb10538dbf53040"
+source = "git+https://github.com/storytold/craft-libs?rev=c5a1cbfb5f9049f9270955caffb5bee5997aebf7#c5a1cbfb5f9049f9270955caffb5bee5997aebf7"
dependencies = [
- "miniz_oxide 0.8.9",
- "roxmltree",
+ "craft-winprint-gdi",
+ "log",
+ "miniz_oxide 0.9.1",
+ "roxmltree 0.21.2",
"tempfile",
"winprint",
"winsafe",
@@ -1162,22 +1248,67 @@ dependencies = [
[[package]]
name = "craft-quit"
version = "0.1.0"
-source = "git+https://github.com/storytold/craft-libs?rev=f58bc1a3df490992d633393d0898af0588ce882c#f58bc1a3df490992d633393d0898af0588ce882c"
+source = "git+https://github.com/storytold/craft-libs?rev=c5a1cbfb5f9049f9270955caffb5bee5997aebf7#c5a1cbfb5f9049f9270955caffb5bee5997aebf7"
dependencies = [
"dispatch2",
"egui",
- "muda",
- "objc2 0.6.4",
+ "objc2 0.6.5",
"objc2-app-kit 0.3.2",
]
[[package]]
name = "craft-ui"
version = "0.1.0"
-source = "git+https://github.com/storytold/craft-libs?rev=5c874d51eeb3de70e735c8ce87a59a7dedd47675#5c874d51eeb3de70e735c8ce87a59a7dedd47675"
+source = "git+https://github.com/storytold/craft-libs?rev=c5a1cbfb5f9049f9270955caffb5bee5997aebf7#c5a1cbfb5f9049f9270955caffb5bee5997aebf7"
dependencies = [
+ "craft-fonts-build",
+ "craft-i18n",
"egui",
+ "epaint",
+ "js-sys",
+ "log",
+ "objc2 0.6.5",
+ "objc2-app-kit 0.3.2",
+ "objc2-foundation 0.3.2",
"serde",
+ "skrifa 0.44.0",
+ "wasm-bindgen",
+ "wasm-bindgen-futures",
+ "web-sys",
+]
+
+[[package]]
+name = "craft-update"
+version = "0.1.0"
+source = "git+https://github.com/storytold/craft-libs?rev=c5a1cbfb5f9049f9270955caffb5bee5997aebf7#c5a1cbfb5f9049f9270955caffb5bee5997aebf7"
+dependencies = [
+ "chrono",
+ "dunce",
+ "egui",
+ "flate2",
+ "getrandom 0.3.4",
+ "image",
+ "log",
+ "plist",
+ "semver",
+ "serde",
+ "serde_json",
+ "sha2 0.10.9",
+ "tar",
+ "tempfile",
+ "ureq",
+ "verifysign",
+ "winreg",
+ "winsafe",
+ "zip",
+]
+
+[[package]]
+name = "craft-winprint-gdi"
+version = "0.1.0"
+source = "git+https://github.com/storytold/winprint.rs?rev=417ac3c4d14d3da5485a99c7d5ce8f05664ec312#417ac3c4d14d3da5485a99c7d5ce8f05664ec312"
+dependencies = [
+ "windows-sys 0.61.2",
]
[[package]]
@@ -1243,9 +1374,9 @@ checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5"
[[package]]
name = "crypto-bigint"
-version = "0.7.5"
+version = "0.7.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "1a52aa3fcda4e6302a9f48734f234d35d4721b96f8fe07d073f07ce9df4f0271"
+checksum = "6bb08299a6046833a879ace115697f12fd2bd6c8d3e93c909ffc9e0738e7cae0"
dependencies = [
"cpubits",
"ctutils",
@@ -1291,9 +1422,9 @@ dependencies = [
[[package]]
name = "ctutils"
-version = "0.4.2"
+version = "0.4.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "7d5515a3834141de9eafb9717ad39eea8247b5674e6066c404e8c4b365d2a29e"
+checksum = "38d28fb6aa7dd680b3ffd1a3e64e115c177bdba63c0ae7f9cd48129ff05c4fa0"
dependencies = [
"cmov",
"subtle",
@@ -1439,7 +1570,7 @@ dependencies = [
"bitflags 2.13.2",
"block2 0.6.2",
"libc",
- "objc2 0.6.4",
+ "objc2 0.6.5",
]
[[package]]
@@ -1535,9 +1666,8 @@ dependencies = [
[[package]]
name = "ecolor"
-version = "0.36.2"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "0656a7530b9e2841ee7a5f983965bdd9840c32f4012f759cfd44b912cced6b34"
+version = "0.37.0"
+source = "git+https://github.com/storytold/egui.git?rev=6d518fc799681571963842f95b55a6b68431b15a#6d518fc799681571963842f95b55a6b68431b15a"
dependencies = [
"bytemuck",
"emath",
@@ -1561,16 +1691,15 @@ checksum = "6ebaa1a2bf1290ab3bfe5a7b771d050ebffab2711c19a81691c683a5144a25de"
dependencies = [
"curve25519-dalek",
"ed25519",
- "sha2 0.11.0",
+ "sha2 0.11.1",
"subtle",
"zeroize",
]
[[package]]
name = "eframe"
-version = "0.36.2"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "55ad41fac6e3149abb4866e93df37dbd33baa95db525c3368c944584e95a5dc2"
+version = "0.37.0"
+source = "git+https://github.com/storytold/egui.git?rev=6d518fc799681571963842f95b55a6b68431b15a#6d518fc799681571963842f95b55a6b68431b15a"
dependencies = [
"ahash",
"bytemuck",
@@ -1585,7 +1714,7 @@ dependencies = [
"image",
"js-sys",
"log",
- "objc2 0.6.4",
+ "objc2 0.6.5",
"objc2-app-kit 0.3.2",
"objc2-foundation 0.3.2",
"parking_lot",
@@ -1594,6 +1723,7 @@ dependencies = [
"profiling",
"raw-window-handle",
"ron",
+ "send_wrapper",
"serde",
"static_assertions",
"wasm-bindgen",
@@ -1606,9 +1736,8 @@ dependencies = [
[[package]]
name = "egui"
-version = "0.36.2"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "dc938cc27cd911415e1e4d151bc56ff9df063d8db081374e87aeb805ea74b2ba"
+version = "0.37.0"
+source = "git+https://github.com/storytold/egui.git?rev=6d518fc799681571963842f95b55a6b68431b15a#6d518fc799681571963842f95b55a6b68431b15a"
dependencies = [
"accesskit",
"ahash",
@@ -1628,7 +1757,8 @@ dependencies = [
[[package]]
name = "egui-wgpu"
-version = "0.36.2"
+version = "0.37.0"
+source = "git+https://github.com/storytold/egui.git?rev=6d518fc799681571963842f95b55a6b68431b15a#6d518fc799681571963842f95b55a6b68431b15a"
dependencies = [
"ahash",
"bytemuck",
@@ -1646,32 +1776,35 @@ dependencies = [
[[package]]
name = "egui-winit"
-version = "0.36.2"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "98466000559d66ba4db786a30448b01483946add4188e7f10f522a27b4fb89cc"
+version = "0.37.0"
+source = "git+https://github.com/storytold/egui.git?rev=6d518fc799681571963842f95b55a6b68431b15a#6d518fc799681571963842f95b55a6b68431b15a"
dependencies = [
"accesskit_winit",
"arboard",
+ "block2 0.6.2",
"bytemuck",
"egui",
+ "image",
"log",
- "objc2 0.6.4",
+ "objc2 0.6.5",
+ "objc2-app-kit 0.3.2",
"objc2-foundation 0.3.2",
"objc2-ui-kit 0.3.2",
+ "parking_lot",
"profiling",
"raw-window-handle",
"serde",
"smithay-clipboard",
"web-time",
"webbrowser",
+ "windows-sys 0.61.2",
"winit",
]
[[package]]
name = "egui_extras"
-version = "0.36.2"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "05cbdc785020861553f230f23d330ba1fb72613b9d8e0e457f3bdc8ba6db6dcf"
+version = "0.37.0"
+source = "git+https://github.com/storytold/egui.git?rev=6d518fc799681571963842f95b55a6b68431b15a#6d518fc799681571963842f95b55a6b68431b15a"
dependencies = [
"ahash",
"egui",
@@ -1684,9 +1817,8 @@ dependencies = [
[[package]]
name = "egui_glow"
-version = "0.36.2"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "606ed4167daf341e5753d703285c4c1d562324b2c07aa8f405cd7d066fe58f3b"
+version = "0.37.0"
+source = "git+https://github.com/storytold/egui.git?rev=6d518fc799681571963842f95b55a6b68431b15a#6d518fc799681571963842f95b55a6b68431b15a"
dependencies = [
"bytemuck",
"egui",
@@ -1698,9 +1830,8 @@ dependencies = [
[[package]]
name = "egui_kittest"
-version = "0.36.2"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "5eb3f3effb2726fee23449293e67acbf6cc6ce35525b1722bd5743bb348114d5"
+version = "0.37.0"
+source = "git+https://github.com/storytold/egui.git?rev=6d518fc799681571963842f95b55a6b68431b15a#6d518fc799681571963842f95b55a6b68431b15a"
dependencies = [
"eframe",
"egui",
@@ -1742,9 +1873,8 @@ dependencies = [
[[package]]
name = "emath"
-version = "0.36.2"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "52c8d4141cf0f60fb2f7aefaf352d3ffa5171ef0ab09731c64391801e95e48f1"
+version = "0.37.0"
+source = "git+https://github.com/storytold/egui.git?rev=6d518fc799681571963842f95b55a6b68431b15a#6d518fc799681571963842f95b55a6b68431b15a"
dependencies = [
"bytemuck",
"serde",
@@ -1772,22 +1902,22 @@ checksum = "66b7e2430c6dff6a955451e2cfc438f09cea1965a9d6f87f7e3b90decc014099"
[[package]]
name = "enum-map"
-version = "2.7.3"
+version = "3.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "6866f3bfdf8207509a033af1a75a7b08abda06bbaaeae6669323fd5a097df2e9"
+checksum = "d4532960034c15ba2f55e9eee631040ecc473c32e6c4b200828c759840f13a06"
dependencies = [
"enum-map-derive",
]
[[package]]
name = "enum-map-derive"
-version = "0.17.0"
+version = "1.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "f282cfdfe92516eb26c2af8589c274c7c17681f5ecc03c18255fe741c6aa64eb"
+checksum = "7127272f64b3e098f7d81f350d672081b879bff896d219c42370dc858b1af9ff"
dependencies = [
"proc-macro2",
"quote",
- "syn 2.0.119",
+ "syn 3.0.6",
]
[[package]]
@@ -1824,9 +1954,8 @@ dependencies = [
[[package]]
name = "epaint"
-version = "0.36.2"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "a0ec0308dc23130fc1a7d4623a8534c2ed069c6dc84e258ee4f3de99615aa1a3"
+version = "0.37.0"
+source = "git+https://github.com/storytold/egui.git?rev=6d518fc799681571963842f95b55a6b68431b15a#6d518fc799681571963842f95b55a6b68431b15a"
dependencies = [
"ahash",
"bytemuck",
@@ -1843,16 +1972,17 @@ dependencies = [
"serde",
"skrifa 0.44.0",
"smallvec",
+ "unicode-bidi",
"unicode-general-category",
+ "unicode-properties",
"unicode-segmentation",
- "vello_cpu 0.1.0",
+ "vello_cpu 0.2.0",
]
[[package]]
name = "epaint_default_fonts"
-version = "0.36.2"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "773fa9c96dd0dbef887e39d0ed6177f141cce4d68e6041df77570aa3702dfa13"
+version = "0.37.0"
+source = "git+https://github.com/storytold/egui.git?rev=6d518fc799681571963842f95b55a6b68431b15a#6d518fc799681571963842f95b55a6b68431b15a"
[[package]]
name = "equator"
@@ -1887,7 +2017,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
dependencies = [
"libc",
- "windows-sys 0.52.0",
+ "windows-sys 0.61.2",
]
[[package]]
@@ -1990,6 +2120,17 @@ version = "0.1.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "aedcfb3409746eddb02b9e19ebda1c3394f759a152e48ee875a0844d1b955484"
+[[package]]
+name = "fixed_decimal"
+version = "0.7.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2214fb00f669ee96bee5bb29dfb1ddabe058b2bc426817efc79afa03c9bc55ae"
+dependencies = [
+ "displaydoc",
+ "smallvec",
+ "writeable",
+]
+
[[package]]
name = "fixedbitset"
version = "0.5.7"
@@ -2057,7 +2198,7 @@ version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "90642cf66dde7f205dc8b42dfe57b47d0f3a29f571b832742883ef512e23fe55"
dependencies = [
- "objc2 0.6.4",
+ "objc2 0.6.5",
"objc2-app-kit 0.3.2",
"objc2-foundation 0.3.2",
]
@@ -2068,6 +2209,12 @@ version = "1.0.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1"
+[[package]]
+name = "foldhash"
+version = "0.1.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2"
+
[[package]]
name = "foldhash"
version = "0.2.0"
@@ -2099,7 +2246,7 @@ version = "0.5.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bbc773e24e02d4ddd8395fd30dc147524273a83e54e0f312d986ea30de5f5646"
dependencies = [
- "roxmltree",
+ "roxmltree 0.20.0",
]
[[package]]
@@ -2318,7 +2465,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d99fc21d493812643aae86d53b7bbd02f376434a90317e8a790bc209fdd6605e"
dependencies = [
"bytemuck",
- "foldhash",
+ "foldhash 0.2.0",
"hashbrown 0.17.1",
"log",
"peniko",
@@ -2330,18 +2477,18 @@ dependencies = [
[[package]]
name = "glifo"
-version = "0.2.0"
+version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "ed4a1bb24121291d27230c1b1b44e07d6a9b28cefdb32fe1581dfb84e14f940a"
+checksum = "282a26c1e23de04bdab3e34a21b6f877a479b96737792516b9b8b8f69b6661be"
dependencies = [
"bytemuck",
- "foldhash",
+ "foldhash 0.2.0",
"hashbrown 0.17.1",
"log",
"peniko",
"skrifa 0.44.0",
"smallvec",
- "vello_common 0.1.0",
+ "vello_common 0.2.0",
]
[[package]]
@@ -2370,7 +2517,7 @@ dependencies = [
"glutin_glx_sys",
"glutin_wgl_sys",
"libloading",
- "objc2 0.6.4",
+ "objc2 0.6.5",
"objc2-app-kit 0.3.2",
"objc2-core-foundation",
"objc2-foundation 0.3.2",
@@ -2505,6 +2652,15 @@ version = "0.14.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1"
+[[package]]
+name = "hashbrown"
+version = "0.15.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1"
+dependencies = [
+ "foldhash 0.1.5",
+]
+
[[package]]
name = "hashbrown"
version = "0.16.1"
@@ -2513,7 +2669,7 @@ checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100"
dependencies = [
"allocator-api2",
"equivalent",
- "foldhash",
+ "foldhash 0.2.0",
]
[[package]]
@@ -2524,7 +2680,7 @@ checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a"
dependencies = [
"allocator-api2",
"equivalent",
- "foldhash",
+ "foldhash 0.2.0",
]
[[package]]
@@ -2549,8 +2705,6 @@ checksum = "9f4d0e94ddd48749f06bbe4e5389fb9799a0c45bcaf00495042076ef05e3241a"
[[package]]
name = "hayro-cmap"
version = "0.1.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "24d285dc30731c8485de5fa732fbdf2b3affdf01e4da7c2135022ca6fa664bf6"
dependencies = [
"brotli",
"hayro-postscript",
@@ -2579,6 +2733,7 @@ version = "0.3.0"
dependencies = [
"fearless_simd",
"hayro-ccitt",
+ "image",
]
[[package]]
@@ -2593,8 +2748,6 @@ dependencies = [
[[package]]
name = "hayro-postscript"
version = "0.1.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "885c5ef0654933139a9b9546fc2c69e18d37f38aa2520f079092b1be18f1fcaa"
[[package]]
name = "hayro-syntax"
@@ -2788,6 +2941,23 @@ dependencies = [
"zerovec",
]
+[[package]]
+name = "icu_decimal"
+version = "2.3.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9eb8f655bba2d0c0459e43a5b0e6cd3cd388109898fb3d85d048165e8b0abd08"
+dependencies = [
+ "displaydoc",
+ "fixed_decimal",
+ "icu_locale_core",
+ "icu_pattern",
+ "icu_plurals",
+ "icu_provider",
+ "serde",
+ "writeable",
+ "zerovec",
+]
+
[[package]]
name = "icu_locale_core"
version = "2.3.0"
@@ -2796,11 +2966,32 @@ checksum = "d56e28588da92eee5c3201a6eff33fabdd49b62269c8938d4ff050ce4d900deb"
dependencies = [
"displaydoc",
"litemap",
+ "serde",
"tinystr",
"writeable",
"zerovec",
]
+[[package]]
+name = "icu_locale_fallback"
+version = "2.3.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "251af8e57c9400e3eb58242fe5b8b1152b2a64fdf4cf632f923c38ccee6f2fa9"
+dependencies = [
+ "icu_locale_core",
+ "icu_locale_fallback_data",
+ "icu_provider",
+ "potential_utf",
+ "tinystr",
+ "zerovec",
+]
+
+[[package]]
+name = "icu_locale_fallback_data"
+version = "2.3.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "decf2a22ec8fa68f1a0c1129a3f8583f8f8bc24e8b9ccbe98ead99f62a4dc3a8"
+
[[package]]
name = "icu_normalizer"
version = "2.3.0"
@@ -2813,6 +3004,7 @@ dependencies = [
"icu_provider",
"smallvec",
"utf16_iter",
+ "utf8_iter",
"write16",
"zerovec",
]
@@ -2823,6 +3015,32 @@ version = "2.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1563da1ed3e0b3bf3d74c9b85917ac9c56464d2f57242270c09c9e752f8021a0"
+[[package]]
+name = "icu_pattern"
+version = "0.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2b3db611446bd92c246b03e4aa0bafa71ddb194af431ddbb42b9d70e4bfce1a0"
+dependencies = [
+ "displaydoc",
+ "either",
+ "serde",
+ "writeable",
+ "zerovec",
+]
+
+[[package]]
+name = "icu_plurals"
+version = "2.3.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e475e6766ef87b1d3c1f97be6363995b0bfaeddbc789671615df598a97ff0593"
+dependencies = [
+ "displaydoc",
+ "fixed_decimal",
+ "icu_provider",
+ "serde",
+ "zerovec",
+]
+
[[package]]
name = "icu_properties"
version = "2.3.0"
@@ -2852,6 +3070,9 @@ checksum = "d27bbb9d3abbefac45d55f647c9de1d44aafcd1186eb91879afef17c396c3e73"
dependencies = [
"displaydoc",
"icu_locale_core",
+ "postcard",
+ "serde",
+ "stable_deref_trait",
"writeable",
"yoke",
"zerofrom",
@@ -2859,6 +3080,30 @@ dependencies = [
"zerovec",
]
+[[package]]
+name = "icu_provider_adapters"
+version = "2.3.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "df887bc4f55dbc773c7c45a0c7dfa75be886f7b7812830a71ab7271cc1801820"
+dependencies = [
+ "icu_locale_fallback",
+ "icu_provider",
+]
+
+[[package]]
+name = "icu_provider_blob"
+version = "2.3.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5916b44fac319d3f58e4d8d805a627df19e27937b582cd99fcde3bb6b7ce1b0e"
+dependencies = [
+ "icu_provider",
+ "postcard",
+ "serde",
+ "writeable",
+ "zerotrie",
+ "zerovec",
+]
+
[[package]]
name = "idna"
version = "1.1.0"
@@ -3069,12 +3314,12 @@ checksum = "e2db585e1d738fc771bf08a151420d3ed193d9d895a36df7f6f8a9456b911ddc"
[[package]]
name = "kittest"
-version = "0.4.0"
+version = "0.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "90ceaa75eb0036a32b6b9833962eb18137449e9817e2e586006471925b727fd5"
+checksum = "0bb81a721ba76aa095b7f45deb180504f786904e56ca4b8e141f66eb89a78b75"
dependencies = [
"accesskit",
- "accesskit_consumer 0.35.0",
+ "accesskit_consumer",
]
[[package]]
@@ -3157,6 +3402,9 @@ name = "litemap"
version = "0.8.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "47d9d19d1d6efa0109d2f65ff4c85cddd50bd572e5a00127ab10987290bcefae"
+dependencies = [
+ "serde_core",
+]
[[package]]
name = "litrs"
@@ -3197,10 +3445,10 @@ dependencies = [
"log",
"md-5",
"nom",
- "rand 0.10.3",
+ "rand",
"rangemap",
"rayon",
- "sha2 0.11.0",
+ "sha2 0.11.1",
"stringprep",
"thiserror 2.0.21",
"weezl 0.2.1",
@@ -3302,13 +3550,13 @@ dependencies = [
"crossbeam-channel",
"dpi",
"keyboard-types",
- "objc2 0.6.4",
+ "objc2 0.6.5",
"objc2-app-kit 0.3.2",
"objc2-core-foundation",
"objc2-foundation 0.3.2",
"png 0.18.1",
"thiserror 2.0.21",
- "windows-sys 0.60.2",
+ "windows-sys 0.61.2",
]
[[package]]
@@ -3385,6 +3633,18 @@ dependencies = [
"jni-sys 0.3.1",
]
+[[package]]
+name = "nix"
+version = "0.31.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cf20d2fde8ff38632c426f1165ed7436270b44f199fc55284c38276f9db47c3d"
+dependencies = [
+ "bitflags 2.13.2",
+ "cfg-if",
+ "cfg_aliases",
+ "libc",
+]
+
[[package]]
name = "nohash-hasher"
version = "0.2.0"
@@ -3495,9 +3755,9 @@ dependencies = [
[[package]]
name = "objc2"
-version = "0.6.4"
+version = "0.6.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "3a12a8ed07aefc768292f076dc3ac8c48f3781c8f2d5851dd3d98950e8c5a89f"
+checksum = "08849bbd4767dfae9457696856ae1c84fe4e0281bbe4a7abff2d0e06fb7981f8"
dependencies = [
"objc2-encode",
]
@@ -3526,7 +3786,7 @@ checksum = "d49e936b501e5c5bf01fda3a9452ff86dc3ea98ad5f283e1455153142d97518c"
dependencies = [
"bitflags 2.13.2",
"block2 0.6.2",
- "objc2 0.6.4",
+ "objc2 0.6.5",
"objc2-core-foundation",
"objc2-core-graphics",
"objc2-foundation 0.3.2",
@@ -3576,7 +3836,7 @@ checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536"
dependencies = [
"bitflags 2.13.2",
"dispatch2",
- "objc2 0.6.4",
+ "objc2 0.6.5",
]
[[package]]
@@ -3587,7 +3847,7 @@ checksum = "e022c9d066895efa1345f8e33e584b9f958da2fd4cd116792e15e07e4720a807"
dependencies = [
"bitflags 2.13.2",
"dispatch2",
- "objc2 0.6.4",
+ "objc2 0.6.5",
"objc2-core-foundation",
"objc2-io-surface",
]
@@ -3643,7 +3903,7 @@ checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272"
dependencies = [
"bitflags 2.13.2",
"block2 0.6.2",
- "objc2 0.6.4",
+ "objc2 0.6.5",
"objc2-core-foundation",
]
@@ -3654,7 +3914,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "180788110936d59bab6bd83b6060ffdfffb3b922ba1396b312ae795e1de9d81d"
dependencies = [
"bitflags 2.13.2",
- "objc2 0.6.4",
+ "objc2 0.6.5",
"objc2-core-foundation",
]
@@ -3690,7 +3950,7 @@ checksum = "a0125f776a10d00af4152d74616409f0d4a2053a6f57fa5b7d6aa2854ac04794"
dependencies = [
"bitflags 2.13.2",
"block2 0.6.2",
- "objc2 0.6.4",
+ "objc2 0.6.5",
"objc2-foundation 0.3.2",
]
@@ -3714,7 +3974,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "96c1358452b371bf9f104e21ec536d37a650eb10f7ee379fff67d2e08d537f1f"
dependencies = [
"bitflags 2.13.2",
- "objc2 0.6.4",
+ "objc2 0.6.5",
"objc2-core-foundation",
"objc2-core-graphics",
"objc2-foundation 0.3.2",
@@ -3759,7 +4019,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d87d638e33c06f577498cbcc50491496a3ed4246998a7fbba7ccb98b1e7eab22"
dependencies = [
"bitflags 2.13.2",
- "objc2 0.6.4",
+ "objc2 0.6.5",
"objc2-core-foundation",
"objc2-foundation 0.3.2",
]
@@ -3850,6 +4110,16 @@ dependencies = [
"pin-project-lite",
]
+[[package]]
+name = "os_pipe"
+version = "1.2.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7d8fae84b431384b68627d0f9b3b1245fcf9f46f6c0e3dc902e9dce64edd1967"
+dependencies = [
+ "libc",
+ "windows-sys 0.61.2",
+]
+
[[package]]
name = "p256"
version = "0.14.0"
@@ -3860,7 +4130,7 @@ dependencies = [
"elliptic-curve",
"primefield",
"primeorder",
- "sha2 0.11.0",
+ "sha2 0.11.1",
]
[[package]]
@@ -3874,7 +4144,7 @@ dependencies = [
"fiat-crypto",
"primefield",
"primeorder",
- "sha2 0.11.0",
+ "sha2 0.11.1",
]
[[package]]
@@ -3888,7 +4158,7 @@ dependencies = [
"elliptic-curve",
"primefield",
"primeorder",
- "sha2 0.11.0",
+ "sha2 0.11.1",
]
[[package]]
@@ -3938,9 +4208,10 @@ dependencies = [
[[package]]
name = "pdfcraft"
-version = "0.9.0"
+version = "0.16.1"
dependencies = [
- "craft-quit",
+ "chrono",
+ "craft-update",
"eframe",
"egui",
"fmv-macos-events",
@@ -3949,10 +4220,8 @@ dependencies = [
"pdfcraft-ui-egui",
"pollster 1.0.1",
"raw-window-handle",
- "rustls-native-certs",
"serde_json",
"uds_windows",
- "ureq",
"wayland-client",
"wayland-protocols",
"windows",
@@ -3963,7 +4232,7 @@ dependencies = [
[[package]]
name = "pdfcraft-a11y"
-version = "0.9.0"
+version = "0.16.1"
dependencies = [
"pdfcraft-content",
"pdfcraft-cos",
@@ -3975,7 +4244,7 @@ dependencies = [
[[package]]
name = "pdfcraft-annot"
-version = "0.9.0"
+version = "0.16.1"
dependencies = [
"hayro-syntax",
"pdfcraft-cos",
@@ -3986,7 +4255,7 @@ dependencies = [
[[package]]
name = "pdfcraft-audit"
-version = "0.9.0"
+version = "0.16.1"
dependencies = [
"pdfcraft-content",
"pdfcraft-cos",
@@ -3997,9 +4266,9 @@ dependencies = [
[[package]]
name = "pdfcraft-automation"
-version = "0.9.0"
+version = "0.16.1"
dependencies = [
- "base64 0.22.1",
+ "base64 0.23.1",
"flate2",
"image",
"pdfcraft-cos",
@@ -4011,16 +4280,17 @@ dependencies = [
"pdfcraft-render",
"pdfcraft-xfa",
"png 0.18.1",
+ "rustix 1.1.5",
"serde_json",
- "sha2 0.11.0",
+ "sha2 0.11.1",
"tempfile",
]
[[package]]
name = "pdfcraft-cli"
-version = "0.9.0"
+version = "0.16.1"
dependencies = [
- "base64 0.22.1",
+ "base64 0.23.1",
"pdfcraft-audit",
"pdfcraft-automation",
"pdfcraft-cos",
@@ -4028,6 +4298,7 @@ dependencies = [
"pdfcraft-platform",
"pdfcraft-preflight",
"pdfcraft-render",
+ "png 0.18.1",
"rustix 1.1.5",
"serde_json",
"winresource",
@@ -4035,19 +4306,21 @@ dependencies = [
[[package]]
name = "pdfcraft-compare"
-version = "0.9.0"
+version = "0.16.1"
[[package]]
name = "pdfcraft-content"
-version = "0.9.0"
+version = "0.16.1"
dependencies = [
+ "hayro-syntax",
"pdfcraft-cos",
]
[[package]]
name = "pdfcraft-cos"
-version = "0.9.0"
+version = "0.16.1"
dependencies = [
+ "chrono",
"hayro-syntax",
"pdfcraft-crypt",
"pdfcraft-filters",
@@ -4060,14 +4333,18 @@ dependencies = [
[[package]]
name = "pdfcraft-create"
-version = "0.9.0"
+version = "0.16.1"
dependencies = [
"encoding_rs",
+ "hayro-ccitt",
+ "hayro-jbig2",
"hayro-syntax",
"image",
"pdfcraft-cos",
+ "pdfcraft-filters",
"pdfcraft-fonts",
"pdfcraft-model",
+ "pdfcraft-xfa",
"png 0.18.1",
"thiserror 2.0.21",
"tiff",
@@ -4076,19 +4353,19 @@ dependencies = [
[[package]]
name = "pdfcraft-crypt"
-version = "0.9.0"
+version = "0.16.1"
dependencies = [
"aes",
"md-5",
"proptest",
- "sha2 0.11.0",
+ "sha2 0.11.1",
"stringprep",
"thiserror 2.0.21",
]
[[package]]
name = "pdfcraft-edit"
-version = "0.9.0"
+version = "0.16.1"
dependencies = [
"hayro-syntax",
"kurbo 0.13.1",
@@ -4097,16 +4374,18 @@ dependencies = [
"pdfcraft-cos",
"pdfcraft-fonts",
"pdfcraft-model",
+ "sha2 0.11.1",
"thiserror 2.0.21",
"unicode-bidi",
]
[[package]]
name = "pdfcraft-engine"
-version = "0.9.0"
+version = "0.16.1"
dependencies = [
"chrono",
"craft-docpath",
+ "getrandom 0.4.3",
"icu_properties",
"idna",
"image",
@@ -4132,6 +4411,7 @@ dependencies = [
"pdfcraft-redact",
"pdfcraft-render",
"pdfcraft-sign",
+ "pdfcraft-testkit",
"pdfcraft-xfa",
"pdfcraft-xfdf",
"png 0.18.1",
@@ -4143,16 +4423,19 @@ dependencies = [
[[package]]
name = "pdfcraft-export"
-version = "0.9.0"
+version = "0.16.1"
dependencies = [
"flate2",
]
[[package]]
name = "pdfcraft-filters"
-version = "0.9.0"
+version = "0.16.1"
dependencies = [
+ "fax",
"flate2",
+ "hayro-ccitt",
+ "hayro-jbig2",
"proptest",
"thiserror 2.0.21",
"weezl 0.2.1",
@@ -4160,7 +4443,7 @@ dependencies = [
[[package]]
name = "pdfcraft-fonts"
-version = "0.9.0"
+version = "0.16.1"
dependencies = [
"harfrust",
"pdfcraft-cos",
@@ -4170,30 +4453,32 @@ dependencies = [
[[package]]
name = "pdfcraft-forms"
-version = "0.9.0"
+version = "0.16.1"
dependencies = [
"hayro-syntax",
"pdfcraft-content",
"pdfcraft-cos",
"pdfcraft-fonts",
"pdfcraft-model",
+ "proptest",
"thiserror 2.0.21",
]
[[package]]
name = "pdfcraft-geom"
-version = "0.9.0"
+version = "0.16.1"
[[package]]
name = "pdfcraft-js"
-version = "0.9.0"
+version = "0.16.1"
dependencies = [
"boa_engine",
+ "chrono",
]
[[package]]
name = "pdfcraft-measure"
-version = "0.9.0"
+version = "0.16.1"
dependencies = [
"pdfcraft-annot",
"pdfcraft-content",
@@ -4206,41 +4491,45 @@ dependencies = [
[[package]]
name = "pdfcraft-model"
-version = "0.9.0"
+version = "0.16.1"
dependencies = [
"pdfcraft-cos",
]
[[package]]
name = "pdfcraft-ocr"
-version = "0.9.0"
+version = "0.16.1"
dependencies = [
"ocrs",
"pdfcraft-fonts",
"rten",
+ "tempfile",
"thiserror 2.0.21",
]
[[package]]
name = "pdfcraft-optimize"
-version = "0.9.0"
+version = "0.16.1"
dependencies = [
"image",
"pdfcraft-annot",
"pdfcraft-content",
"pdfcraft-cos",
+ "pdfcraft-filters",
"pdfcraft-model",
"pdfcraft-render",
+ "proptest",
"thiserror 2.0.21",
+ "zune-jpeg",
]
[[package]]
name = "pdfcraft-oracle"
-version = "0.9.0"
+version = "0.16.1"
[[package]]
name = "pdfcraft-organize"
-version = "0.9.0"
+version = "0.16.1"
dependencies = [
"hayro-syntax",
"pdfcraft-content",
@@ -4251,7 +4540,7 @@ dependencies = [
[[package]]
name = "pdfcraft-platform"
-version = "0.9.0"
+version = "0.16.1"
dependencies = [
"async-channel",
"futures-lite",
@@ -4261,32 +4550,36 @@ dependencies = [
[[package]]
name = "pdfcraft-preflight"
-version = "0.9.0"
+version = "0.16.1"
dependencies = [
+ "flate2",
"pdfcraft-content",
"pdfcraft-cos",
"pdfcraft-model",
+ "roxmltree 0.21.2",
]
[[package]]
name = "pdfcraft-print"
-version = "0.9.0"
+version = "0.16.1"
dependencies = [
"craft-print",
"pdfcraft-content",
"pdfcraft-cos",
"pdfcraft-model",
+ "pdfcraft-render",
"thiserror 2.0.21",
]
[[package]]
name = "pdfcraft-redact"
-version = "0.9.0"
+version = "0.16.1"
dependencies = [
"pdfcraft-annot",
"pdfcraft-content",
"pdfcraft-cos",
"pdfcraft-edit",
+ "pdfcraft-filters",
"pdfcraft-fonts",
"pdfcraft-forms",
"pdfcraft-model",
@@ -4297,7 +4590,7 @@ dependencies = [
[[package]]
name = "pdfcraft-render"
-version = "0.9.0"
+version = "0.16.1"
dependencies = [
"bytemuck",
"flate2",
@@ -4316,14 +4609,15 @@ dependencies = [
[[package]]
name = "pdfcraft-sign"
-version = "0.9.0"
+version = "0.16.1"
dependencies = [
"aes",
"aws-lc-rs",
- "base64 0.22.1",
+ "base64 0.23.1",
"bp256",
"bp384",
"cbc",
+ "chrono",
"crypto-bigint",
"des",
"ecdsa",
@@ -4337,30 +4631,36 @@ dependencies = [
"pdfcraft-annot",
"pdfcraft-cos",
"pdfcraft-fonts",
+ "pdfcraft-model",
"rc2",
"ripemd",
"rsa",
"rustls-cng",
"security-framework",
"sha1 0.11.0",
- "sha2 0.11.0",
+ "sha2 0.11.1",
"sha3",
"thiserror 2.0.21",
]
[[package]]
name = "pdfcraft-testkit"
-version = "0.9.0"
+version = "0.16.1"
[[package]]
name = "pdfcraft-ui-egui"
-version = "0.9.0"
+version = "0.16.1"
dependencies = [
"arboard",
- "base64 0.22.1",
+ "base64 0.23.1",
+ "block2 0.6.2",
"bytemuck",
+ "chrono",
+ "craft-keymap",
+ "craft-menu",
"craft-quit",
"craft-ui",
+ "craft-update",
"eframe",
"egui",
"egui_extras",
@@ -4370,7 +4670,7 @@ dependencies = [
"image",
"js-sys",
"log",
- "objc2 0.6.4",
+ "objc2 0.6.5",
"objc2-app-kit 0.3.2",
"objc2-foundation 0.3.2",
"pdfcraft-cos",
@@ -4380,6 +4680,7 @@ dependencies = [
"pdfcraft-platform",
"pdfcraft-render",
"pdfcraft-xfa",
+ "plist",
"png 0.18.1",
"pollster 1.0.1",
"rfd",
@@ -4393,12 +4694,14 @@ dependencies = [
"wasm-bindgen",
"wasm-bindgen-futures",
"web-sys",
+ "web-time",
"winsafe",
+ "xattr",
]
[[package]]
name = "pdfcraft-web"
-version = "0.9.0"
+version = "0.16.1"
dependencies = [
"eframe",
"egui",
@@ -4410,27 +4713,27 @@ dependencies = [
[[package]]
name = "pdfcraft-xfa"
-version = "0.9.0"
+version = "0.16.1"
dependencies = [
- "base64 0.22.1",
+ "base64 0.23.1",
"image",
"pdfcraft-cos",
"pdfcraft-fonts",
"png 0.18.1",
- "roxmltree",
- "sha2 0.11.0",
+ "roxmltree 0.21.2",
+ "sha2 0.11.1",
"thiserror 2.0.21",
]
[[package]]
name = "pdfcraft-xfdf"
-version = "0.9.0"
+version = "0.16.1"
dependencies = [
"pdfcraft-annot",
"pdfcraft-cos",
"pdfcraft-forms",
"pdfcraft-model",
- "roxmltree",
+ "roxmltree 0.21.2",
"thiserror 2.0.21",
]
@@ -4481,6 +4784,17 @@ version = "2.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220"
+[[package]]
+name = "petgraph"
+version = "0.8.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8701b58ea97060d5e5b155d383a69952a60943f0e6dfe30b04c287beb0b27455"
+dependencies = [
+ "fixedbitset",
+ "hashbrown 0.15.5",
+ "indexmap",
+]
+
[[package]]
name = "phf"
version = "0.13.1"
@@ -4489,7 +4803,6 @@ checksum = "c1562dc717473dbaa4c1f85a36410e03c047b2e7df7f45ee938fbef64ae7fadf"
dependencies = [
"phf_macros 0.13.1",
"phf_shared 0.13.1",
- "serde",
]
[[package]]
@@ -4500,6 +4813,7 @@ checksum = "010378780309880b08997fae13be7834dba947d36393bd372f2b1556deb2a2f6"
dependencies = [
"phf_macros 0.14.0",
"phf_shared 0.14.0",
+ "serde",
]
[[package]]
@@ -4651,6 +4965,19 @@ version = "0.2.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b4596b6d070b27117e987119b4dac604f3c58cfb0b191112e24771b2faeac1a6"
+[[package]]
+name = "plist"
+version = "1.10.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "327a4cb2ad49dc48adfdc9da89e22c64d488b508c67a044d71ba729e4521a285"
+dependencies = [
+ "base64 0.23.1",
+ "indexmap",
+ "quick-xml 0.42.0",
+ "serde",
+ "time",
+]
+
[[package]]
name = "png"
version = "0.17.16"
@@ -4727,12 +5054,24 @@ dependencies = [
"portable-atomic",
]
+[[package]]
+name = "postcard"
+version = "1.1.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6764c3b5dd454e283a30e6dfe78e9b31096d9e32036b5d1eaac7a6119ccb9a24"
+dependencies = [
+ "cobs",
+ "serde",
+]
+
[[package]]
name = "potential_utf"
version = "0.1.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d83eb9bc6d8e5cf568e7a1101d60ee05e81ed50ea106026f3d18deeb046d7661"
dependencies = [
+ "serde_core",
+ "writeable",
"zerovec",
]
@@ -4742,15 +5081,6 @@ version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391"
-[[package]]
-name = "ppv-lite86"
-version = "0.2.21"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9"
-dependencies = [
- "zerocopy",
-]
-
[[package]]
name = "presser"
version = "0.3.1"
@@ -4834,16 +5164,17 @@ checksum = "3d595e54a326bc53c1c197b32d295e14b169e3cfeaa8dc82b529f947fba6bcf5"
[[package]]
name = "proptest"
-version = "1.11.0"
+version = "1.12.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "4b45fcc2344c680f5025fe57779faef368840d0bd1f42f216291f0dc4ace4744"
+checksum = "b8530004ccb15eae51c7e40009fbe317f341f804db54dc033eec1c50be28cfa0"
dependencies = [
- "bit-set 0.8.0",
- "bit-vec 0.8.0",
+ "bit-set 0.11.1",
+ "bit-vec 0.10.1",
"bitflags 2.13.2",
+ "chacha20",
+ "core_detect",
"num-traits",
- "rand 0.9.5",
- "rand_chacha",
+ "rand",
"rand_xorshift",
"regex-syntax",
"rusty-fork",
@@ -4878,6 +5209,15 @@ dependencies = [
"memchr",
]
+[[package]]
+name = "quick-xml"
+version = "0.42.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "41b1177fdf999d2321d3fb46ff47159d9c1fb9ad66a4879f8c50a0b504615e9b"
+dependencies = [
+ "memchr",
+]
+
[[package]]
name = "quinn"
version = "0.11.12"
@@ -4908,7 +5248,7 @@ dependencies = [
"bytes",
"getrandom 0.4.3",
"lru-slab",
- "rand 0.10.3",
+ "rand",
"rand_pcg",
"ring",
"rustc-hash 2.1.3",
@@ -4932,7 +5272,7 @@ dependencies = [
"once_cell",
"socket2",
"tracing",
- "windows-sys 0.52.0",
+ "windows-sys 0.61.2",
]
[[package]]
@@ -4956,16 +5296,6 @@ version = "6.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf"
-[[package]]
-name = "rand"
-version = "0.9.5"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "b9ef1d0d795eb7d84685bca4f72f3649f064e6641543d3a8c415898726a57b41"
-dependencies = [
- "rand_chacha",
- "rand_core 0.9.5",
-]
-
[[package]]
name = "rand"
version = "0.10.3"
@@ -4977,31 +5307,12 @@ dependencies = [
"rand_core 0.10.1",
]
-[[package]]
-name = "rand_chacha"
-version = "0.9.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb"
-dependencies = [
- "ppv-lite86",
- "rand_core 0.9.5",
-]
-
[[package]]
name = "rand_core"
version = "0.6.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c"
-[[package]]
-name = "rand_core"
-version = "0.9.5"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c"
-dependencies = [
- "getrandom 0.3.4",
-]
-
[[package]]
name = "rand_core"
version = "0.10.1"
@@ -5019,11 +5330,11 @@ dependencies = [
[[package]]
name = "rand_xorshift"
-version = "0.4.0"
+version = "0.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "513962919efc330f829edb2535844d1b912b0fbe2ca165d613e4e8788bb05a5a"
+checksum = "60aa6af80be32871323012e02e6e65f8a7cc7890931ae421d217ad8fe0df2ccf"
dependencies = [
- "rand_core 0.9.5",
+ "rand_core 0.10.1",
]
[[package]]
@@ -5050,7 +5361,7 @@ version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "40d213455a5f1dc59214213c7330e074ddf8114c9a42411eb890c767357ce135"
dependencies = [
- "objc2 0.6.4",
+ "objc2 0.6.5",
"objc2-core-foundation",
"objc2-foundation 0.3.2",
"objc2-quartz-core 0.3.2",
@@ -5243,7 +5554,7 @@ dependencies = [
"js-sys",
"libc",
"log",
- "objc2 0.6.4",
+ "objc2 0.6.5",
"objc2-app-kit 0.3.2",
"objc2-core-foundation",
"objc2-foundation 0.3.2",
@@ -5311,6 +5622,15 @@ version = "0.20.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6c20b6793b5c2fa6553b250154b78d6d0db37e72700ae35fad9387a46f487c97"
+[[package]]
+name = "roxmltree"
+version = "0.21.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9da7f59328b09405b181fee1018925fad50f157afb43c0c0f5c522c256dd5907"
+dependencies = [
+ "memchr",
+]
+
[[package]]
name = "rsa"
version = "0.10.0-rc.18"
@@ -5340,6 +5660,7 @@ dependencies = [
"rten-base",
"rten-gemm",
"rten-model-file",
+ "rten-onnx",
"rten-parallel",
"rten-shape-inference",
"rten-simd",
@@ -5389,6 +5710,12 @@ dependencies = [
"rten-base",
]
+[[package]]
+name = "rten-onnx"
+version = "0.26.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "96944374568990b8d1bbc5bae5957585866f9dd8256130460405730948bfd42e"
+
[[package]]
name = "rten-parallel"
version = "0.26.0"
@@ -5469,7 +5796,7 @@ dependencies = [
"errno",
"libc",
"linux-raw-sys 0.4.15",
- "windows-sys 0.52.0",
+ "windows-sys 0.59.0",
]
[[package]]
@@ -5482,7 +5809,7 @@ dependencies = [
"errno",
"libc",
"linux-raw-sys 0.12.1",
- "windows-sys 0.52.0",
+ "windows-sys 0.61.2",
]
[[package]]
@@ -5514,9 +5841,9 @@ dependencies = [
[[package]]
name = "rustls-native-certs"
-version = "0.8.4"
+version = "0.8.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "dab5152771c58876a2146916e53e35057e1a4dfa2b9df0f0305b07f611fdea4d"
+checksum = "4a073f5dc7469f984c52ad2752b63b0807745133b6de880b7b64c1ac4c48aec4"
dependencies = [
"openssl-probe",
"rustls-pki-types",
@@ -5552,7 +5879,7 @@ dependencies = [
"security-framework",
"security-framework-sys",
"webpki-root-certs",
- "windows-sys 0.52.0",
+ "windows-sys 0.61.2",
]
[[package]]
@@ -5563,9 +5890,9 @@ checksum = "eec689c0bc40ff2458a5977b6619cb718087084a18e02a131c599b62d05e1a5f"
[[package]]
name = "rustls-webpki"
-version = "0.103.15"
+version = "0.103.16"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "f3c3cf1d8b1e7d4927e2d154c3fcb02979afb9939629c62cd9048d4f07b60ac2"
+checksum = "4443b1b6d39d7c663139942cc4558262562d2c84123bb6282ef177009ca8b281"
dependencies = [
"aws-lc-rs",
"ring",
@@ -5681,6 +6008,16 @@ name = "semver"
version = "1.0.28"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd"
+dependencies = [
+ "serde",
+ "serde_core",
+]
+
+[[package]]
+name = "send_wrapper"
+version = "0.6.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cd0b0ec5f1c1ca621c432a25813d8d60c88abe6d3e08a3eb9cf37d97a0fe3d73"
[[package]]
name = "serde"
@@ -5714,9 +6051,9 @@ dependencies = [
[[package]]
name = "serde_json"
-version = "1.0.151"
+version = "1.0.154"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14"
+checksum = "e7e9cc8b1b85264074fbcc02a88680c4096b1e47df8f739dceb03bf482f04bd6"
dependencies = [
"itoa",
"memchr",
@@ -5813,9 +6150,9 @@ dependencies = [
[[package]]
name = "sha2"
-version = "0.11.0"
+version = "0.11.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "446ba717509524cb3f22f17ecc096f10f4822d76ab5c0b9822c5f9c284e825f4"
+checksum = "47d7069beb7d6ac7b9acd1039986e73443f24234f41074da099d6f994ac9ad19"
dependencies = [
"cfg-if",
"cpufeatures 0.3.1",
@@ -5971,7 +6308,7 @@ dependencies = [
"const-oid 0.9.6",
"der 0.7.10",
"hex",
- "rand 0.10.3",
+ "rand",
"reqwest",
"rustls-pki-types",
"rustls-webpki",
@@ -6207,7 +6544,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4"
dependencies = [
"libc",
- "windows-sys 0.60.2",
+ "windows-sys 0.61.2",
]
[[package]]
@@ -6372,7 +6709,10 @@ version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8eab9a99a024a169fe8a903cf9d4a3b3601109bcc13bd9e3c6fff259138626c4"
dependencies = [
+ "js-sys",
"libc",
+ "wasm-bindgen",
+ "web-sys",
]
[[package]]
@@ -6426,10 +6766,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd"
dependencies = [
"fastrand",
- "getrandom 0.3.4",
+ "getrandom 0.4.3",
"once_cell",
"rustix 1.1.5",
- "windows-sys 0.52.0",
+ "windows-sys 0.61.2",
]
[[package]]
@@ -6567,6 +6907,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b1e27c91459209c2986af3dcf603a5a74a4368754ce37414f59acc971167f643"
dependencies = [
"displaydoc",
+ "serde_core",
"zerovec",
]
@@ -6763,6 +7104,17 @@ dependencies = [
"once_cell",
]
+[[package]]
+name = "tree_magic_mini"
+version = "3.2.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b8765b90061cba6c22b5831f675da109ae5561588290f9fa2317adab2714d5a6"
+dependencies = [
+ "memchr",
+ "nom",
+ "petgraph",
+]
+
[[package]]
name = "try-lock"
version = "0.2.5"
@@ -6778,6 +7130,12 @@ dependencies = [
"rustc-hash 2.1.3",
]
+[[package]]
+name = "typed-path"
+version = "0.13.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9ce898823d8cd0f028662d2f349694b1af0a5cc9be83f1d213a00d3147445e1e"
+
[[package]]
name = "typeid"
version = "1.0.3"
@@ -6798,7 +7156,7 @@ checksum = "f2f6fb2847f6742cd76af783a2a2c49e9375d0a111c7bef6f71cd9e738c72d6e"
dependencies = [
"memoffset",
"tempfile",
- "windows-sys 0.60.2",
+ "windows-sys 0.61.2",
]
[[package]]
@@ -6866,24 +7224,26 @@ checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1"
[[package]]
name = "ureq"
-version = "3.4.2"
+version = "3.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "9a7ac20be9b7726e0bbdbf974c059676d9acb1cd414961f570a4e8231cacd7fc"
+checksum = "cde93ff05758a36a851a398a772b0b79a82fbe3c186a46f2696baaeaf84c966b"
dependencies = [
"base64 0.23.1",
+ "flate2",
"log",
"percent-encoding",
"rustls",
"rustls-pki-types",
+ "rustls-platform-verifier",
"ureq-proto",
"utf8-zero",
]
[[package]]
name = "ureq-proto"
-version = "0.6.4"
+version = "0.6.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "f86fd172ccca569e458f61b6bdd6220965a9ef36e672a6852953b51a0e1583be"
+checksum = "841583fc36089d7d59419568ae157f43a5a03a2e5a81ba87c77d1596ef46e922"
dependencies = [
"base64 0.23.1",
"http",
@@ -6916,7 +7276,7 @@ dependencies = [
"kurbo 0.11.3",
"log",
"pico-args",
- "roxmltree",
+ "roxmltree 0.20.0",
"simplecss",
"siphasher",
"strict-num",
@@ -6988,9 +7348,9 @@ dependencies = [
[[package]]
name = "vello_common"
-version = "0.1.0"
+version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "b2e9aed918117e8152c9eddfd8362d73c465c23f26c44786aa331707b8a64fa2"
+checksum = "bbb2141a2bca6e6d598e471fd4d1d7eed8e020aad6a28187edda07f091a325dd"
dependencies = [
"bytemuck",
"fearless_simd",
@@ -7004,8 +7364,6 @@ dependencies = [
[[package]]
name = "vello_cpu"
version = "0.0.8"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "6d8ded630e8316bb94a55881256506d1f3b9947b5f66db8a7d32ca7ba02decd0"
dependencies = [
"bytemuck",
"glifo 0.1.1",
@@ -7016,14 +7374,24 @@ dependencies = [
[[package]]
name = "vello_cpu"
-version = "0.1.0"
+version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "ac7349e1f55f6b801c7c277958df4ea53e7f20f21e8014910ad888b2ecda93ea"
+checksum = "ace506ca414548966fcf3c283be94eb1cad8254de488791ee698bf438b0890a9"
dependencies = [
"bytemuck",
- "glifo 0.2.0",
+ "glifo 0.3.0",
"hashbrown 0.17.1",
- "vello_common 0.1.0",
+ "vello_common 0.2.0",
+]
+
+[[package]]
+name = "verifysign"
+version = "1.0.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6acfcc42a8d44f340d001193d97ef56bf33210b51ad760ae8308c97d5241ca51"
+dependencies = [
+ "core-foundation 0.10.1",
+ "windows-sys 0.61.2",
]
[[package]]
@@ -7250,7 +7618,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "338e30461b3a2b67d70eb30a6d89f8e0c93a833e07d2ae89085cd070c4a00ac0"
dependencies = [
"proc-macro2",
- "quick-xml",
+ "quick-xml 0.41.0",
"quote",
]
@@ -7295,7 +7663,7 @@ dependencies = [
"jni",
"log",
"ndk-context",
- "objc2 0.6.4",
+ "objc2 0.6.5",
"objc2-app-kit 0.3.2",
"objc2-foundation 0.3.2",
"url",
@@ -7304,9 +7672,9 @@ dependencies = [
[[package]]
name = "webpki-root-certs"
-version = "1.0.9"
+version = "1.0.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "b96554aa2acc8ccdb7e1c9a58a7a68dd5d13bccc69cd124cb09406db612a1c9b"
+checksum = "58ec80e73fd8baf57bf0d9615554c75348bda990d6d5bbeab964dd068319fecb"
dependencies = [
"rustls-pki-types",
]
@@ -7451,7 +7819,7 @@ dependencies = [
"naga",
"naga-types",
"ndk-sys",
- "objc2 0.6.4",
+ "objc2 0.6.5",
"objc2-core-foundation",
"objc2-core-graphics",
"objc2-foundation 0.3.2",
@@ -7512,7 +7880,7 @@ version = "0.1.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22"
dependencies = [
- "windows-sys 0.52.0",
+ "windows-sys 0.61.2",
]
[[package]]
@@ -7633,16 +8001,16 @@ version = "0.52.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d"
dependencies = [
- "windows-targets 0.52.6",
+ "windows-targets",
]
[[package]]
name = "windows-sys"
-version = "0.60.2"
+version = "0.59.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb"
+checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b"
dependencies = [
- "windows-targets 0.53.5",
+ "windows-targets",
]
[[package]]
@@ -7660,31 +8028,14 @@ version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973"
dependencies = [
- "windows_aarch64_gnullvm 0.52.6",
- "windows_aarch64_msvc 0.52.6",
- "windows_i686_gnu 0.52.6",
- "windows_i686_gnullvm 0.52.6",
- "windows_i686_msvc 0.52.6",
- "windows_x86_64_gnu 0.52.6",
- "windows_x86_64_gnullvm 0.52.6",
- "windows_x86_64_msvc 0.52.6",
-]
-
-[[package]]
-name = "windows-targets"
-version = "0.53.5"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3"
-dependencies = [
- "windows-link",
- "windows_aarch64_gnullvm 0.53.1",
- "windows_aarch64_msvc 0.53.1",
- "windows_i686_gnu 0.53.1",
- "windows_i686_gnullvm 0.53.1",
- "windows_i686_msvc 0.53.1",
- "windows_x86_64_gnu 0.53.1",
- "windows_x86_64_gnullvm 0.53.1",
- "windows_x86_64_msvc 0.53.1",
+ "windows_aarch64_gnullvm",
+ "windows_aarch64_msvc",
+ "windows_i686_gnu",
+ "windows_i686_gnullvm",
+ "windows_i686_msvc",
+ "windows_x86_64_gnu",
+ "windows_x86_64_gnullvm",
+ "windows_x86_64_msvc",
]
[[package]]
@@ -7702,99 +8053,52 @@ version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3"
-[[package]]
-name = "windows_aarch64_gnullvm"
-version = "0.53.1"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53"
-
[[package]]
name = "windows_aarch64_msvc"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469"
-[[package]]
-name = "windows_aarch64_msvc"
-version = "0.53.1"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006"
-
[[package]]
name = "windows_i686_gnu"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b"
-[[package]]
-name = "windows_i686_gnu"
-version = "0.53.1"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3"
-
[[package]]
name = "windows_i686_gnullvm"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66"
-[[package]]
-name = "windows_i686_gnullvm"
-version = "0.53.1"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c"
-
[[package]]
name = "windows_i686_msvc"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66"
-[[package]]
-name = "windows_i686_msvc"
-version = "0.53.1"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2"
-
[[package]]
name = "windows_x86_64_gnu"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78"
-[[package]]
-name = "windows_x86_64_gnu"
-version = "0.53.1"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499"
-
[[package]]
name = "windows_x86_64_gnullvm"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d"
-[[package]]
-name = "windows_x86_64_gnullvm"
-version = "0.53.1"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1"
-
[[package]]
name = "windows_x86_64_msvc"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec"
-[[package]]
-name = "windows_x86_64_msvc"
-version = "0.53.1"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650"
-
[[package]]
name = "winit"
version = "0.30.13"
+source = "git+https://github.com/storytold/winit.git?rev=a517b3846ee8457d84ee4975110bfda6797c44dc#a517b3846ee8457d84ee4975110bfda6797c44dc"
dependencies = [
"ahash",
"android-activity",
@@ -7872,10 +8176,20 @@ dependencies = [
]
[[package]]
-name = "winresource"
-version = "0.1.31"
+name = "winreg"
+version = "0.55.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "0986a8b1d586b7d3e4fe3d9ea39fb451ae22869dcea4aa109d287a374d866087"
+checksum = "cb5a765337c50e9ec252c2069be9bf91c7df47afb103b642ba3a53bf8101be97"
+dependencies = [
+ "cfg-if",
+ "windows-sys 0.59.0",
+]
+
+[[package]]
+name = "winresource"
+version = "0.1.34"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a2bc6d36480b829272a5bdabf08e6dca8004761c1da7e35efef800540c66221b"
dependencies = [
"toml",
"version_check",
@@ -7893,6 +8207,24 @@ version = "0.57.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e"
+[[package]]
+name = "wl-clipboard-rs"
+version = "0.9.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4d7888ccd4896447b2d14d3a9350a85df2aeb6f181e2e7a31349d104ac46cac1"
+dependencies = [
+ "libc",
+ "log",
+ "os_pipe",
+ "rustix 1.1.5",
+ "thiserror 2.0.21",
+ "tree_magic_mini",
+ "wayland-backend",
+ "wayland-client",
+ "wayland-protocols",
+ "wayland-protocols-wlr",
+]
+
[[package]]
name = "wnaf"
version = "0.14.1"
@@ -7916,6 +8248,9 @@ name = "writeable"
version = "0.6.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3ad82d2a33cdc9674dc7465672f271e096168fcdbe0f799d9e6db8c5892679dc"
+dependencies = [
+ "either",
+]
[[package]]
name = "x11-dl"
@@ -8032,13 +8367,14 @@ name = "xtask"
version = "0.1.0"
dependencies = [
"anyhow",
+ "chrono",
"lopdf",
"pdfcraft-cos",
"pdfcraft-oracle",
"png 0.18.1",
"serde",
"serde_json",
- "sha2 0.11.0",
+ "sha2 0.11.1",
"toml",
]
@@ -8239,8 +8575,11 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4ea269c3bd32f0a32c321907a2ae912ba6f4649bb0fc764a15627e99a7095a3f"
dependencies = [
"displaydoc",
+ "litemap",
+ "serde_core",
"yoke",
"zerofrom",
+ "zerovec",
]
[[package]]
@@ -8249,6 +8588,7 @@ version = "0.11.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bb0464e17806c1d976d5cba29399c7f08e516e279e2ba493f63123b5fca67dd8"
dependencies = [
+ "serde",
"yoke",
"zerofrom",
"zerovec-derive",
@@ -8265,6 +8605,23 @@ dependencies = [
"syn 3.0.6",
]
+[[package]]
+name = "zip"
+version = "9.1.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1156b39d142a34b999c2310a50455e0cb3760bf7efad0122f796d813bee26ccb"
+dependencies = [
+ "crc32fast",
+ "flate2",
+ "getrandom 0.4.3",
+ "indexmap",
+ "memchr",
+ "nix",
+ "typed-path",
+ "windows-sys 0.61.2",
+ "zopfli",
+]
+
[[package]]
name = "zlib-rs"
version = "0.6.8"
@@ -8277,6 +8634,18 @@ version = "1.0.23"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b"
+[[package]]
+name = "zopfli"
+version = "0.8.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "aaf7fc5d30c28483d93805c4a5e12b05bbb52407fa67c5f8bd552374cd01fb11"
+dependencies = [
+ "bumpalo",
+ "crc32fast",
+ "log",
+ "simd-adler32",
+]
+
[[package]]
name = "zune-core"
version = "0.5.3"
@@ -8332,3 +8701,18 @@ dependencies = [
"syn 3.0.6",
"winnow",
]
+
+[[patch.unused]]
+name = "egui_demo_lib"
+version = "0.37.0"
+source = "git+https://github.com/storytold/egui.git?rev=6d518fc799681571963842f95b55a6b68431b15a#6d518fc799681571963842f95b55a6b68431b15a"
+
+[[patch.unused]]
+name = "egui_inspection"
+version = "0.37.0"
+source = "git+https://github.com/storytold/egui.git?rev=6d518fc799681571963842f95b55a6b68431b15a#6d518fc799681571963842f95b55a6b68431b15a"
+
+[[patch.unused]]
+name = "egui_system_font_provider"
+version = "0.37.0"
+source = "git+https://github.com/storytold/egui.git?rev=6d518fc799681571963842f95b55a6b68431b15a#6d518fc799681571963842f95b55a6b68431b15a"
diff --git a/Cargo.toml b/Cargo.toml
index ec89098021..85d02570ba 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -5,7 +5,7 @@ exclude = ["vendor"]
default-members = ["crates/*", "apps/pdfcraft", "apps/pdfcraft-cli", "xtask"]
[workspace.package]
-version = "0.9.0"
+version = "0.16.1"
edition = "2024"
license = "MIT OR Apache-2.0"
rust-version = "1.95"
@@ -13,47 +13,52 @@ repository = "https://github.com/storytold/pdfcraft"
[workspace.dependencies]
# Shared frontend components; engines remain independent of egui.
-craft-ui = { git = "https://github.com/storytold/craft-libs", version = "0.1.0", rev = "5c874d51eeb3de70e735c8ce87a59a7dedd47675", features = ["serde"] }
+craft-ui = { git = "https://github.com/storytold/craft-libs", version = "0.1.0", rev = "c5a1cbfb5f9049f9270955caffb5bee5997aebf7", features = ["serde"] }
# Quit the macOS way (#1136): ⌘Q asks about unsaved changes, and closing quits through `terminate:`.
-craft-quit = { git = "https://github.com/storytold/craft-libs", version = "0.1.0", rev = "f58bc1a3df490992d633393d0898af0588ce882c" }
+craft-quit = { git = "https://github.com/storytold/craft-libs", version = "0.1.0", rev = "c5a1cbfb5f9049f9270955caffb5bee5997aebf7" }
+# One menu definition for the native macOS menu bar and the in-window bar elsewhere.
+craft-menu = { git = "https://github.com/storytold/craft-libs", version = "0.1.0", rev = "c5a1cbfb5f9049f9270955caffb5bee5997aebf7" }
+craft-keymap = { git = "https://github.com/storytold/craft-libs", version = "0.1.0", rev = "c5a1cbfb5f9049f9270955caffb5bee5997aebf7", default-features = false }
# Shared system spooler; document composition remains in pdfcraft-print.
-craft-print = { git = "https://github.com/storytold/craft-libs", version = "0.1.0", rev = "666c358065261a2816b376fa3bb10538dbf53040" }
+craft-print = { git = "https://github.com/storytold/craft-libs", version = "0.1.0", rev = "c5a1cbfb5f9049f9270955caffb5bee5997aebf7" }
# Whether a path stored in a document may be opened (GoToR link targets).
-craft-docpath = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "82a24b006eefa036b43c09a67fb9a3fbd17ffe63" }
+craft-docpath = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "c5a1cbfb5f9049f9270955caffb5bee5997aebf7" }
+# craft-update: app-specific pretend versions apply to startup and manual checks.
+craft-update = { git = "https://github.com/storytold/craft-libs", version = "=0.1.0", rev = "c5a1cbfb5f9049f9270955caffb5bee5997aebf7", features = ["egui"] }
# internal
-pdfcraft-geom = { path = "crates/geom", version = "0.9.0" }
-pdfcraft-filters = { path = "crates/filters", version = "0.9.0" }
-pdfcraft-cos = { path = "crates/cos", version = "0.9.0" }
-pdfcraft-crypt = { path = "crates/crypt", version = "0.9.0" }
-pdfcraft-organize = { path = "crates/organize", version = "0.9.0" }
-pdfcraft-measure = { path = "crates/measure", version = "0.9.0" }
-pdfcraft-annot = { path = "crates/annot", version = "0.9.0" }
-pdfcraft-audit = { path = "crates/audit", version = "0.9.0" }
-pdfcraft-fonts = { path = "crates/fonts", version = "0.9.0" }
-pdfcraft-model = { path = "crates/model", version = "0.9.0" }
-pdfcraft-content = { path = "crates/content", version = "0.9.0" }
-pdfcraft-redact = { path = "crates/redact", version = "0.9.0" }
-pdfcraft-print = { path = "crates/print", version = "0.9.0" }
-pdfcraft-xfdf = { path = "crates/xfdf", version = "0.9.0" }
-pdfcraft-sign = { path = "crates/sign", version = "0.9.0" }
-pdfcraft-optimize = { path = "crates/optimize", version = "0.9.0" }
-pdfcraft-a11y = { path = "crates/a11y", version = "0.9.0" }
-pdfcraft-oracle = { path = "crates/oracle", version = "0.9.0" }
-pdfcraft-testkit = { path = "crates/testkit", version = "0.9.0" }
-pdfcraft-ocr = { path = "crates/ocr", version = "0.9.0" }
-pdfcraft-js = { path = "crates/js", version = "0.9.0" }
-pdfcraft-compare = { path = "crates/compare", version = "0.9.0" }
-pdfcraft-preflight = { path = "crates/preflight", version = "0.9.0" }
-pdfcraft-export = { path = "crates/export", version = "0.9.0" }
-pdfcraft-edit = { path = "crates/edit", version = "0.9.0" }
-pdfcraft-create = { path = "crates/create", version = "0.9.0" }
-pdfcraft-forms = { path = "crates/forms", version = "0.9.0" }
-pdfcraft-xfa = { path = "crates/xfa", version = "0.9.0" }
-pdfcraft-render = { path = "crates/render", version = "0.9.0" }
-pdfcraft-engine = { path = "crates/engine", version = "0.9.0" }
-pdfcraft-platform = { path = "crates/platform", version = "0.9.0" }
-pdfcraft-ui-egui = { path = "crates/ui-egui", version = "0.9.0" }
-pdfcraft-automation = { path = "crates/automation", version = "0.9.0", default-features = false }
+pdfcraft-geom = { path = "crates/geom", version = "0.16.1" }
+pdfcraft-filters = { path = "crates/filters", version = "0.16.1" }
+pdfcraft-cos = { path = "crates/cos", version = "0.16.1" }
+pdfcraft-crypt = { path = "crates/crypt", version = "0.16.1" }
+pdfcraft-organize = { path = "crates/organize", version = "0.16.1" }
+pdfcraft-measure = { path = "crates/measure", version = "0.16.1" }
+pdfcraft-annot = { path = "crates/annot", version = "0.16.1" }
+pdfcraft-audit = { path = "crates/audit", version = "0.16.1" }
+pdfcraft-fonts = { path = "crates/fonts", version = "0.16.1" }
+pdfcraft-model = { path = "crates/model", version = "0.16.1" }
+pdfcraft-content = { path = "crates/content", version = "0.16.1" }
+pdfcraft-redact = { path = "crates/redact", version = "0.16.1" }
+pdfcraft-print = { path = "crates/print", version = "0.16.1" }
+pdfcraft-xfdf = { path = "crates/xfdf", version = "0.16.1" }
+pdfcraft-sign = { path = "crates/sign", version = "0.16.1" }
+pdfcraft-optimize = { path = "crates/optimize", version = "0.16.1" }
+pdfcraft-a11y = { path = "crates/a11y", version = "0.16.1" }
+pdfcraft-oracle = { path = "crates/oracle", version = "0.16.1" }
+pdfcraft-testkit = { path = "crates/testkit", version = "0.16.1" }
+pdfcraft-ocr = { path = "crates/ocr", version = "0.16.1" }
+pdfcraft-js = { path = "crates/js", version = "0.16.1" }
+pdfcraft-compare = { path = "crates/compare", version = "0.16.1" }
+pdfcraft-preflight = { path = "crates/preflight", version = "0.16.1" }
+pdfcraft-export = { path = "crates/export", version = "0.16.1" }
+pdfcraft-edit = { path = "crates/edit", version = "0.16.1" }
+pdfcraft-create = { path = "crates/create", version = "0.16.1" }
+pdfcraft-forms = { path = "crates/forms", version = "0.16.1" }
+pdfcraft-xfa = { path = "crates/xfa", version = "0.16.1" }
+pdfcraft-render = { path = "crates/render", version = "0.16.1" }
+pdfcraft-engine = { path = "crates/engine", version = "0.16.1" }
+pdfcraft-platform = { path = "crates/platform", version = "0.16.1" }
+pdfcraft-ui-egui = { path = "crates/ui-egui", version = "0.16.1" }
+pdfcraft-automation = { path = "crates/automation", version = "0.16.1", default-features = false }
# pdf
hayro = "0.7.1"
@@ -61,6 +66,11 @@ hayro-syntax = "0.7.2"
lopdf = "0.45"
flate2 = { version = "1.1", default-features = false, features = ["rust_backend"] }
weezl = "0.2"
+# CCITT Group 4 encoding (pdfcraft-filters); already in the tree through tiff.
+fax = "0.2.7"
+# Independent CCITT and JBIG2 codecs, already in the tree through hayro.
+hayro-ccitt = "0.3"
+hayro-jbig2 = "0.3"
# foundation
thiserror = "2"
@@ -68,39 +78,46 @@ serde = { version = "1", features = ["derive", "rc"] }
serde_json = "1"
log = "0.4"
kurbo = "0.13"
+# All date/time conversion goes through chrono (no hand-rolled calendar code beyond the 400-year range extension; AGENTS.md).
+chrono = { version = "0.4.45", default-features = false, features = ["std"] }
# testing
proptest = "1"
# ui
-egui = "0.36"
-egui_extras = { version = "0.36", default-features = false, features = ["svg"] }
-eframe = { version = "0.36", default-features = false }
-egui_kittest = "0.36"
+egui = "0.37"
+egui_extras = { version = "0.37", default-features = false, features = ["svg"] }
+eframe = { version = "0.37", default-features = false }
+egui_kittest = "0.37"
rfd = "0.17"
+# Monotonic time for logic-only work on native and web (`App::logic` runs without a UI pass).
+web-time = "1.1"
[workspace.lints.rust]
unsafe_code = "forbid"
[workspace.lints.clippy]
+cognitive_complexity = "deny"
+too_many_arguments = "deny"
+wildcard_imports = "deny"
dbg_macro = "warn"
todo = "warn"
# `"*"` matches every non-workspace package: crates.io dependencies and the
-# vendored crates under `vendor/` (excluded from the workspace). Our own crates
-# keep full debug info. Dependencies keep only line tables, so backtraces still
-# show file and line, but debug info for the whole dependency graph stays out of
-# every test binary (each UI test binary had a 500-600 MB PDB on Windows, #454).
+# vendored crates under `vendor/` (excluded from the workspace). Dependencies
+# carry no debug info and our own crates only line tables: backtraces and
+# panics still show file and line, while test binaries and the target dir stay
+# small (each UI test binary had a 500-600 MB PDB on Windows, #454).
[profile.dev.package."*"]
opt-level = 2
-debug = "line-tables-only"
+debug = false
[profile.dev]
opt-level = 1
+debug = "line-tables-only"
[profile.low-disk]
inherits = "dev"
-debug = "line-tables-only"
incremental = false
[profile.release]
@@ -112,8 +129,29 @@ codegen-units = 1
hayro-interpret = { path = "vendor/hayro-interpret" }
hayro = { path = "vendor/hayro" }
hayro-syntax = { path = "vendor/hayro-syntax" }
+hayro-cmap = { path = "vendor/hayro-cmap" }
+hayro-postscript = { path = "vendor/hayro-postscript" }
hayro-jbig2 = { path = "vendor/hayro-jbig2" }
lopdf = { path = "vendor/lopdf" }
-winit = { path = "vendor/winit" }
-egui-wgpu = { path = "vendor/egui-wgpu" }
vello_common = { path = "vendor/vello_common" }
+vello_cpu = { path = "vendor/vello_cpu" }
+# The egui and winit crates below are pinned to immutable storytold git revisions (egui
+# 6d518fc799681571963842f95b55a6b68431b15a, winit a517b3846ee8457d84ee4975110bfda6797c44dc).
+# Only the retained `vendor/winit` and `vendor/egui-wgpu` copies are provenance for the app's
+# earlier patches and their source guards, not path patches; the other `vendor/` entries above
+# remain real path patches. See vendor/README.md and the migration plan.
+ecolor = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
+emath = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
+epaint = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
+epaint_default_fonts = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
+egui = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
+egui-winit = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
+egui-wgpu = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
+egui_glow = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
+egui_extras = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
+egui_kittest = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
+egui_system_font_provider = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
+eframe = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
+egui_demo_lib = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
+egui_inspection = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
+winit = { git = "https://github.com/storytold/winit.git", rev = "a517b3846ee8457d84ee4975110bfda6797c44dc" }
diff --git a/NOTICE b/NOTICE
index 8cacb0b585..63a8fe672c 100644
--- a/NOTICE
+++ b/NOTICE
@@ -58,3 +58,21 @@ dependencies change (plan/adr/0002, 0006).
Vendored (vendor/, patched; see vendor/README.md):
- hayro 0.7.1 — Apache-2.0 OR MIT, Copyright the hayro authors (licence files kept in vendor/hayro).
- hayro-interpret 0.7.0 — Apache-2.0 OR MIT, Copyright the hayro authors (LICENSE-APACHE, LICENSE-MIT kept in vendor/hayro-interpret).
+
+Bundled egui font icons
+
+The pinned egui dependency includes egui-icons.ttf, a small subset of John Slegers'
+emoji-icon-font, through default_fonts. The parent emoji-icon-font.ttf face is
+included through monochrome_emoji_fonts. Their upstream project is
+https://github.com/jslegers/emoji-icon-font; both use the same exact copyright
+and permission notice below.
+
+MIT License
+
+Copyright (c) 2014 John Slegers
+
+Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
diff --git a/README.md b/README.md
index 1ea7f4f692..ce9e71d4a9 100644
--- a/README.md
+++ b/README.md
@@ -8,6 +8,10 @@
+
+
+
+
PdfCraft
@@ -17,10 +21,13 @@
-
-
-
-
+
+
+
+
+
+
+
@@ -139,6 +146,8 @@ Right-click a PDF tab to close it, reopen the last closed file-backed tab, or re
Bookmarks, page thumbnails and the document's own page labels (i, ii, 1, 2…) keep you oriented in long documents. Search bookmark titles in the Bookmarks panel to find nested entries even when their parents are collapsed. Matches keep their ancestors for context; Clear restores the unfiltered tree without changing its expansion state.
+In **Inspector > Pages**, use **Thumbnail size** and the smaller/larger buttons to show more pages or inspect larger previews (50–150%). This changes only the thumbnails, keeping multi-page selection and the current page in view. The size belongs to the open document view and survives reloading its file; it is not remembered after restarting. Agents can set it with `ui.set {"key":"thumbnail-zoom","value":"75"}` and read `active.thumbnail_zoom` from `ui.state`.
+
@@ -325,15 +334,13 @@ pdfcraft-cli ui --control ~/.pdfcraft-control.json inspect focused=true
pdfcraft-cli ui --control ~/.pdfcraft-control.json quit
```
-`ui.dock` arranges the `tools` and `inspector` panels; `canvas` is the protected document view.
-Each per-panel request requires a `panel` ID and an `operation`: `open`, `close`,
-`activate`, `float` with `rect: [x, y, width, height]`, or `move` with `target` and
-`zone: center|left|right|top|bottom`. For example,
-`{"operation":"float","panel":"tools","rect":[100,100,320,440]}` floats Tools.
-`ui.dock.reset` restores the default
-arrangement. The control channel accepts these methods directly; `ui.command` also accepts
-`{"id":"ui.dock","params":{...}}`. Saved preferences retain groups, active tabs, visibility
-and floating geometry. Invalid requests return an error without changing the document or layout.
+`ui.panel` opens, closes or sizes a side panel: `{"panel":"tools"|"side","open":true|false,"width":320}`.
+`tools` is the Tools panel and `side` the panel the rail opens (reopening it shows the one open last).
+`width` is clamped to the panel's limits (Tools 240–520, side panel 260–520 points). `ui.dock.reset`
+resets the side panels: Tools open on the left, default widths. The control channel accepts these
+methods directly; `ui.command` also accepts `{"id":"ui.panel","params":{...}}`. Saved preferences keep
+the Tools side, the open panels and their widths. Invalid requests return an error without changing
+the document or the panels.
`resize` requests a native inner-window size in logical points (each dimension must be between 320 and 8192); the window manager may constrain it. `state` reports the observed `window.width`, `window.height` and `window.pixels_per_point`. `focus` accepts exactly one widget `id` or unique enabled, focusable `label`; subsequent `key` requests traverse or activate the actual widgets. `inspect` includes focusability and accepts a `focused` filter. `quit` takes no parameters: clean documents exit normally, while unsaved edits or typing open the existing Save/Don't save/Cancel prompt and return `quitting: false, needs_confirmation: true`. It never silently discards work. Window resizing and application quit are native-only.
@@ -454,11 +461,13 @@ The portable zip runs from any folder, a USB stick included. Its `portable.txt`
logs and crash recovery in a `PdfCraftData` folder next to `pdfcraft.exe`, so nothing is written to
`%APPDATA%`; delete that file to use the normal per-user folders.
-The MSI installs for all users and requires administrator privileges. It adds "Edit with PdfCraft"
+The MSI installs for your account only by default, without administrator privileges. Choose
+"Everyone on this computer" on its first page, or pass `ALLUSERS=1` from an elevated terminal, to install
+for all users. It adds "Edit with PdfCraft"
to the right-click menu of PDF files and "Create PDF with PdfCraft…" to the right-click menu of
images (.png .jpg .jpeg .tif .tiff .gif .bmp .jp2 .j2k .jpx) in File Explorer (on Windows 11, under
"Show more options"), without changing which app opens them by default. For unattended deployment
-without a desktop shortcut, run from an elevated terminal:
+without a desktop shortcut (add `ALLUSERS=1`, from an elevated terminal, for an all-users install):
```powershell
msiexec /i "pdfcraft--windows-x64.msi" /qn /norestart INSTALLDESKTOPSHORTCUT=0
@@ -466,7 +475,7 @@ msiexec /i "pdfcraft--windows-x64.msi" /qn /norestart INSTALLDESKTOPSHORTCU
| Property | `=0` leaves out |
|---|---|
-| `INSTALLDESKTOPSHORTCUT` | the all-users desktop shortcut |
+| `INSTALLDESKTOPSHORTCUT` | the desktop shortcut |
| `INSTALLPDFCONTEXTMENU` | "Edit with PdfCraft" on PDF files |
| `INSTALLIMAGECONTEXTMENU` | "Create PDF with PdfCraft…" on images |
@@ -588,13 +597,4 @@ Forks and modified versions must remove them.
Made by the ArtCraft team and community.
-Tools and Inspector panels can be dragged into tab groups, split beside the PDF, or floated within the app window. Read and full-screen modes keep their existing focused document view. Drag a panel header to a target edge or another header. Its context menu offers Float panel, Move to group, and Close panel. Existing panel toggles reopen panels at their saved location; Reset Panel Layout restores the app layout. Floating panels remain inside the current app window.
-
-Advanced `ui.dock` operations use the same validated transaction as pointer gestures:
-`moveFloating` updates `rect`, `resizeSplit` accepts a boolean child `path` and
-`size` (`{"Ratio":0.5}`, `{"FixedFirst":280}`, or `{"FixedSecond":280}`),
-`setStackOpen` takes `open`, and `resizeStack` takes a point `height` or null.
-A center `move` can include `before` (panel ID or null to append) for tab order.
-The equivalent `{"action": ...}` form accepts a serialized shared docking action;
-this is also the form emitted by the renderer. Protected-canvas and app-specific panel
-rules apply equally to both forms.
+The Tools panel sits on the left, or on the right with Preferences ▸ Tools panel side. The rail stays at the right edge and opens one side panel beside it. Drag a panel's inner edge to resize it; widths are remembered. F4 shows or hides the side panel and Shift+F4 the Tools panel (also in the View menu); View ▸ Reset side panels restores the default arrangement.
diff --git a/ROADMAP.md b/ROADMAP.md
index 5e3d79b40d..00622a79b7 100644
--- a/ROADMAP.md
+++ b/ROADMAP.md
@@ -1,7 +1,39 @@
# PdfCraft roadmap
+> **Last reviewed:** 2026-10-31 | **Last updated:** 2026-10-31 | **Change:** patch (M2, PR #4172: GBK rasters compared with explicit Unicode and independently selected installed faces; wrong-glyph and wrong-family negative controls fail; estimates unchanged)
+> **Last reviewed:** 2026-10-31 | **Last updated:** 2026-10-31 | **Change:** patch (M2, PR #4172: integrate main fa5f9f8c; retain CJK viewer and upstream raster regressions, restore explicit display_config imports; estimates unchanged)
+> **Last reviewed:** 2026-10-28 · **Last updated:** 2026-10-28 · **Change:** patch (M2, PR #4172: integrate main 5af10276; retain both roadmap histories and viewer-only CJK font policy; estimates unchanged)
+> **Last reviewed:** 2026-10-27 · **Last updated:** 2026-10-27 · **Change:** patch (M2, PR #4172: integrate main 1dd2737b; preserve viewer-only CJK fallback and regression coverage across engine, renderer and Combine module splits; estimates unchanged)
+> **Last reviewed:** 2026-10-27 · **Last updated:** 2026-10-27 · **Change:** patch (M2, PR #4172: integrate main 9a9813bf and preserve viewer-only CJK fallback through the engine module split; estimates unchanged)
+> **Last reviewed:** 2026-10-26 · **Last updated:** 2026-10-26 · **Change:** patch (M2, PR #4172: document installed-font resolution helpers and regression contracts; behavior and estimates unchanged)
+> **Last reviewed:** 2026-10-26 · **Last updated:** 2026-10-26 · **Change:** patch (M2, PR #4172: print preview excludes installed CJK fonts and isolates its font cache from the viewer; estimates unchanged)
+> **Last reviewed:** 2026-10-26 · **Last updated:** 2026-10-26 · **Change:** patch (M2, PR #4172: reconcile current main while retaining display-only CJK fallback and upstream Japanese font selection; estimates unchanged)
+> **Last reviewed:** 2026-10-23 · **Last updated:** 2026-10-23 · **Change:** minor (M2, PR #4172: extend on-screen CJK fallback to Combine thumbnails and figure previews; recognise FangSong and subset-prefixed Founder names; estimates unchanged)
+> **Last reviewed:** 2026-10-15 · **Last updated:** 2026-10-15 · **Change:** minor (typed small Edit font sizes and Fill & Sign text/date colour, #2444/#2496; estimates unchanged)
+
**Stage: alpha** · next: beta, ~25% (ready for real work 50% → 75%) and ~400–700 h away
+> **Last reviewed:** 2026-10-29 | **Last updated:** 2026-10-29 | **Change:** minor (M7 #2517: a narrowed then widened paragraph keeps the typed spaces the wrap consumed, after save and reopen; estimates unchanged)
+> **Last reviewed:** 2026-10-27 | **Last updated:** 2026-10-27 | **Change:** minor (M10: OCR model downloads use verified ONNX pairs with legacy discovery fallback; estimates unchanged)
+> **Last reviewed:** 2026-10-25 | **Last updated:** 2026-10-25 | **Change:** minor (M14 localization current state: Arabic logical catalog, explicit RTL label direction and mirrored stock chrome; native/browser RTL workflows unqualified. Native release Hans face via LXGW WenKai Lite; estimates unchanged)
+> **Last reviewed:** 2026-10-23 · **Last updated:** 2026-10-23 · **Change:** minor (M7, #2636 partial: pasting added text boxes and images in the engine and through `content_copy`; estimates unchanged)
+> **Last reviewed:** 2026-10-23 · **Last updated:** 2026-10-23 · **Change:** minor (M11: image optimization accounts for page-local UserUnit; estimates unchanged)
+> **Last reviewed:** 2026-10-23 | **Last updated:** 2026-10-23 | **Change:** minor (M10: Word table cells use the widths of their own grid columns; estimates unchanged)
+> **Last reviewed:** 2026-10-23 | **Last updated:** 2026-10-23 | **Change:** minor (M10: table spans retain text from covered slots in Office exports; estimates unchanged)
+> **Last reviewed:** 2026-10-23 · **Last updated:** 2026-10-23 · **Change:** minor (M10: image export preserves soft-mask Decode opacity ranges; estimates unchanged)
+> **Last reviewed:** 2026-10-23 · **Last updated:** 2026-10-23 · **Change:** minor (M10: vector printing preserves existing page XObjects when adding annotation appearances; estimates unchanged)
+> **Last reviewed:** 2026-10-21 · **Last updated:** 2026-10-21 · **Change:** minor (M3, #3808: the Pages inspector has an independent 50–150% thumbnail size slider and buttons; selection and current-page following are retained, with headless ui.set coverage. Estimates unchanged.)
+> **Last reviewed:** 2026-10-20 | **Last updated:** 2026-10-20 | **Change:** minor (M11 #3507: image merging preserves content stream boundaries; estimates unchanged)
+> **Last reviewed:** 2026-10-20 | **Last updated:** 2026-10-20 | **Change:** minor (M10, #3532 partial: vector printing retains existing appearances with absent Subtype; estimates unchanged)
+> **Last reviewed:** 2026-10-17 · **Last updated:** 2026-10-17 · **Change:** minor (M8, gap 6 #2662: the redaction audit counts a cover over one word of a long line; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS)
+> **Last reviewed:** 2026-10-20 · **Last updated:** 2026-10-20 · **Change:** minor (M12 #3673: Character encoding accepts Identity-H/V with Adobe CJK collections; estimates unchanged)
+> **Last reviewed:** 2026-10-20 · **Last updated:** 2026-10-20 · **Change:** minor (M2, #3601: missing spacing glyphs in non-embedded Japanese CID substitutes draw as blanks while PDF widths and Unicode stay unchanged; estimates unchanged)
+> **Last reviewed:** 2026-10-20 · **Last updated:** 2026-10-20 · **Change:** minor (M3, #3541/#3620: comment hover follows individual marked lines and original annotation paint order; estimates unchanged)
+> **Last reviewed:** 2026-10-20 - **Last updated:** 2026-10-20 - **Change:** minor (M6, #3502: form detection uses the existing content-stream boundary policy; estimates unchanged)
+> **Last reviewed:** 2026-10-20 | **Last updated:** 2026-10-20 | **Change:** minor (M2, #3574: named form properties retain page origin and own dictionary boundaries; estimates unchanged)
+> **Last reviewed:** 2026-10-20 · **Last updated:** 2026-10-20 · **Change:** minor (M4: initial-view options resolve indirect boolean preferences; estimates unchanged)
+> **Last reviewed:** 2026-10-16; **Last updated:** 2026-10-16; **Change:** minor (M5 #2669, item 5: reply groups and page root counts per frame; estimates unchanged)
+> **Last reviewed:** 2026-10-18 · **Last updated:** 2026-10-18 · **Change:** minor (M7 #2517: a partial rewrite keeps its marker around a kept row, and off the next marked region; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS)
> **Last reviewed:** 2026-10-15 · **Last updated:** 2026-10-15 · **Change:** minor (M7: the Edit text caret is as tall as the row it is on; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS)
> **Last reviewed:** 2026-10-15 · **Last updated:** 2026-10-15 · **Change:** minor (M6: CSV tab-containing headers; estimates unchanged)
> **Last reviewed:** 2026-10-18 · **Last updated:** 2026-10-18 · **Change:** minor (M3, #3149: discard render demand after page geometry changes and check the GPU texture limit before upload; estimates unchanged)
@@ -15,10 +47,12 @@
> **Last reviewed:** 2026-10-13 · **Last updated:** 2026-10-13 · **Change:** minor (M6: preserve XML/XFDF attribute whitespace; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS)
> **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-14 · **Change:** minor (M1: raw-deflate fallback preserves decoding-limit errors; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS)
> **Last reviewed:** 2026-10-16 | **Last updated:** 2026-10-16 | **Change:** minor (M10 #1201 partial: lossless image Decode; estimates unchanged) | **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS)
+> **Last reviewed:** 2026-10-15 · **Last updated:** 2026-10-16 · **Change:** minor (M14: Czech catalog completed and reviewed; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS)
> **Last reviewed:** 2026-10-13 · **Last updated:** 2026-10-14 · **Change:** minor (M14: the Italian catalog has every entry the other catalogs had and the code looks up; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS)
> **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-14 · **Change:** minor (M3 #2080 candidate: explicit search jumps join per-tab history; rail and control-channel Back/Forward; estimates unchanged)
> **Last reviewed:** 2026-10-13 · **Last updated:** 2026-10-13 · **Change:** minor (M4 #1910: Organize Pages opens on the current page; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS)
> **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-14 · **Change:** minor (M3 candidate: coordinate control clicks keep press/release together on slow frames and preserve text multi-click selection; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS)
+> **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-14 · **Change:** minor (M10 #2156 candidate: remember selected print paper by canonical name; estimates unchanged)
> **Last reviewed:** 2026-10-13 · **Last updated:** 2026-10-13 · **Change:** minor (M3 candidate: native opens retain chosen tools through password retries and defer startup intake while preparation is pending; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS)
> **Last reviewed:** 2026-10-12 · **Last updated:** 2026-10-12 · **Change:** minor (M4 #1310 #1311 #1312: undo, revert and the next save keep the last saved revision; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS)
> **Last reviewed:** 2026-10-13 · **Last updated:** 2026-10-13 · **Change:** minor (M3 #1980: exact quit modifiers preserve Ctrl+Alt+Q email input; M3 candidate: native opens retain chosen tools through password retries and defer startup intake while preparation is pending; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS)
@@ -40,6 +74,14 @@
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (architectural paper controls from #599; localized shortcut labels, #496; readiness table with hours per audience; full number as weighted sum 49%; earlier: second look: ready 40% → 47%, mainstream 55% added; core-workflow gate applied → alpha) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS)
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (closed issues synced: #774, #756 and the #799–#821 batch; earlier: localized shortcut labels, #496; readiness table with hours per audience; full number as weighted sum 49%; earlier: second look: ready 40% → 47%, mainstream 55% added; core-workflow gate applied → alpha) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS)
> **Last reviewed:** 2026-10-18 - **Last updated:** 2026-10-18 - **Change:** minor (M6 #2892: Push-button normal labels are editable per widget through Options and form_set_props, independently of field names; rendering, undo/redo and incremental/full save/reopen are covered. Estimates unchanged)
+> **Last reviewed:** 2026-10-18 - **Last updated:** 2026-10-18 - **Change:** minor (M12 #2589: Active XFA skips PDF-container fallback scripts while retaining requested messages and saved scripts; ordinary/static/unlaid-out documents and explicit console/field calls keep their existing behavior. Estimates unchanged)
+> **Last reviewed:** 2026-10-18 - **Last updated:** 2026-10-18 - **Change:** minor (M7 #2592: Styled fallback fonts batch up to 240 glyphs per actual face and retain source styles. Budget refusal and distinct same-BaseFont descriptor weights have mutation-checked regressions; coverage and complex mixed runs remain limited. Estimates unchanged)
+> **Last reviewed:** 2026-10-18 - **Last updated:** 2026-10-18 - **Change:** minor (M7 #3187: Added-image automation normalizes extreme signed rotation angles before combining them with the current orientation; integer bounds, history, pixels, save/reopen and MCP have regressions. Estimates unchanged)
+> **Last reviewed:** 2026-10-18 - **Last updated:** 2026-10-18 - **Change:** minor (M12 #2569: Growable multiline XFA text reflows after edits, with undo/redo, save/reopen and print coverage; oversized fields still become read-only page text. Structured-data synchronization warnings retain accepted whitespace without relayout from stale data. Estimates unchanged)
+> **Last reviewed:** 2026-10-18 - **Last updated:** 2026-10-18 - **Change:** minor (M12 #2572: Every repeated page-master button resolves its own field to the shared XFA click script, including print buttons. Estimates unchanged)
+> **Last reviewed:** 2026-10-18 - **Last updated:** 2026-10-18 - **Change:** minor (M12 #2570: Dynamic XFA rich-text fields carry exData/maxLength into AcroForm MaxLen for reported and enforced Unicode character limits, retained after save/reopen. Estimates unchanged)
+> **Last reviewed:** 2026-10-18 - **Last updated:** 2026-10-18 - **Change:** minor (M4 #3309: bounded iterative named-destination pruning on page deletion; estimates unchanged)
+> **Last reviewed:** 2026-10-18 - **Last updated:** 2026-10-18 - **Change:** minor (M1 #2658: macOS atomic saves retain destination extended attributes, including Finder tags, and reject partial resource-fork reads after size changes. Metadata failures retain the original PDF and unsaved edit. Estimates unchanged)
PdfCraft's one-page summary: how close it is to Adobe Acrobat Pro's offline feature set, and what
comes next. Adobe's cloud services (Document Cloud, Acrobat Sign requests, shared review, the AI
@@ -66,8 +108,8 @@ and add a line to the [progress log](#progress-log); when a gap closes, remove i
| | Value | Kind |
|---|---|---|
-| **Feature breadth** | **≈ 67%** tier-weighted (56.9% unweighted: 423 shipped + 74 partial of 808; P0 94.2%) | measured, `parity/acrobat-features.toml` |
-| Feature breadth, mainstream practitioner | ≈ 70% tier-weighted (63.2% unweighted; P0 95.3%) | measured ([split](docs/target-app-parity.md#mainstream-practitioner-vs-full-acrobat-pro)) |
+| **Feature breadth** | **≈ 67%** tier-weighted (57.0% unweighted: 423 shipped + 75 partial of 808; P0 94.2%) | measured, `parity/acrobat-features.toml` |
+| Feature breadth, mainstream practitioner | ≈ 70% tier-weighted (63.3% unweighted; P0 95.3%) | measured ([split](docs/target-app-parity.md#mainstream-practitioner-vs-full-acrobat-pro)) |
| **Ready for real work**, full Acrobat Pro | **≈ 50%** (45–53%) | estimated, weighted sum over [dimensions](#by-dimension) |
| **Ready for real work**, mainstream practitioner | **≈ 55%** (50–60%) | estimated ([split](docs/target-app-parity.md#mainstream-practitioner-vs-full-acrobat-pro)) |
| **Ready for real work**, essentials user | **≈ 66%** (60–70%) | estimated ([method](docs/target-app-parity.md#essentials-user)) |
@@ -122,7 +164,7 @@ Hours are calibrated as in [Effort and calibration](docs/target-app-parity.md#ef
| H Digital signatures | 62.2% | 45% | 40–70 |
| I Scan and OCR | 23.1% | 15% | 40–75 |
| J Create and export | 39.7% | 25% | 55–100 |
-| K Optimize and standards | 34.5% | 20% | 65–115 |
+| K Optimize and standards | 35.3% | 20% | 65–115 |
| L Print | 39.7% | 35% | 30–55 |
| M Accessibility | 63.1% | 30% | 40–70 |
| N Automation, preferences, misc | 30.0% | 35% | 25–45 |
@@ -131,18 +173,18 @@ What's strong and missing in each: [target-app-parity.md](docs/target-app-parity
## Languages
-Twelve key languages (historical % snapshot vs 2,143 keys, except Vietnamese vs the current
-union). Detail and script notes: [localization-parity.md](docs/localization-parity.md).
+Twelve key languages (historical % snapshot vs 2,143 keys, except Vietnamese, Brazilian
+Portuguese and Spanish vs the current union). Detail and script notes: [localization-parity.md](docs/localization-parity.md).
| Language | Code | Strings | Status |
|---|---|---|---|
| English | `en` | 100% | full |
-| Simplified Chinese | `zh-hans` | 97.4% | partial (no Hans UI face in releases, #826) |
-| Spanish | `es` | 95.5% | partial |
+| Simplified Chinese | `zh-hans` | 97.4% | partial (native LXGW WenKai Lite; no Hans face in the web build, #826) |
+| Spanish | `es` | 100% of current union | partial (dialogs / tooltips; no help) |
| Hindi | `hi` | 0% | none |
-| Arabic | `ar` | 100% | partial (layout not mirrored) |
+| Arabic | `ar` | 100% | partial (logical catalog, explicit RTL labels, mirrored stock chrome; native/browser/IME workflows unqualified) |
| French | `fr` | 99.3% | partial |
-| Portuguese (Brazil) | `pt-br` | 99.4% | partial |
+| Portuguese (Brazil) | `pt-br` | 100% of current union | partial |
| Indonesian | `id` | 0% | none |
| Japanese | `ja` | 99.1% | partial |
| German | `de` | 99.3% | partial |
@@ -150,8 +192,8 @@ union). Detail and script notes: [localization-parity.md](docs/localization-pari
| Vietnamese | `vi` | 100% of current union | partial (dialogs / tooltips; no help) |
Also shipped: Traditional Chinese, Russian, Bulgarian, Telugu, Hungarian, Ukrainian, Italian,
-Kazakh, Polish, Slovenian, Greek, Turkish, Lithuanian and Czech (menus only): **14 more catalogs**
-(23 interface languages including English).
+Kazakh, Polish, Slovenian, Greek, Turkish, Lithuanian, Romanian and Czech: **15 more catalogs**
+(24 interface languages including English).
## Upcoming
@@ -160,7 +202,7 @@ Ranked; detail and the full milestone table in [docs/roadmap.md](docs/roadmap.md
| Rank | Milestone | Estimate (h) |
|---|---|---|
| 0 | Convert and share: Create PDF from Office, Word export that keeps layout (#773) | 32–55 |
-| 1 | Backlog and platform: crash and wrong-result reports, Chinese UI face (#826) | 50–90 |
+| 1 | Backlog and platform: crash and wrong-result reports, Chinese web UI face (#826) | 50–90 |
| 2 | Fidelity harness against Acrobat and the renderer decision (M2; owner decision on #841) | 110–190 |
| 3 | Editing existing content (M7) | 100–180 |
| 4 | OCR languages and Office export/import (M10) | 95–175 |
@@ -172,10 +214,121 @@ Ranked; detail and the full milestone table in [docs/roadmap.md](docs/roadmap.md
## Progress log
- **2026-10-18 (M3, #1354, local candidate):** Integrated --two-paged with current main, retaining upstream modal/picker ownership and single-tab reuse. Native launch/opening/password regressions, negative checks against the old wrapper, visual inspection, formatting, strict scoped Clippy, layers, assets, parity and scoped WASM compilation pass. Three existing socket tests are unavailable in this workspace; one existing network test remains ignored. No push or GitHub merge; estimates unchanged.
+- **2026-10-31 (M2, PR #4172 review):** Added same-machine pixel oracles for GBK Song, Hei, Kai and FangSong, including all four subset-prefixed Founder aliases. Reference PDFs use explicit Unicode mappings and independently selected installed faces; font data and rasters remain in memory. All eight cases ran; wrong-glyph and wrong-family production mutations each failed the new assertion and were restored. Validation: 422 renderer unit tests, renderer all-target Clippy with -D warnings, formatting, layers/assets/parity and renderer WASM checks passed; existing optional-patch/WASM warnings and parity notes remain. Estimates unchanged.
+
+- **2026-10-31 (M2, PR #4172):** Integrated main fa5f9f8c, retaining both roadmap histories and raster regression groups. Restored display_config and thumbnail_config imports after the upstream explicit-import migration. Validation: 422 renderer unit tests, engine/UI all-target checks, scoped all-target Clippy with -D warnings, formatting, layers, assets, parity and renderer/engine WASM checks passed. Existing optional-patch/WASM warnings and parity notes remain; no full engine/UI runtime test rerun. Display-only font policy and estimates unchanged.
+
+- **2026-10-28 (M2, PR #4172):** Integrated main 5af10276, preserving both roadmap histories and reviewing auto-merged export tests and accessibility UI changes. Validation: 488 scoped tests passed (18 engine, 412 renderer unit, 43 renderer integration, 15 Combine; one existing renderer integration test ignored); scoped all-target Clippy with -D warnings, formatting, layers, assets, parity and renderer/engine WASM checks passed. Viewer-only CJK fallback and print/output font policy remain unchanged; estimates unchanged.
+
+- **2026-10-27 (M2, PR #4172 module-split conflicts):** Integrated main 1dd2737b, retaining viewer-only CJK fallback in split session modules and Combine thumbnail workers, and moving renderer regressions to the new test module. Validation: 798 scoped tests passed (371 engine, 412 renderer, 15 Combine; two existing engine tests ignored); scoped all-target Clippy with -D warnings, formatting, layers, assets, parity and renderer/engine WASM checks passed. Estimates unchanged.
+
+- **2026-10-27 (M10, OCR models):** Model staging downloads and verifies the ONNX pair after the upstream RTEN URL brownout; runtime discovery prefers complete ONNX pairs per directory, retains complete RTEN fallback and preserves directory precedence.
+
+- **2026-10-26 (M2, PR #4172):** Reconciled current main, preserving its Japanese serif/weight selection and both branches' progress records. Installed CJK viewer and preview fallback remains display-only; estimates unchanged.
+
+- **2026-10-29 (M7, #2517):** Widening a paragraph rejoins a wrap with the number of spaces that wrap consumed, including a typed run and none for a Chinese or Japanese word split. The count survives an incremental save and a full rewrite, and a width-only `text_edit` uses it. Estimates unchanged.
+- **2026-10-26 (M11, JBIG2 symbols):** Lossless JBIG2 now stores each distinct mark of a black-and-white image once, as a symbol, and places its exact copies, when that is smaller than the generic region: on six pages of rendered 300 dpi text 28% of the generic region's size and 17% of Group 4's, and the same bits back through two independent decoders (hayro-jbig2 and Poppler). Scans, whose letters are seldom identical, gain little until refinement coding; a dictionary shared between pages (`/JBIG2Globals`) is next. Estimates unchanged.
+- **2026-10-25 (M5, comment replies):** One reply can be deleted from the Comments panel without its thread. On the selected card each reply has its own "…" menu (Edit, Copy text, Delete); Delete takes that reply with its pop-up and the replies to it, as `comment_delete` already did headlessly, and the comment and its other replies stay. The card stays selected, and the panel's editor stays open, on what they were on though the delete moved it in `/Annots`. Undo and Redo close the editor, since either can move what it was open on. A locked reply can't be deleted. Estimates unchanged.
+- **2026-10-24 (M10, #4158 partial):** Word, HTML, RTF and PowerPoint export keep a block that continues another on its line (an italic case name, a link, a superscript) as a run in its own style inside that paragraph, with the source's word space, instead of a paragraph of its own. Style changes that wrap onto the next line still split paragraphs (the reading blocks keep style boundaries). Estimates unchanged.
+- **2026-10-24 (M3, window move on Wayland, #3970 follow-up):** After a move or resize by PdfCraft's own title bar or edges, the button release given back to egui goes before the pointer leaving the window, so hover no longer sticks at the old place until the pointer moves. Found by CodeRabbit in review of #3970; a unit test covers it. Estimates unchanged.
+- **2026-10-24 (M14, #4253):** The annotation quick bar remembers Keep tool selected across restarts, including unpinning. Old and malformed settings keep safe defaults; the opt-in UI control channel reads and changes the same preference. Estimates unchanged.
+- **2026-10-22 (M7, #2517):** A partial rewrite of the last paragraph closes its marker after that paragraph's last `ET`, so a row left in the source stays in the paragraph. When the next paragraph shares the text object, the marker closes before that neighbour instead of after the shared `ET`. The extra marker is not added across two marked-content regions. Estimates unchanged.
+- **2026-10-15 (M7, #2517):** A blank line between paragraphs stays a paragraph break. An explicit `TL` is the measured baseline gap. Without one, three or more rows at a repeated step stay one paragraph, and a single wider gap stays a break. Adding a row, even when an earlier row is left in the source, does not absorb the next paragraph on a later edit. Estimates unchanged.
+
+- **2026-10-24 (M11, more black-and-white images):** The optimizer re-stores Group 3 fax images as Group 4 when that is smaller, instead of leaving any CCITT image it doesn't resample as it is. Black-and-white images stored as eight-bit RGB (as some phone and scanner apps write them) become one-bit grey, and indexed images that use only their first two palette entries become one bit with their palette, so Monochrome Images compresses and downsamples them too.
+- **2026-10-24 (M11, monochrome downsampling):** The PDF Optimizer and Reduce File Size average black-and-white images above 450 ppi down to 300 ppi (Monochrome Images ▸ Downsample to), Group 4 scans included, so a 600 ppi text page goes from 79 KB to 40 KB. CCITT decoding moved into `pdfcraft-filters` (`decode_ccitt`), shared by Export ▸ Images and the optimizer through `Document::decoded_image`.
+- **2026-10-24 (M11, black-and-white pictures):** A scan saved as a 1-bit PNG became an eight-bit grey image when made into a PDF, larger than the PNG; Create PDF now stores pictures of only black and white at one bit (Group 4 when smaller), the optimizer packs eight-bit black-and-white grey images to one bit unless the grey settings make them smaller, as they can a dithered scan, soft mask counted, and Export ▸ Images reads CCITT fax images.
+- **2026-10-23 (M11, JBIG2):** The PDF Optimizer can store black-and-white images as lossless JBIG2 (a generic region, written from the JBIG2 draft standard and checked against its arithmetic-coder test sequence), about a third smaller than Group 4 on 300 dpi text, and Export ▸ Images reads JBIG2 images. JBIG2 symbol coding, where its largest gains on text are, is next.
+- **2026-10-23 (M11, monochrome compression):** The PDF Optimizer and Reduce File Size store black-and-white images (scans, image masks) losslessly as CCITT Group 4 when that is smaller, with a Monochrome Images setting in the Optimizer and `doc_optimize` `mono`. JBIG2 and monochrome downsampling are next.
+- **2026-10-23 (M6, calculation checks):** The calculation order now flags a formula that reads a field the form doesn't have (a field deleted from under a sum, a typo in notation, a script's `getField`) and a script that builds the names it reads at run time. Prepare a form ▸ Calculation order marks both on the row and in a card that names the broken formulas, in place of its all-clear. `form_calculation_order` gives each field's formula, and `form_fields` shows a custom Calculate script.
+- **2026-10-23 (M7, #4280):** Edit text names each standard stand-in font after its face (`PCEdTimesBold`, ...) instead of one shared `PCEdHelv`. Rewriting a bold paragraph whose subset lacks an accent no longer repoints the paragraphs set earlier in a regular stand-in at Times-Bold, and the editor's frame stays on the paragraph being edited. Pages saved by older builds keep their shared name and are not repaired. Estimates unchanged.
+- **2026-10-23 (M8, #3205 follow-up):** Phone-pattern redaction matches complete labelled Italian geographic numbers such as `Tel. +39 0426/123456`, with synthetic matcher and mark/apply/save/reopen coverage; unlabelled international slash numbers remain unsupported.
+- **2026-10-23 (M7, #2636 partial):** A new `PasteContent` edit pastes an added text box or image copied in the same document or another, keeping its style, picture, turns, flips and crop, as one undo step; `content_copy` drives it headlessly. A paste lands beside earlier copies (18 pt steps) and stays on the page. A pasted image reuses its picture object in its own document and after the first paste into another; there a colour profile is dropped and CMYK colours stored losslessly are turned to RGB. The desktop keys and menus follow in separate PRs. Estimates unchanged.
+- **2026-10-23 (M11, optimizer resolution):** Image downsampling includes each page's `/UserUnit` scale, preserving images already below the physical resolution threshold, including shared nested forms.
+ Synthetic measurement and optimization regressions cover direct and indirect units and page-local defaults.
+ Estimates unchanged.
+- **2026-10-23 (M10, Word table widths):** Word export sizes each cell from the grid columns it spans, keeping unequal columns and cells after a merged cell consistent with the table grid.
+- **2026-10-23 (M10, table continuation text):** Office exporters retain text from table slots covered by another cell's span as paragraph text.
+ Synthetic regressions check every input fragment survives exactly once in Word, HTML, RTF and PowerPoint, while ordinary superscripts stay in their table cells.
+- **2026-10-23 (M10, image export):** Soft-mask Decode intervals preserve partial opacity in Export all images and Save Image As.
+ Synthetic regressions cover direct and indirect ranges, inverted/subrange alpha, packed masks, JPEG masks and unchanged image component Decode.
+- **2026-10-23 (M10, print resources):** Vector printing preserves original page artwork when existing XObjects use annotation resource names.
+ The regression checks two distinct appearances, inherited resources, print/reopen pixels and unchanged source data.
+ Estimates unchanged.
+- **2026-10-21 (M3, window resize and move on Wayland):** With PdfCraft's own title bar on GNOME/Wayland, a second resize by a window edge, or a second move by the empty tab strip, works straight after the first; it waited for a click inside the window, because the compositor keeps the button's release from egui. A unit test covers the release given back; checked by hand in a test build. Estimates unchanged.
+- **2026-10-24 (M5, move preview):** A comment being moved is drawn where it goes while it is dragged (a rectangle or oval showed only its blue frame): the comment is rendered alone once as the drag starts and laid over the page, which shows it where it was until the move is applied. Engine `Document::comment_layer`; synthetic engine and UI regressions. Estimates unchanged.
+- **2026-10-22 (M4, #3916 partial):** Document edits and Undo/Redo cancel an Organize page drag. Pointer-release regressions check exact page order and save/reopen.
+- **2026-10-22 (M10, #3752 paper):** CUPS jobs ask for the chosen paper in every size. The 13 sizes the Print dialog sent no paper for (A6, A0–A2, ARCH, ANSI C–E) use PWG 5101.1 names, checked against a synthetic PPD with `cupsfilter`, and `doc_print` to a printer sends the paper too. The macOS tray half (Auto Select instead of the PPD's default tray) is a craft-print change that needs a pin bump here. Estimates unchanged.
+
+- **2026-10-23 (M2, #234, continuation of #1567):** Reconciled installed CJK viewer fallback with current rendering changes; Windows preserves installed Song/Hei/Kai families and bounds font reads. GBK-EUC-H CIDFontType2 regression coverage includes text extraction; viewer fallback also survives reload with retained preferences. System fonts remain display-only. No overall readiness estimate changed.
+- **2026-10-22 (M14, macOS association recovery candidate):** Successful desktop open/save/sign can queue recovery of an existing per-file binding to the running PDFCraft bundle through NSWorkspace. Metadata-only watcher changes retain undo and avoid false overwrite prompts; real same-length/same-mtime edits remain protected. Rebased on 0.12.0 and reuses #3042's shared atomic metadata preservation; the bounded recovery parser does not narrow save limits. Prior native Preview TEST-signature and Finder workflows passed on macOS 27.0.1; other versions remain unqualified. Estimates unchanged.
+- **2026-10-13 (M7, free image rotation):** A selected page image has a rotate knob (Shift: 15 degree steps) and `image_edit` a `rotate_by` action; the angle is written as one undoable `cm`. Snapping to page edges while moving is in #1740; resize snapping is not done. Estimates unchanged.
+
+- **2026-10-20 (M4, initial view):** Window and interface options read indirect boolean ViewerPreferences values. Synthetic regressions cover true/false, direct/indirect preference dictionaries and save/reopen; malformed entries retain false defaults. Estimates unchanged.
+- **2026-10-20 (M3, #3535):** Escape with a menu open closes the menu and keeps the text selection; the next Escape clears it, as before. One keypress used to do both. A synthetic UI regression checks it. Estimates unchanged.
+- **2026-10-16 (M3, close prompt keyboard):** The "Save changes?" prompt opens with Save chosen (Enter saves, as before) and the arrow keys and Tab / Shift+Tab move the choice to Cancel or Don't save, shown with a focus ring; Enter or Space press the chosen button and Escape still cancels. Before, Enter always saved whatever was Tab-focused. Estimates unchanged.
+- **2026-10-21 (M3, #3808):** Page thumbnails in the Inspector can be resized independently from the document and Organize, with a slider, buttons and `thumbnail-zoom` control option. Selection, page following and reload are covered by regressions. Estimates unchanged.
+- **2026-10-22 (M5, comment threads):** Replies join the comment their `/IRT` references, not every comment sharing its `/NM`; PyMuPDF names each page's first comment `fitz-A0`, so all replies used to land under page 1. The Comments header counts comments without their replies, with the number of replies in small text under it (in every catalogue), and a reply's thread line runs from the avatar above it to its own. A reply to a reply shows in its thread in the panel too (it was listed nowhere), an `/IRT` that leads to no comment (itself, a loop, a pop-up, an unlisted object) leaves its annotation a comment of its own instead of hiding it (a status or checkmark that leads nowhere is listed nowhere), and a Replace Text caret isn't counted as a reply. The comment tools refuse an `id` several comments share instead of acting on the first (page 1's), and take `page` + `index` for those; `comment_list` reads a status or checkmark from the annotation it was set on, as the panel does, and reports a reply's own on the reply. UI, engine and automation regressions cover it. Estimates unchanged.
+- **2026-10-22 (M2, #3107 / PR #3438):** Inline-image parsing falls back to the existing EI heuristic when missing declared samples, headers or terminators exceed the content-window budget, instead of waiting for an impossible allocation. Exact-length parsing for available samples and ordinary large streamed images is retained. Estimates unchanged.
+- **2026-10-21 (M14, Brazilian Portuguese):** Completed the `pt-br` catalog against the current 2,674-key union, including signature validation and accessibility messages; no build or compilation run. Localization remains partial because help and unpredictable engine/OS error details are not translated.
+- **2026-10-21 (M14, #3557):** Preferences: the OK button stays below the scrolling settings instead of being the last thing in them, so it is in reach in a short window.
+
+- **2026-10-19 (M11, #2178):** Optimize checks image and soft-mask dimensions and byte counts before decoding; hostile-dimension regressions pass. Estimates unchanged.
+
+- **2026-10-20 (M11, #3507):** Image merging keeps trailing comments, split operands and graphics state across content streams. Synthetic tool tests check saved text, rendered regions and exact image rows.
+
+- **2026-10-17 (M8, gap 6, #2662):** A filled cover over a long line counts when it hides half of any one word, not only 40% of the line's words or box, so a black box over one number in a long line — or an unapplied redaction mark over it — no longer audits clean while the number is still extractable; a sliver under half of every word it touches and under 40% of the line is still not flagged
+- **2026-10-13 (M10, #1757):** Office export keeps each dotted-leader contents entry in one paragraph, with its page number, instead of inferring table cells from its separate text objects.
+- **2026-10-22 (M6, calculation scripts):** The Calculate tab and a rename read simplified field notation only from a comment, where Acrobat writes it: a string that spells `BVCALC … EVCALC` is no longer taken for the notation, or rewritten by a rename. A `/` after the condition of `if`, `while`, `for` or `with` starts a regular expression, so a rename no longer rewrites a name inside one, and a quote inside one no longer hides the `getField` calls after it. Both found in review of #3550. A `//` comment ends at `\r`, U+2028 or U+2029 as well as `\n`, so in a script with `\r` line endings the code after one is no longer hidden from a rename, or taken for the notation. Estimates unchanged.
+- **2026-10-20 (M6, calculation order page):** Prepare a form ▸ Calculation order, a page of its own, lists the calculated fields in the order they calculate with their formulas and values, moved with Up/Down arrows (as Acrobat's Set Field Calculation Order), Alt+↑/↓ or by dragging. A card says what is wrong with the order and holds Fix order; a loop has a card of its own that opens one of its formulas. On the page, each calculated field shows its step, and arrows show what the field in focus reads and what reads it. Estimates unchanged.
+- **2026-10-20 (M6, calculation order):** The order a form's fields calculate in (`/CO`) is read, checked, fixed and set headlessly with `form_calculation_order`: it says which fields read one calculated later (they show stale values), which read each other in a loop, and which the PDF's order leaves out; Fix order moves only what must move, in one undo step, and the form recalculates. Editing a formula no longer moves its field to the end of the order, and a field the order lists twice calculates once. Estimates unchanged.
+- **2026-10-20 (M2, #3574):** Forms without Resources use page Properties for ActualText and layer visibility. Synthetic tests cover boundaries, nested/shared forms, and save/reopen. Estimates unchanged.
+- **2026-10-20 (M2, PR #3348 review):** Tracking samples retain short repeated punctuation (up to four glyphs), keeping tracked ellipses intact and searchable while longer TOC leaders still preserve title word spaces. Synthetic regressions cover isolated and surrounded ellipses. Estimates unchanged.
+- **2026-10-20 (M2, #3601):** A missing spacing glyph in an OpenType substitute uses its ordinary-space glyph. Synthetic Japanese CID renderer tests check blank spaces, `/W` and `/DW` placement, and unchanged Unicode extraction. Font selection and embedded fonts are unchanged. Estimates unchanged.
+- **2026-10-20 (M12, #3673):** Character encoding accepts Identity-H/V fonts with Adobe CJK collections and recognizes predefined non-Identity CMaps independently. Unknown CMaps and foreign registries remain refused; synthetic checker and read-only automation regressions passed after failing before the fix. Per-glyph mapping validation and embedded-font extraction remain separate. Estimates unchanged.
+- **2026-10-19 (M6, renames keep formulas):** Renaming a field renames it in every calculation that reads it, in the same undo step: `AFSimple_Calculate` field lists, simplified field notation (the comment and the JavaScript after it) and `getField` calls naming it in one string literal; the rest of each script is kept as written. A sum used to drop the renamed field, notation read it as 0 and a script kept a stale value. Field Properties says how many formulas changed and `form_set_props` lists them as `formulas_updated`. Names built at run time and other events' scripts are not rewritten. Estimates unchanged.
+- **2026-10-19 (M6, text field table):** Prepare a form ▸ Text field table drags out an area and adds rows and columns of named text fields as one undoable edit, laid out on the page as displayed, with a live preview, per-column name patterns and widths, and refusal before anything is added when a cell would be too small or a name taken; `form_add_field_table` does the same headlessly. `form.multiple-copies` is partial: copying an existing field into a grid is still missing. Estimates unchanged.
+
+- **2026-10-16 (M7, #2516):** A paragraph number from `text_paragraphs` keeps naming the paragraph it listed after edits regroup the page: `text_edit`, `text_edit_layout` and `page_render`'s `hide_text_block` find it again by its text and box, and refuse one that was merged into a neighbour (the edited paragraph included) instead of rewriting the paragraph that took its number (`stale_paragraphs` lists the numbers gone). The inline editor's switch-paragraph click holds the same key. No stable identity across save and reopen yet (#2508).
+- **2026-10-19 (M7, #3591):** A background added to a page that already has one is painted over the old one instead of going under it and staying hidden. Behind the content, backgrounds come first, in the order added, and then behind-content watermarks, so a watermark added after a background no longer disappears. Synthetic regressions in `crates/edit` check the order. Estimates unchanged.
+- **2026-10-19 (M2, radial luminosity masks):** Added synthetic radial-fade coverage for the existing masked-gradient fix (#2132): centre, midpoint, antialiased clip and per-pixel shading reference, including previews, scales and tiles. Removing the existing native-gradient mask guard reproduces an opaque midpoint; restoring it passes. No new renderer behavior or estimate change.
+- **2026-10-20 (M6, #3502):** Form detection preserves comments across content streams and does not add fields over rectangles hidden by those comments. Synthetic tests cover visible controls, graphics state, and saved field inventories. Estimates unchanged.
+- **2026-10-20 (M6, #2273 / PR #2279):** Rebased the field-highlight default and Appearance preference onto main, retaining secured-form parity evidence and updating secured AcroForm/XFA toggle expectations. Build and test validation delegated to GitHub CI. Estimates unchanged.
+- **2026-10-20 (M5, reshape lines):** A line or arrow could only be moved after it was drawn; its length and angle stayed. A selected line, connected lines, polygon or cloud now has a handle on each end or corner: dragging one reshapes it (Shift holds it to 45°), as one undo step, with the new shape drawn while dragging. `pdfcraft_annot::set_points`, `Edit::ReshapeAnnotation`, `comment_edit points`; comment lists carry the points. Estimates unchanged.
+- **2026-10-20 (M3, #3620):** Overlapping comment hover uses the original annotation index independently of spatial inspection order. A synthetic UI regression covers different annotation upper edges. Estimates unchanged.
+
+- **2026-10-20 (M3, #3541/#3620):** Comment hover follows the individual marked lines instead of the enclosing multiline rectangle, and overlapping comments follow paint order. Synthetic UI regressions cover all four text-markup kinds, page/view rotations, neighboring highlights, legacy rectangle fallback and unchanged PDF bytes. Estimates unchanged.
+- **2026-10-20 (M10, #3532 partial):** Vector printing retains existing widget artwork when its appearance lacks Subtype. A print-owned copy adds Form dispatch metadata. The source stream and all explicit subtypes stay unchanged. Synthetic source/output raster checks cover resources, selected/shared appearances, placement, flags and content modes. Appearance regeneration and physical printer validation remain outside this fix. Estimates unchanged.
+
+- **2026-10-20 (M5, arrow keys move comments):** With a comment selected, the arrow keys move it a screen pixel and ⌘/Ctrl or Shift and an arrow ten, instead of scrolling the view or turning the page; up is up on screen at any zoom and rotation, and each press is one undo step. Text markup and locked comments stay put. Estimates unchanged.
+
+- **2026-10-19 (M14, #3373):** The main menu and its long submenus scroll in short windows, keeping their commands reachable. Estimates unchanged.
+
+- **2026-10-19 (M3, #3387):** Save-prompt shortcuts yield to later consent and utility dialogs; a synthetic UI regression checks that an edited document stays open. Estimates unchanged.
+
+- **2026-10-18 (M3, PR #3101 review):** Gap changes in Organize Pages preserve document scroll position; menu and control changes apply to both comparison panes without changing unrelated tabs. Three synthetic UI regressions were observed failing before the fixes. Readiness estimates unchanged.
+
+- **2026-10-18 (M3, PR #3101):** Optional gapless continuous page display with persisted defaults and UI control support; both new labels translated in all 22 catalogs. Rebased onto current main, preserving navigation and rendering fixes. Readiness estimates unchanged.
+
+- **2026-10-18 (M10, #3120):** Export to Word, PowerPoint, HTML and RTF reads text from form XObjects nested as deep as the renderer draws them (50 levels), and up to 50 000 forms a page (the renderer's budget for nested ones); it stopped at 12 levels and 4 096 forms and left the rest out silently. A page past those limits is listed in `doc_export_office`'s `truncated_pages`, with a warning. Estimates unchanged.
+
+- **2026-10-18 (M5, PR #1971 review):** Nested FDF annotation values that identify a source `/Page` or `/Pages` dictionary become null at every copy depth. A synthetic three-page PDF saved and reopened before export verifies that destination actions cannot pull source page content into the FDF, while unrelated nested metadata and shared references remain intact. FDF import semantics, copied-graph limits and estimates are unchanged.
+- **2026-10-15 (M5, PR #1971 refresh):** Nested FDF annotation values retain shared or cyclic copied graphs. Discovery borrows direct values and records annotation positions with lazy array owners; retained entries are budgeted before cloning. Synthetic stress fixtures include repeated aliases and saved-PDF cache eviction. Limits cover the copied graph; source parsing and page discovery retain their existing limits, with no total source-memory bound. FDF import semantics and estimates are unchanged.
+- **2026-10-13 (M5, nested FDF values):** Comment export copies and renumbers nested indirect values, retaining geometry, colours, borders and shared graphs. Graph/data limits return an error before the destination is written; synthetic saved-PDF and automation exchange regressions. Estimates unchanged.
+- **2026-10-18 (M2, #3086):** Text extraction estimates letter tracking without repeated punctuation gaps so dotted TOC leaders do not swallow word spaces. Synthetic PDF regressions cover leader lengths, phrase search and selection; strongly tracked text stays intact. Estimates unchanged.
- **2026-10-18 (M6, #2892):** Push-button normal labels are editable per widget through Options and form_set_props, independently of field names; rendering, undo/redo and incremental/full save/reopen are covered. Estimates unchanged.
- **2026-10-18 (M7, #3212):** Headers, Bates numbers, watermarks and added text stay visible after unfinished marked content, with original content preserved.
+- **2026-10-18 (M12, #2589):** Active XFA skips PDF-container fallback scripts while retaining requested messages and saved scripts; ordinary/static/unlaid-out documents and explicit console/field calls keep their existing behavior. Estimates unchanged.
+- **2026-10-18 (M7, #2592):** Styled fallback fonts batch up to 240 glyphs per actual face and retain source styles. Budget refusal and distinct same-BaseFont descriptor weights have mutation-checked regressions; coverage and complex mixed runs remain limited. Estimates unchanged.
+- **2026-10-18 (M7, #3187):** Added-image automation normalizes extreme signed rotation angles before combining them with the current orientation; integer bounds, history, pixels, save/reopen and MCP have regressions. Estimates unchanged.
+- **2026-10-18 (M12, #2569):** Growable multiline XFA text reflows after edits, with undo/redo, save/reopen and print coverage; oversized fields still become read-only page text. Structured-data synchronization warnings retain accepted whitespace without relayout from stale data. Estimates unchanged.
+- **2026-10-18 (M12, #2572):** Every repeated page-master button resolves its own field to the shared XFA click script, including print buttons. Estimates unchanged.
+- **2026-10-18 (M12, #2570):** Dynamic XFA rich-text fields carry exData/maxLength into AcroForm MaxLen for reported and enforced Unicode character limits, retained after save/reopen. Estimates unchanged.
+- **2026-10-19 (M4, #3309 review):** Page deletion keeps working with 60,000 named destinations, without a name-count traversal limit. Synthetic regressions verify every surviving target after deletion and restoration of the deleted page and its destinations by undo/redo, full/incremental save/reopen and unchanged rendering. Estimates unchanged.
+- **2026-10-18 (M4, #3309):** Deleting a page prunes named destinations without recursive calls. Cyclic and shared indirect nodes are visited once. Synthetic deep/cyclic graph, save/reopen and automation regressions cover the change. Estimates unchanged.
+- **2026-10-18 (M1, #2658):** macOS atomic saves retain destination extended attributes, including Finder tags, and reject partial resource-fork reads after size changes. Metadata failures retain the original PDF and unsaved edit. Estimates unchanged.
- **2026-10-15 (M7, Edit text caret):** In a text region that mixes sizes (a 24 pt title over 12 pt lines), the inline editor's caret on a small row was as tall as the region's first size and reached into the row below: egui draws the caret at least one row of the text field's font tall, and that font had the paragraph's first size. The field's font is now half the region's smallest size, so each row's own height decides. Kittest regression; checked in the app on a real document. Estimates unchanged.
- **2026-10-16 (M3, #2781):** The Pages panel follows the current page, as Acrobat's page thumbnails do: going to a page whose thumbnail is out of sight (scrolling, Page Down, Go to page, a bookmark) scrolls the panel just enough to show it. A thumbnail already in view, or clicked, leaves the panel where it is, and a page change while the user scrolls the panel or drags thumbnails does not move it. Estimates unchanged.
- **2026-10-16 (M12, #1645):** CLI info, text and render use the engine's dynamic XFA layout rather than placeholder pages. Synthetic two-page and encrypted-form regressions cover counts, text, pixels, copy permissions and unchanged input bytes. Estimates unchanged.
@@ -183,31 +336,52 @@ Ranked; detail and the full milestone table in [docs/roadmap.md](docs/roadmap.md
- **2026-10-18 (M4, PR #1919 review):** repeated page indices apply each rotation to the latest page-local value, preserving indirect/inherited angles. Synthetic adjacent/interleaved duplicates retain siblings and page contents through incremental and full saves. Estimates unchanged.
- **2026-10-15 (M4, #1919 refresh):** Integrated upstream `99941daa` while retaining the indirect page rotation correction and all upstream progress. Only independent documentation conflicts required resolution; focused production and test files are unchanged from the previously green CI head. Estimates unchanged.
- **2026-10-18 (M3, #3149):** Page replacement cancels render demand collected for the previous geometry; all viewer raster upload paths reject empty or oversized textures before cache eviction. Synthetic regressions reproduce the reported 1880×2350 upload at a 2048 limit, retain cached images and unsaved edits, and resume bounded backdrops and tiles. Estimates unchanged.
+- **2026-10-19 (M3, #3360):** Switching between Read and the docking workspace retains the current document’s scroll position at fixed zoom, including within-page position; separate tabs retain their own offsets. Estimates unchanged.
- **2026-10-18 (M8, #1350):** Integrated current main while preserving content-stream comment boundaries. Hidden-text cleanup retires unused named replacement descriptions on rewritten pages and forms, including inherited/shared resource holders, without removing descriptions still used by surviving content. Synthetic saved-byte and preservation regressions cover the cleanup. Estimates unchanged.
- **2026-10-17 (M4, #2997):** Dragging page thumbnails in the Organize grid with Ctrl held (Option on macOS) drops copies at the gap instead of moving the pages: a "+" badge sits by the pointer, the copies land at the gap selected, and one undo step ("Duplicate pages") takes them out again. `page_duplicate` takes `at` (1-based, where the first copy goes; left out, after the last of them) so agents can drive the same thing, and the UI control channel's `ui.drag` modifiers now reach gestures that read the input state. Estimates unchanged.
+- **2026-10-16 (M7, Add Background):** A background added behind a page that paints its own opaque white backdrop (browsers' Save as PDF, print drivers) was hidden completely, so the tool looked broken. The dialog now detects such pages and offers to replace the backdrop (on by default): the one fill becomes `n` in a per-page copy of its stream, and Remove background turns the fill back on, after edits and redactions too, unless the operators before it changed or it would no longer be painted first; then it says which pages keep it off. The copy's tag holds no reference to the original stream, which had kept text a redaction or Remove Hidden Information removed in the saved file. Scanned pages are warned about, not touched. New `doc_backdrops` tool; `doc_background` takes `replace_backdrop`. The default colour is a visible light yellow and the preview shows text over it. Acrobat's own behaviour on such pages is unobserved. Estimates unchanged.
- **2026-10-15 (M6, AcroForm XML text):** Plain AcroForm XML imports retain all direct text around comments and processing instructions, including CDATA and character references. Synthetic regressions verify exact values and unknown catalog data after incremental and full save/reopen. Nested-element and XFA interpretation are unchanged. Estimates unchanged.
- **2026-10-15 (M6, #1917 refresh):** Integrated upstream `99941daa` while retaining the XML/XFDF attribute whitespace correction and all upstream progress. Only independent documentation conflicts required resolution; focused production and test files are unchanged from the previously green CI head. Estimates unchanged.
- **2026-10-15 (M1, #2267 refresh):** Integrated upstream `99941daa` while retaining the raw-deflate fallback correction and all upstream progress. Only independent documentation conflicts required resolution; focused production and test files are unchanged from the previously green CI head. Estimates unchanged.
- **2026-10-16 (M10, #1201 partial):** Lossless image export applies each component's Decode pair. Indexed values round and clip before palette lookup. Color-key masks keep raw samples. The existing 16-bit precision limit remains. JPEG and mask Decode, ICC transforms, and calibrated colors remain outside this fix. Estimates unchanged.
- **2026-10-16 (M3, #2739):** Wrapped revision buttons stay above Binding in Document Properties. A synthetic regression checks control bounds and opens a saved revision at two viewport widths. Estimates unchanged.
+- **2026-10-19 (M7, tracked text):** Editable paragraphs and object labels distinguish repeated glyph tracking from word gaps in TJ arrays and separately positioned glyphs. Synthetic regressions cover character spacing, horizontal scaling, rotated baselines, larger word gaps, and rows of one-glyph words or cells (inferred tracking is capped at half an em, so `a b c` and digit or Y/N columns keep their spaces). Estimates unchanged.
+- **2026-10-16 (M5, #2669, item 5):** The Comments panel groups replies by name and counts all page roots once per frame. Estimates unchanged.
+- **2026-10-16 (M7, #2140):** Header & footer (and Bates numbering, which uses the same dialog) can shrink the page content so the header and footer don't cover it: the original content is scaled evenly into the band they leave free, centred across, also on rotated pages; Remove and Update take the scaling away, and margins that leave no room are refused. Dialog checkbox with a preview of the band, `shrink` on `doc_header_footer`, all catalogs. Annotations and added text and images are not moved yet. Estimates unchanged.
+- **2026-10-20 (M14, Spanish catalog gaps):** `es.tsv` gains the 143 `(context, English)` keys the other catalogs carried and it was missing, so Spanish covers the whole bundled key union like Lithuanian and Vietnamese: calendar months and weekdays, the `certificate` context, X.509 key usages, signature-revision and digital-ID lines, password validation, eight plural messages, the combine-from-folder strings, the rendering and stale-file notices and the shortcut-spelled tooltips. Counts in `localization-parity.md` re-measured against the current union (`vi` restated against it for the same reason). Status stays `partial`: no translated help, and OS details stay outside the catalog. Estimates unchanged.
+- **2026-10-16 (M14, localization):** Czech goes from menus-and-dialogs to the whole surface: 2,674 entries after adding the ~220 keys the other catalogs had -- the sentences the engine words (signature details, accessibility findings and report, PDF/A problems, pages to print, history labels), the date picker's weekday heads, the certificate viewer's fields and shared buttons. "Tag" is *tag* throughout (the Tags panel said *značky*, which is also a redaction mark), and "{n} tags" no longer reads "1 značek". Two new catalog tests. Estimates unchanged.
+- **2026-10-16 (M5, #2583, #1869):** A sticky note on a page with `/Rotate` is drawn upright, with its top-left where it was placed. Notes are saved with the NoRotate flag, and the renderer now honours it: the appearance is turned against the page's rotation about the upper-left corner of `/Rect`, which stays put (ISO 32000-2 §12.5.3; vendored hayro-interpret patch 37). Attachment icons follow the same rule. Selection, hover, the edit composer, the Comments panel's flash and the `comment_list` and `doc_info` rectangles use the drawn box, and `comment_add` anchors an icon's displayed top-left at `at`. Estimates unchanged.
+- **2026-10-16 (M11, #1201):** Optimize decodes a JPEG with its colour transform: an Adobe APP14 marker's flag, else the filter's `/ColorTransform` (ISO 32000-2 §7.4.8). An RGB JPEG stored untransformed (`/ColorTransform 0`, no marker) used to be converted from YCbCr anyway and came out in other colours, recompressed or merged from strips; it now keeps them. A JPEG whose marker and `/ColorTransform` disagree, or whose components are named R, G and B without `/ColorTransform 0`, is left as it is, since readers differ on it. Estimates unchanged.
- **2026-10-15 (M2, gap 12):** `cargo xtask text-oracle` scores reading order as well as word F1: the longest common subsequence of the two word sequences over the longer one. F1 is a bag of words and cannot see order at all -- measuring #2431, a change that moves text and nothing else, it called 26 of the 29 files whose output changed identical, where the order score separated 18 improvements from 6 regressions. Reports, does not gate; a disagreement with Poppler, not a verdict. Estimates unchanged.
- **2026-10-15 (M2, #2305 follow-up):** WinAnsiEncoding 0xA0 and 0xAD and MacRomanEncoding 0xCA extract as the ordinary space and hyphen Annex D.2 names them, in the vendored hayro tables (patch 30) as well as `crates/fonts`, so search, copy and redaction see characters that were hidden behind U+00A0 and U+00AD. Across the pdf.js corpus the soft hyphens we extract fall 117 to 13 and the non-breaking spaces 30 to 26; the rest come from `/ToUnicode`. An explicit `/Differences` name keeps its glyph list value, as pdftotext does. Estimates unchanged.
-- **2026-10-15 (M12, XFA, #2574):** A `pageArea` with no `` repeats without limit instead of taking a subform's default of one use, so a later page area with `occur max="1"` is no longer drawn (with a copy of its fields) over every body page. A page area's `occur min` is honoured too: one the body never reached gets the pages it still needs after the body (the once-only closing page of the public ISCIII form, with its button, is page 7 of 7). Acrobat Pro shows the form as 7 pages with that page last, as here; when printing, Acrobat keeps that page but leaves it blank (`relevant="-print"` on the page area), which PdfCraft does not do yet. Synthetic regression tests. Estimates unchanged.
+- **2026-10-15 (M12, XFA, #2574):** A `pageArea` with no `` repeats without limit instead of taking a subform's default of one use, so a later page area with `occur max="1"` is no longer drawn (with a copy of its fields) over every body page. A page area's `occur min` is honoured too: one the body never reached gets the pages it still needs after the body (the once-only closing page of a form, with its button, comes last). Acrobat Pro also puts that page last, and when printing keeps it but leaves it blank (`relevant="-print"` on the page area), which PdfCraft does not do yet; both re-observed on a synthetic fixture, see the 2026-10-19 line. Synthetic regression tests. Estimates unchanged.
+- **2026-10-19 (M12, XFA, #2574):** The Acrobat observations behind the line above were first made on a public real-world form, which the Privacy rule does not allow; they are replaced by ones made on a synthetic form (three `breakBefore pageArea` sections, then a page area with `occur min="1" max="1"`, with and without `relevant="-print"`). Acrobat Pro shows the closing page last in both and prints it in both, blank only with `relevant="-print"`: confirmed. The synthetic form also shows a difference not fixed yet: Acrobat opens it as 5 pages, with an empty first body page before the first section, where PdfCraft lays out 4. Two more synthetic forms narrow it down: with `breakBefore targetType="pageArea"` on the first section, with or without `startNew`, Acrobat opens an empty body page first; without a break on the first section it lays out 4, as PdfCraft does (so `startNew` is not the cause). Not fixed yet: `layout.rs` skips a break while the page is still empty. Estimates unchanged.
- **2026-10-15 (M8, #2447):** Removing links or comments (Remove Hidden Information, Sanitize) also drops the tagged-PDF structure tree's object references to them, so the full rewrite no longer keeps a removed link's URL or a comment's text. Regression seen to fail without the fix. Estimates unchanged.
- **2026-10-15 (M6, CSV tab-containing headers):** CSV export and spreadsheet merging quote tabs, and import distinguishes quoted tabs from TSV delimiters without changing field identities. Synthetic export/import and incremental/full save regressions cover header tabs, quotes and line breaks, plus ordinary TSV controls. Formula-cell policy is unchanged. Estimates unchanged.
- **2026-10-15 (M6, AcroForm XML text):** Plain AcroForm XML imports retain all direct text around comments and processing instructions, including CDATA and character references. Synthetic regressions verify exact values and unknown catalog data after incremental and full save/reopen. Nested-element and XFA interpretation are unchanged. Estimates unchanged.
- **2026-10-13 (M6, data exchange fidelity):** XML and XFDF exports preserve tabs and line breaks in field names and comment metadata using character references. Saved-PDF regressions cover distinct names that XML would normalize to the same attribute value, restored field values and replacement of the original comment identities. Estimates unchanged.
- **2026-10-15 (M12, XFA, #2574):** A `pageArea` with no `` repeats without limit instead of taking a subform's default of one use, so a later page area with `occur max="1"` is no longer drawn (with a copy of its fields) over every body page. Synthetic regression test; the page area's `occur min` (a once-only page of its own) is still not laid out, recorded in `form.xfa-dynamic`. Estimates unchanged.
+- **2026-10-14 (M12, reading colours, #2059):** Pending page backgrounds use the document's reading colour immediately, preventing white flashes while scrolling to uncached pages. A headless canvas regression covers three pending pages in every preset and a custom background. Estimates unchanged.
+- **2026-10-15 (community, M6, #2608):** In Prepare a form the selected field moves with the arrow keys: a point per press, ten with Shift, as the object nudge and dragging it do. Each press is one undo step ("Change field properties"), and the keys no longer turn the page while a field is selected. Estimates unchanged.
+- **2026-10-15 (M3, closable community card):** The ArtCraft community card on Home has a close button that hides it for the rest of the run; it returns on the next launch, and its links stay in the top bar's Discord button and the Help menu. A UI regression covers closing and the fresh-launch default. Estimates unchanged.
- **2026-10-13 (community, M3, #1569 review):** Pixel-only Wayland wheel, wheel-tilt and continuous input use standalone moved events, so Ctrl zoom does not remain latched after the modifier is released (#991); vendored winit patch (13), with a policy test. Estimates unchanged.
- **2026-10-15 (M14, #2383 candidate):** Device-limit requests now clamp unsupported maxima and minimum alignments before enabling the adapter's full 2D texture extent. This addresses the reported V3D request of 16 shader variables on an adapter supporting 15. Synthetic regressions fail before the clamp and pass afterward, along with the existing HiDPI case and Windows GPU-required application tests. Raspberry Pi startup remains unverified; milestone and overall estimates are unchanged.
- **2026-10-12 (M6):** XFDF field import and data-file extraction join all text nodes in a value, preserving text after XML comments and processing instructions. Synthetic regressions cover Unicode, CDATA, empty values and PDF save/reopen. Estimates unchanged.
- **2026-10-12 (M7, picture right-click menu):** The Edit text & images menu of a picture gains Cut Image, Redact Image (a redaction mark over the picture, applied from Redact a PDF), Create Link… and Recognize Text… (for that page), in all 21 catalogs. The menu was drawn only while the pointer was over the picture, so its items outside the picture (most of a logo's menu, and Delete below a page) could not be clicked; an open menu now stays drawn. Still missing from Acrobat's menu: Arrange (bring to front, send backward), Remove crop, Draw freehand and Edit image using… (opening an external editor).
+- **2026-10-15 (M14, Norwegian Bokmål localization):** Added Norwegian Bokmål (`nb`) as an interface and date language: a catalog covering every key the other catalogs have, Norwegian key names, month and weekday names for Fill & Sign dates, and `no`/`nn` locales resolving to it until a Nynorsk catalog exists. Written from the meaning of the English labels; terminology reviewed by a native-speaking contributor, not yet every line. Estimates unchanged.
- **2026-10-12 (M2, inline-image boundaries):** Unfiltered inline images with known sample layouts keep pixel bytes that look like the `EI` terminator when content is rewritten. Regression tests cover row padding, colour-space and parameter aliases, masks and malformed sizes; classic and packed saved PDFs retain the pixels in independent readers and renderers. Filtered and resource-defined layouts retain the existing tolerant heuristic. Estimates unchanged.
- **2026-10-12 (M3, drawing markups):** While a comment is being drawn, the pointer in a band along any edge of the view (or just past it) scrolls the view up, down, left or right, so points beyond what is shown can be reached. A polygon, cloud or run of connected lines takes back its last point with Backspace, Delete or Undo (the first point's removal cancels it). Edge scrolling has unit tests of its maths only; it has not been exercised end to end in the app by an automated test.
+- **2026-10-15 (M2, large object streams):** An object stream's index of `objnum offset` pairs is read once per stream instead of once per object fetched from it (vendored hayro-syntax patch 22), so reading every object of a stream of N objects no longer takes N² steps. A Firefox page whose 65,542 objects share one stream (pdf.js corpus `bug1978317.pdf`) drew in 37 s and extracted its text in 40 s; it now takes 0.5 s and 1.9 s, and leaves the robustness baseline's known timeouts. 40,000 pages in one stream open in 0.2 s instead of 54 s. The regression fails before the fix. Estimates unchanged.
- **2026-10-12 (gap 6, #1319, #1305, #1324):** Editing an XFA form keeps a radio item's stored text when it contains a space, so a later layout still selects it. Replace Pages on a split selection replaces those pages and leaves the gap; editing the dialog range still replaces the span. Marking a figure decorative rewrites only that figure's content stream, so a header, added text and unknown stream entries stay. Estimates unchanged.
- **2026-10-15 (M14, gap 24):** `misc.localization` and `misc.installers` move from planned to partial. Localization: 22 catalogs ship, but Chinese shows tofu in releases, Telugu has no release font and Arabic is not mirrored. Installers: macOS and Windows releases are signed, Linux packages are not. The Acrobat menu diff itself remains open. Estimates unchanged.
+- **2026-10-15 (M14, PNG export):** Export ▸ Image as PNG, `pdfcraft-cli render` and the `page_render` automation/MCP tool write an opaque page as RGB at deflate level 3 instead of RGBA at the default level 6: the files stay the same size and are written 3–5× faster (a 300-dpi scan drawn at 220 dpi: 805 → 167 ms; a text page at 220 dpi: 70 → 40 ms; an annual-report page at 150 dpi: 179 → 64 ms). A page with translucent pixels is still written as RGBA. The automation tools use the engine's encoder instead of a copy of it. Estimates unchanged.
+- **2026-10-15 (G14, Romanian localization):** Added Romanian (`ro`, Română) as an interface and date language with a 2,673-entry catalog covering every bundled key, and a CLDR plural rule (`plural_ro`: one / few / other, "20 de pagini"). Locale tags `ro` / `ro-RO` / `ro_RO.UTF-8` / `ro-MD` resolve, so Auto picks it on Romanian Windows. Coverage and plural tests added. Status `partial` like the other catalogs (no translated help).
+- **2026-10-22 (M14, JPEG and TIFF export):** Export ▸ Image as JPEG or TIFF refuses a raster that is not exactly width × height pixels, as PNG export has since #2425. A raster a pixel short panicked inside the JPEG and TIFF encoders, and a partial pixel at the end was dropped and the rest written as a valid image. Rendered pages are always the right size, so what exports is unchanged. Estimates unchanged.
- **2026-10-13 (G14, Vietnamese localization):** Added Vietnamese (`vi`, Tiếng Việt) as an interface and date language with a 2,591-entry catalog (`plural_none`) covering 100% of the current localizable key union (plus `theme`/`Appearance`). Locale tags `vi` / `vi-VN` / `vi_VN.UTF-8` resolve; calendar months use numeric `Tháng 1`…`12` / `tháng 1`…`12`. Native-speaker community review recorded. The twelve-language localization mean moves from ≈59% to ≈68%; weighted readiness ≈49% → ≈50%. Status stays `partial`: engine/OS details, document contents, filenames, CLI/MCP and external help stay outside the catalog by design.
+- **2026-10-12 (M10, Edit on a scanned page):** Turning Edit on (its tab, Edit text & images or Add text) for a scanned page (an image covering at least half of a page with no text of its own; comments and text boxes over it do not count) recognizes the page first, like Acrobat's "Converting scanned page to Editable Text and Images", so its words open for editing. Once per page per window, one undoable "Recognize text" step, with the language of the Recognize Text dialog (English only for now, gap 7). Pages with real text (a flyer with a background picture), read-only files and other tools are left alone. The photographs on a scan are still part of the one page image.
+
+- **2026-10-12 (M2, installed CJK fonts):** A CID font of an Adobe CJK collection (Japan1, GB1, CNS1, Korea1) that a PDF doesn't embed, such as the Kozuka Gothic of an Acrobat text box or the SongtiTC of a Pages export, drew nothing unless craft-fonts had a Japanese face. After craft-fonts, it now draws with a face already installed on the machine (PingFang, Heiti, Microsoft JhengHei / YaHei, Malgun and the like; read at runtime, never bundled, and used only for the on-screen viewer, never for exports, printing or OCR; Noto CJK and Source Han excluded; `PDFCRAFT_SYSTEM_FONTS=0` turns it off). The face of a collection with several regions (PingFang SC, TC) is chosen by region. Checked on macOS with a synthetic Traditional Chinese, Simplified Chinese and Korean page; the Windows and Linux font lists are untested, and a machine with none of the listed faces still draws nothing.
- **2026-10-13 (M3, typed zoom):** The right rail's zoom percentage is an editable field: type 150, 150% or 62.5 % and press Enter, Escape discards, and nonsense keeps the current zoom. The control channel's zoom option shares its parser, which now refuses zero, negative and non-finite values.
- **2026-10-13 (M4, indirect page rotation):** Rotation queries and edits resolve indirect integer values on pages and their page-tree ancestors. Turning a page already at 270° by 90° now saves it at 0°, including when its rotation is inherited. Synthetic full/incremental save-and-reopen tests cover nested inheritance, sibling preservation, negative/large angles and invalid-value fallback. Estimates unchanged.
- **2026-10-14 (M1, Flate fallback):** Raw deflate whose first bytes resemble a zlib header no longer loses a decoding-limit error during fallback. Strict and tolerant filter regressions and a COS stream regression cover refusal at the caller limit and successful decoding within it. No parity status or estimate changes.
@@ -248,6 +422,16 @@ Ranked; detail and the full milestone table in [docs/roadmap.md](docs/roadmap.md
- **2026-10-14 (M3, control-click candidate):** Coordinate clicks retain hover warmup and pair press/release in one input pass so slow frames do not turn a click into a hold. Text multi-click selection accepts the actual press event when egui has already cleared its origin. Regression cases cover slow-frame button activation, disabled controls and coalesced text selection; estimates unchanged.
- **2026-10-14 (community, M7, #128 Hebrew in Add text):** Add text and `page_add_text` draw Hebrew with the craft-fonts Hebr face (Noto Sans Hebrew), through the same shaped path as Arabic and Thai: right-to-left display order with numbers and Latin in place, points and cantillation kept on their letter as one Type 3 glyph, ToUnicode and per-line ActualText so extraction, search and the editor get typing order. Builds without the face refuse Hebrew rather than write `?`. Regular style only. Estimates unchanged.
- **2026-10-14 (M9, #1787):** Changes after signing are judged by the role an object held when signed for a page's resources too: a rewritten font, XObject, graphics state or resources dictionary is page content however its new version is labelled, so a `/FT` or `/Subtype /Widget` added by the attacker no longer reads as a permitted form fill. A category dictionary that only gained a name is judged as before.
+- **2026-10-14 (M3, full screen as presentation, #379/#1459/#1353):** Full Screen Mode shows one page (or one spread) at a time fitted to the screen on black, with no tools; click, Space, Enter and the arrows turn pages; Esc restores the display from before. Transitions, auto-advance and loop remain planned.
+- **2026-10-14 (M10, #1818):** Properties… reads a CUPS printer's driver options off the GUI thread, the dialog saying so meanwhile, and `lpoptions` is given ten seconds before it is killed and the PPD's defaults stand; a read that dies leaves the panel with nothing rather than a frozen window. The bounded wait is tested with a stand-in command that never answers.
+- **2026-10-14 (M10 #2156 candidate):** Print paper selection survives Cancel and settings restoration; canonical names avoid preset-index dependence. Other print preferences and estimates unchanged.
+
+- **2026-10-15 (community, M14, Polish signature panel):** A Polish signature panel listed its details in English: the catalog lacked every signature wording, the changes after signing they name and 34 UI literals. All 101 are translated; `polish_covers_ui_literals` and `polish_says_what_a_signature_says` keep them so. Estimates unchanged.
+- **2026-10-14 (M14, Finnish localization):** Added Finnish as another interface and date language, with a 2,674-entry catalog covering every key of the other bundled catalogs and two-form plurals (partitive after numbers other than 1). Calendar dates use partitive month names (`14. lokakuuta 2026`). Coverage tests check commands/tools, scanned UI literals and bundled catalog keys. AI-assisted; native proofreading pending.
+- **2026-10-14 (M14, Catalan localization):** Added Catalan as another interface and date language, with a 2,592-entry clean-room catalog, one/other plurals and coverage tests for commands, tools, UI literals and every key in the other catalogs. A native Catalan speaker (the contributor) has reviewed the translation. Estimates unchanged.
+
+- **2026-10-15 (M3, #1248):** A fresh install follows the desktop's appearance: the theme preference defaults to System (dark on a dark desktop, Light where the desktop says nothing); saved settings and `--theme` still win. Dark page rendering is Reading colours, and the page reading themes are #1732.
+- **2026-10-15 (M3, #1767):** Restore last view settings when reopening documents, as Acrobat's preference does: a file opened again shows the page, zoom and page display it was last shown with (remembered for the last 100 files when a tab closes or settings are saved), over the file's own initial view; on by default, Preferences ▸ Documents and view turns it off.
- **2026-10-13 (M3, native-opening candidate):** Chosen tools survive preparation, conversion and password retries; startup intake waits for pending preparation. Regression tests cover completion, cancellation and document identity. Estimates unchanged.
- **2026-10-13 (community, M3, #531 touchpad momentum):** On Wayland a two-finger swipe keeps gliding after the fingers lift and slows to a stop, measured from the gesture's last 0.1 s and decaying with a 0.35 s time constant through the #759 1:1 path. Any wheel input, click, key, zoom or edge ends it (fingers resting on the touchpad don't yet: the vendored winit binds no Wayland hold gestures); single-page paging, macOS (which sends its own momentum), the web, and Animation effects Off never glide. Unit tests for the physics and two headless view tests. Estimates unchanged.
- **2026-10-13 (M3, native-open admission):** Later prepared replies stay queued while a modal or native picker owns the foreground. Picker completion, cancellation and abandonment wake retained opens. Regressions cover recipient ownership, exactly-once admission, source/history preservation and one-shot repaint requests. Estimates unchanged.
@@ -307,6 +491,7 @@ Ranked; detail and the full milestone table in [docs/roadmap.md](docs/roadmap.md
Newest first: the date, what moved, and the new overall estimate.
+- **2026-10-28 (M10, #1595):** Export to Markdown: `doc_export_office` with a `.md` or `.markdown` path writes CommonMark with GitHub pipe tables (headings, paragraphs with bold and italic, tables with their first row as the header, a rule between pages); text that Markdown would read as markup is escaped. Images, lists and links are not in the export's layout model and are left out. No menu item yet. Estimates unchanged.
- **2026-10-12 (M3, GNOME title bar, #553):** On GNOME's Wayland session, whose compositor draws no window frames (it offers no `zxdg_decoration_manager_v1`), PdfCraft no longer shows winit's fallback title bar: it turns the frame off and the tab strip becomes the header bar, as in GNOME's own apps. Minimize, maximize/restore and close are drawn in Adwaita's style, in the order and on the side GNOME's `button-layout` names (Fedora's default shows only close); the window's edges and corners resize it, and a hairline outlines it while it isn't maximized. Preferences ▸ Use system title bar (shown only there) gives the window back winit's frame at once, without a restart (part of #1001), and `PDFCRAFT_TITLEBAR=app|system` overrides the choice. KDE, Sway, Hyprland, COSMIC and X11 keep the frame their desktop draws. Not done: right-clicking the header bar doesn't open GNOME's window menu (egui can't ask for it), the window has no shadow or rounded corners, the resize edges lie just inside the window rather than outside it, and Windows still uses its system frame (the rest of #1001). Estimates unchanged.
- **2026-10-12 (M0, build label):** A local or patched build can carry a label, set at compile time with `PDFCRAFT_BUILD_LABEL`: `pdfcraft --version`, `pdfcraft-cli --version`, About and the log show `0.7.1 (titlebar)`, and `packaging/linux/package.sh` names the files `pdfcraft-0.7.1-titlebar-…` and makes the .rpm/.deb release `1.titlebar`, which upgrades over the release of the same version (and the next release over it). The version itself is unchanged, so the update check still compares it with published releases. Unlabelled builds are as before. Estimates unchanged.
- **2026-10-11 (M6, #1208):** Dropdown and list-box Field Properties expose the existing Left/Center/Right alignment picker. Existing alignment stays intact until changed; cancel, undo/redo and save/reopen are covered through the real UI without changing options, values or flags. Estimates unchanged.
@@ -369,10 +554,10 @@ Newest first: the date, what moved, and the new overall estimate.
- **2026-10-10 (L Print, #756):** Windows prints. The Print dialog lists the system's printers and its default from `Win32_Printer` (through PowerShell's CIM; `wmic` is gone from Windows 11), and Print spools the print-ready PDF: the in-box Windows.Data.Pdf renders each sheet at the printer's own resolution and System.Drawing.Printing carries copies, collation, duplex and colour as the job's settings — both reached through `powershell`, so the Rust code touches no Win32 API and the machine needs no PDF reader. This closes #756 and the "printer not showing" duplicates #160, #244, #293, #465 and #644, using the CIM approach community-tested in #452; the tests render the job's sheets through the whole WinRT path on CI (dry run, no printer needed) and, where a printer can be added, catch a real job in its paused queue. Web printing is still missing; milestone and effort estimates are unchanged.
- **2026-10-10 (M3, #740):** Column select, as in Acrobat: holding Alt (Option on macOS) when a drag starts selects only the text inside the rectangle, with the Select tool, the text markup tools and Redact; Edit ▸ Column select does it with a plain drag, for Linux desktops whose window manager takes Alt-drag (Xfce, Cinnamon, MATE, KDE Plasma 5 by default). Copy gives rows top to bottom with tab-separated cells (right to left in right-to-left rows, reading order for vertical text); `text_extract` takes the same rectangle as `rect`. The new label is in all 15 catalogs. Also repaired `parity/acrobat-features.toml`, which #650 had left unparseable (duplicate keys on `form.field-list`). Still missing: column selection across pages and ⇧-click extension. Estimates unchanged.
-- **2026-10-10 (progress docs, hours per audience):** Each readiness number gets its own hours to ≈ 95%: full ≈ 49% / 780–1,400 h, mainstream ≈ 55% / 500–900 h, essentials ≈ 66% / 180–330 h. The full number is now the standard's additive weighted sum over the dimensions (written weights), 47% → 49%: method aligned with the standard, no new evidence. Beta ≈ 26 points, ≈ 400–700 h.
+- **2026-10-10 (progress docs, hours per audience):** Each readiness number gets its own hours to ≈ 95%: full ≈ 49% / 780–1,400 h, mainstream ≈ 55% / 500–900 h, essentials ≈ 66% / 180–330 h. The full number is now the additive weighted sum over the dimensions (written weights), 47% → 49%; no new evidence. Beta ≈ 26 points, ≈ 400–700 h.
- **2026-10-10 (progress docs, gate consistency check):** Re-judged the convert-and-share row with the rule applied across all Crafting Apps (a workflow blocks only when it can't be completed at all; lossy exchange and missing sub-cases are beta items, as for FilmCraft's .prproj or VectorCraft's .ai). Unencrypted sharing works, encryption interop (#774) and Office→PDF are sub-cases, Word export is lossy rather than absent: partial, not blocking. **Stage pre-alpha → alpha**; numbers unchanged (47%, mainstream 55%; beta ≈ 400–700 h). Added an essentials-user score: ≈ 66% for someone who only opens, reads, comments, fills, signs, prints and rearranges pages.
-- **2026-10-10 (progress docs, second look and alpha gate):** At the owner's request, after practitioners praised PdfCraft, re-examined the numbers. User sentiment: 7,595 stars, 177k + 39k downloads of 0.4.0/0.5.0, 23 user issues with explicit praise and switching reports (#179, #232, #273), issue themes concentrated in core paths (launch/GPU, rendering, editing, CJK, signing, printing). The checklist is not niche-dominated (497 mainstream vs 311 specialist rows; P0 95% / 91%). The first pass's 9-point cross-cutting deduction double-counted Windows printing, interop and glyphs: **ready for real work 40% → 47%**, and **≈ 55% for the mainstream practitioner** (new). The new core-workflow gate (craftrules progress-docs) fails on convert and share (#774, no Office import, #773): **stage alpha → pre-alpha**, ≈ 40–70 h from alpha; beta still ≈ 400–700 h.
-- **2026-10-10 (progress docs, full re-measure):** Re-measured against Acrobat Pro 26.002.21931 (installed bundle inspected: document types, localizations, plug-in and framework names). Checklist: 423 shipped + 74 partial of 808 (56.9%; P0 94.2%), ≈ 67% tier-weighted. Ready for real work ≈ 40%; stage **alpha**; ≈ 400–700 h to beta, ≈ 780–1,400 h to full parity (up from 600–1,100 h: the first public releases surfaced Acrobat interop of encrypted output, Windows printing, Chinese UI glyphs, Word-export layout and a 245-issue backlog). This file restructured to the craftrules progress-docs standard; the honest assessment moved to `docs/target-app-parity.md`, the gap list to `docs/gaps.md`, the milestones (duplicated rows merged) to `docs/roadmap.md`; new `docs/architecture.md`, `docs/localization-parity.md`, `docs/file-format-parity.md`, `docs/pdf-spec-parity.md`, `docs/ui-parity.md`, `docs/hardware-parity.md`.
+- **2026-10-10 (progress docs, second look and alpha gate):** At the owner's request, after practitioners praised PdfCraft, re-examined the numbers. User sentiment: 7,595 stars, 177k + 39k downloads of 0.4.0/0.5.0, 23 user issues with explicit praise and switching reports (#179, #232, #273), issue themes concentrated in core paths (launch/GPU, rendering, editing, CJK, signing, printing). The checklist is not niche-dominated (497 mainstream vs 311 specialist rows; P0 95% / 91%). The first pass's 9-point cross-cutting deduction double-counted Windows printing, interop and glyphs: **ready for real work 40% → 47%**, and **≈ 55% for the mainstream practitioner** (new). The new core-workflow gate fails on convert and share (#774, no Office import, #773): **stage alpha → pre-alpha**, ≈ 40–70 h from alpha; beta still ≈ 400–700 h.
+- **2026-10-10 (progress docs, full re-measure):** Re-measured against Acrobat Pro 26.002.21931 (installed bundle inspected: document types, localizations, plug-in and framework names). Checklist: 423 shipped + 74 partial of 808 (56.9%; P0 94.2%), ≈ 67% tier-weighted. Ready for real work ≈ 40%; stage **alpha**; ≈ 400–700 h to beta, ≈ 780–1,400 h to full parity (up from 600–1,100 h: the first public releases surfaced Acrobat interop of encrypted output, Windows printing, Chinese UI glyphs, Word-export layout and a 245-issue backlog). This file restructured; the honest assessment moved to `docs/target-app-parity.md`, the gap list to `docs/gaps.md`, the milestones (duplicated rows merged) to `docs/roadmap.md`; new `docs/architecture.md`, `docs/localization-parity.md`, `docs/file-format-parity.md`, `docs/pdf-spec-parity.md`, `docs/ui-parity.md`, `docs/hardware-parity.md`.
- **2026-10-10 (M8, redact word lists):** Find text and redact gains "Multiple words or phrases": a list with one word or phrase per line, typed or imported from a `.txt` file, whose every match is marked in one undo step. Lists are trimmed, deduplicated and bounded (1,000 entries of 256 characters, files up to 1 MB). `redact_mark` takes `words` and reports how many marks each made, so an agent sees which found nothing. Tests cover the list parsing, the tool end to end (mark, undo, apply, invalid lists) and the dialog with an imported list; the new labels are translated in all 13 UI catalogs. `protect.redact-word-lists` is shipped; whole-word and case-sensitive matching and exporting the list are still missing. Estimates unchanged (about 30–35%).
- **2026-10-10 (M4, bookmarks from structure):** Bookmark options ▸ New bookmarks from structure (command `bookmark.from_structure`, tool `bookmark_from_structure`) turns a tagged document's headings into bookmarks: H1–H6 through the role map, a plain H by its section nesting, nested by level under a new first "Untitled" bookmark as in Acrobat, each linked to its structure element (/SE). Titles come from /ActualText, the marked text on the page (decoded through the font's ToUnicode or encoding), /Alt or /T. The walk is bounded (depth, visits, heading and title caps, cycle-safe), an untagged document gets a clear error, and the edit is one undo step. Tests cover role maps, nesting, ToUnicode text, a self-referencing heading, the tool, undo and the panel. Still missing: Acrobat's element-type choice and destinations at the heading's position. The M4 row no longer lists Replace pages as missing (it shipped earlier). Overall estimate unchanged (about 30–35%).
- **2026-10-10 (M9, signing encrypted documents):** Digital signatures, certification and document timestamps work on password-protected PDFs instead of failing with "not supported: signing encrypted documents". `pdfcraft-cos` no longer encrypts or decrypts a signature dictionary's `/Contents` (§7.6.2; by `/Type /Sig|/DocTimeStamp` or `/Contents` + `/ByteRange`), so the placeholder is patched after hashing as in a plain file and existing signatures in encrypted files read their real CMS (which also fixes their DSS `/VRI` keys and keeps them intact in a full rewrite). Opened with the user password, the signer applies the document's permissions (fill-forms for existing fields and timestamps, annotate + modify for new fields, modify for certifying) and names the permissions password when it refuses. Synthetic tests cover all four algorithms, counter-signing, tampering, permissions and the automation tools. Overall estimate unchanged (about 30–35%).
@@ -536,16 +721,34 @@ Newest first: the date, what moved, and the new overall estimate.
## Revision history
+**2026-10-25 · minor:** M14 localization current state: Arabic catalogs stay logical, Arabic/Kurdish labels take an explicit RTL direction, and stock chrome mirrors; native/browser RTL input, IME and complex workflows remain unqualified. Native Simplified Chinese uses LXGW WenKai Lite; the web build has no Hans face. Estimates unchanged.
+
**2026-10-15 · minor:** M14/#2383 candidate: GPU device requests clamp unsupported limits while retaining the adapter texture extent. Synthetic V3D/downlevel regressions and Windows GPU-required tests pass; Raspberry Pi hardware remains unverified. Estimates unchanged.
| Date | Change | Summary |
|---|---|---|
+| 2026-10-28 | patch | M2, PR #4172: integrate main 5af10276 and retain both roadmap histories; viewer-only CJK and print/output font policies and estimates unchanged. |
+| 2026-10-27 | minor | M10: OCR stages SHA-256-verified ONNX models after the upstream RTEN URL brownout; discovery prefers a complete ONNX pair per directory, falls back to a complete RTEN pair, and never mixes formats or directories; language/web limits and estimates unchanged. |
+| 2026-10-23 | minor | M8, #3205 follow-up: phone-pattern redaction includes the +39 country code on labelled Italian geographic numbers with a slash; dates, protocol identifiers and whole-run guards remain. Estimates unchanged. |
+| 2026-10-23 | minor | M11: image downsampling measures physical resolution after page-local UserUnit scaling, including shared nested forms; synthetic measurement and optimization regressions. Estimates unchanged. |
+| 2026-10-23 | minor | M10: Word table cells use the grid columns they span, including cells after a merged cell; synthetic export regression and CLI reproduction. Estimates unchanged. |
+| 2026-10-23 | minor | M10: covered table slots retain their text in paragraph flow across Office exporters; emitted superscripts remain in their cells. Synthetic source and CLI regressions; estimates unchanged. |
+| 2026-10-23 | minor | M10: Export all images and Save Image As apply soft-mask Decode intervals to PNG alpha before resizing, including inverted/subrange, indirect, packed and JPEG masks. Estimates unchanged. |
+| 2026-10-23 | minor | M10: annotation appearances use unoccupied XObject names, preserving original page artwork and distinct appearances through inherited resources and print/reopen. Estimates unchanged. |
+| 2026-10-22 | minor | M4, #3916 partial: document edits and Undo/Redo cancel Organize drags. Pointer-release and save/reopen regressions check page order. Estimates unchanged. |
+| 2026-10-20 | minor | M4: initial-view window and interface options resolve indirect boolean ViewerPreferences values; synthetic true/false and save/reopen regressions retain defaults for malformed entries. Estimates unchanged. |
+| 2026-10-20 | minor | M6, #3502: form detection preserves comments across content streams; estimates unchanged. |
+| 2026-10-20 | minor | M10, #3532 partial: vector printing retains existing appearances with absent Subtype; estimates unchanged. |
+| 2026-10-20 | minor | M11 #3507: image merging preserves comments and state across content streams. Estimates unchanged. |
+| 2026-10-18 | minor | M5, PR #1971: nested source `/Page` and `/Pages` dictionaries export as null without traversing page content or the page tree. Synthetic saved/reopened three-page regression retains unrelated metadata and aliases. FDF import semantics, copied-graph limits and estimates unchanged. |
+| 2026-10-15 | minor | M5: budget retained FDF entries before cloning; discovery keeps lazy array owners and annotation positions, with alias/cache-eviction stress fixtures. Limits cover the copied graph; source parsing/page discovery retain existing COS/parser limits, with no total source-memory bound. FDF import semantics and estimates unchanged. |
| 2026-10-15 | minor | M7: the Edit text caret is as tall as the row it is on in a region with several sizes; estimates unchanged. |
| 2026-10-18 | minor | M4, PR #1919 review: repeated page indices apply each rotation to the latest page-local value, preserving indirect/inherited angles. Synthetic adjacent/interleaved duplicates retain siblings and page contents through incremental and full saves. Estimates unchanged. |
| 2026-10-18 | minor | M3, #3149: page replacement cancels render demand collected for the previous geometry; all viewer raster upload paths reject empty or oversized textures before cache eviction. Synthetic regressions reproduce the reported 1880×2350 upload at a 2048 limit, retain cached images and unsaved edits, and resume bounded backdrops and tiles. Estimates unchanged. |
| 2026-10-15 | minor | M6: Plain AcroForm XML imports retain all direct text around comments and processing instructions, including CDATA and character references. Estimates unchanged. |
| 2026-10-13 | minor | M6: XML/XFDF attribute whitespace survives form and comment export/import; estimates unchanged |
| 2026-10-14 | minor | M1: raw-deflate fallback preserves decoding-limit errors in strict and tolerant decoding; synthetic filter and COS stream regressions. Estimates unchanged. |
+| 2026-10-16 | minor | M14: Czech catalog completed (2,674 entries) and "tag" terminology unified. Estimates unchanged. |
| 2026-10-13 | minor | M9: separate page validation overlays preserve signature artwork, and snapshot tabs reuse instead of multiplying; baked captions remain unchanged (#79 open); estimates unchanged |
| 2026-10-16 | minor | M10 #1201 partial: lossless image export applies component Decode pairs and Indexed mapping. Estimates unchanged. |
| 2026-10-15 | minor | M6: CSV export and spreadsheet merging quote tabs, and import distinguishes quoted tabs from TSV delimiters without changing field identities. Estimates unchanged. |
@@ -563,11 +766,11 @@ Newest first: the date, what moved, and the new overall estimate.
| 2026-10-11 | minor | M7: #594 paragraph-drag candidate and source regressions prepared; subsequent Linux validation passed all eight CI gates. Estimates unchanged. |
| 2026-10-10 | minor | Recorded the remaining System appearance repair after #881; estimates unchanged |
| 2026-10-11 | minor | Contributor fork: document-font selection and subset completion, CFF, style ranges, contextual/bidi save/reopen and all-page Chinese keyboard acceptance; curve/hole hit testing (shared clips stay fixed, as on main). Scope remains bounded; percentages unchanged |
-| 2026-10-10 | minor | Readiness table with hours per audience; full number restated as the additive weighted sum over dimensions (47% → 49%; method aligned with the standard, no new evidence) |
+| 2026-10-10 | minor | Readiness table with hours per audience; full number restated as the additive weighted sum over dimensions (47% → 49%; no new evidence) |
| 2026-10-10 | minor | Essentials-user score added (≈ 66%) |
| 2026-10-10 | minor | Gate consistency check: convert and share re-judged partial, not blocking; stage pre-alpha → alpha |
| 2026-10-10 | minor | Second look: ready 40% → 47% (cross-cutting double count removed), mainstream-practitioner numbers and user sentiment added; core-workflow alpha gate applied, stage alpha → pre-alpha |
-| 2026-10-10 | major | Restructured to the progress-docs standard (stage, two numbers, dimensions, features, languages, upcoming, log); full re-measure; assessment, gaps and milestones moved to `docs/`; log sorted newest first and a stray merged entry dated |
+| 2026-10-10 | major | Progress docs restructured (stage, two numbers, dimensions, features, languages, upcoming, log); full re-measure; assessment, gaps and milestones moved to `docs/`; log sorted newest first and a stray merged entry dated |
| 2026-10-07 | minor | 806 tracked features, 51.0% shipped, effort-weighted ≈ 30–35%, 600–1,100 h |
| 2026-10-05 | major | Honest assessment and "Where we're lacking" added |
| 2026-10-12 | minor | M8: cleanup removes replacement descriptions only from changed marked-content spans, preserving surviving accessibility text. Estimates unchanged. |
@@ -577,3 +780,29 @@ Newest first: the date, what moved, and the new overall estimate.
- **2026-10-13 (M7):** Edit a PDF ▸ Add content: Header and footer, Watermark and Background open an Add / Update / Remove menu (Update and Remove enabled only when the document has that mark), as Acrobat's Watermark control does. The catalogue gains `ToolItem::actions`, so any tool can list more commands (#1192).
- **2026-10-18 (M8, #1350):** Published the history-preserving integration; simplified the inherited-form resource guard for Rust 1.96 Clippy. Formatting, 64 redaction tests, scoped Clippy, layering, assets and the scoped wasm check pass locally. CI remains pending; estimates unchanged.
+
+- 2026-10-18: M11 / #3227: mark target-version compatibility partial; document the unchecked newer-content limitation.
+
+- **2026-10-18 (PR #3227 integration):** M11: integrate upstream b0e5c6eb; retain partial target-version compatibility claims and effective-version reporting, correcting the borrowed version parser lifetime. Scope and estimates unchanged.
+
+- **2026-10-12 (i18n):** Albanian (`sq`, Shqip) added: 2,343-entry catalog, month and weekday names, Preferences entry; not yet reviewed by a native speaker.
+
+- **2026-10-21 (M4, #3865):** A new bookmark records where the reader is: the top-left of the selected text, or the top of the window, as `/XYZ left top null` (zoom kept), instead of the page top (`Edit::AddBookmark` takes `left` and `top`; `bookmark_add` takes `top` and `left`; `PageInfo::xyz_for_view`).
+
+- **2026-10-18 (renderer, hayro patches 19/35):** A mesh shading (types 4–7) is sampled over its own triangles instead of the whole path: a BricsCAD plan with 310 one-triangle `sh` fills took 2 s per 1024 px tile at every zoom (and hit the 20 s watchdog on a busy machine); now about 70 ms, so zooming into dense topology drawings stays fluid. Estimates unchanged.
+
+- **2026-10-22 (PR #3227 integration):** M11: integrate upstream `80b7d215`; retain partial target-version compatibility and upstream content-boundary and image-size regression coverage. Scope and estimates unchanged.
+
+- **2026-10-23 (M2, PR #4172):** Combine thumbnails and accessibility figure previews use the viewer's display-only CJK fallback. Windows family matching includes FangSong and subset-prefixed Founder names. Export, print, OCR and automation remain off by default; estimates unchanged.
+
+- **2026-10-26 (M2, PR #4172 print-preview review):** Print-preview requests disable installed CJK fallback and use an isolated cache so preview font availability matches output. Viewer fallback survives switching in either direction. Synthetic GBK regressions cover inline and single-worker rendering, full pages and tiles; estimates unchanged.
+
+- **2026-10-26 (M2, PR #4172 documentation review):** Added function documentation for installed-font script selection, bounded candidate reads, platform paths, resolver precedence and regression contracts. No runtime behavior or estimate change.
+
+- **2026-10-27 (M2, PR #4172 conflict resolution):** Integrated main `9a9813bf` and its engine module split. Opening, layer changes and session renderer rebuilds retain display-only CJK fallback; output configuration and print-preview cache isolation are preserved. Validation passed: 16 scoped engine regressions, 412 renderer unit tests, 43 renderer integration tests (one existing corpus case ignored), 15 Combine tests, scoped Clippy, formatting, layers/assets/parity and renderer/engine WASM checks. Estimates unchanged.
+## Pages sidebar keyboard refresh (2026-10-30)
+
+PR #2133 retains document-local Pages sidebar focus for Ctrl/Cmd+A and Escape after the UI module split. The selection does not navigate. The focused regression is in `crates/ui-egui/tests/it/view.rs`; current-head hosted validation is pending. Feature counts and estimates are unchanged.
+## Button export-value refresh (2026-10-30)
+
+PR #1952 retains per-widget checkbox/radio export-value editing in the split form-authoring and Prepare modules. Caption editing remains supported. Tests cover preserved appearances and selection, invalid/locked refusals, data-format round trips, and Apply/Cancel/Undo. Current-head hosted validation is pending; feature estimates are unchanged.
diff --git a/apps/pdfcraft-cli/Cargo.toml b/apps/pdfcraft-cli/Cargo.toml
index bab8c28a88..3d9d87a812 100644
--- a/apps/pdfcraft-cli/Cargo.toml
+++ b/apps/pdfcraft-cli/Cargo.toml
@@ -21,7 +21,11 @@ pdfcraft-cos = { workspace = true }
pdfcraft-preflight = { workspace = true }
pdfcraft-automation = { workspace = true }
pdfcraft-platform = { workspace = true }
-base64 = "0.22"
+base64 = "0.23"
+
+[dev-dependencies]
+# Decodes `render` output in tests/it/owner_password.rs; already a dependency of the engine.
+png = "0.18"
[target.'cfg(unix)'.dependencies]
# `geteuid` for `ui`'s control-file owner check: safe, so no `unsafe` libc call here.
diff --git a/apps/pdfcraft-cli/src/check.rs b/apps/pdfcraft-cli/src/check.rs
new file mode 100644
index 0000000000..62993ad6dc
--- /dev/null
+++ b/apps/pdfcraft-cli/src/check.rs
@@ -0,0 +1,208 @@
+use super::commands::{parse_dpi, read};
+use super::options::{AUDIT_OPTIONS, CHECK_ONE_OPTIONS, CHECK_OPTIONS, flag, positional, validate_options};
+use super::{
+ Arc, CliError, Command, Duration, Instant, PageRenderer, Path, PathBuf, RenderConfig, RenderRequest, RequestKind, Stdio, inspect, stdout_line,
+};
+
+/// Audit a document for faux redactions. Prints the findings as JSON; fails when any are
+/// found so scripts and CI can gate on it, and when the audit itself cannot run.
+pub(crate) fn audit(args: &[String]) -> Result<(), CliError> {
+ validate_options("audit", args, AUDIT_OPTIONS)?;
+ let path = *positional(args, AUDIT_OPTIONS).first().ok_or("audit: missing file")?;
+ let bytes = read(path)?;
+ let password = flag(args, "--password");
+ let doc = pdfcraft_cos::Document::open_with_password(bytes.clone(), password).map_err(|e| format!("{path}: {e}"))?;
+ let findings = pdfcraft_audit::audit_redactions(&doc).map_err(|e| format!("{path}: {e}"))?;
+ // Rectangles in points from the top-left of the displayed page, as `doc_audit_redactions`
+ // reports them; the page geometry is read only when there is something to place.
+ let info = if findings.is_empty() {
+ None
+ } else {
+ let render_password = pdfcraft_engine::render_password_for(bytes.clone(), password);
+ Some(inspect(bytes, render_password.as_deref()).map_err(|e| format!("{path}: {e}"))?)
+ };
+ let items: Vec = findings
+ .into_iter()
+ .map(|f| {
+ let rect = f.rect.map(|v| v as f32);
+ serde_json::json!({
+ "page": f.page + 1,
+ "kind": f.kind.id(),
+ "rect": info.as_ref().map_or(rect, |i| pdfcraft_automation::view_rect(i, f.page, rect)),
+ "covered_text": f.covered_text,
+ })
+ })
+ .collect();
+ println!("{}", serde_json::json!({ "file": path, "findings": items, "count": items.len() }));
+ if items.is_empty() { Ok(()) } else { Err(format!("audit: {} faux redaction(s) found", items.len()).into()) }
+}
+
+/// Child-process body for `check`: prints one JSON line.
+pub(crate) fn check_one(args: &[String]) -> Result<(), CliError> {
+ validate_options("check-one", args, CHECK_ONE_OPTIONS)?;
+ let path = *positional(args, CHECK_ONE_OPTIONS).first().ok_or("check-one: missing file")?;
+ let dpi = parse_dpi(flag(args, "--dpi").unwrap_or("36"))?;
+ let start = Instant::now();
+ let bytes = read(path)?;
+ let (status, pages, failed, detail, warnings) = match inspect(bytes.clone(), None) {
+ Err(e) => (format!("open-error:{}", short(&e.to_string())), 0, 0, e.to_string(), 0),
+ Ok(info) => {
+ let mut r = PageRenderer::new(bytes, RenderConfig::default());
+ let mut failed = Vec::new();
+ for i in 0..info.pages.len().min(500) {
+ let p = r.render(RenderRequest { page: i, kind: RequestKind::Pixels, tile: None, scale: dpi / 72.0, tag: 0, preview: false });
+ if let Some(e) = r.render(RenderRequest { page: i, kind: RequestKind::Text, tile: None, scale: 1.0, tag: 0, preview: false }).error {
+ failed.push(format!("p{} text: {e}", i + 1));
+ }
+ if let Some(e) = p.error {
+ failed.push(format!("p{}: {e}", i + 1));
+ }
+ }
+ let status = if failed.is_empty() { "ok".to_string() } else { "page-errors".to_string() };
+ (status, info.pages.len(), failed.len(), failed.join(" | "), info.warnings.len())
+ }
+ };
+ // `--edit` (fuzzing): also run our own object layer end to end, with no panic safety net,
+ // so a panic in cos shows up as a crash of this process.
+ if args.iter().any(|a| a == "--edit") {
+ edit_round_trip(&std::fs::read(path).map_err(|e| e.to_string())?);
+ }
+ let line = serde_json::json!({ "file": path, "status": status, "pages": pages, "failed_pages": failed, "warnings": warnings,
+ "ms": start.elapsed().as_millis() as u64, "detail": detail.chars().take(400).collect::() });
+ stdout_line(format_args!("{line}"))?;
+ Ok(())
+}
+
+/// Open with cos, touch every object, edit the catalog, save incrementally and in full (both
+/// output styles), and reopen each result. Errors are fine; panics, hangs and aborts are bugs.
+fn edit_round_trip(bytes: &[u8]) {
+ use pdfcraft_cos::{Document, Object, SaveOptions, write_full, write_incremental};
+ let Ok(mut doc) = Document::open(Arc::new(bytes.to_vec())) else { return };
+ for num in doc.object_numbers().into_iter().take(20_000) {
+ if let Ok(o) = doc.try_get(num)
+ && let Object::Stream(s) = &*o
+ {
+ let _ = s.decoded();
+ }
+ }
+ if let Some(root) = doc.root() {
+ let _ = doc.update_dict(root, |d| d.set(b"PdfCraftFuzz".to_vec(), Object::Bool(true)));
+ }
+ let classic = SaveOptions { object_streams: false, ..SaveOptions::default() };
+ for out in [write_incremental(&doc, &SaveOptions::default()), write_full(&doc, &SaveOptions::default()), write_full(&doc, &classic)]
+ .into_iter()
+ .flatten()
+ {
+ if let Ok(again) = Document::open(Arc::new(out)) {
+ let _ = again.root().map(|r| again.get(r));
+ }
+ }
+}
+
+fn short(s: &str) -> String {
+ s.split(|c: char| !c.is_alphanumeric()).filter(|w| !w.is_empty()).take(4).collect::>().join("-").to_lowercase()
+}
+
+fn collect(paths: &[&str]) -> Vec {
+ let mut out = Vec::new();
+ let mut stack: Vec = paths.iter().map(PathBuf::from).collect();
+ let mut visited = std::collections::HashSet::new();
+ while let Some(p) = stack.pop() {
+ if p.is_dir() {
+ // Directory aliases and symlink cycles must not scan the same tree repeatedly.
+ let identity = match p.canonicalize() {
+ Ok(identity) => identity,
+ Err(error) => {
+ eprintln!("{}: could not resolve directory: {error}", p.display());
+ continue;
+ }
+ };
+ if !visited.insert(identity) {
+ continue;
+ }
+ if let Ok(rd) = std::fs::read_dir(&p) {
+ stack.extend(rd.flatten().map(|e| e.path()));
+ }
+ } else if p.extension().and_then(std::ffi::OsStr::to_str).is_some_and(|e| e.eq_ignore_ascii_case("pdf")) {
+ out.push(p);
+ }
+ }
+ out.sort();
+ out
+}
+
+pub(crate) fn check(args: &[String]) -> Result<(), CliError> {
+ validate_options("check", args, CHECK_OPTIONS)?;
+ // Every option is checked before the inputs are scanned, so a bad value is one message,
+ // not one problem per file: a zero timeout used to make every file time out, and a bad
+ // dpi used to reach each child, which printed nothing and was reported as a crash.
+ let timeout_value = flag(args, "--timeout").unwrap_or("20");
+ let timeout = Duration::from_secs(
+ timeout_value
+ .parse::()
+ .ok()
+ .filter(|secs| *secs > 0)
+ .ok_or_else(|| format!("bad --timeout: expected a positive whole number of seconds, got {timeout_value:?}"))?,
+ );
+ let dpi = flag(args, "--dpi").unwrap_or("36").to_string();
+ parse_dpi(&dpi)?;
+ let files = collect(&positional(args, CHECK_OPTIONS));
+ if files.is_empty() {
+ return Err("check: no PDF files found".into());
+ }
+ let exe = std::env::current_exe().map_err(|e| e.to_string())?;
+ let mut results = Vec::new();
+ let mut counts: std::collections::BTreeMap = Default::default();
+ for (n, f) in files.iter().enumerate() {
+ let r = run_child(&exe, f, &dpi, timeout);
+ let status = r["status"].as_str().unwrap_or("?").to_string();
+ *counts.entry(status.split(':').next().unwrap_or("?").to_string()).or_default() += 1;
+ if status != "ok" {
+ eprintln!(
+ "[{}/{}] {status:<28} {} {}",
+ n + 1,
+ files.len(),
+ f.display(),
+ r["detail"].as_str().unwrap_or("").chars().take(160).collect::()
+ );
+ }
+ results.push(r);
+ }
+ eprintln!("\nchecked {} files: {:?}", files.len(), counts);
+ if let Some(out) = flag(args, "--json") {
+ std::fs::write(out, serde_json::to_string_pretty(&results).unwrap_or_default()).map_err(|e| e.to_string())?;
+ }
+ Ok(())
+}
+
+fn run_child(exe: &Path, file: &Path, dpi: &str, timeout: Duration) -> serde_json::Value {
+ let file_s = file.to_string_lossy().to_string();
+ let child = Command::new(exe).args(["check-one", &file_s, "--dpi", dpi]).stdout(Stdio::piped()).stderr(Stdio::null()).spawn();
+ let mut child = match child {
+ Ok(c) => c,
+ Err(e) => return serde_json::json!({ "file": file_s, "status": "harness-error", "detail": e.to_string() }),
+ };
+ let start = Instant::now();
+ loop {
+ match child.try_wait() {
+ Ok(Some(status)) => {
+ let mut out = String::new();
+ if let Some(mut so) = child.stdout.take() {
+ use std::io::Read;
+ let _ = so.read_to_string(&mut out);
+ }
+ return match serde_json::from_str::(out.trim()) {
+ Ok(v) => v,
+ Err(_) => serde_json::json!({ "file": file_s, "status": "crash", "detail": format!("child exited with {status}") }),
+ };
+ }
+ Ok(None) if start.elapsed() > timeout => {
+ let _ = child.kill();
+ let _ = child.wait();
+ return serde_json::json!({ "file": file_s, "status": "timeout", "detail": format!("exceeded {}s", timeout.as_secs()) });
+ }
+ Ok(None) => std::thread::sleep(Duration::from_millis(20)),
+ Err(e) => return serde_json::json!({ "file": file_s, "status": "harness-error", "detail": e.to_string() }),
+ }
+ }
+}
diff --git a/apps/pdfcraft-cli/src/commands.rs b/apps/pdfcraft-cli/src/commands.rs
new file mode 100644
index 0000000000..9d0b2057f0
--- /dev/null
+++ b/apps/pdfcraft-cli/src/commands.rs
@@ -0,0 +1,413 @@
+use super::options::{
+ COMBINE_OPTIONS, EXTRACT_OPTIONS, INFO_OPTIONS, RENDER_OPTIONS, SPLIT_OPTIONS, TEXT_OPTIONS, can_be_path_value, flag, needs_value, positional,
+ validate_options,
+};
+use super::{Arc, CliError, ImageFormat, PageRenderer, Path, PathBuf, RenderConfig, RenderRequest, RequestKind, Write, inspect, stdout_line};
+
+pub(crate) fn read(path: &str) -> Result>, String> {
+ std::fs::read(path).map(Arc::new).map_err(|e| format!("{path}: {e}"))
+}
+
+pub(crate) fn info(args: &[String]) -> Result<(), CliError> {
+ validate_options("info", args, INFO_OPTIONS)?;
+ let path = *positional(args, INFO_OPTIONS).first().ok_or("info: missing file")?;
+ let bytes = read(path)?;
+ let password = flag(args, "--password");
+ // The renderer takes only the user password of R2–R4 files; `Session::open` takes either.
+ let render_password = pdfcraft_engine::render_password_for(bytes.clone(), password);
+ let mut info = inspect(bytes.clone(), render_password.as_deref()).map_err(|e| e.to_string())?;
+ let pdf_pages = info.pages.len();
+ let acroform_fields = info.fields.len();
+ // Dynamic XFA forms: say what laying the template out gives (what the app shows).
+ let xfa_layout = (info.xfa == Some(pdfcraft_render::Xfa::Dynamic))
+ .then(|| {
+ let mut s = pdfcraft_engine::Session::new();
+ let id = s.open("info.pdf", None, bytes.clone(), password).ok()?;
+ let d = s.get(id)?;
+ d.xfa.as_ref().map(|x| {
+ info = d.info.clone();
+ // The in-memory layout is an appended revision, not the file being inspected.
+ info.file_size = bytes.len();
+ serde_json::json!({ "pages": x.pages, "fields": x.fields, "warnings": x.warnings })
+ })
+ })
+ .flatten();
+ // What the document declares as a standard, read the way the Standards panel reads it, so
+ // `info` and the app agree. A file that declares nothing leaves these null and empty.
+ let declared =
+ pdfcraft_cos::Document::open_with_password(bytes.clone(), password).map(|doc| pdfcraft_preflight::declared(&doc)).unwrap_or_default();
+ let json = serde_json::json!({
+ "file": path,
+ "pdf_version": info.pdf_version,
+ "file_size": info.file_size,
+ "pages": info.pages.len(),
+ "pdf_pages": pdf_pages, "acroform_fields": acroform_fields,
+ "first_page_pt": info.pages.first().map(|p| [p.width, p.height]),
+ "title": info.title, "author": info.author, "producer": info.producer, "creator": info.creator,
+ "encrypted": info.encrypted, "tagged": info.tagged, "javascript": info.has_javascript,
+ "pdfa": declared.pdfa.as_ref().map(|(part, conformance)| format!("PDF/A-{part}{}", conformance.to_lowercase())),
+ "pdfua": declared.pdfua,
+ "output_intents": declared.output_intents,
+ "xfa": info.xfa.map(|x| match x { pdfcraft_render::Xfa::Static => "static", pdfcraft_render::Xfa::Dynamic => "dynamic" }),
+ "xfa_layout": xfa_layout,
+ "bookmarks": info.outline.len(), "annotations": info.annotations.len(), "fields": info.fields.len(),
+ "links": info.links.len(), "layers": info.layers.len(), "attachments": info.attachments.len(),
+ "transitions": info.transitions.len(),
+ "custom_properties": info.custom.len(),
+ "page_labels": info.pages.iter().take(8).map(|p| p.label.clone()).collect::>(),
+ "warnings": info.warnings,
+ });
+ stdout_line(format_args!("{}", serde_json::to_string_pretty(&json).unwrap_or_default()))?;
+ Ok(())
+}
+
+pub(crate) fn text(args: &[String]) -> Result<(), CliError> {
+ validate_options("text", args, TEXT_OPTIONS)?;
+ let path = *positional(args, TEXT_OPTIONS).first().ok_or("text: missing file")?;
+ let mut selected_page = None;
+ // Validate every supplied value before reading, retaining the first valid selection.
+ let mut options = args.iter();
+ while let Some(option) = options.next() {
+ if option == "--page" {
+ let page = options
+ .next()
+ .and_then(|value| value.parse::().ok())
+ .filter(|page| *page > 0)
+ .ok_or("bad --page: expected a positive page number")?;
+ selected_page.get_or_insert(page - 1);
+ } else if option.starts_with("--") {
+ // Match positional(): an option's operand is not itself another option.
+ options.next();
+ }
+ }
+ let password = flag(args, "--password");
+ let mut bytes = read(path)?;
+ // The renderer takes only the user password of R2–R4 files; `Session::open` takes either.
+ // Worked out from the file as read: `bytes` may become the session's display copy below.
+ let render_password = pdfcraft_engine::render_password_for(bytes.clone(), password);
+ // Printing the text is copying it, so ask what `text_extract` asks (#1645): without the
+ // permissions password, a document that doesn't allow copying gives no text.
+ let mut session = pdfcraft_engine::Session::new();
+ if let Some(doc) = session.open(path, None, bytes.clone(), password).ok().and_then(|id| session.get(id)) {
+ // The engine's hint is about editing in the app; on the command line the way in is
+ // `--password`.
+ doc.check_copying()
+ .map_err(|_| "text: the document's permissions don't allow copying its text; pass the owner password with --password to extract it")?;
+ if doc.xfa.is_some() {
+ bytes = doc.display.clone();
+ }
+ }
+ let mut r = PageRenderer::new(bytes.clone(), RenderConfig { password: render_password.as_deref().map(Arc::from), ..Default::default() });
+ // A document the renderer could not open (wrong or missing password, unparseable file) has
+ // no pages, and "extract every page" of nothing would print nothing and exit 0 (#132). Ask
+ // `inspect` why instead, so a protected file fails the same way `info` does.
+ if r.page_count() == 0 {
+ let info = inspect(bytes.clone(), render_password.as_deref()).map_err(|e| format!("text: {e}"))?;
+ if !info.pages.is_empty() {
+ return Err("text: the document could not be parsed".into());
+ }
+ }
+ let pages: Vec = match selected_page {
+ Some(page) => vec![page],
+ None => (0..r.page_count()).collect(),
+ };
+ let mut failed: Vec = Vec::new();
+ for (n, p) in pages.iter().enumerate() {
+ let out = r.render(RenderRequest { page: *p, kind: RequestKind::Text, tile: None, scale: 1.0, tag: 0, preview: false });
+ if let Some(e) = out.error {
+ let _ = writeln!(std::io::stderr().lock(), "page {}: {e}", p + 1);
+ failed.push(p + 1);
+ continue;
+ }
+ if n > 0 {
+ stdout_line(format_args!("\u{c}"))?;
+ }
+ stdout_line(format_args!("{}", out.text.map(|t| t.plain_text()).unwrap_or_default()))?;
+ }
+ if failed.is_empty() {
+ Ok(())
+ } else {
+ Err(format!("text: page(s) {} could not be read", failed.iter().map(usize::to_string).collect::>().join(", ")).into())
+ }
+}
+
+/// The most pages one `A-B` range may name: a bound on what a typed range can allocate.
+const MAX_RANGE: usize = 1_000_000;
+
+/// 1-based pages ("1,3,5-8": single pages and inclusive ranges, in the order given) → 0-based
+/// indices (#1923).
+fn page_list(s: &str) -> Result, String> {
+ let number = |t: &str, item: &str| t.trim().parse::().ok().filter(|n| *n > 0).ok_or_else(|| format!("bad page number {item:?}"));
+ let mut out = Vec::new();
+ for item in s.split(',').map(str::trim) {
+ match item.split_once('-') {
+ Some((from, to)) => {
+ let (from, to) = (number(from, item)?, number(to, item)?);
+ if to < from {
+ return Err(format!("bad page range {item:?}: it ends before it starts"));
+ }
+ if to - from >= MAX_RANGE {
+ return Err(format!("bad page range {item:?}: more than {MAX_RANGE} pages"));
+ }
+ out.extend(from - 1..to);
+ }
+ None => out.push(number(item, item)? - 1),
+ }
+ }
+ Ok(out)
+}
+
+/// The options `edit` understands, for its "unknown option" message.
+const EDIT_OPTIONS: &str = "--out, --password, --full, --rotate, --delete, --move, --insert-blank, --title, --author";
+
+/// Apply page and metadata edits through the engine and save (incrementally unless `--full`).
+///
+/// Every argument is parsed before the file is opened: an option this command does not know
+/// (a typo like `--rotat`) is an error, not something to skip — skipping it would write an
+/// unchanged copy and exit 0, which looks like a successful edit (#133).
+pub(crate) fn edit(args: &[String]) -> Result<(), CliError> {
+ use pdfcraft_engine::{Edit, Session};
+ let mut path: Option<&str> = None;
+ let mut out: Option<&str> = None;
+ let mut password: Option<&str> = None;
+ let mut full = false;
+ let mut edits = Vec::new();
+ let mut i = 0;
+ while let Some(arg) = args.get(i).map(String::as_str) {
+ // Options that take no value, and the input file.
+ match arg {
+ "--full" => {
+ full = true;
+ i += 1;
+ continue;
+ }
+ a if a.starts_with("--") => {}
+ a => {
+ if path.is_some() {
+ return Err(format!("edit: unexpected argument {a:?} (one input file, then options)").into());
+ }
+ path = Some(a);
+ i += 1;
+ continue;
+ }
+ }
+ // `edit` parses its own options (they run in order), so `--out` is checked here in the
+ // same way: it wrote a PDF to a file called `--full`.
+ let value = args.get(i + 1).map(String::as_str);
+ if !can_be_path_value(arg, value) {
+ return Err(needs_value("edit", arg, value).into());
+ }
+ let value = value.ok_or_else(|| format!("edit: {arg} needs a value"))?;
+ match arg {
+ "--out" => out = Some(value),
+ "--password" => password = Some(value),
+ "--rotate" => {
+ let (pages, deg) = value.split_once(':').ok_or("--rotate PAGES:DEGREES")?;
+ let degrees: i64 = deg.parse().map_err(|_| format!("bad --rotate degrees: expected a whole number of degrees, got {deg:?}"))?;
+ // The same rule `rotate_pages` enforces, checked here so a bad angle is named
+ // with its option before any file is opened.
+ if degrees % 90 != 0 {
+ return Err(format!("bad --rotate degrees: expected a multiple of 90 (such as 90, -90 or 180), got {degrees}").into());
+ }
+ edits.push(Edit::RotatePages { pages: page_list(pages)?, degrees });
+ }
+ "--delete" => edits.push(Edit::DeletePages { pages: page_list(value)? }),
+ "--move" => {
+ let (pages, to) = value.split_once(':').ok_or("--move PAGES:TO")?;
+ let target: usize = to
+ .parse()
+ .ok()
+ .filter(|target| *target > 0)
+ .ok_or_else(|| format!("bad --move target: expected a page number of at least 1, got {to:?}"))?;
+ edits.push(Edit::MovePages { pages: page_list(pages)?, to: target - 1 });
+ }
+ "--insert-blank" => {
+ let position: usize = value
+ .parse()
+ .ok()
+ .filter(|position| *position > 0)
+ .ok_or_else(|| format!("bad --insert-blank position: expected a page position of at least 1, got {value:?}"))?;
+ edits.push(Edit::InsertBlankPage { at: position - 1, width: 612.0, height: 792.0 });
+ }
+ "--title" => edits.push(Edit::SetInfo { key: "Title".into(), value: value.into() }),
+ "--author" => edits.push(Edit::SetInfo { key: "Author".into(), value: value.into() }),
+ other => return Err(format!("edit: unknown option {other:?} (expected one of: {EDIT_OPTIONS})").into()),
+ }
+ i += 2;
+ }
+ let path = path.ok_or("edit: missing file")?;
+ let out = out.ok_or("edit: missing --out")?;
+ let mut session = Session::new();
+ let id = session.open(path, Some(path.to_string()), read(path)?, password).map_err(|e| e.to_string())?;
+ for e in edits {
+ // A destination past the end must fail like `page_move` / `page_insert_blank` do, not
+ // silently become an append (#1330). Checked per edit: earlier edits change the count.
+ let n = session.get(id).map_or(0, |d| d.info.pages.len());
+ let destination = match &e {
+ Edit::MovePages { to, .. } => Some(("--move target", *to)),
+ Edit::InsertBlankPage { at, .. } => Some(("--insert-blank", *at)),
+ _ => None,
+ };
+ if let Some((option, at)) = destination.filter(|(_, at)| *at > n) {
+ return Err(format!("{option} {} is past the end: it must be between 1 and {} (the document has {n} pages)", at + 1, n + 1).into());
+ }
+ // Pages count from the document as the earlier options left it, so a page past the end is
+ // reported with its own option and the current page count (#1550).
+ let listed = match &e {
+ Edit::RotatePages { pages, .. } => Some(("--rotate", pages)),
+ Edit::DeletePages { pages } => Some(("--delete", pages)),
+ Edit::MovePages { pages, .. } => Some(("--move", pages)),
+ _ => None,
+ };
+ if let Some((option, bad)) = listed.and_then(|(option, pages)| pages.iter().find(|p| **p >= n).map(|p| (option, *p))) {
+ return Err(format!("{option}: page {} does not exist; after the earlier options the document has {n} pages", bad + 1).into());
+ }
+ session.apply(id, e).map_err(|e| e.to_string())?;
+ }
+ let bytes = if full { session.save_full_bytes(id) } else { session.save_bytes(id) }.map_err(|e| e.to_string())?;
+ // As the app's Save: the file must reopen with its pages before it replaces anything (#1698).
+ session.check_save(id, &bytes).map_err(|e| e.to_string())?;
+ write_pdf(Path::new(out), &bytes)
+}
+
+/// Write a PDF a command produced, through a staging file beside the target (#2166): a write
+/// that fails, or `--out` naming the input, never leaves a truncated file where a good one was.
+fn write_pdf(out: &Path, bytes: &[u8]) -> Result<(), CliError> {
+ use pdfcraft_platform::staging::{StagingName, write_atomically};
+ write_atomically(out, bytes, StagingName::SuffixThenTag).map_err(|e| format!("{}: {e}", out.display()).into())
+}
+
+fn file_stem(path: &str) -> String {
+ Path::new(path).file_stem().map(|s| s.to_string_lossy().into_owned()).unwrap_or_else(|| path.to_string())
+}
+
+pub(crate) fn combine(args: &[String]) -> Result<(), CliError> {
+ validate_options("combine", args, COMBINE_OPTIONS)?;
+ let out = flag(args, "--out").ok_or("combine: missing --out")?;
+ let inputs = positional(args, COMBINE_OPTIONS);
+ if inputs.len() < 2 {
+ return Err("combine: give at least two input files".into());
+ }
+ let sources = inputs.iter().map(|p| Ok((file_stem(p), read(p)?))).collect::, String>>()?;
+ let bytes = pdfcraft_engine::Session::new().combine(&sources).map_err(|e| e.to_string())?;
+ write_pdf(Path::new(out), &bytes)
+}
+
+pub(crate) fn extract(args: &[String]) -> Result<(), CliError> {
+ validate_options("extract", args, EXTRACT_OPTIONS)?;
+ let path = *positional(args, EXTRACT_OPTIONS).first().ok_or("extract: missing file")?;
+ let out = flag(args, "--out").ok_or("extract: missing --out")?;
+ let pages = page_list(flag(args, "--pages").ok_or("extract: missing --pages")?)?;
+ let mut session = pdfcraft_engine::Session::new();
+ let id = session.open(path, None, read(path)?, flag(args, "--password")).map_err(|e| e.to_string())?;
+ let bytes = session.extract(id, &pages).map_err(|e| e.to_string())?;
+ write_pdf(Path::new(out), &bytes)
+}
+
+pub(crate) fn split(args: &[String]) -> Result<(), CliError> {
+ validate_options("split", args, SPLIT_OPTIONS)?;
+ use pdfcraft_engine::SplitBy;
+ let path = *positional(args, SPLIT_OPTIONS).first().ok_or("split: missing file")?;
+ let by = match (flag(args, "--every"), flag(args, "--before")) {
+ (Some(n), None) => {
+ // A count the splitter cannot hold is refused here, before the file is opened,
+ // rather than silently clamped to 1 further down.
+ let count = n
+ .parse::()
+ .ok()
+ .filter(|count| *count > 0)
+ .ok_or_else(|| format!("bad --every: expected a positive whole number of pages, got {n:?}"))?;
+ SplitBy::PageCount(count)
+ }
+ (None, Some(list)) => SplitBy::Before(page_list(list)?),
+ _ => return Err("split: give either --every N or --before PAGES".into()),
+ };
+ let dir = PathBuf::from(flag(args, "--out-dir").unwrap_or("."));
+ let mut session = pdfcraft_engine::Session::new();
+ let id = session.open(path, None, read(path)?, flag(args, "--password")).map_err(|e| e.to_string())?;
+ let stem = file_stem(path);
+ let parts = session.split(id, &by).map_err(|e| e.to_string())?;
+ // An output folder that does not exist yet is created (#249); one that can't be is reported
+ // as the folder's problem, not as the first part's.
+ std::fs::create_dir_all(&dir).map_err(|e| format!("split: --out-dir {}: {e}", dir.display()))?;
+ for (a, b, bytes) in parts {
+ let name = dir.join(if a == b { format!("{stem}-p{a}.pdf") } else { format!("{stem}-p{a}-{b}.pdf") });
+ write_pdf(&name, &bytes)?;
+ stdout_line(format_args!("{}", name.display()))?;
+ }
+ Ok(())
+}
+
+/// A `--dpi` the renderer can be asked for: a positive, finite number. `0`, a negative value
+/// or `NaN` parse as an `f32` but reach the renderer as a scale it refuses with its own
+/// internal message (and in `check`, passed on as text, they would turn every file into a
+/// child that printed nothing), so the CLI names the option and the value instead.
+pub(crate) fn parse_dpi(value: &str) -> Result {
+ value
+ .parse::()
+ .ok()
+ .filter(|dpi| dpi.is_finite() && *dpi > 0.0)
+ .ok_or_else(|| format!("bad --dpi: expected a positive number of dots per inch, got {value:?}"))
+}
+
+pub(crate) fn render(args: &[String]) -> Result<(), CliError> {
+ validate_options("render", args, RENDER_OPTIONS)?;
+ let path = *positional(args, RENDER_OPTIONS).first().ok_or("render: missing file")?;
+ let page: usize =
+ flag(args, "--page").unwrap_or("1").parse().ok().filter(|page| *page > 0).ok_or("bad --page: expected a positive page number")?;
+ let page_index = page - 1;
+ let dpi = parse_dpi(flag(args, "--dpi").unwrap_or("96"))?;
+ let out = flag(args, "--out").ok_or("render: missing --out (.png, .jpg, .tif or .pam)")?;
+ // The file is what its name says (#248): a `.png` used to get a netpbm PAM stream.
+ let format = match Path::new(out).extension().and_then(std::ffi::OsStr::to_str).map(str::to_ascii_lowercase).as_deref() {
+ Some("png") => Some(ImageFormat::Png),
+ Some("jpg" | "jpeg") => Some(ImageFormat::Jpeg { quality: 90 }),
+ Some("tif" | "tiff") => Some(ImageFormat::Tiff),
+ Some("pam") => None,
+ _ => return Err(format!("render: --out {out}: use a .png, .jpg, .tif or .pam name").into()),
+ };
+ // The app renders a display copy that draws comments missing their /AP streams; the CLI
+ // must agree with it (and with independent viewers), while keeping the file it reads intact.
+ let bytes = read(path)?;
+ let password = flag(args, "--password");
+ // The renderer takes only the user password of R2–R4 files; `Session::open` and the display
+ // copy take either.
+ let render_password = pdfcraft_engine::render_password_for(bytes.clone(), password);
+ let display = if inspect(bytes.clone(), render_password.as_deref()).is_ok_and(|info| info.xfa == Some(pdfcraft_render::Xfa::Dynamic)) {
+ let mut session = pdfcraft_engine::Session::new();
+ let id = session.open(path, None, bytes, password).map_err(|e| e.to_string())?;
+ session.get(id).ok_or("render: the document could not be opened")?.display.clone()
+ } else {
+ pdfcraft_engine::display_bytes_for(bytes, password)
+ };
+ let mut r =
+ PageRenderer::new(display, RenderConfig { password: render_password.as_deref().map(Arc::from), reject_oversize: true, ..Default::default() });
+ let p = r.render(RenderRequest { page: page_index, kind: RequestKind::Pixels, tile: None, scale: dpi / 72.0, tag: 0, preview: false });
+ if let Some(e) = p.error {
+ return Err(e.into());
+ }
+ let bytes = match format {
+ // With `reject_oversize` the page is drawn at `--dpi` or not at all, so the file can say so.
+ Some(f) => pdfcraft_engine::export::encode_image(p.width, p.height, &p.rgba, f, f64::from(dpi))?,
+ None => {
+ // PAM (netpbm RGB_ALPHA): the raw premultiplied pixels, for tools that read it.
+ let mut pam = format!("P7\nWIDTH {}\nHEIGHT {}\nDEPTH 4\nMAXVAL 255\nTUPLTYPE RGB_ALPHA\nENDHDR\n", p.width, p.height).into_bytes();
+ pam.extend_from_slice(&p.rgba);
+ pam
+ }
+ };
+ std::fs::write(out, bytes).map_err(|e| format!("{out}: {e}"))?;
+ let _ = writeln!(std::io::stderr().lock(), "rendered page {page} at {dpi} dpi: {}×{} px in {} ms", p.width, p.height, p.millis);
+ for warning in &p.warnings {
+ match warning {
+ pdfcraft_render::RenderWarning::ContentTruncated => {
+ let _ = writeln!(std::io::stderr().lock(), "warning: page {page}: content past the page's safety budget was skipped");
+ }
+ pdfcraft_render::RenderWarning::ColourFallback => {
+ let _ =
+ writeln!(std::io::stderr().lock(), "warning: page {page}: a colour that could not be computed was painted in a fallback colour");
+ }
+ }
+ }
+ Ok(())
+}
diff --git a/apps/pdfcraft-cli/src/main.rs b/apps/pdfcraft-cli/src/main.rs
index 5888e51114..0c7fa9ba22 100644
--- a/apps/pdfcraft-cli/src/main.rs
+++ b/apps/pdfcraft-cli/src/main.rs
@@ -53,6 +53,20 @@ macro_rules! mcp_usage {
};
}
+mod check;
+mod commands;
+mod options;
+mod run;
+mod ui;
+
+use check::{audit, check, check_one};
+use commands::{combine, edit, extract, info, render, split, text};
+use options::{OptionSpec, flag, validate_options};
+#[cfg(feature = "mcp")]
+use run::mcp;
+use run::{automation, print_output, run, tools};
+use ui::ui;
+
/// The full usage, printed by `--help` (also after a command, e.g. `render --help`).
const USAGE: &str = concat!(
"\
@@ -84,7 +98,24 @@ help and feedback: https://discord.gg/artcraft"
);
fn main() -> ExitCode {
- let args: Vec = std::env::args().skip(1).collect();
+ // `std::env::args` panics on an argument that is not UTF-8 ("called `Result::unwrap()` on an
+ // `Err` value"), which a Latin-1 file name is, and it panics while collecting them: even
+ // `--help` ended that way. Such a name cannot be passed on either, since the paths here are
+ // `str`, so it is a usage error (photocraft#1108).
+ let mut args: Vec = Vec::new();
+ for arg in std::env::args_os().skip(1) {
+ match arg.into_string() {
+ Ok(a) => args.push(a),
+ Err(bad) => {
+ let _ = writeln!(
+ std::io::stderr().lock(),
+ "pdfcraft-cli: `{}` is not valid UTF-8, so it cannot be used as an argument",
+ bad.to_string_lossy()
+ );
+ return ExitCode::FAILURE;
+ }
+ }
+ }
// ` --help` too; only right after the command, so a later `-h` (a value) still reaches it.
let command = match args.get(1).map(String::as_str) {
Some("--help" | "-h") => Some("--help"),
@@ -164,947 +195,3 @@ fn version() -> Result<(), CliError> {
stdout_line(format_args!("Source: {}", pdfcraft_engine::links::GITHUB))?;
Ok(())
}
-
-/// Audit a document for faux redactions. Prints the findings as JSON; fails when any are
-/// found so scripts and CI can gate on it, and when the audit itself cannot run.
-fn audit(args: &[String]) -> Result<(), CliError> {
- validate_options("audit", args, AUDIT_OPTIONS)?;
- let path = *positional(args).first().ok_or("audit: missing file")?;
- let bytes = read(path)?;
- let password = flag(args, "--password");
- let doc = pdfcraft_cos::Document::open_with_password(bytes.clone(), password).map_err(|e| format!("{path}: {e}"))?;
- let findings = pdfcraft_audit::audit_redactions(&doc).map_err(|e| format!("{path}: {e}"))?;
- // Rectangles in points from the top-left of the displayed page, as `doc_audit_redactions`
- // reports them; the page geometry is read only when there is something to place.
- let info = if findings.is_empty() { None } else { Some(inspect(bytes, password).map_err(|e| format!("{path}: {e}"))?) };
- let items: Vec = findings
- .into_iter()
- .map(|f| {
- let rect = f.rect.map(|v| v as f32);
- serde_json::json!({
- "page": f.page + 1,
- "kind": f.kind.id(),
- "rect": info.as_ref().map_or(rect, |i| pdfcraft_automation::view_rect(i, f.page, rect)),
- "covered_text": f.covered_text,
- })
- })
- .collect();
- println!("{}", serde_json::json!({ "file": path, "findings": items, "count": items.len() }));
- if items.is_empty() { Ok(()) } else { Err(format!("audit: {} faux redaction(s) found", items.len()).into()) }
-}
-
-fn flag<'a>(args: &'a [String], name: &str) -> Option<&'a str> {
- args.iter().position(|a| a == name).and_then(|i| args.get(i + 1)).map(String::as_str)
-}
-
-#[derive(Clone, Copy)]
-struct OptionSpec {
- name: &'static str,
- takes_value: bool,
-}
-
-/// Refuse unknown options and malformed confinement flags before any file is read.
-fn validate_options(command: &str, args: &[String], specs: &[OptionSpec]) -> Result<(), CliError> {
- if specs.iter().any(|spec| spec.name == "--root") {
- // `flag` searches every token, including another option's value. Match that lookup so
- // a malformed confinement flag cannot be hidden there and fall back to no root.
- let mut root_seen = false;
- for (i, _) in args.iter().enumerate().filter(|(_, arg)| arg.as_str() == "--root") {
- if root_seen {
- return Err(format!("{command}: --root may only be supplied once").into());
- }
- root_seen = true;
- if args.get(i + 1).is_none_or(|value| value.is_empty() || value.starts_with("--")) {
- return Err(format!("{command}: --root needs a directory value").into());
- }
- }
- }
- let mut i = 0;
- while let Some(arg) = args.get(i) {
- if let Some(name) = arg.strip_prefix("--") {
- let Some(spec) = specs.iter().find(|spec| spec.name == arg) else {
- return Err(format!("{command}: unknown option --{name}").into());
- };
- // Other missing or malformed values are left to the command, which reports them in its own
- // terms (`text` and `extract` explain what a page value must be).
- i += if spec.takes_value { 2 } else { 1 };
- } else {
- i += 1;
- }
- }
- Ok(())
-}
-
-const INFO_OPTIONS: &[OptionSpec] = &[OptionSpec { name: "--password", takes_value: true }];
-const AUDIT_OPTIONS: &[OptionSpec] = &[OptionSpec { name: "--password", takes_value: true }];
-const TEXT_OPTIONS: &[OptionSpec] = &[OptionSpec { name: "--page", takes_value: true }, OptionSpec { name: "--password", takes_value: true }];
-const COMBINE_OPTIONS: &[OptionSpec] = &[OptionSpec { name: "--out", takes_value: true }];
-const EXTRACT_OPTIONS: &[OptionSpec] = &[
- OptionSpec { name: "--out", takes_value: true },
- OptionSpec { name: "--pages", takes_value: true },
- OptionSpec { name: "--password", takes_value: true },
-];
-const SPLIT_OPTIONS: &[OptionSpec] = &[
- OptionSpec { name: "--every", takes_value: true },
- OptionSpec { name: "--before", takes_value: true },
- OptionSpec { name: "--out-dir", takes_value: true },
- OptionSpec { name: "--password", takes_value: true },
-];
-const RENDER_OPTIONS: &[OptionSpec] = &[
- OptionSpec { name: "--page", takes_value: true },
- OptionSpec { name: "--dpi", takes_value: true },
- OptionSpec { name: "--out", takes_value: true },
- OptionSpec { name: "--password", takes_value: true },
-];
-const CHECK_ONE_OPTIONS: &[OptionSpec] = &[OptionSpec { name: "--dpi", takes_value: true }, OptionSpec { name: "--edit", takes_value: false }];
-const CHECK_OPTIONS: &[OptionSpec] = &[
- OptionSpec { name: "--timeout", takes_value: true },
- OptionSpec { name: "--dpi", takes_value: true },
- OptionSpec { name: "--json", takes_value: true },
-];
-const RUN_OPTIONS: &[OptionSpec] = &[
- OptionSpec { name: "--root", takes_value: true },
- OptionSpec { name: "--script", takes_value: true },
- OptionSpec { name: "--out", takes_value: true },
- OptionSpec { name: "--report", takes_value: false },
-];
-const UI_OPTIONS: &[OptionSpec] = &[OptionSpec { name: "--control", takes_value: true }, OptionSpec { name: "--out", takes_value: true }];
-
-#[cfg(feature = "mcp")]
-const MCP_OPTIONS: &[OptionSpec] = &[OptionSpec { name: "--root", takes_value: true }, OptionSpec { name: "--compact", takes_value: false }];
-
-fn positional(args: &[String]) -> Vec<&str> {
- let mut out = Vec::new();
- let mut skip = false;
- for a in args {
- if skip {
- skip = false;
- continue;
- }
- if a.starts_with("--") {
- skip = true;
- continue;
- }
- out.push(a.as_str());
- }
- out
-}
-
-fn read(path: &str) -> Result>, String> {
- std::fs::read(path).map(Arc::new).map_err(|e| format!("{path}: {e}"))
-}
-
-fn info(args: &[String]) -> Result<(), CliError> {
- validate_options("info", args, INFO_OPTIONS)?;
- let path = *positional(args).first().ok_or("info: missing file")?;
- let bytes = read(path)?;
- let password = flag(args, "--password");
- let mut info = inspect(bytes.clone(), password).map_err(|e| e.to_string())?;
- let pdf_pages = info.pages.len();
- let acroform_fields = info.fields.len();
- // Dynamic XFA forms: say what laying the template out gives (what the app shows).
- let xfa_layout = (info.xfa == Some(pdfcraft_render::Xfa::Dynamic))
- .then(|| {
- let mut s = pdfcraft_engine::Session::new();
- let id = s.open("info.pdf", None, bytes.clone(), password).ok()?;
- let d = s.get(id)?;
- d.xfa.as_ref().map(|x| {
- info = d.info.clone();
- // The in-memory layout is an appended revision, not the file being inspected.
- info.file_size = bytes.len();
- serde_json::json!({ "pages": x.pages, "fields": x.fields, "warnings": x.warnings })
- })
- })
- .flatten();
- // What the document declares as a standard, read the way the Standards panel reads it, so
- // `info` and the app agree. A file that declares nothing leaves these null and empty.
- let declared =
- pdfcraft_cos::Document::open_with_password(bytes.clone(), password).map(|doc| pdfcraft_preflight::declared(&doc)).unwrap_or_default();
- let json = serde_json::json!({
- "file": path,
- "pdf_version": info.pdf_version,
- "file_size": info.file_size,
- "pages": info.pages.len(),
- "pdf_pages": pdf_pages, "acroform_fields": acroform_fields,
- "first_page_pt": info.pages.first().map(|p| [p.width, p.height]),
- "title": info.title, "author": info.author, "producer": info.producer, "creator": info.creator,
- "encrypted": info.encrypted, "tagged": info.tagged, "javascript": info.has_javascript,
- "pdfa": declared.pdfa.as_ref().map(|(part, conformance)| format!("PDF/A-{part}{}", conformance.to_lowercase())),
- "pdfua": declared.pdfua,
- "output_intents": declared.output_intents,
- "xfa": info.xfa.map(|x| match x { pdfcraft_render::Xfa::Static => "static", pdfcraft_render::Xfa::Dynamic => "dynamic" }),
- "xfa_layout": xfa_layout,
- "bookmarks": info.outline.len(), "annotations": info.annotations.len(), "fields": info.fields.len(),
- "links": info.links.len(), "layers": info.layers.len(), "attachments": info.attachments.len(),
- "transitions": info.transitions.len(),
- "custom_properties": info.custom.len(),
- "page_labels": info.pages.iter().take(8).map(|p| p.label.clone()).collect::>(),
- "warnings": info.warnings,
- });
- stdout_line(format_args!("{}", serde_json::to_string_pretty(&json).unwrap_or_default()))?;
- Ok(())
-}
-
-fn text(args: &[String]) -> Result<(), CliError> {
- validate_options("text", args, TEXT_OPTIONS)?;
- let path = *positional(args).first().ok_or("text: missing file")?;
- let mut selected_page = None;
- // Validate every supplied value before reading, retaining the first valid selection.
- let mut options = args.iter();
- while let Some(option) = options.next() {
- if option == "--page" {
- let page = options
- .next()
- .and_then(|value| value.parse::().ok())
- .filter(|page| *page > 0)
- .ok_or("bad --page: expected a positive page number")?;
- selected_page.get_or_insert(page - 1);
- } else if option.starts_with("--") {
- // Match positional(): an option's operand is not itself another option.
- options.next();
- }
- }
- let password = flag(args, "--password");
- let mut bytes = read(path)?;
- // Printing the text is copying it, so ask what `text_extract` asks (#1645): without the
- // permissions password, a document that doesn't allow copying gives no text.
- let mut session = pdfcraft_engine::Session::new();
- if let Some(doc) = session.open(path, None, bytes.clone(), password).ok().and_then(|id| session.get(id)) {
- // The engine's hint is about editing in the app; on the command line the way in is
- // `--password`.
- doc.check_copying()
- .map_err(|_| "text: the document's permissions don't allow copying its text; pass the owner password with --password to extract it")?;
- if doc.xfa.is_some() {
- bytes = doc.display.clone();
- }
- }
- let mut r = PageRenderer::new(bytes.clone(), RenderConfig { password: password.map(Arc::from), ..Default::default() });
- // A document the renderer could not open (wrong or missing password, unparseable file) has
- // no pages, and "extract every page" of nothing would print nothing and exit 0 (#132). Ask
- // `inspect` why instead, so a protected file fails the same way `info` does.
- if r.page_count() == 0 {
- let info = inspect(bytes.clone(), password).map_err(|e| format!("text: {e}"))?;
- if !info.pages.is_empty() {
- return Err("text: the document could not be parsed".into());
- }
- }
- let pages: Vec = match selected_page {
- Some(page) => vec![page],
- None => (0..r.page_count()).collect(),
- };
- let mut failed: Vec = Vec::new();
- for (n, p) in pages.iter().enumerate() {
- let out = r.render(RenderRequest { page: *p, kind: RequestKind::Text, tile: None, scale: 1.0, tag: 0, preview: false });
- if let Some(e) = out.error {
- let _ = writeln!(std::io::stderr().lock(), "page {}: {e}", p + 1);
- failed.push(p + 1);
- continue;
- }
- if n > 0 {
- stdout_line(format_args!("\u{c}"))?;
- }
- stdout_line(format_args!("{}", out.text.map(|t| t.plain_text()).unwrap_or_default()))?;
- }
- if failed.is_empty() {
- Ok(())
- } else {
- Err(format!("text: page(s) {} could not be read", failed.iter().map(usize::to_string).collect::>().join(", ")).into())
- }
-}
-
-/// The most pages one `A-B` range may name: a bound on what a typed range can allocate.
-const MAX_RANGE: usize = 1_000_000;
-
-/// 1-based pages ("1,3,5-8": single pages and inclusive ranges, in the order given) → 0-based
-/// indices (#1923).
-fn page_list(s: &str) -> Result, String> {
- let number = |t: &str, item: &str| t.trim().parse::().ok().filter(|n| *n > 0).ok_or_else(|| format!("bad page number {item:?}"));
- let mut out = Vec::new();
- for item in s.split(',').map(str::trim) {
- match item.split_once('-') {
- Some((from, to)) => {
- let (from, to) = (number(from, item)?, number(to, item)?);
- if to < from {
- return Err(format!("bad page range {item:?}: it ends before it starts"));
- }
- if to - from >= MAX_RANGE {
- return Err(format!("bad page range {item:?}: more than {MAX_RANGE} pages"));
- }
- out.extend(from - 1..to);
- }
- None => out.push(number(item, item)? - 1),
- }
- }
- Ok(out)
-}
-
-/// The options `edit` understands, for its "unknown option" message.
-const EDIT_OPTIONS: &str = "--out, --password, --full, --rotate, --delete, --move, --insert-blank, --title, --author";
-
-/// Apply page and metadata edits through the engine and save (incrementally unless `--full`).
-///
-/// Every argument is parsed before the file is opened: an option this command does not know
-/// (a typo like `--rotat`) is an error, not something to skip — skipping it would write an
-/// unchanged copy and exit 0, which looks like a successful edit (#133).
-fn edit(args: &[String]) -> Result<(), CliError> {
- use pdfcraft_engine::{Edit, Session};
- let mut path: Option<&str> = None;
- let mut out: Option<&str> = None;
- let mut password: Option<&str> = None;
- let mut full = false;
- let mut edits = Vec::new();
- let mut i = 0;
- while let Some(arg) = args.get(i).map(String::as_str) {
- // Options that take no value, and the input file.
- match arg {
- "--full" => {
- full = true;
- i += 1;
- continue;
- }
- a if a.starts_with("--") => {}
- a => {
- if path.is_some() {
- return Err(format!("edit: unexpected argument {a:?} (one input file, then options)").into());
- }
- path = Some(a);
- i += 1;
- continue;
- }
- }
- let value = args.get(i + 1).map(String::as_str).ok_or_else(|| format!("edit: {arg} needs a value"))?;
- match arg {
- "--out" => out = Some(value),
- "--password" => password = Some(value),
- "--rotate" => {
- let (pages, deg) = value.split_once(':').ok_or("--rotate PAGES:DEGREES")?;
- edits.push(Edit::RotatePages { pages: page_list(pages)?, degrees: deg.parse().map_err(|_| "bad degrees")? });
- }
- "--delete" => edits.push(Edit::DeletePages { pages: page_list(value)? }),
- "--move" => {
- let (pages, to) = value.split_once(':').ok_or("--move PAGES:TO")?;
- let to: usize = to.parse().map_err(|_| "bad target")?;
- edits.push(Edit::MovePages { pages: page_list(pages)?, to: to.checked_sub(1).ok_or("--move target must be at least 1")? });
- }
- "--insert-blank" => {
- let at: usize = value.parse().map_err(|_| "bad position")?;
- edits.push(Edit::InsertBlankPage { at: at.checked_sub(1).ok_or("--insert-blank must be at least 1")?, width: 612.0, height: 792.0 });
- }
- "--title" => edits.push(Edit::SetInfo { key: "Title".into(), value: value.into() }),
- "--author" => edits.push(Edit::SetInfo { key: "Author".into(), value: value.into() }),
- other => return Err(format!("edit: unknown option {other:?} (expected one of: {EDIT_OPTIONS})").into()),
- }
- i += 2;
- }
- let path = path.ok_or("edit: missing file")?;
- let out = out.ok_or("edit: missing --out")?;
- let mut session = Session::new();
- let id = session.open(path, Some(path.to_string()), read(path)?, password).map_err(|e| e.to_string())?;
- for e in edits {
- // A destination past the end must fail like `page_move` / `page_insert_blank` do, not
- // silently become an append (#1330). Checked per edit: earlier edits change the count.
- let n = session.get(id).map_or(0, |d| d.info.pages.len());
- let destination = match &e {
- Edit::MovePages { to, .. } => Some(("--move target", *to)),
- Edit::InsertBlankPage { at, .. } => Some(("--insert-blank", *at)),
- _ => None,
- };
- if let Some((option, at)) = destination.filter(|(_, at)| *at > n) {
- return Err(format!("{option} {} is past the end: it must be between 1 and {} (the document has {n} pages)", at + 1, n + 1).into());
- }
- // Pages count from the document as the earlier options left it, so a page past the end is
- // reported with its own option and the current page count (#1550).
- let listed = match &e {
- Edit::RotatePages { pages, .. } => Some(("--rotate", pages)),
- Edit::DeletePages { pages } => Some(("--delete", pages)),
- Edit::MovePages { pages, .. } => Some(("--move", pages)),
- _ => None,
- };
- if let Some((option, bad)) = listed.and_then(|(option, pages)| pages.iter().find(|p| **p >= n).map(|p| (option, *p))) {
- return Err(format!("{option}: page {} does not exist; after the earlier options the document has {n} pages", bad + 1).into());
- }
- session.apply(id, e).map_err(|e| e.to_string())?;
- }
- let bytes = if full { session.save_full_bytes(id) } else { session.save_bytes(id) }.map_err(|e| e.to_string())?;
- // As the app's Save: the file must reopen with its pages before it replaces anything (#1698).
- session.check_save(id, &bytes).map_err(|e| e.to_string())?;
- write_pdf(Path::new(out), &bytes)
-}
-
-/// Write a PDF a command produced, through a staging file beside the target (#2166): a write
-/// that fails, or `--out` naming the input, never leaves a truncated file where a good one was.
-fn write_pdf(out: &Path, bytes: &[u8]) -> Result<(), CliError> {
- use pdfcraft_platform::staging::{StagingName, write_atomically};
- write_atomically(out, bytes, StagingName::SuffixThenTag).map_err(|e| format!("{}: {e}", out.display()).into())
-}
-
-fn file_stem(path: &str) -> String {
- Path::new(path).file_stem().map(|s| s.to_string_lossy().into_owned()).unwrap_or_else(|| path.to_string())
-}
-
-fn combine(args: &[String]) -> Result<(), CliError> {
- validate_options("combine", args, COMBINE_OPTIONS)?;
- let out = flag(args, "--out").ok_or("combine: missing --out")?;
- let inputs = positional(args);
- if inputs.len() < 2 {
- return Err("combine: give at least two input files".into());
- }
- let sources = inputs.iter().map(|p| Ok((file_stem(p), read(p)?))).collect::, String>>()?;
- let bytes = pdfcraft_engine::Session::new().combine(&sources).map_err(|e| e.to_string())?;
- write_pdf(Path::new(out), &bytes)
-}
-
-fn extract(args: &[String]) -> Result<(), CliError> {
- validate_options("extract", args, EXTRACT_OPTIONS)?;
- let path = *positional(args).first().ok_or("extract: missing file")?;
- let out = flag(args, "--out").ok_or("extract: missing --out")?;
- let pages = page_list(flag(args, "--pages").ok_or("extract: missing --pages")?)?;
- let mut session = pdfcraft_engine::Session::new();
- let id = session.open(path, None, read(path)?, flag(args, "--password")).map_err(|e| e.to_string())?;
- let bytes = session.extract(id, &pages).map_err(|e| e.to_string())?;
- write_pdf(Path::new(out), &bytes)
-}
-
-fn split(args: &[String]) -> Result<(), CliError> {
- validate_options("split", args, SPLIT_OPTIONS)?;
- use pdfcraft_engine::SplitBy;
- let path = *positional(args).first().ok_or("split: missing file")?;
- let by = match (flag(args, "--every"), flag(args, "--before")) {
- (Some(n), None) => SplitBy::PageCount(n.parse().map_err(|_| "bad --every")?),
- (None, Some(list)) => SplitBy::Before(page_list(list)?),
- _ => return Err("split: give either --every N or --before PAGES".into()),
- };
- let dir = PathBuf::from(flag(args, "--out-dir").unwrap_or("."));
- let mut session = pdfcraft_engine::Session::new();
- let id = session.open(path, None, read(path)?, flag(args, "--password")).map_err(|e| e.to_string())?;
- let stem = file_stem(path);
- let parts = session.split(id, &by).map_err(|e| e.to_string())?;
- // An output folder that does not exist yet is created (#249); one that can't be is reported
- // as the folder's problem, not as the first part's.
- std::fs::create_dir_all(&dir).map_err(|e| format!("split: --out-dir {}: {e}", dir.display()))?;
- for (a, b, bytes) in parts {
- let name = dir.join(if a == b { format!("{stem}-p{a}.pdf") } else { format!("{stem}-p{a}-{b}.pdf") });
- write_pdf(&name, &bytes)?;
- stdout_line(format_args!("{}", name.display()))?;
- }
- Ok(())
-}
-
-fn render(args: &[String]) -> Result<(), CliError> {
- validate_options("render", args, RENDER_OPTIONS)?;
- let path = *positional(args).first().ok_or("render: missing file")?;
- let page: usize = flag(args, "--page").unwrap_or("1").parse().map_err(|_| "bad --page")?;
- let page_index = page.checked_sub(1).ok_or("--page must be at least 1")?;
- let dpi: f32 = flag(args, "--dpi").unwrap_or("96").parse().map_err(|_| "bad --dpi")?;
- let out = flag(args, "--out").ok_or("render: missing --out (.png, .jpg, .tif or .pam)")?;
- // The file is what its name says (#248): a `.png` used to get a netpbm PAM stream.
- let format = match Path::new(out).extension().and_then(|e| e.to_str()).map(str::to_ascii_lowercase).as_deref() {
- Some("png") => Some(ImageFormat::Png),
- Some("jpg" | "jpeg") => Some(ImageFormat::Jpeg { quality: 90 }),
- Some("tif" | "tiff") => Some(ImageFormat::Tiff),
- Some("pam") => None,
- _ => return Err(format!("render: --out {out}: use a .png, .jpg, .tif or .pam name").into()),
- };
- // The app renders a display copy that draws comments missing their /AP streams; the CLI
- // must agree with it (and with independent viewers), while keeping the file it reads intact.
- let bytes = read(path)?;
- let password = flag(args, "--password");
- let display = if inspect(bytes.clone(), password).is_ok_and(|info| info.xfa == Some(pdfcraft_render::Xfa::Dynamic)) {
- let mut session = pdfcraft_engine::Session::new();
- let id = session.open(path, None, bytes, password).map_err(|e| e.to_string())?;
- session.get(id).ok_or("render: the document could not be opened")?.display.clone()
- } else {
- pdfcraft_engine::display_bytes_for(bytes, password)
- };
- let mut r = PageRenderer::new(display, RenderConfig { password: password.map(Arc::from), reject_oversize: true, ..Default::default() });
- let p = r.render(RenderRequest { page: page_index, kind: RequestKind::Pixels, tile: None, scale: dpi / 72.0, tag: 0, preview: false });
- if let Some(e) = p.error {
- return Err(e.into());
- }
- let bytes = match format {
- // With `reject_oversize` the page is drawn at `--dpi` or not at all, so the file can say so.
- Some(f) => pdfcraft_engine::export::encode_image(p.width, p.height, &p.rgba, f, f64::from(dpi))?,
- None => {
- // PAM (netpbm RGB_ALPHA): the raw premultiplied pixels, for tools that read it.
- let mut pam = format!("P7\nWIDTH {}\nHEIGHT {}\nDEPTH 4\nMAXVAL 255\nTUPLTYPE RGB_ALPHA\nENDHDR\n", p.width, p.height).into_bytes();
- pam.extend_from_slice(&p.rgba);
- pam
- }
- };
- std::fs::write(out, bytes).map_err(|e| format!("{out}: {e}"))?;
- let _ = writeln!(std::io::stderr().lock(), "rendered page {page} at {dpi} dpi: {}×{} px in {} ms", p.width, p.height, p.millis);
- for warning in &p.warnings {
- match warning {
- pdfcraft_render::RenderWarning::ContentTruncated => {
- let _ = writeln!(std::io::stderr().lock(), "warning: page {page}: content past the page's safety budget was skipped");
- }
- }
- }
- Ok(())
-}
-
-/// Child-process body for `check`: prints one JSON line.
-fn check_one(args: &[String]) -> Result<(), CliError> {
- validate_options("check-one", args, CHECK_ONE_OPTIONS)?;
- let path = *positional(args).first().ok_or("check-one: missing file")?;
- let dpi: f32 = flag(args, "--dpi").unwrap_or("36").parse().map_err(|_| "bad --dpi")?;
- let start = Instant::now();
- let bytes = read(path)?;
- let (status, pages, failed, detail, warnings) = match inspect(bytes.clone(), None) {
- Err(e) => (format!("open-error:{}", short(&e.to_string())), 0, 0, e.to_string(), 0),
- Ok(info) => {
- let mut r = PageRenderer::new(bytes, RenderConfig::default());
- let mut failed = Vec::new();
- for i in 0..info.pages.len().min(500) {
- let p = r.render(RenderRequest { page: i, kind: RequestKind::Pixels, tile: None, scale: dpi / 72.0, tag: 0, preview: false });
- if let Some(e) = r.render(RenderRequest { page: i, kind: RequestKind::Text, tile: None, scale: 1.0, tag: 0, preview: false }).error {
- failed.push(format!("p{} text: {e}", i + 1));
- }
- if let Some(e) = p.error {
- failed.push(format!("p{}: {e}", i + 1));
- }
- }
- let status = if failed.is_empty() { "ok".to_string() } else { "page-errors".to_string() };
- (status, info.pages.len(), failed.len(), failed.join(" | "), info.warnings.len())
- }
- };
- // `--edit` (fuzzing): also run our own object layer end to end, with no panic safety net,
- // so a panic in cos shows up as a crash of this process.
- if args.iter().any(|a| a == "--edit") {
- edit_round_trip(&std::fs::read(path).map_err(|e| e.to_string())?);
- }
- let line = serde_json::json!({ "file": path, "status": status, "pages": pages, "failed_pages": failed, "warnings": warnings,
- "ms": start.elapsed().as_millis() as u64, "detail": detail.chars().take(400).collect::() });
- stdout_line(format_args!("{line}"))?;
- Ok(())
-}
-
-/// Open with cos, touch every object, edit the catalog, save incrementally and in full (both
-/// output styles), and reopen each result. Errors are fine; panics, hangs and aborts are bugs.
-fn edit_round_trip(bytes: &[u8]) {
- use pdfcraft_cos::{Document, Object, SaveOptions, write_full, write_incremental};
- let Ok(mut doc) = Document::open(Arc::new(bytes.to_vec())) else { return };
- for num in doc.object_numbers().into_iter().take(20_000) {
- if let Ok(o) = doc.try_get(num)
- && let Object::Stream(s) = &*o
- {
- let _ = s.decoded();
- }
- }
- if let Some(root) = doc.root() {
- let _ = doc.update_dict(root, |d| d.set(b"PdfCraftFuzz".to_vec(), Object::Bool(true)));
- }
- let classic = SaveOptions { object_streams: false, ..SaveOptions::default() };
- for out in [write_incremental(&doc, &SaveOptions::default()), write_full(&doc, &SaveOptions::default()), write_full(&doc, &classic)]
- .into_iter()
- .flatten()
- {
- if let Ok(again) = Document::open(Arc::new(out)) {
- let _ = again.root().map(|r| again.get(r));
- }
- }
-}
-
-fn short(s: &str) -> String {
- s.split(|c: char| !c.is_alphanumeric()).filter(|w| !w.is_empty()).take(4).collect::>().join("-").to_lowercase()
-}
-
-fn collect(paths: &[&str]) -> Vec {
- let mut out = Vec::new();
- let mut stack: Vec = paths.iter().map(PathBuf::from).collect();
- let mut visited = std::collections::HashSet::new();
- while let Some(p) = stack.pop() {
- if p.is_dir() {
- // Directory aliases and symlink cycles must not scan the same tree repeatedly.
- let identity = match p.canonicalize() {
- Ok(identity) => identity,
- Err(error) => {
- eprintln!("{}: could not resolve directory: {error}", p.display());
- continue;
- }
- };
- if !visited.insert(identity) {
- continue;
- }
- if let Ok(rd) = std::fs::read_dir(&p) {
- stack.extend(rd.flatten().map(|e| e.path()));
- }
- } else if p.extension().and_then(|e| e.to_str()).is_some_and(|e| e.eq_ignore_ascii_case("pdf")) {
- out.push(p);
- }
- }
- out.sort();
- out
-}
-
-fn check(args: &[String]) -> Result<(), CliError> {
- validate_options("check", args, CHECK_OPTIONS)?;
- let files = collect(&positional(args));
- if files.is_empty() {
- return Err("check: no PDF files found".into());
- }
- let timeout = Duration::from_secs(flag(args, "--timeout").unwrap_or("20").parse().map_err(|_| "bad --timeout")?);
- let dpi = flag(args, "--dpi").unwrap_or("36").to_string();
- let exe = std::env::current_exe().map_err(|e| e.to_string())?;
- let mut results = Vec::new();
- let mut counts: std::collections::BTreeMap = Default::default();
- for (n, f) in files.iter().enumerate() {
- let r = run_child(&exe, f, &dpi, timeout);
- let status = r["status"].as_str().unwrap_or("?").to_string();
- *counts.entry(status.split(':').next().unwrap_or("?").to_string()).or_default() += 1;
- if status != "ok" {
- eprintln!(
- "[{}/{}] {status:<28} {} {}",
- n + 1,
- files.len(),
- f.display(),
- r["detail"].as_str().unwrap_or("").chars().take(160).collect::()
- );
- }
- results.push(r);
- }
- eprintln!("\nchecked {} files: {:?}", files.len(), counts);
- if let Some(out) = flag(args, "--json") {
- std::fs::write(out, serde_json::to_string_pretty(&results).unwrap_or_default()).map_err(|e| e.to_string())?;
- }
- Ok(())
-}
-
-fn run_child(exe: &Path, file: &Path, dpi: &str, timeout: Duration) -> serde_json::Value {
- let file_s = file.to_string_lossy().to_string();
- let child = Command::new(exe).args(["check-one", &file_s, "--dpi", dpi]).stdout(Stdio::piped()).stderr(Stdio::null()).spawn();
- let mut child = match child {
- Ok(c) => c,
- Err(e) => return serde_json::json!({ "file": file_s, "status": "harness-error", "detail": e.to_string() }),
- };
- let start = Instant::now();
- loop {
- match child.try_wait() {
- Ok(Some(status)) => {
- let mut out = String::new();
- if let Some(mut so) = child.stdout.take() {
- use std::io::Read;
- let _ = so.read_to_string(&mut out);
- }
- return match serde_json::from_str::(out.trim()) {
- Ok(v) => v,
- Err(_) => serde_json::json!({ "file": file_s, "status": "crash", "detail": format!("child exited with {status}") }),
- };
- }
- Ok(None) if start.elapsed() > timeout => {
- let _ = child.kill();
- let _ = child.wait();
- return serde_json::json!({ "file": file_s, "status": "timeout", "detail": format!("exceeded {}s", timeout.as_secs()) });
- }
- Ok(None) => std::thread::sleep(Duration::from_millis(20)),
- Err(e) => return serde_json::json!({ "file": file_s, "status": "harness-error", "detail": e.to_string() }),
- }
- }
-}
-
-// ---- automation --------------------------------------------------------------------------------
-
-fn automation(args: &[String]) -> Result {
- let a = pdfcraft_automation::Automation::new();
- match flag(args, "--root") {
- Some(root) => a.with_root(root).map_err(|e| format!("--root {root}: {e}")),
- None => Ok(a),
- }
-}
-
-fn tools() -> Result<(), CliError> {
- let list: Vec = pdfcraft_automation::tools()
- .iter()
- .map(|t| serde_json::json!({ "name": t.name, "description": t.description, "read_only": t.read_only, "command": t.command, "input_schema": t.input_schema }))
- .collect();
- stdout_line(format_args!("{}", serde_json::to_string_pretty(&list).unwrap_or_default()))?;
- Ok(())
-}
-
-/// Print a tool's result: JSON as JSON; images go to `--out` (or are summarised). The image is
-/// written like a tool's own output, so `--root` confines it too.
-fn print_output(auto: &pdfcraft_automation::Automation, content: Vec, out: Option<&str>) -> Result<(), CliError> {
- for c in content {
- match c {
- pdfcraft_automation::Content::Json(v) => stdout_line(format_args!("{}", serde_json::to_string_pretty(&v).unwrap_or_default()))?,
- pdfcraft_automation::Content::Png { data, width, height } => match out {
- Some(path) => {
- let written = auto.write_output(path, &data).map_err(|e| e.to_string())?;
- stdout_line(format_args!("{}", serde_json::json!({ "image": written.to_string_lossy(), "width": width, "height": height })))?;
- }
- None => stdout_line(format_args!("{}", png_summary(&data, width, height)))?,
- },
- }
- }
- Ok(())
-}
-
-/// What a PNG result is reported as when it isn't written to a file.
-fn png_summary(data: &[u8], width: u32, height: u32) -> serde_json::Value {
- serde_json::json!({ "image": "png", "width": width, "height": height, "bytes": data.len(), "hint": "pass --out file.png to save it" })
-}
-
-/// A tool's result as JSON values, for the `--report` of a script. Same content as
-/// [`print_output`], collected instead of printed.
-fn output_values(
- auto: &pdfcraft_automation::Automation,
- content: Vec,
- out: Option<&str>,
-) -> Result, String> {
- let mut values = Vec::new();
- for c in content {
- values.push(match c {
- pdfcraft_automation::Content::Json(v) => v,
- pdfcraft_automation::Content::Png { data, width, height } => match out {
- Some(path) => {
- let written = auto.write_output(path, &data).map_err(|e| e.to_string())?;
- serde_json::json!({ "image": written.to_string_lossy(), "width": width, "height": height })
- }
- None => png_summary(&data, width, height),
- },
- });
- }
- Ok(values)
-}
-
-/// Run a script and print one JSON document: per step its number, tool and `ok`/`error`/`skipped`
-/// with the result or the message. The first failing step ends the run, as without `--report`;
-/// the steps after it are listed as skipped and the exit status is the same.
-fn run_script_report(auto: &mut pdfcraft_automation::Automation, steps: &[serde_json::Value]) -> Result<(), CliError> {
- let mut report = Vec::with_capacity(steps.len());
- let mut failure: Option = None;
- for (i, step) in steps.iter().enumerate() {
- let n = i + 1;
- let tool = step["tool"].as_str();
- if failure.is_some() {
- report.push(serde_json::json!({ "step": n, "tool": tool, "status": "skipped" }));
- continue;
- }
- let outcome = match tool {
- None => Err(format!("step {n}: missing \"tool\"")),
- Some(t) => auto
- .call(t, &step["args"])
- .map_err(|e| e.to_string())
- .and_then(|content| output_values(auto, content, step["out"].as_str()))
- .map_err(|e| format!("step {n} ({t}): {e}")),
- };
- match outcome {
- Ok(result) => report.push(serde_json::json!({ "step": n, "tool": tool, "status": "ok", "result": result })),
- Err(message) => {
- report.push(serde_json::json!({ "step": n, "tool": tool, "status": "error", "error": message }));
- failure = Some(message);
- }
- }
- }
- let document = serde_json::json!({ "ok": failure.is_none(), "steps": report });
- stdout_line(format_args!("{}", serde_json::to_string_pretty(&document).unwrap_or_default()))?;
- failure.map_or(Ok(()), |message| Err(CliError::Message(message)))
-}
-
-fn run(args: &[String]) -> Result<(), CliError> {
- validate_options("run", args, RUN_OPTIONS)?;
- let report = args.iter().any(|a| a == "--report");
- if report && flag(args, "--script").is_none() {
- return Err("run: --report needs --script".to_string().into());
- }
- let mut auto = automation(args)?;
- if let Some(script) = flag(args, "--script") {
- let text = std::fs::read_to_string(script).map_err(|e| format!("{script}: {e}"))?;
- let steps: Vec = serde_json::from_str(&text).map_err(|e| format!("{script}: {e}"))?;
- if report {
- return run_script_report(&mut auto, &steps);
- }
- for (i, step) in steps.iter().enumerate() {
- let tool = step["tool"].as_str().ok_or(format!("step {}: missing \"tool\"", i + 1))?;
- let content = auto.call(tool, &step["args"]).map_err(|e| format!("step {} ({tool}): {e}", i + 1))?;
- print_output(&auto, content, step["out"].as_str()).map_err(|e| match e {
- CliError::Message(message) => CliError::Message(format!("step {} ({tool}): {message}", i + 1)),
- stdout => stdout,
- })?;
- }
- return Ok(());
- }
- let tool = *positional(args).first().ok_or("run: missing tool name (see `pdfcraft-cli tools`)")?;
- let mut obj = serde_json::Map::new();
- for kv in positional(args).iter().skip(1) {
- let (k, v) = kv.split_once('=').ok_or(format!("run: expected key=value, got {kv:?}"))?;
- let value = serde_json::from_str(v).unwrap_or_else(|_| serde_json::Value::String(v.to_string()));
- obj.insert(k.to_string(), value);
- }
- let content = auto.call(tool, &serde_json::Value::Object(obj)).map_err(|e| e.to_string())?;
- print_output(&auto, content, flag(args, "--out"))
-}
-
-/// Take `--automation-read-root` / `--automation-write-root` (each `--flag DIR` or `--flag=DIR`,
-/// once) out of `args`, and open the roots, which must be existing folders. Returns the arguments
-/// that are left. A malformed root flag is an error, never a root silently left out.
-#[cfg(feature = "mcp")]
-fn automation_roots(command: &str, args: &[String]) -> Result<(Option>, Vec), String> {
- let mut found = automation_args::AutomationArgs::new(None, None);
- let mut left = Vec::new();
- let mut it = args.iter().cloned();
- while let Some(arg) = it.next() {
- if !found.consume(&arg, &mut it).map_err(|e| format!("{command}: {e}"))? {
- left.push(arg);
- }
- }
- let roots = pdfcraft_automation::file_access::AutomationRoots::new(found.read_root.as_deref(), found.write_root.as_deref())
- .map_err(|e| format!("{command}: {e}"))?;
- Ok((roots, left))
-}
-
-#[cfg(feature = "mcp")]
-fn mcp(args: &[String]) -> Result<(), CliError> {
- let (roots, args) = automation_roots("mcp", args)?;
- let args = args.as_slice();
- validate_options("mcp", args, MCP_OPTIONS)?;
- let compact = args.iter().any(|a| a == "--compact");
- let mut auto = automation(args)?;
- if let Some(roots) = roots {
- let show = |root: Option<&Path>| root.map_or_else(|| "none".to_string(), |r| r.display().to_string());
- let _ = writeln!(
- std::io::stderr().lock(),
- "pdfcraft-cli: files confined: read root {}, write root {}",
- show(roots.read_root()),
- show(roots.write_root())
- );
- auto = auto.with_automation_roots(Some(roots));
- }
- let mut server = pdfcraft_automation::mcp::McpServer::new(auto).with_compact(compact);
- let _ = writeln!(
- std::io::stderr().lock(),
- "pdfcraft-cli: MCP server on stdio (protocol {}{}); close stdin to stop",
- pdfcraft_automation::mcp::PROTOCOL_VERSIONS[0],
- if compact { ", compact tool list" } else { "" }
- );
- server.serve(std::io::stdin().lock(), std::io::stdout().lock()).map_err(|e| CliError::Message(e.to_string()))
-}
-
-// ---- UI control channel client -----------------------------------------------------------------
-
-/// One request to a running app's control channel (`pdfcraft --control FILE`).
-fn ui(args: &[String]) -> Result<(), CliError> {
- validate_options("ui", args, UI_OPTIONS)?;
- use std::io::{BufRead, BufReader, Write as _};
- let file = flag(args, "--control").ok_or("ui: missing --control FILE (start the app with `pdfcraft --control FILE`)")?;
- let info = read_control_file(file)?;
- let port = info["port"].as_u64().ok_or(format!("{file}: no port"))?;
- // Refused rather than truncated, which would quietly reach some other port.
- let port = u16::try_from(port).map_err(|_| format!("{file}: {port} is not a port number"))?;
- let token = info["token"].as_str().ok_or(format!("{file}: no token"))?;
- let pos = positional(args);
- let method = *pos.first().ok_or("ui: missing method (state, inspect, click, drag, type, key, command, commands, set, open, screenshot)")?;
- let method = if method.starts_with("ui.") { method.to_string() } else { format!("ui.{method}") };
- let mut params = serde_json::Map::new();
- for kv in pos.iter().skip(1) {
- let (k, v) = kv.split_once('=').ok_or(format!("ui: expected key=value, got {kv:?}"))?;
- params.insert(k.to_string(), serde_json::from_str(v).unwrap_or_else(|_| serde_json::Value::String(v.to_string())));
- }
- let stream =
- std::net::TcpStream::connect(("127.0.0.1", port)).map_err(|e| format!("can't reach the app on port {port}: {e} (is it still running?)"))?;
- stream.set_read_timeout(Some(Duration::from_secs(40))).map_err(|e| e.to_string())?;
- let mut write = stream.try_clone().map_err(|e| e.to_string())?;
- let mut lines = BufReader::new(stream).lines();
- let mut rpc = |id: u64, method: &str, params: serde_json::Value| -> Result {
- writeln!(write, "{}", serde_json::json!({ "jsonrpc": "2.0", "id": id, "method": method, "params": params })).map_err(|e| e.to_string())?;
- let line = lines.next().ok_or("the app closed the connection")?.map_err(|e| e.to_string())?;
- let reply: serde_json::Value = serde_json::from_str(&line).map_err(|e| e.to_string())?;
- match reply.get("error") {
- Some(e) => Err(e["message"].as_str().unwrap_or("error").to_string()),
- None => Ok(reply["result"].clone()),
- }
- };
- rpc(1, "auth", serde_json::json!({ "token": token }))?;
- let mut result = rpc(2, &method, serde_json::Value::Object(params))?;
- if let (Some(out), Some(data)) = (flag(args, "--out"), result.get("png_base64").and_then(|d| d.as_str())) {
- use base64::Engine as _;
- let png = base64::engine::general_purpose::STANDARD.decode(data).map_err(|e| e.to_string())?;
- std::fs::write(out, png).map_err(|e| format!("{out}: {e}"))?;
- result["png_base64"] = serde_json::json!(format!("written to {out}"));
- }
- stdout_line(format_args!("{}", serde_json::to_string_pretty(&result).unwrap_or_default()))?;
- Ok(())
-}
-
-/// Most a control file may hold. The app writes under 100 bytes.
-const CONTROL_FILE_MAX: u64 = 64 * 1024;
-
-const CONTROL_FILE_ADVICE: &str =
- "Start the app with a control file in a folder only you can write, such as `pdfcraft --control ~/.pdfcraft-control.json`";
-
-/// Read the file written by `pdfcraft --control FILE`.
-///
-/// It names the port that receives the token and every command, typed text included, so it must
-/// be the user's own. In a shared folder such as `/tmp`, another local user can create the file
-/// first; the app then can't write it, and the CLI would talk to the other user's listener.
-/// Refused: a symbolic link, anything but a regular file, and on Unix a file owned by someone
-/// else or open to other users (the app writes it with mode 600).
-fn read_control_file(file: &str) -> Result {
- use std::io::Read as _;
- let at_path = std::fs::symlink_metadata(file).map_err(|e| format!("{file}: {e}"))?;
- if at_path.file_type().is_symlink() {
- return Err(format!("{file}: is a symbolic link, which could point at another user's file. {CONTROL_FILE_ADVICE}"));
- }
- if !at_path.is_file() {
- return Err(format!("{file}: not a regular file. {CONTROL_FILE_ADVICE}"));
- }
- let f = std::fs::File::open(file).map_err(|e| format!("{file}: {e}"))?;
- #[cfg(unix)]
- {
- use std::os::unix::fs::MetadataExt;
- // Checked on the open file, so nothing swapped in after `symlink_metadata` gets through.
- let opened = f.metadata().map_err(|e| format!("{file}: {e}"))?;
- if (opened.dev(), opened.ino()) != (at_path.dev(), at_path.ino()) {
- return Err(format!("{file}: was replaced while being opened. {CONTROL_FILE_ADVICE}"));
- }
- if let Some(problem) = control_file_problem(opened.uid(), opened.mode(), current_uid()) {
- return Err(format!("{file}: {problem}. {CONTROL_FILE_ADVICE}"));
- }
- }
- let mut text = String::new();
- f.take(CONTROL_FILE_MAX.saturating_add(1)).read_to_string(&mut text).map_err(|e| format!("{file}: {e}"))?;
- if u64::try_from(text.len()).unwrap_or(u64::MAX) > CONTROL_FILE_MAX {
- return Err(format!("{file}: too large for a control file (over {} KiB)", CONTROL_FILE_MAX / 1024));
- }
- serde_json::from_str(&text).map_err(|e| format!("{file}: {e}"))
-}
-
-/// Why user `me` can't trust a control file with this owner and mode, if they can't.
-#[cfg(any(test, unix))]
-fn control_file_problem(owner: u32, mode: u32, me: u32) -> Option {
- if owner != me {
- return Some(format!("owned by another user (uid {owner}; you are uid {me}), who may be the one listening for your commands"));
- }
- let permissions = mode & 0o7777;
- (permissions & 0o077 != 0).then(|| format!("other users have access to it (mode {permissions:o}; the app writes it with mode 600)"))
-}
-
-/// The effective user id: the owner of the files this process creates, and so of a control file
-/// the app wrote for this user. std has no `geteuid` and this crate forbids `unsafe`; rustix's is
-/// safe and can't fail.
-#[cfg(unix)]
-fn current_uid() -> u32 {
- rustix::process::geteuid().as_raw()
-}
-
-#[cfg(test)]
-mod tests {
- use super::control_file_problem;
-
- #[test]
- fn a_control_file_must_be_yours_and_private() {
- // Regular file type bits (0o100000) don't matter, only the owner and the permission bits.
- assert_eq!(control_file_problem(1000, 0o100600, 1000), None);
- assert_eq!(control_file_problem(1000, 0o600, 1000), None);
- assert_eq!(control_file_problem(1000, 0o400, 1000), None);
- let other = control_file_problem(1001, 0o600, 1000).unwrap_or_default();
- assert!(other.contains("owned by another user") && other.contains("1001"), "{other}");
- // Root gets no exception: a file a user planted is still theirs.
- assert!(control_file_problem(1000, 0o600, 0).is_some_and(|p| p.contains("owned by another user")));
- for mode in [0o644, 0o640, 0o604, 0o660, 0o606, 0o620, 0o602, 0o610, 0o601, 0o100666] {
- let open = control_file_problem(1000, mode, 1000).unwrap_or_default();
- assert!(open.contains("other users"), "{mode:o}: {open}");
- }
- assert!(control_file_problem(1000, 0o100644, 1000).unwrap_or_default().contains("mode 644"));
- }
-}
diff --git a/apps/pdfcraft-cli/src/options.rs b/apps/pdfcraft-cli/src/options.rs
new file mode 100644
index 0000000000..1ddbfabcd5
--- /dev/null
+++ b/apps/pdfcraft-cli/src/options.rs
@@ -0,0 +1,148 @@
+use super::CliError;
+
+pub(crate) fn flag<'a>(args: &'a [String], name: &str) -> Option<&'a str> {
+ args.iter().position(|a| a == name).and_then(|i| args.get(i + 1)).map(String::as_str)
+}
+
+#[derive(Clone, Copy)]
+pub(crate) struct OptionSpec {
+ pub(crate) name: &'static str,
+ pub(crate) takes_value: bool,
+}
+
+/// The options whose value is a file or folder. Only these refuse a value that is another
+/// option: a file named `--password` is never what was meant, while a password can be anything
+/// (`--password --page` is a password of `--page`, which tests/text.rs pins), and so can a title.
+/// The way to name a file that does start with `-` is `./--name`, as `--root` already offered.
+const PATH_VALUE_OPTIONS: [&str; 6] = ["--out", "--out-dir", "--json", "--root", "--control", "--script"];
+
+/// Whether a token can be the value of a path option. Another option cannot: `--out --password`
+/// used to write the PDF to a file called `--password` and exit 0 (photocraft#6944).
+pub(crate) fn can_be_path_value(name: &str, value: Option<&str>) -> bool {
+ !PATH_VALUE_OPTIONS.contains(&name) || value.is_some_and(|v| !v.is_empty() && !v.starts_with("--"))
+}
+
+/// The complaint about a path option whose value is missing or is another option.
+pub(crate) fn needs_value(command: &str, name: &str, value: Option<&str>) -> String {
+ match value {
+ Some(v) if !v.is_empty() => {
+ format!("{command}: {name} needs a value and `{v}` is another option; write `./{v}` for a file of that name")
+ }
+ _ => format!("{command}: {name} needs a value"),
+ }
+}
+
+/// Refuse unknown options and malformed confinement flags before any file is read.
+pub(crate) fn validate_options(command: &str, args: &[String], specs: &[OptionSpec]) -> Result<(), CliError> {
+ if specs.iter().any(|spec| spec.name == "--root") {
+ // `flag` searches every token, including another option's value. Match that lookup so
+ // a malformed confinement flag cannot be hidden there and fall back to no root.
+ let mut root_seen = false;
+ for (i, _) in args.iter().enumerate().filter(|(_, arg)| arg.as_str() == "--root") {
+ if root_seen {
+ return Err(format!("{command}: --root may only be supplied once").into());
+ }
+ root_seen = true;
+ if args.get(i + 1).is_none_or(|value| value.is_empty() || value.starts_with("--")) {
+ return Err(format!("{command}: --root needs a directory value").into());
+ }
+ }
+ }
+ let mut i = 0;
+ while let Some(arg) = args.get(i) {
+ if let Some(name) = arg.strip_prefix("--") {
+ let Some(spec) = specs.iter().find(|spec| spec.name == arg) else {
+ return Err(format!("{command}: unknown option --{name}").into());
+ };
+ // A file's name is not another option, which also closes the gap the walk left for
+ // those: an unknown option in that value position was skipped along with it. Other
+ // values, missing or malformed, are left to the command, which reports them in its
+ // own terms (`text` and `extract` explain what a page value must be).
+ i += if spec.takes_value {
+ let value = args.get(i + 1).map(String::as_str);
+ if !can_be_path_value(spec.name, value) {
+ return Err(needs_value(command, spec.name, value).into());
+ }
+ 2
+ } else {
+ 1
+ };
+ } else {
+ i += 1;
+ }
+ }
+ Ok(())
+}
+
+pub(crate) const INFO_OPTIONS: &[OptionSpec] = &[OptionSpec { name: "--password", takes_value: true }];
+
+pub(crate) const AUDIT_OPTIONS: &[OptionSpec] = &[OptionSpec { name: "--password", takes_value: true }];
+
+pub(crate) const TEXT_OPTIONS: &[OptionSpec] =
+ &[OptionSpec { name: "--page", takes_value: true }, OptionSpec { name: "--password", takes_value: true }];
+
+pub(crate) const COMBINE_OPTIONS: &[OptionSpec] = &[OptionSpec { name: "--out", takes_value: true }];
+
+pub(crate) const EXTRACT_OPTIONS: &[OptionSpec] = &[
+ OptionSpec { name: "--out", takes_value: true },
+ OptionSpec { name: "--pages", takes_value: true },
+ OptionSpec { name: "--password", takes_value: true },
+];
+
+pub(crate) const SPLIT_OPTIONS: &[OptionSpec] = &[
+ OptionSpec { name: "--every", takes_value: true },
+ OptionSpec { name: "--before", takes_value: true },
+ OptionSpec { name: "--out-dir", takes_value: true },
+ OptionSpec { name: "--password", takes_value: true },
+];
+
+pub(crate) const RENDER_OPTIONS: &[OptionSpec] = &[
+ OptionSpec { name: "--page", takes_value: true },
+ OptionSpec { name: "--dpi", takes_value: true },
+ OptionSpec { name: "--out", takes_value: true },
+ OptionSpec { name: "--password", takes_value: true },
+];
+
+pub(crate) const CHECK_ONE_OPTIONS: &[OptionSpec] =
+ &[OptionSpec { name: "--dpi", takes_value: true }, OptionSpec { name: "--edit", takes_value: false }];
+
+pub(crate) const CHECK_OPTIONS: &[OptionSpec] = &[
+ OptionSpec { name: "--timeout", takes_value: true },
+ OptionSpec { name: "--dpi", takes_value: true },
+ OptionSpec { name: "--json", takes_value: true },
+];
+
+pub(crate) const RUN_OPTIONS: &[OptionSpec] = &[
+ OptionSpec { name: "--root", takes_value: true },
+ OptionSpec { name: "--script", takes_value: true },
+ OptionSpec { name: "--out", takes_value: true },
+ OptionSpec { name: "--report", takes_value: false },
+];
+
+pub(crate) const UI_OPTIONS: &[OptionSpec] = &[OptionSpec { name: "--control", takes_value: true }, OptionSpec { name: "--out", takes_value: true }];
+
+#[cfg(feature = "mcp")]
+pub(crate) const MCP_OPTIONS: &[OptionSpec] =
+ &[OptionSpec { name: "--root", takes_value: true }, OptionSpec { name: "--compact", takes_value: false }];
+
+/// The arguments that are not options or their values. Which tokens are values is the spec's
+/// answer: skipping the one after every option made `check-one --edit file.pdf` report "missing
+/// file", the file having been read as `--edit`'s value.
+pub(crate) fn positional<'a>(args: &'a [String], specs: &[OptionSpec]) -> Vec<&'a str> {
+ let mut out = Vec::new();
+ let mut i = 0;
+ while let Some(a) = args.get(i) {
+ match specs.iter().find(|spec| spec.name == a.as_str()) {
+ Some(spec) if spec.takes_value => i += 2,
+ // An option that takes no value, and an unknown one, which `validate_options` has
+ // already refused by the time this runs.
+ Some(_) => i += 1,
+ None if a.starts_with("--") => i += 1,
+ None => {
+ out.push(a.as_str());
+ i += 1;
+ }
+ }
+ }
+ out
+}
diff --git a/apps/pdfcraft-cli/src/run.rs b/apps/pdfcraft-cli/src/run.rs
new file mode 100644
index 0000000000..0726087d9d
--- /dev/null
+++ b/apps/pdfcraft-cli/src/run.rs
@@ -0,0 +1,188 @@
+#[cfg(feature = "mcp")]
+use super::options::MCP_OPTIONS;
+use super::options::{RUN_OPTIONS, flag, positional, validate_options};
+#[cfg(feature = "mcp")]
+use super::{Arc, Path, Write, automation_args};
+use super::{CliError, stdout_line};
+
+// ---- automation --------------------------------------------------------------------------------
+
+pub(crate) fn automation(args: &[String]) -> Result {
+ let a = pdfcraft_automation::Automation::new();
+ match flag(args, "--root") {
+ Some(root) => a.with_root(root).map_err(|e| format!("--root {root}: {e}")),
+ None => Ok(a),
+ }
+}
+
+pub(crate) fn tools() -> Result<(), CliError> {
+ let list: Vec = pdfcraft_automation::tools()
+ .iter()
+ .map(|t| serde_json::json!({ "name": t.name, "description": t.description, "read_only": t.read_only, "command": t.command, "input_schema": t.input_schema }))
+ .collect();
+ stdout_line(format_args!("{}", serde_json::to_string_pretty(&list).unwrap_or_default()))?;
+ Ok(())
+}
+
+/// Print a tool's result: JSON as JSON; images go to `--out` (or are summarised). The image is
+/// written like a tool's own output, so `--root` confines it too.
+pub(crate) fn print_output(
+ auto: &pdfcraft_automation::Automation,
+ content: Vec,
+ out: Option<&str>,
+) -> Result<(), CliError> {
+ for c in content {
+ match c {
+ pdfcraft_automation::Content::Json(v) => stdout_line(format_args!("{}", serde_json::to_string_pretty(&v).unwrap_or_default()))?,
+ pdfcraft_automation::Content::Png { data, width, height } => match out {
+ Some(path) => {
+ let written = auto.write_output(path, &data).map_err(|e| e.to_string())?;
+ stdout_line(format_args!("{}", serde_json::json!({ "image": written.to_string_lossy(), "width": width, "height": height })))?;
+ }
+ None => stdout_line(format_args!("{}", png_summary(&data, width, height)))?,
+ },
+ }
+ }
+ Ok(())
+}
+
+/// What a PNG result is reported as when it isn't written to a file.
+fn png_summary(data: &[u8], width: u32, height: u32) -> serde_json::Value {
+ serde_json::json!({ "image": "png", "width": width, "height": height, "bytes": data.len(), "hint": "pass --out file.png to save it" })
+}
+
+/// A tool's result as JSON values, for the `--report` of a script. Same content as
+/// [`print_output`], collected instead of printed.
+fn output_values(
+ auto: &pdfcraft_automation::Automation,
+ content: Vec,
+ out: Option<&str>,
+) -> Result, String> {
+ let mut values = Vec::new();
+ for c in content {
+ values.push(match c {
+ pdfcraft_automation::Content::Json(v) => v,
+ pdfcraft_automation::Content::Png { data, width, height } => match out {
+ Some(path) => {
+ let written = auto.write_output(path, &data).map_err(|e| e.to_string())?;
+ serde_json::json!({ "image": written.to_string_lossy(), "width": width, "height": height })
+ }
+ None => png_summary(&data, width, height),
+ },
+ });
+ }
+ Ok(values)
+}
+
+/// Run a script and print one JSON document: per step its number, tool and `ok`/`error`/`skipped`
+/// with the result or the message. The first failing step ends the run, as without `--report`;
+/// the steps after it are listed as skipped and the exit status is the same.
+fn run_script_report(auto: &mut pdfcraft_automation::Automation, steps: &[serde_json::Value]) -> Result<(), CliError> {
+ let mut report = Vec::with_capacity(steps.len());
+ let mut failure: Option = None;
+ for (i, step) in steps.iter().enumerate() {
+ let n = i + 1;
+ let tool = step["tool"].as_str();
+ if failure.is_some() {
+ report.push(serde_json::json!({ "step": n, "tool": tool, "status": "skipped" }));
+ continue;
+ }
+ let outcome = match tool {
+ None => Err(format!("step {n}: missing \"tool\"")),
+ Some(t) => auto
+ .call(t, &step["args"])
+ .map_err(|e| e.to_string())
+ .and_then(|content| output_values(auto, content, step["out"].as_str()))
+ .map_err(|e| format!("step {n} ({t}): {e}")),
+ };
+ match outcome {
+ Ok(result) => report.push(serde_json::json!({ "step": n, "tool": tool, "status": "ok", "result": result })),
+ Err(message) => {
+ report.push(serde_json::json!({ "step": n, "tool": tool, "status": "error", "error": message }));
+ failure = Some(message);
+ }
+ }
+ }
+ let document = serde_json::json!({ "ok": failure.is_none(), "steps": report });
+ stdout_line(format_args!("{}", serde_json::to_string_pretty(&document).unwrap_or_default()))?;
+ failure.map_or(Ok(()), |message| Err(CliError::Message(message)))
+}
+
+pub(crate) fn run(args: &[String]) -> Result<(), CliError> {
+ validate_options("run", args, RUN_OPTIONS)?;
+ let report = args.iter().any(|a| a == "--report");
+ if report && flag(args, "--script").is_none() {
+ return Err("run: --report needs --script".to_string().into());
+ }
+ let mut auto = automation(args)?;
+ if let Some(script) = flag(args, "--script") {
+ let text = std::fs::read_to_string(script).map_err(|e| format!("{script}: {e}"))?;
+ let steps: Vec = serde_json::from_str(&text).map_err(|e| format!("{script}: {e}"))?;
+ if report {
+ return run_script_report(&mut auto, &steps);
+ }
+ for (i, step) in steps.iter().enumerate() {
+ let tool = step["tool"].as_str().ok_or(format!("step {}: missing \"tool\"", i + 1))?;
+ let content = auto.call(tool, &step["args"]).map_err(|e| format!("step {} ({tool}): {e}", i + 1))?;
+ print_output(&auto, content, step["out"].as_str()).map_err(|e| match e {
+ CliError::Message(message) => CliError::Message(format!("step {} ({tool}): {message}", i + 1)),
+ stdout => stdout,
+ })?;
+ }
+ return Ok(());
+ }
+ let tool = *positional(args, RUN_OPTIONS).first().ok_or("run: missing tool name (see `pdfcraft-cli tools`)")?;
+ let mut obj = serde_json::Map::new();
+ for kv in positional(args, RUN_OPTIONS).iter().skip(1) {
+ let (k, v) = kv.split_once('=').ok_or(format!("run: expected key=value, got {kv:?}"))?;
+ let value = serde_json::from_str(v).unwrap_or_else(|_| serde_json::Value::String(v.to_string()));
+ obj.insert(k.to_string(), value);
+ }
+ let content = auto.call(tool, &serde_json::Value::Object(obj)).map_err(|e| e.to_string())?;
+ print_output(&auto, content, flag(args, "--out"))
+}
+
+/// Take `--automation-read-root` / `--automation-write-root` (each `--flag DIR` or `--flag=DIR`,
+/// once) out of `args`, and open the roots, which must be existing folders. Returns the arguments
+/// that are left. A malformed root flag is an error, never a root silently left out.
+#[cfg(feature = "mcp")]
+fn automation_roots(command: &str, args: &[String]) -> Result<(Option>, Vec), String> {
+ let mut found = automation_args::AutomationArgs::new(None, None);
+ let mut left = Vec::new();
+ let mut it = args.iter().cloned();
+ while let Some(arg) = it.next() {
+ if !found.consume(&arg, &mut it).map_err(|e| format!("{command}: {e}"))? {
+ left.push(arg);
+ }
+ }
+ let roots = pdfcraft_automation::file_access::AutomationRoots::new(found.read_root.as_deref(), found.write_root.as_deref())
+ .map_err(|e| format!("{command}: {e}"))?;
+ Ok((roots, left))
+}
+
+#[cfg(feature = "mcp")]
+pub(crate) fn mcp(args: &[String]) -> Result<(), CliError> {
+ let (roots, args) = automation_roots("mcp", args)?;
+ let args = args.as_slice();
+ validate_options("mcp", args, MCP_OPTIONS)?;
+ let compact = args.iter().any(|a| a == "--compact");
+ let mut auto = automation(args)?;
+ if let Some(roots) = roots {
+ let show = |root: Option<&Path>| root.map_or_else(|| "none".to_string(), |r| r.display().to_string());
+ let _ = writeln!(
+ std::io::stderr().lock(),
+ "pdfcraft-cli: files confined: read root {}, write root {}",
+ show(roots.read_root()),
+ show(roots.write_root())
+ );
+ auto = auto.with_automation_roots(Some(roots));
+ }
+ let mut server = pdfcraft_automation::mcp::McpServer::new(auto).with_compact(compact);
+ let _ = writeln!(
+ std::io::stderr().lock(),
+ "pdfcraft-cli: MCP server on stdio (protocol {}{}); close stdin to stop",
+ pdfcraft_automation::mcp::PROTOCOL_VERSIONS[0],
+ if compact { ", compact tool list" } else { "" }
+ );
+ server.serve(std::io::stdin().lock(), std::io::stdout().lock()).map_err(|e| CliError::Message(e.to_string()))
+}
diff --git a/apps/pdfcraft-cli/src/ui.rs b/apps/pdfcraft-cli/src/ui.rs
new file mode 100644
index 0000000000..dc419171b0
--- /dev/null
+++ b/apps/pdfcraft-cli/src/ui.rs
@@ -0,0 +1,131 @@
+use super::options::{UI_OPTIONS, flag, positional, validate_options};
+use super::{CliError, Duration, stdout_line};
+
+// ---- UI control channel client -----------------------------------------------------------------
+
+/// One request to a running app's control channel (`pdfcraft --control FILE`).
+pub(crate) fn ui(args: &[String]) -> Result<(), CliError> {
+ validate_options("ui", args, UI_OPTIONS)?;
+ use std::io::{BufRead, BufReader, Write as _};
+ let file = flag(args, "--control").ok_or("ui: missing --control FILE (start the app with `pdfcraft --control FILE`)")?;
+ let info = read_control_file(file)?;
+ let port = info["port"].as_u64().ok_or(format!("{file}: no port"))?;
+ // Refused rather than truncated, which would quietly reach some other port.
+ let port = u16::try_from(port).map_err(|_| format!("{file}: {port} is not a port number"))?;
+ let token = info["token"].as_str().ok_or(format!("{file}: no token"))?;
+ let pos = positional(args, UI_OPTIONS);
+ let method = *pos.first().ok_or("ui: missing method (state, inspect, click, drag, type, key, command, commands, set, open, screenshot)")?;
+ let method = if method.starts_with("ui.") { method.to_string() } else { format!("ui.{method}") };
+ let mut params = serde_json::Map::new();
+ for kv in pos.iter().skip(1) {
+ let (k, v) = kv.split_once('=').ok_or(format!("ui: expected key=value, got {kv:?}"))?;
+ params.insert(k.to_string(), serde_json::from_str(v).unwrap_or_else(|_| serde_json::Value::String(v.to_string())));
+ }
+ let stream =
+ std::net::TcpStream::connect(("127.0.0.1", port)).map_err(|e| format!("can't reach the app on port {port}: {e} (is it still running?)"))?;
+ stream.set_read_timeout(Some(Duration::from_secs(40))).map_err(|e| e.to_string())?;
+ let mut write = stream.try_clone().map_err(|e| e.to_string())?;
+ let mut lines = BufReader::new(stream).lines();
+ let mut rpc = |id: u64, method: &str, params: serde_json::Value| -> Result {
+ writeln!(write, "{}", serde_json::json!({ "jsonrpc": "2.0", "id": id, "method": method, "params": params })).map_err(|e| e.to_string())?;
+ let line = lines.next().ok_or("the app closed the connection")?.map_err(|e| e.to_string())?;
+ let reply: serde_json::Value = serde_json::from_str(&line).map_err(|e| e.to_string())?;
+ match reply.get("error") {
+ Some(e) => Err(e["message"].as_str().unwrap_or("error").to_string()),
+ None => Ok(reply["result"].clone()),
+ }
+ };
+ rpc(1, "auth", serde_json::json!({ "token": token }))?;
+ let mut result = rpc(2, &method, serde_json::Value::Object(params))?;
+ if let (Some(out), Some(data)) = (flag(args, "--out"), result.get("png_base64").and_then(|d| d.as_str())) {
+ use base64::Engine as _;
+ let png = base64::engine::general_purpose::STANDARD.decode(data).map_err(|e| e.to_string())?;
+ std::fs::write(out, png).map_err(|e| format!("{out}: {e}"))?;
+ result["png_base64"] = serde_json::json!(format!("written to {out}"));
+ }
+ stdout_line(format_args!("{}", serde_json::to_string_pretty(&result).unwrap_or_default()))?;
+ Ok(())
+}
+
+/// Most a control file may hold. The app writes under 100 bytes.
+const CONTROL_FILE_MAX: u64 = 64 * 1024;
+
+const CONTROL_FILE_ADVICE: &str =
+ "Start the app with a control file in a folder only you can write, such as `pdfcraft --control ~/.pdfcraft-control.json`";
+
+/// Read the file written by `pdfcraft --control FILE`.
+///
+/// It names the port that receives the token and every command, typed text included, so it must
+/// be the user's own. In a shared folder such as `/tmp`, another local user can create the file
+/// first; the app then can't write it, and the CLI would talk to the other user's listener.
+/// Refused: a symbolic link, anything but a regular file, and on Unix a file owned by someone
+/// else or open to other users (the app writes it with mode 600).
+fn read_control_file(file: &str) -> Result {
+ use std::io::Read as _;
+ let at_path = std::fs::symlink_metadata(file).map_err(|e| format!("{file}: {e}"))?;
+ if at_path.file_type().is_symlink() {
+ return Err(format!("{file}: is a symbolic link, which could point at another user's file. {CONTROL_FILE_ADVICE}"));
+ }
+ if !at_path.is_file() {
+ return Err(format!("{file}: not a regular file. {CONTROL_FILE_ADVICE}"));
+ }
+ let f = std::fs::File::open(file).map_err(|e| format!("{file}: {e}"))?;
+ #[cfg(unix)]
+ {
+ use std::os::unix::fs::MetadataExt;
+ // Checked on the open file, so nothing swapped in after `symlink_metadata` gets through.
+ let opened = f.metadata().map_err(|e| format!("{file}: {e}"))?;
+ if (opened.dev(), opened.ino()) != (at_path.dev(), at_path.ino()) {
+ return Err(format!("{file}: was replaced while being opened. {CONTROL_FILE_ADVICE}"));
+ }
+ if let Some(problem) = control_file_problem(opened.uid(), opened.mode(), current_uid()) {
+ return Err(format!("{file}: {problem}. {CONTROL_FILE_ADVICE}"));
+ }
+ }
+ let mut text = String::new();
+ f.take(CONTROL_FILE_MAX.saturating_add(1)).read_to_string(&mut text).map_err(|e| format!("{file}: {e}"))?;
+ if u64::try_from(text.len()).unwrap_or(u64::MAX) > CONTROL_FILE_MAX {
+ return Err(format!("{file}: too large for a control file (over {} KiB)", CONTROL_FILE_MAX / 1024));
+ }
+ serde_json::from_str(&text).map_err(|e| format!("{file}: {e}"))
+}
+
+/// Why user `me` can't trust a control file with this owner and mode, if they can't.
+#[cfg(any(test, unix))]
+fn control_file_problem(owner: u32, mode: u32, me: u32) -> Option {
+ if owner != me {
+ return Some(format!("owned by another user (uid {owner}; you are uid {me}), who may be the one listening for your commands"));
+ }
+ let permissions = mode & 0o7777;
+ (permissions & 0o077 != 0).then(|| format!("other users have access to it (mode {permissions:o}; the app writes it with mode 600)"))
+}
+
+/// The effective user id: the owner of the files this process creates, and so of a control file
+/// the app wrote for this user. std has no `geteuid` and this crate forbids `unsafe`; rustix's is
+/// safe and can't fail.
+#[cfg(unix)]
+fn current_uid() -> u32 {
+ rustix::process::geteuid().as_raw()
+}
+
+#[cfg(test)]
+mod tests {
+ use super::control_file_problem;
+
+ #[test]
+ fn a_control_file_must_be_yours_and_private() {
+ // Regular file type bits (0o100000) don't matter, only the owner and the permission bits.
+ assert_eq!(control_file_problem(1000, 0o100600, 1000), None);
+ assert_eq!(control_file_problem(1000, 0o600, 1000), None);
+ assert_eq!(control_file_problem(1000, 0o400, 1000), None);
+ let other = control_file_problem(1001, 0o600, 1000).unwrap_or_default();
+ assert!(other.contains("owned by another user") && other.contains("1001"), "{other}");
+ // Root gets no exception: a file a user planted is still theirs.
+ assert!(control_file_problem(1000, 0o600, 0).is_some_and(|p| p.contains("owned by another user")));
+ for mode in [0o644, 0o640, 0o604, 0o660, 0o606, 0o620, 0o602, 0o610, 0o601, 0o100666] {
+ let open = control_file_problem(1000, mode, 1000).unwrap_or_default();
+ assert!(open.contains("other users"), "{mode:o}: {open}");
+ }
+ assert!(control_file_problem(1000, 0o100644, 1000).unwrap_or_default().contains("mode 644"));
+ }
+}
diff --git a/apps/pdfcraft-cli/tests/audit.rs b/apps/pdfcraft-cli/tests/it/audit.rs
similarity index 100%
rename from apps/pdfcraft-cli/tests/audit.rs
rename to apps/pdfcraft-cli/tests/it/audit.rs
diff --git a/apps/pdfcraft-cli/tests/automation_roots.rs b/apps/pdfcraft-cli/tests/it/automation_roots.rs
similarity index 100%
rename from apps/pdfcraft-cli/tests/automation_roots.rs
rename to apps/pdfcraft-cli/tests/it/automation_roots.rs
diff --git a/apps/pdfcraft-cli/tests/batch.rs b/apps/pdfcraft-cli/tests/it/batch.rs
similarity index 100%
rename from apps/pdfcraft-cli/tests/batch.rs
rename to apps/pdfcraft-cli/tests/it/batch.rs
diff --git a/apps/pdfcraft-cli/tests/check.rs b/apps/pdfcraft-cli/tests/it/check.rs
similarity index 79%
rename from apps/pdfcraft-cli/tests/check.rs
rename to apps/pdfcraft-cli/tests/it/check.rs
index a206df5912..7a6236419c 100644
--- a/apps/pdfcraft-cli/tests/check.rs
+++ b/apps/pdfcraft-cli/tests/it/check.rs
@@ -1,4 +1,5 @@
//! The robustness harness visits each directory once, including symlink aliases and cycles.
+//! Its `--timeout` and `--dpi` options are checked before anything is scanned.
use std::path::{Path, PathBuf};
use std::process::{Command, Stdio};
@@ -109,3 +110,22 @@ fn check_follows_directory_and_file_symlinks() {
assert_eq!(results[0]["status"], "ok");
assert_eq!(results[0]["file"], file_alias.to_string_lossy().as_ref());
}
+
+/// `--timeout` and `--dpi` are checked before any input is scanned: a zero timeout used to
+/// make every file time out at once, and a dpi that is not a positive finite number used to
+/// reach each child, which printed nothing and was reported as a crash per file.
+#[test]
+fn a_bad_timeout_or_dpi_is_refused_before_scanning() {
+ let out = Command::new(BIN).args(["check", "no-such-input", "--timeout", "0"]).output().unwrap();
+ assert_eq!(out.status.code(), Some(1));
+ assert_eq!(out.stderr, b"pdfcraft-cli: bad --timeout: expected a positive whole number of seconds, got \"0\"\n");
+ for value in ["abc", "0", "-3", "NaN"] {
+ let out = Command::new(BIN).args(["check", "no-such-input", "--dpi", value]).output().unwrap();
+ assert_eq!(out.status.code(), Some(1), "--dpi {value}");
+ assert_eq!(
+ out.stderr,
+ format!("pdfcraft-cli: bad --dpi: expected a positive number of dots per inch, got {value:?}\n").as_bytes(),
+ "--dpi {value}"
+ );
+ }
+}
diff --git a/apps/pdfcraft-cli/tests/edit.rs b/apps/pdfcraft-cli/tests/it/edit.rs
similarity index 88%
rename from apps/pdfcraft-cli/tests/edit.rs
rename to apps/pdfcraft-cli/tests/it/edit.rs
index b35b71dddc..3cd1442d81 100644
--- a/apps/pdfcraft-cli/tests/edit.rs
+++ b/apps/pdfcraft-cli/tests/it/edit.rs
@@ -141,9 +141,9 @@ mod one_based_arguments {
for destination in ["output.pam", "new.pam", "input.pdf"] {
std::fs::write(dir.0.join("output.pam"), b"keep destination").unwrap();
for (command, option, value, diagnostic) in [
- ("render", "--page", "0", "--page must be at least 1"),
- ("edit", "--move", "1:0", "--move target must be at least 1"),
- ("edit", "--insert-blank", "0", "--insert-blank must be at least 1"),
+ ("render", "--page", "0", "bad --page: expected a positive page number"),
+ ("edit", "--move", "1:0", "bad --move target: expected a page number of at least 1, got \"0\""),
+ ("edit", "--insert-blank", "0", "bad --insert-blank position: expected a page position of at least 1, got \"0\""),
] {
let out = Command::new(BIN).current_dir(&dir.0).args([command, input, "--out", destination, option, value]).output().unwrap();
assert_eq!(out.status.code(), Some(1), "{command} {input} {option} {value}");
@@ -194,6 +194,33 @@ mod one_based_arguments {
}
}
+/// A value the option cannot hold names the option and the value, and fails before the input
+/// file is opened: a missing input still gets the option's own message.
+#[test]
+fn a_value_an_option_cannot_hold_is_named_before_the_file_is_opened() {
+ let dir = tmp("values", "");
+ for (option, value, diagnostic) in [
+ ("--rotate", "1:abc", "bad --rotate degrees: expected a whole number of degrees, got \"abc\""),
+ ("--rotate", "1:45", "bad --rotate degrees: expected a multiple of 90 (such as 90, -90 or 180), got 45"),
+ ("--rotate", "1:-45", "bad --rotate degrees: expected a multiple of 90 (such as 90, -90 or 180), got -45"),
+ ("--move", "1:abc", "bad --move target: expected a page number of at least 1, got \"abc\""),
+ ("--move", "1:0", "bad --move target: expected a page number of at least 1, got \"0\""),
+ ("--insert-blank", "abc", "bad --insert-blank position: expected a page position of at least 1, got \"abc\""),
+ ("--insert-blank", "0", "bad --insert-blank position: expected a page position of at least 1, got \"0\""),
+ ] {
+ let out = Command::new(BIN).current_dir(&dir).args(["edit", "missing.pdf", "--out", "out.pdf", option, value]).output().unwrap();
+ assert_eq!(out.status.code(), Some(1), "{option} {value}");
+ assert_eq!(out.stderr, format!("pdfcraft-cli: {diagnostic}\n").as_bytes(), "{option} {value}");
+ assert!(out.stdout.is_empty(), "{option} {value}");
+ assert!(!dir.join("out.pdf").exists(), "{option} {value}: no output may be written");
+ }
+ // A negative multiple of 90 is still a rotation the engine accepts: the refusal above is
+ // the multiple-of-90 rule, not a sign problem (the missing input is the next error).
+ let out = Command::new(BIN).current_dir(&dir).args(["edit", "missing.pdf", "--out", "out.pdf", "--rotate", "1:-90"]).output().unwrap();
+ assert_eq!(out.status.code(), Some(1));
+ assert!(!String::from_utf8_lossy(&out.stderr).contains("--rotate degrees"), "{}", String::from_utf8_lossy(&out.stderr));
+}
+
/// A destination past the end must fail, not silently become an append (#1330).
#[test]
fn a_destination_past_the_end_fails_and_writes_nothing() {
diff --git a/apps/pdfcraft-cli/tests/extract.rs b/apps/pdfcraft-cli/tests/it/extract.rs
similarity index 100%
rename from apps/pdfcraft-cli/tests/extract.rs
rename to apps/pdfcraft-cli/tests/it/extract.rs
diff --git a/apps/pdfcraft-cli/tests/help.rs b/apps/pdfcraft-cli/tests/it/help.rs
similarity index 100%
rename from apps/pdfcraft-cli/tests/help.rs
rename to apps/pdfcraft-cli/tests/it/help.rs
diff --git a/apps/pdfcraft-cli/tests/info_standards.rs b/apps/pdfcraft-cli/tests/it/info_standards.rs
similarity index 100%
rename from apps/pdfcraft-cli/tests/info_standards.rs
rename to apps/pdfcraft-cli/tests/it/info_standards.rs
diff --git a/apps/pdfcraft-cli/tests/it/main.rs b/apps/pdfcraft-cli/tests/it/main.rs
new file mode 100644
index 0000000000..f0b87c97a7
--- /dev/null
+++ b/apps/pdfcraft-cli/tests/it/main.rs
@@ -0,0 +1,24 @@
+//! The crate's integration tests, linked into one test binary.
+
+mod audit;
+mod automation_roots;
+mod batch;
+mod check;
+mod edit;
+mod extract;
+mod help;
+mod info_standards;
+mod mcp_compact;
+mod options;
+mod owner_password;
+mod render;
+mod root_arguments;
+mod run_report;
+mod run_root;
+mod split;
+mod stderr;
+mod stdout;
+mod text;
+mod ui_control;
+mod version_resource;
+mod xfa;
diff --git a/apps/pdfcraft-cli/tests/mcp_compact.rs b/apps/pdfcraft-cli/tests/it/mcp_compact.rs
similarity index 100%
rename from apps/pdfcraft-cli/tests/mcp_compact.rs
rename to apps/pdfcraft-cli/tests/it/mcp_compact.rs
diff --git a/apps/pdfcraft-cli/tests/it/options.rs b/apps/pdfcraft-cli/tests/it/options.rs
new file mode 100644
index 0000000000..7955157e28
--- /dev/null
+++ b/apps/pdfcraft-cli/tests/it/options.rs
@@ -0,0 +1,115 @@
+//! Every command rejects misspelled long options before opening or writing a file.
+
+use std::process::Command;
+
+const BIN: &str = env!("CARGO_BIN_EXE_pdfcraft-cli");
+
+#[test]
+fn non_edit_commands_reject_unknown_options_before_io() {
+ for args in [
+ vec!["info", "missing.pdf", "--passwrod", "secret"],
+ vec!["text", "missing.pdf", "--pgae", "1"],
+ vec!["combine", "a.pdf", "b.pdf", "--ot", "out.pdf"],
+ vec!["extract", "missing.pdf", "--pgaes", "1", "--out", "out.pdf"],
+ vec!["split", "missing.pdf", "--ever", "2"],
+ vec!["render", "missing.pdf", "--dppi", "96", "--out", "out.png"],
+ vec!["check-one", "missing.pdf", "--edti"],
+ vec!["check", "missing.pdf", "--timout", "2"],
+ vec!["audit", "missing.pdf", "--passwrod", "x"],
+ vec!["audit", "missing.pdf", "--nonsense", "1"],
+ vec!["run", "doc_open", "--roto", "/tmp"],
+ vec!["ui", "--contro", "control.json", "state"],
+ ] {
+ let unknown = args.iter().find(|arg| arg.starts_with("--")).expect("test option");
+ let out = Command::new(BIN).args(&args).output().expect("run cli");
+ let stderr = String::from_utf8_lossy(&out.stderr);
+ assert!(!out.status.success(), "{args:?} unexpectedly succeeded");
+ assert!(stderr.contains("unknown option") && stderr.contains(unknown), "{args:?}: {stderr}");
+ }
+}
+
+/// photocraft#6944: an option took the next token whatever it was, so a line with a value missing
+/// in the middle of it did something else entirely — `extract in.pdf --pages 1 --out --password`
+/// wrote the PDF to a file called `--password` and exited 0. The unknown option hidden in a value
+/// position passed the check above too, since the walk skipped it along with the option.
+///
+/// Only the options whose value is a file or folder refuse one: a password or a title can be any
+/// string, `--page` included, which `text`'s own tests pin and this does not change.
+#[test]
+fn a_file_option_refuses_a_value_that_is_another_option() {
+ let dir = std::env::temp_dir().join(format!("pdfcraft-cli-values-{}", std::process::id()));
+ let _ = std::fs::remove_dir_all(&dir);
+ std::fs::create_dir_all(&dir).expect("temp dir");
+ for (option, args) in [
+ ("--out", vec!["extract", "missing.pdf", "--pages", "1", "--out", "--password"]),
+ ("--out", vec!["combine", "a.pdf", "b.pdf", "--out", "--nonsense"]),
+ ("--out", vec!["render", "missing.pdf", "--out", "--dpi", "96"]),
+ ("--out-dir", vec!["split", "missing.pdf", "--every", "2", "--out-dir", "--password"]),
+ ("--json", vec!["check", "missing.pdf", "--json", "--dpi"]),
+ ("--control", vec!["ui", "--control", "--out", "state"]),
+ // `edit` parses its own options, in the order given, and had the same gap.
+ ("--out", vec!["edit", "missing.pdf", "--out", "--full"]),
+ ] {
+ let out = Command::new(BIN).current_dir(&dir).args(&args).output().expect("run cli");
+ let stderr = String::from_utf8_lossy(&out.stderr);
+ assert!(!out.status.success(), "{args:?} unexpectedly succeeded");
+ assert!(stderr.contains(&format!("{option} needs a value")), "{args:?}: {stderr}");
+ assert!(stderr.contains("another option"), "{args:?}: {stderr}");
+ }
+ assert_eq!(std::fs::read_dir(&dir).expect("read dir").count(), 0, "nothing was written");
+ let _ = std::fs::remove_dir_all(&dir);
+}
+
+/// And a file whose name really does start with `-` is still reachable, the way the shell reaches
+/// one and `--root` already documented: `./--name`. The error is then about the input file, which
+/// is as far as these lines get here.
+#[test]
+fn a_file_whose_name_starts_with_dashes_is_still_reachable() {
+ // The error is about the file the command could not open, so the test asks which file it
+ // names -- not how the OS words "not found", which differs on Windows (#3849 review).
+ for (names, args) in [
+ ("missing.pdf", vec!["extract", "missing.pdf", "--pages", "1", "--out", "./--dash.pdf"]),
+ ("missing.pdf", vec!["edit", "missing.pdf", "--out", "./--dash.pdf"]),
+ ("./--dash.pdf", vec!["info", "./--dash.pdf"]),
+ ] {
+ let out = Command::new(BIN).args(&args).output().expect("run cli");
+ let stderr = String::from_utf8_lossy(&out.stderr);
+ assert!(!out.status.success(), "{args:?}: no such file either way");
+ assert!(stderr.contains(names), "{args:?}: the error is about {names}: {stderr}");
+ // Not a usage error: the `./--dash.pdf` reached the command as a file name.
+ assert!(!stderr.contains("needs a value") && !stderr.contains("unknown option"), "{args:?}: {stderr}");
+ }
+}
+
+/// The token after an option that takes no value is not its value: `check-one --edit file.pdf`
+/// reported "check-one: missing file", the file having been read as `--edit`'s operand.
+#[test]
+fn a_flag_that_takes_no_value_does_not_swallow_the_file() {
+ for args in [vec!["check-one", "--edit", "missing.pdf"], vec!["check-one", "missing.pdf", "--edit"]] {
+ let out = Command::new(BIN).args(&args).output().expect("run cli");
+ let stderr = String::from_utf8_lossy(&out.stderr);
+ assert!(!out.status.success(), "{args:?}");
+ // Which file the error is about, not how the OS says it is missing (#3849 review).
+ assert!(stderr.contains("missing.pdf"), "{args:?}: the error is about the file: {stderr}");
+ assert!(!stderr.contains("missing file"), "the file was found, and then not found: {stderr}");
+ assert!(!stderr.contains("needs a value"), "{args:?}: {stderr}");
+ }
+}
+
+/// photocraft#1108: an argument that is not UTF-8 panicked in `std::env::args` ("called
+/// `Result::unwrap()` on an `Err` value"), while collecting the arguments — so it panicked even
+/// for `--help`. Such a name cannot be used here, where paths are `str`, so it is a usage error.
+#[cfg(unix)]
+#[test]
+fn an_argument_that_is_not_utf8_is_an_error_not_a_panic() {
+ use std::ffi::OsStr;
+ use std::os::unix::ffi::OsStrExt;
+
+ for args in [vec![OsStr::new("info"), OsStr::from_bytes(b"/tmp/caf\xe9.pdf")], vec![OsStr::new("--help"), OsStr::from_bytes(b"caf\xe9")]] {
+ let out = Command::new(BIN).args(&args).output().expect("run cli");
+ let stderr = String::from_utf8_lossy(&out.stderr);
+ assert_eq!(out.status.code(), Some(1), "an error, not a panic (101): {stderr}");
+ assert!(stderr.contains("not valid UTF-8"), "{stderr}");
+ assert!(!stderr.contains("panicked"), "{stderr}");
+ }
+}
diff --git a/apps/pdfcraft-cli/tests/it/owner_password.rs b/apps/pdfcraft-cli/tests/it/owner_password.rs
new file mode 100644
index 0000000000..6796980982
--- /dev/null
+++ b/apps/pdfcraft-cli/tests/it/owner_password.rs
@@ -0,0 +1,154 @@
+//! `info`, `text` and `render` take the owner password of revision 2–4 files, as `run doc_open`
+//! does (#2670, item 8). The renderer authenticates only user passwords of those revisions, so
+//! the CLI must hand it the user password that owner authentication recovers.
+
+use std::process::Command;
+use std::sync::Arc;
+
+use pdfcraft_cos::{Algorithm, NewEncryption};
+
+const BIN: &str = env!("CARGO_BIN_EXE_pdfcraft-cli");
+
+/// A one-page 200×300 pt PDF reading "Page 1", drawn by `content`.
+fn fixture(content: &str) -> Vec {
+ let stream = format!("<< /Length {} >>\nstream\n{content}\nendstream", content.len());
+ let objs: [&[u8]; 5] = [
+ b"<< /Type /Catalog /Pages 2 0 R >>",
+ b"<< /Type /Pages /Kids [4 0 R] /Count 1 /MediaBox [0 0 200 300] >>",
+ b"<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>",
+ b"<< /Type /Page /Parent 2 0 R /Contents 5 0 R /Resources << /Font << /F1 3 0 R >> >> >>",
+ stream.as_bytes(),
+ ];
+ let mut out = b"%PDF-1.7\n".to_vec();
+ let mut offsets = Vec::new();
+ for (i, o) in objs.iter().enumerate() {
+ offsets.push(out.len());
+ out.extend_from_slice(format!("{} 0 obj\n", i + 1).as_bytes());
+ out.extend_from_slice(o);
+ out.extend_from_slice(b"\nendobj\n");
+ }
+ let xref = out.len();
+ out.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objs.len() + 1).as_bytes());
+ for o in offsets {
+ out.extend_from_slice(format!("{o:010} 00000 n \n").as_bytes());
+ }
+ out.extend_from_slice(format!("trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n", objs.len() + 1).as_bytes());
+ out
+}
+
+const PAGE: &str = "BT /F1 24 Tf 20 150 Td (Page 1) Tj ET";
+
+/// `fixture` protected with user password "user" and owner password "owner", allowing nothing,
+/// so copying its text needs the owner password.
+fn protected(algorithm: Algorithm) -> Vec {
+ protected_with(algorithm, PAGE)
+}
+
+fn protected_with(algorithm: Algorithm, content: &str) -> Vec {
+ let mut doc = pdfcraft_cos::Document::open(Arc::new(fixture(content))).unwrap();
+ doc.set_encryption(&NewEncryption {
+ algorithm,
+ user_password: "user",
+ owner_password: "owner",
+ permissions: 0,
+ encrypt_metadata: true,
+ seed: [3; 32],
+ })
+ .unwrap();
+ pdfcraft_cos::write_full(&doc, &Default::default()).unwrap()
+}
+
+fn tmp(name: &str) -> std::path::PathBuf {
+ let d = std::env::temp_dir().join(format!("pdfcraft-cli-owner-{}", std::process::id()));
+ std::fs::create_dir_all(&d).unwrap();
+ d.join(name)
+}
+
+const ALGORITHMS: [Algorithm; 4] = [Algorithm::Rc4_40, Algorithm::Rc4_128, Algorithm::Aes128, Algorithm::Aes256];
+
+#[test]
+fn info_accepts_the_owner_password() {
+ for alg in ALGORITHMS {
+ let path = tmp(&format!("info-{alg:?}.pdf"));
+ std::fs::write(&path, protected(alg)).unwrap();
+ for pw in ["owner", "user"] {
+ let out = Command::new(BIN).args(["info", path.to_str().unwrap(), "--password", pw]).output().unwrap();
+ let stderr = String::from_utf8_lossy(&out.stderr);
+ assert!(out.status.success(), "{alg:?} with {pw:?}: {stderr}");
+ let json: serde_json::Value = serde_json::from_slice(&out.stdout).unwrap();
+ assert_eq!(json["pages"], 1, "{alg:?} with {pw:?}");
+ assert_eq!(json["encrypted"], true, "{alg:?} with {pw:?}");
+ }
+ let out = Command::new(BIN).args(["info", path.to_str().unwrap(), "--password", "wrong"]).output().unwrap();
+ assert!(!out.status.success(), "{alg:?}: a wrong password still fails");
+ assert!(String::from_utf8_lossy(&out.stderr).contains("incorrect"), "{alg:?}");
+ }
+}
+
+#[test]
+fn text_accepts_the_owner_password() {
+ for alg in ALGORITHMS {
+ let path = tmp(&format!("text-{alg:?}.pdf"));
+ std::fs::write(&path, protected(alg)).unwrap();
+ let out = Command::new(BIN).args(["text", path.to_str().unwrap(), "--password", "owner"]).output().unwrap();
+ let stderr = String::from_utf8_lossy(&out.stderr);
+ assert!(out.status.success(), "{alg:?}: {stderr}");
+ assert!(String::from_utf8_lossy(&out.stdout).contains("Page 1"), "{alg:?}");
+ // The user password opens it, but the permissions still forbid copying.
+ let out = Command::new(BIN).args(["text", path.to_str().unwrap(), "--password", "user"]).output().unwrap();
+ assert!(!out.status.success(), "{alg:?}: copying needs the owner password");
+ assert!(String::from_utf8_lossy(&out.stderr).contains("don't allow copying"), "{alg:?}");
+ }
+}
+
+/// Asserts that `data` is a PNG of the fixture's 200×300 pt page at `dpi` with the text drawn:
+/// a blank page of the right size fails too.
+fn assert_rendered_page(data: &[u8], dpi: u32, what: &str) {
+ let mut reader = png::Decoder::new(std::io::Cursor::new(data)).read_info().unwrap_or_else(|e| panic!("{what}: {e}"));
+ let mut buf = vec![0; reader.output_buffer_size().unwrap()];
+ let frame = reader.next_frame(&mut buf).unwrap();
+ assert_eq!((frame.width, frame.height), (200 * dpi / 72, 300 * dpi / 72), "{what}");
+ // A rendered page is opaque, so `render` writes it as 8-bit RGB (`pdfcraft_engine::export::encode_png`).
+ assert_eq!((frame.color_type, frame.bit_depth), (png::ColorType::Rgb, png::BitDepth::Eight), "{what}");
+ // Pixels with every colour channel below half intensity: ink, not white paper.
+ let inked = buf[..frame.buffer_size()].as_chunks::<3>().0.iter().filter(|px| px.iter().all(|c| *c < 128)).count();
+ assert!(inked > 100, "{what}: only {inked} inked pixels, the page is blank");
+}
+
+#[test]
+fn render_accepts_the_owner_password() {
+ const DPI: u32 = 144;
+ for alg in ALGORITHMS {
+ let path = tmp(&format!("render-{alg:?}.pdf"));
+ std::fs::write(&path, protected(alg)).unwrap();
+ for pw in ["owner", "user"] {
+ let png = tmp(&format!("render-{alg:?}-{pw}.png"));
+ let _ = std::fs::remove_file(&png);
+ let out = Command::new(BIN)
+ .args(["render", path.to_str().unwrap(), "--page", "1", "--dpi", &DPI.to_string()])
+ .args(["--password", pw, "--out", png.to_str().unwrap()])
+ .output()
+ .unwrap();
+ let stderr = String::from_utf8_lossy(&out.stderr);
+ assert!(out.status.success(), "{alg:?} with {pw:?}: {stderr}");
+ assert_rendered_page(&std::fs::read(&png).unwrap(), DPI, &format!("{alg:?} with {pw:?}"));
+ }
+ }
+}
+
+#[test]
+fn audit_places_findings_with_the_owner_password() {
+ // A black box over the text: a faux redaction, so `audit` reads the page geometry too.
+ let covered = format!("{PAGE} 0 g 10 140 120 40 re f");
+ for alg in ALGORITHMS {
+ let path = tmp(&format!("audit-{alg:?}.pdf"));
+ std::fs::write(&path, protected_with(alg, &covered)).unwrap();
+ let out = Command::new(BIN).args(["audit", path.to_str().unwrap(), "--password", "owner"]).output().unwrap();
+ let stderr = String::from_utf8_lossy(&out.stderr);
+ assert!(!stderr.contains("incorrect"), "{alg:?}: {stderr}");
+ let report: serde_json::Value = serde_json::from_slice(&out.stdout).unwrap_or_else(|e| panic!("{alg:?}: {e}: {stderr}"));
+ assert_eq!(report["findings"][0]["covered_text"], "Page 1", "{alg:?}");
+ // Top-left points on the 300 pt high page: the box spans y 140–180 from the bottom.
+ assert_eq!(report["findings"][0]["rect"], serde_json::json!([10.0, 120.0, 130.0, 160.0]), "{alg:?}");
+ }
+}
diff --git a/apps/pdfcraft-cli/tests/render.rs b/apps/pdfcraft-cli/tests/it/render.rs
similarity index 68%
rename from apps/pdfcraft-cli/tests/render.rs
rename to apps/pdfcraft-cli/tests/it/render.rs
index 695431b65b..561f9fa787 100644
--- a/apps/pdfcraft-cli/tests/render.rs
+++ b/apps/pdfcraft-cli/tests/it/render.rs
@@ -88,3 +88,27 @@ fn an_unknown_extension_fails_and_writes_nothing() {
assert!(stderr.contains(".png, .jpg, .tif or .pam"), "{stderr}");
assert!(!out.exists());
}
+
+/// `--page` and `--dpi` are checked before the file is opened. A page that is not a positive
+/// whole number names the same expectation `text` names; a dpi that parses as an `f32` but
+/// cannot be rendered (`0`, a negative value, `NaN`) names the option and the value, instead
+/// of reaching the renderer as a scale it refuses with its own internal message.
+#[test]
+fn a_page_or_dpi_the_renderer_cannot_use_is_refused_before_reading_the_file() {
+ for (option, value, diagnostic) in [
+ ("--page", "abc", "bad --page: expected a positive page number"),
+ ("--page", "0", "bad --page: expected a positive page number"),
+ ("--dpi", "abc", "bad --dpi: expected a positive number of dots per inch, got \"abc\""),
+ ("--dpi", "0", "bad --dpi: expected a positive number of dots per inch, got \"0\""),
+ ("--dpi", "-3", "bad --dpi: expected a positive number of dots per inch, got \"-3\""),
+ ("--dpi", "NaN", "bad --dpi: expected a positive number of dots per inch, got \"NaN\""),
+ ("--dpi", "inf", "bad --dpi: expected a positive number of dots per inch, got \"inf\""),
+ ] {
+ let out = tmp("refuse", "out.png");
+ // `missing.pdf` is never read: the option's own message is the error.
+ let r = Command::new(BIN).args(["render", "missing.pdf", "--out", out.to_str().unwrap(), option, value]).output().unwrap();
+ assert_eq!(r.status.code(), Some(1), "{option} {value}");
+ assert_eq!(r.stderr, format!("pdfcraft-cli: {diagnostic}\n").as_bytes(), "{option} {value}");
+ assert!(!out.exists(), "{option} {value}: no output may be written");
+ }
+}
diff --git a/apps/pdfcraft-cli/tests/root_arguments.rs b/apps/pdfcraft-cli/tests/it/root_arguments.rs
similarity index 100%
rename from apps/pdfcraft-cli/tests/root_arguments.rs
rename to apps/pdfcraft-cli/tests/it/root_arguments.rs
diff --git a/apps/pdfcraft-cli/tests/run_report.rs b/apps/pdfcraft-cli/tests/it/run_report.rs
similarity index 100%
rename from apps/pdfcraft-cli/tests/run_report.rs
rename to apps/pdfcraft-cli/tests/it/run_report.rs
diff --git a/apps/pdfcraft-cli/tests/run_root.rs b/apps/pdfcraft-cli/tests/it/run_root.rs
similarity index 100%
rename from apps/pdfcraft-cli/tests/run_root.rs
rename to apps/pdfcraft-cli/tests/it/run_root.rs
diff --git a/apps/pdfcraft-cli/tests/split.rs b/apps/pdfcraft-cli/tests/it/split.rs
similarity index 75%
rename from apps/pdfcraft-cli/tests/split.rs
rename to apps/pdfcraft-cli/tests/it/split.rs
index 7a4a254f89..71523191d7 100644
--- a/apps/pdfcraft-cli/tests/split.rs
+++ b/apps/pdfcraft-cli/tests/it/split.rs
@@ -1,4 +1,5 @@
//! `split --out-dir DIR` creates DIR when it does not exist yet (#249), and says so when it can't.
+//! `--every` must be a positive whole number of pages, checked before the file is opened.
use std::path::PathBuf;
use std::process::{Command, Output};
@@ -74,3 +75,22 @@ fn an_out_dir_that_cannot_be_created_is_reported_as_such() {
assert_eq!(std::fs::read(¬_a_dir).unwrap(), b"in the way");
let _ = std::fs::remove_dir_all(&dir);
}
+
+/// `--every` must be a positive whole number of pages. `0` parses, but the engine used to
+/// clamp it to 1 silently; refused here, before the file is opened, it cannot surprise anyone.
+#[test]
+fn a_non_positive_or_unparseable_every_is_refused_before_the_file_is_opened() {
+ let dir = workdir("every");
+ for value in ["0", "abc"] {
+ let out_dir = dir.join("parts");
+ let out = Command::new(BIN).args(["split", "missing.pdf", "--every", value, "--out-dir", out_dir.to_str().unwrap()]).output().unwrap();
+ assert_eq!(out.status.code(), Some(1), "--every {value}");
+ assert_eq!(
+ out.stderr,
+ format!("pdfcraft-cli: bad --every: expected a positive whole number of pages, got {value:?}\n").as_bytes(),
+ "--every {value}"
+ );
+ assert!(!out_dir.exists(), "--every {value}: nothing may be written");
+ }
+ let _ = std::fs::remove_dir_all(&dir);
+}
diff --git a/apps/pdfcraft-cli/tests/stderr.rs b/apps/pdfcraft-cli/tests/it/stderr.rs
similarity index 100%
rename from apps/pdfcraft-cli/tests/stderr.rs
rename to apps/pdfcraft-cli/tests/it/stderr.rs
diff --git a/apps/pdfcraft-cli/tests/stdout.rs b/apps/pdfcraft-cli/tests/it/stdout.rs
similarity index 100%
rename from apps/pdfcraft-cli/tests/stdout.rs
rename to apps/pdfcraft-cli/tests/it/stdout.rs
diff --git a/apps/pdfcraft-cli/tests/text.rs b/apps/pdfcraft-cli/tests/it/text.rs
similarity index 85%
rename from apps/pdfcraft-cli/tests/text.rs
rename to apps/pdfcraft-cli/tests/it/text.rs
index 3d39a9e284..b7fb2511b7 100644
--- a/apps/pdfcraft-cli/tests/text.rs
+++ b/apps/pdfcraft-cli/tests/it/text.rs
@@ -243,4 +243,40 @@ mod page_argument_tests {
assert_eq!(std::fs::read(&source).unwrap(), bytes);
}
}
+
+ #[test]
+ fn text_clips_glyphs_outside_the_crop_box() {
+ let dir = PageDir::new();
+ let path = dir.0.join("cropped.pdf");
+ let body = "BT /F1 12 Tf 20 260 Td (Outside header) Tj 0 -110 Td (Visible body) Tj 0 -120 Td (Outside footer) Tj ET";
+ let objs: Vec> = vec![
+ b"<< /Type /Catalog /Pages 2 0 R >>".to_vec(),
+ b"<< /Type /Pages /Kids [4 0 R] /Count 1 /MediaBox [0 0 200 300] >>".to_vec(),
+ b"<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>".to_vec(),
+ b"<< /Type /Page /Parent 2 0 R /CropBox [0 100 200 200] /Contents 5 0 R /Resources << /Font << /F1 3 0 R >> >> >>".to_vec(),
+ format!("<< /Length {} >>\nstream\n{body}\nendstream", body.len()).into_bytes(),
+ ];
+ let mut pdf = b"%PDF-1.7\n".to_vec();
+ let mut offsets = Vec::new();
+ for (i, o) in objs.iter().enumerate() {
+ offsets.push(pdf.len());
+ pdf.extend_from_slice(format!("{} 0 obj\n", i + 1).as_bytes());
+ pdf.extend_from_slice(o);
+ pdf.extend_from_slice(b"\nendobj\n");
+ }
+ let xref = pdf.len();
+ pdf.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objs.len() + 1).as_bytes());
+ for o in offsets {
+ pdf.extend_from_slice(format!("{o:010} 00000 n \n").as_bytes());
+ }
+ pdf.extend_from_slice(format!("trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n", objs.len() + 1).as_bytes());
+ std::fs::write(&path, pdf).unwrap();
+
+ for args in [vec![], vec!["--page", "1"]] {
+ let out = Command::new(BIN).arg("text").arg(&path).args(&args).output().unwrap();
+ assert!(out.status.success(), "{args:?}: {out:?}");
+ assert!(out.stderr.is_empty(), "{args:?}: {}", String::from_utf8_lossy(&out.stderr));
+ assert_eq!(String::from_utf8(out.stdout).unwrap().trim(), "Visible body", "{args:?}");
+ }
+ }
}
diff --git a/apps/pdfcraft-cli/tests/ui_control.rs b/apps/pdfcraft-cli/tests/it/ui_control.rs
similarity index 100%
rename from apps/pdfcraft-cli/tests/ui_control.rs
rename to apps/pdfcraft-cli/tests/it/ui_control.rs
diff --git a/apps/pdfcraft-cli/tests/version_resource.rs b/apps/pdfcraft-cli/tests/it/version_resource.rs
similarity index 100%
rename from apps/pdfcraft-cli/tests/version_resource.rs
rename to apps/pdfcraft-cli/tests/it/version_resource.rs
diff --git a/apps/pdfcraft-cli/tests/xfa.rs b/apps/pdfcraft-cli/tests/it/xfa.rs
similarity index 100%
rename from apps/pdfcraft-cli/tests/xfa.rs
rename to apps/pdfcraft-cli/tests/it/xfa.rs
diff --git a/apps/pdfcraft-cli/tests/options.rs b/apps/pdfcraft-cli/tests/options.rs
deleted file mode 100644
index 9576d59a8f..0000000000
--- a/apps/pdfcraft-cli/tests/options.rs
+++ /dev/null
@@ -1,29 +0,0 @@
-//! Every command rejects misspelled long options before opening or writing a file.
-
-use std::process::Command;
-
-const BIN: &str = env!("CARGO_BIN_EXE_pdfcraft-cli");
-
-#[test]
-fn non_edit_commands_reject_unknown_options_before_io() {
- for args in [
- vec!["info", "missing.pdf", "--passwrod", "secret"],
- vec!["text", "missing.pdf", "--pgae", "1"],
- vec!["combine", "a.pdf", "b.pdf", "--ot", "out.pdf"],
- vec!["extract", "missing.pdf", "--pgaes", "1", "--out", "out.pdf"],
- vec!["split", "missing.pdf", "--ever", "2"],
- vec!["render", "missing.pdf", "--dppi", "96", "--out", "out.png"],
- vec!["check-one", "missing.pdf", "--edti"],
- vec!["check", "missing.pdf", "--timout", "2"],
- vec!["audit", "missing.pdf", "--passwrod", "x"],
- vec!["audit", "missing.pdf", "--nonsense", "1"],
- vec!["run", "doc_open", "--roto", "/tmp"],
- vec!["ui", "--contro", "control.json", "state"],
- ] {
- let unknown = args.iter().find(|arg| arg.starts_with("--")).expect("test option");
- let out = Command::new(BIN).args(&args).output().expect("run cli");
- let stderr = String::from_utf8_lossy(&out.stderr);
- assert!(!out.status.success(), "{args:?} unexpectedly succeeded");
- assert!(stderr.contains("unknown option") && stderr.contains(unknown), "{args:?}: {stderr}");
- }
-}
diff --git a/apps/pdfcraft-web/Cargo.toml b/apps/pdfcraft-web/Cargo.toml
index a64d729dcd..ad8243d594 100644
--- a/apps/pdfcraft-web/Cargo.toml
+++ b/apps/pdfcraft-web/Cargo.toml
@@ -12,7 +12,7 @@ workspace = true
[dependencies]
pdfcraft-ui-egui = { workspace = true }
eframe = { workspace = true, features = ["default_fonts", "glow", "persistence", "accesskit"] }
-egui = { workspace = true }
+egui = { workspace = true, features = ["default_fonts", "monochrome_emoji_fonts"] }
[target.'cfg(target_arch = "wasm32")'.dependencies]
wasm-bindgen-futures = "0.4"
diff --git a/apps/pdfcraft/Cargo.toml b/apps/pdfcraft/Cargo.toml
index a27401c700..966af354d1 100644
--- a/apps/pdfcraft/Cargo.toml
+++ b/apps/pdfcraft/Cargo.toml
@@ -11,27 +11,24 @@ workspace = true
[dependencies]
# Help ▸ Check for updates (issue #28): one HTTPS request to GitHub, over rustls with the OS's
# trusted roots. No bundled root list (webpki-roots and webpki-root-certs are CDLA-licensed).
-ureq = { version = "3.4", default-features = false, features = ["rustls-no-provider", "_ring"] }
-rustls-native-certs = "0.8"
pdfcraft-ui-egui = { workspace = true }
# `build_info`: the version and compile-time build label for `--version` and the log.
pdfcraft-engine = { workspace = true }
# wgpu draws; OpenGL (glow) is the fallback when wgpu can't start on an old or unusual GPU or
# driver (#461, #435, #392), and can be chosen with PDFCRAFT_RENDERER=gl.
-eframe = { workspace = true, features = ["default_fonts", "wgpu", "glow", "accesskit", "wayland", "x11", "persistence", "links"] }
-egui = { workspace = true }
+eframe = { workspace = true, features = ["default_fonts", "wgpu", "glow", "accesskit", "wayland", "x11", "persistence", "links", "clipboard"] }
+egui = { workspace = true, features = ["default_fonts", "monochrome_emoji_fonts"] }
serde_json = { workspace = true }
# Records from every crate go to standard error and the log file (src/logging.rs).
log = { workspace = true }
+# The log timestamp goes through chrono (no hand-rolled calendar code).
+chrono = { workspace = true }
[target.'cfg(target_os = "macos")'.dependencies]
# Finder double-click / Open With / Dock drops arrive as Apple events (#73); this small audited
# crate registers the handler (Objective-C interop needs unsafe, which our workspace forbids)
# behind a safe API. Same version as PhotoCraft. Pinned exactly: review the diff before bumping.
fmv-macos-events = "=0.1.0"
-# ⌘Q asks about unsaved changes like the close button (#1136): an app menu whose Quit asks the
-# window to close instead of winit's, which ends the process without asking.
-craft-quit = { workspace = true, features = ["menu"] }
[target.'cfg(target_os = "linux")'.dependencies]
# Asks the Wayland compositor which GPU it draws with (linux-dmabuf feedback), so the window is
@@ -54,8 +51,9 @@ winit = { version = "0.30", default-features = false, features = ["wayland"] }
[target.'cfg(target_os = "windows")'.dependencies]
# Which GPU drives which display (EnumDisplayDevices), so the window is drawn on the GPU that
-# shows it (issue #378). Safe wrappers over Win32; only the `user` feature (which brings `kernel` with it).
-winsafe = { version = "0.0.29", default-features = false, features = ["user"] }
+# shows it (issue #378). Safe wrappers over Win32; only the `user` feature (which brings `kernel` with it),
+# and `advapi` for the session id that names the single-instance socket (#3338).
+winsafe = { version = "0.0.29", default-features = false, features = ["user", "advapi"] }
# Settings ▸ Accessibility ▸ Visual effects ▸ Animation effects (src/motion.rs), through WinRT's
# `UISettings`, whose bindings are safe to call (winsafe's `SystemParametersInfo` is an unsafe
# fn). Already in the tree through wgpu's DX12 backend; this adds one small namespace to it.
@@ -68,3 +66,6 @@ uds_windows = "=1.2.1"
[build-dependencies]
# Icon + VERSIONINFO for pdfcraft.exe; build.rs only uses it when targeting Windows.
winresource = "0.1"
+
+[target.'cfg(windows)'.dependencies]
+craft-update = { workspace = true }
diff --git a/apps/pdfcraft/src/logging.rs b/apps/pdfcraft/src/logging.rs
index 71ed1d2d2f..bef27de169 100644
--- a/apps/pdfcraft/src/logging.rs
+++ b/apps/pdfcraft/src/logging.rs
@@ -90,7 +90,10 @@ fn matches(name: &str, target: &str) -> bool {
}
}
-/// `2026-10-08T07:59:17.728Z` (UTC, milliseconds). A clock before 1970 reads as the epoch.
+/// `2026-10-08T07:59:17.728Z` (UTC, milliseconds). A clock before 1970 reads as the epoch (the
+/// `duration_since` clamp); past the last date chrono can represent (year 262143) the proleptic
+/// Gregorian date is kept via the 400-year cycle rather than falling back to the epoch, so the
+/// output never changes for a value the old formatter accepted and logging never panics.
pub fn timestamp(t: SystemTime) -> String {
let since = t.duration_since(std::time::UNIX_EPOCH).unwrap_or_default();
let secs = since.as_secs();
@@ -99,20 +102,25 @@ pub fn timestamp(t: SystemTime) -> String {
format!("{y:04}-{m:02}-{d:02}T{:02}:{:02}:{:02}.{:03}Z", rem / 3600, rem % 3600 / 60, rem % 60, since.subsec_millis())
}
-/// Days since 1970-01-01 to a proleptic Gregorian (year, month, day) (Howard Hinnant's
-/// `civil_from_days`, unsigned because `timestamp` clamps at the epoch). `days` is at most
-/// `u64::MAX / 86_400`, so none of the arithmetic below can overflow.
-fn civil_from_days(days: u64) -> (u64, u64, u64) {
- let z = days + 719_468;
- let era = z / 146_097;
- let doe = z % 146_097;
- let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365;
- let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
- let mp = (5 * doy + 2) / 153;
- let d = doy - (153 * mp + 2) / 5 + 1;
- let m = if mp < 10 { mp + 3 } else { mp - 9 };
- let y = yoe + era * 400 + u64::from(m <= 2);
- (y, m, d)
+/// Days since 1970-01-01 → proleptic Gregorian (year, month, day).
+///
+/// `timestamp` clamps a pre-1970 clock to the epoch, so `days` is an unsigned count that can run
+/// past `NaiveDate`'s ±262143-year range. The Gregorian calendar repeats every 400 years
+/// (146_097 days), so chrono converts the day within one cycle and whole cycles go back into the
+/// year (an `i128`, then saturated into `i64`), keeping chrono as the only calendar logic and
+/// reproducing the old `civil_from_days` result exactly without hand-rolled day math. 1970-01-01
+/// is `num_days_from_ce` 719_163.
+fn civil_from_days(days: u64) -> (i64, u32, u32) {
+ use chrono::Datelike;
+ let (cycles, rest) = (days / 146_097, days % 146_097);
+ // `rest + 719_163` is 719_163..865_259, always a valid CE day.
+ match i32::try_from(rest + 719_163).ok().and_then(chrono::NaiveDate::from_num_days_from_ce_opt) {
+ Some(d) => {
+ let y = i128::from(d.year()) + i128::from(cycles) * 400;
+ (i64::try_from(y).unwrap_or(i64::MAX), d.month(), d.day())
+ }
+ None => (1970, 1, 1), // unreachable
+ }
}
fn numbered(dir: &Path, n: usize) -> PathBuf {
@@ -337,6 +345,18 @@ mod tests {
assert_eq!(timestamp(UNIX_EPOCH + Duration::from_secs(1_767_225_599)), "2025-12-31T23:59:59.000Z");
// Before the epoch (a clock set wrong) clamps instead of panicking.
assert_eq!(timestamp(UNIX_EPOCH - Duration::from_secs(5)), "1970-01-01T00:00:00.000Z");
+ // Past chrono's range (a clock set absurdly far ahead) keeps the proleptic Gregorian date
+ // the old formatter gave, rather than falling back to the epoch.
+ // Windows' SystemTime holds only up to about year 30828, so this is checked where it fits.
+ if let Some(far) = UNIX_EPOCH.checked_add(Duration::from_secs(10_000_000_000_000)) {
+ assert_eq!(timestamp(far), "318857-05-20T17:46:40.000Z");
+ }
+ // Windows' SystemTime can't hold u64::MAX / 2 seconds, so it is only checked where it exists.
+ if let Some(far) = UNIX_EPOCH.checked_add(Duration::from_secs(u64::MAX / 2)) {
+ assert_eq!(timestamp(far), "292277026596-12-04T15:30:07.000Z");
+ }
+ // Years past 9999 keep the plain zero-padded year (no `+`).
+ assert_eq!(timestamp(UNIX_EPOCH + Duration::from_secs(253_402_300_800)), "10000-01-01T00:00:00.000Z");
}
#[test]
diff --git a/apps/pdfcraft/src/main.rs b/apps/pdfcraft/src/main.rs
index d7d4c1be6d..d2a75c5941 100644
--- a/apps/pdfcraft/src/main.rs
+++ b/apps/pdfcraft/src/main.rs
@@ -1,6 +1,6 @@
//! PdfCraft desktop app.
//!
-//! Usage: `pdfcraft [options] [files…]`
+//! Usage: `pdfcraft [options] [files…]` (`-h` / `--help` prints a short version of this, [`USAGE`]).
//! `--two-paged` opens each command-line PDF in noncontinuous two-page view, without changing defaults.
//! An explicit `--layout` takes precedence for the active document, regardless of argument order.
//! `--create-images [images…]` stages the images in one PDF and asks for the page DPI.
@@ -9,7 +9,8 @@
//! `--page N --zoom 150 --layout single|continuous|two-up|two-page --continuous on|off --panel comments|bookmarks|pages|fields|layers|attachments|none
//! --theme light|dark|system --language auto| --mode all|read|edit|convert|sign --tool --left open|closed
//! --organize on --fields on --dialog properties|shortcuts|about --palette --home on
-//! --cover on|off --default-layout single|continuous|two-up|two-page --default-continuous on|off --default-zoom fit-width|fit-page|
+//! --cover on|off --default-layout single|continuous|two-up|two-page --default-continuous on|off --default-zoom fit-width|fit-page| --always-default-view on|off
+//! --page-gaps on|off --default-page-gaps on|off
//! --combine-view grid|list --combine-zoom 60..200`
//!
//! `--new-window` opens a window of its own. Without it, on Windows, a launch that only names files
@@ -43,7 +44,6 @@ mod settings;
mod single_instance;
mod startup;
mod text_scale;
-mod updates;
#[cfg(test)]
#[path = "windows_manifest.rs"]
mod windows_manifest;
@@ -58,6 +58,33 @@ const APP_ICON_PNG: &[u8] = include_bytes!("../../../assets/app-icon/pdfcraft-10
#[cfg(not(target_os = "macos"))]
const APP_ICON_PNG: &[u8] = include_bytes!("../../../assets/app-icon/hicolor/256x256/apps/ai.storyteller.pdfcraft.png");
+/// What `-h` / `--help` prints (#4076): a hint, not the full option list (that is this file's
+/// doc comment and the README). Scripts want `pdfcraft-cli`, which never opens a window.
+const USAGE: &str = "\
+Usage: pdfcraft [options] [files…]
+
+Opens the files in the PdfCraft desktop app.
+
+Options:
+ --two-paged open command-line PDFs in noncontinuous two-page view
+ --new-window open a window of its own instead of passing the files to a running PdfCraft
+ --create-images [images…] put the images in one PDF and ask for the page DPI
+ --control enable the UI control channel (pdfcraft-cli ui --control …)
+ --page N, --zoom 150, --layout continuous, --theme dark, …
+ view settings applied once the files are open
+ -h, --help print this and exit
+ --version print the version and exit
+
+To inspect, convert or edit PDFs from a script, use pdfcraft-cli (pdfcraft-cli --help).";
+
+/// Print `text` for `--help` and `--version`. `println!` would panic on a full or closed
+/// standard output; this exits normally instead.
+fn print_and_exit(text: &str) -> eframe::Result {
+ use std::io::Write;
+ let _ = writeln!(std::io::stdout().lock(), "{text}");
+ Ok(())
+}
+
/// The app was called PrintCraft before; settings saved then are under this key.
const LEGACY_STORAGE_KEY: &str = "printcraft";
@@ -171,6 +198,11 @@ fn rejoin_split_paths(args: Vec, exists: impl Fn(&str) -> bool) -> Vec eframe::Result {
+ // Windows: the update helper is a copy of this program started with `--finish-update`.
+ #[cfg(windows)]
+ if let Some(result) = craft_update::self_update::finish(&pdfcraft_ui_egui::UPDATE_APP, &std::env::args_os().skip(1).collect::>()) {
+ std::process::exit(i32::from(result.is_err()));
+ }
// First, so the panic hook and every start-up warning are recorded (`logging`).
let logger = logging::install();
// Last-resort guard (AGENTS.md §4): commands, edits, opens and saves catch panics and report
@@ -192,8 +224,10 @@ fn main() -> eframe::Result {
}));
let mut startup = startup::Arguments::parse(std::env::args().skip(1));
if startup.version {
- println!("pdfcraft {}", pdfcraft_engine::build_info::version());
- return Ok(());
+ return print_and_exit(&format!("pdfcraft {}", pdfcraft_engine::build_info::version()));
+ }
+ if startup.help {
+ return print_and_exit(USAGE);
}
startup.files = rejoin_split_paths(startup.files, |p| std::path::Path::new(p).is_file());
let may_hand_off = startup.may_hand_off();
@@ -318,6 +352,9 @@ fn native_options(integrated: bool, app_frame: bool, renderer: eframe::Renderer)
let persistence_path = settings_dir().map(|d| d.join("app.ron"));
// Set explicitly, so the renderer never depends on which one eframe defaults to.
let mut native = eframe::NativeOptions { viewport, persistence_path, renderer, ..Default::default() };
+ // PdfCraft installs its own last-resort system face (`PDFCRAFT_SYSTEM_FONTS`); eframe's
+ // provider would read every installed face, including proprietary ones, into every UI pass.
+ native.system_font_fallback = false;
if renderer == eframe::Renderer::Wgpu {
configure_gpu(&mut native);
}
@@ -378,16 +415,17 @@ fn app_creator<'a>(
{
app.motion.source = Some(motion::source());
}
- app.update_source = Some(std::sync::Arc::new(updates::latest_release));
+ app.set_startup_update_check(true);
app.printer_source = Some(pdfcraft_ui_egui::system_printers());
app.os_key_store_ids = cfg!(any(target_os = "macos", target_os = "windows"));
#[cfg(target_os = "macos")]
{
app.os_events = Some(apple_events.connect(&cc.egui_ctx));
- // winit's Quit (⌘Q) ends the process without asking about unsaved changes (#1136). This
- // menu's Quit closes the window instead, so `guard_quit` asks, as for the close button.
- if let Err(e) = craft_quit::install_app_menu(&cc.egui_ctx, "PdfCraft") {
- log::warn!("couldn't install the app menu, so ⌘Q quits without asking to save: {e}");
+ // The native menu bar. Its Quit (⌘Q) closes the window rather than ending the process as
+ // winit's does (#1136), so `guard_quit` asks about unsaved changes, as for the close
+ // button.
+ if !app.install_native_menu(&cc.egui_ctx) {
+ log::warn!("couldn't install the macOS menu bar; the menus stay in the window");
}
}
if let Some(server) = instance {
@@ -545,6 +583,11 @@ fn write_control_file(path: &str, port: u16, token: &str) -> std::io::Result<()>
/// `WGPU_BACKEND` asks for them. Creating a Vulkan instance loads every installed Vulkan driver
/// into the process, and a faulty one (an Intel driver in issue #37) crashed PdfCraft before
/// its window appeared. D3D12 is the native, best-supported backend there.
+/// - On Windows, Direct3D 12 presents through a DirectComposition visual rather than a swap chain
+/// on the window itself, unless `WGPU_DX12_PRESENTATION_SYSTEM` says otherwise. While the window
+/// is being resized, Windows stretched the window's swap chain, still at the old size, to the
+/// new one, so the text shimmered and the left panel's width bounced for as long as the drag
+/// lasted (issue #2738). A visual's last frame is shown at its own size instead.
fn configure_gpu(native: &mut eframe::NativeOptions) {
let eframe::egui_wgpu::WgpuSetup::CreateNew(setup) = &mut native.wgpu_options.wgpu_setup else { return };
// Nothing draws or dispatches indirectly, so wgpu's check of indirect arguments only costs a
@@ -592,6 +635,15 @@ fn configure_gpu(native: &mut eframe::NativeOptions) {
if cfg!(target_os = "windows") && std::env::var_os("WGPU_BACKEND").is_none() {
setup.instance_descriptor.backends = eframe::wgpu::Backends::DX12 | eframe::wgpu::Backends::GL;
}
+ if cfg!(target_os = "windows") {
+ setup.instance_descriptor.backend_options.dx12.presentation_system = dx12_presentation(eframe::wgpu::Dx12SwapchainKind::from_env());
+ }
+}
+
+/// How Direct3D 12 presents on Windows: through a DirectComposition visual (issue #2738), unless
+/// `WGPU_DX12_PRESENTATION_SYSTEM` asked for something else (`asked`).
+fn dx12_presentation(asked: Option) -> eframe::wgpu::Dx12SwapchainKind {
+ asked.unwrap_or(eframe::wgpu::Dx12SwapchainKind::DxgiFromVisual)
}
/// Surface textures must fit the device's enabled limits, not just the physical GPU's limits.
@@ -742,637 +794,4 @@ fn pick_adapter(adapters: &[(u32, u32, eframe::wgpu::DeviceType)], displays: &[D
}
#[cfg(test)]
-mod tests {
- use pdfcraft_ui_egui::control::Endpoint;
-
- fn endpoint() -> std::io::Result {
- Ok(Endpoint { port: 4321, token: "0123456789abcdef".into() })
- }
-
- /// Tests that open a real GPU device take this first, so only one of them is inside the
- /// graphics libraries at a time. The test threads otherwise run them together, and on
- /// FreeBSD's CI machine the test process then died with SIGSEGV, in about half the runs:
- /// the first device was still being set up when the second test began its own. The UI
- /// tests take turns the same way (`crates/ui-egui/tests/view.rs`). Hold it for the whole
- /// test (declared before the instance, so it's released after the device is dropped).
- static GPU: std::sync::Mutex<()> = std::sync::Mutex::new(());
-
- fn gpu() -> std::sync::MutexGuard<'static, ()> {
- // A test that panicked while holding it leaves nothing to clean up.
- GPU.lock().unwrap_or_else(std::sync::PoisonError::into_inner)
- }
-
- #[test]
- fn a_control_channel_that_cant_be_published_stops_the_app() {
- let dir = std::env::temp_dir().join(format!("pdfcraft-app-control-{}", std::process::id()));
- let _ = std::fs::remove_dir_all(&dir);
- std::fs::create_dir_all(&dir).unwrap();
- let path = |name: &str| dir.join(name).to_str().unwrap().to_owned();
-
- // A folder in the way stands in for a file another user created first: the write fails.
- std::fs::create_dir(dir.join("taken.json")).unwrap();
- let err = super::open_control_channel(&path("taken.json"), endpoint()).unwrap_err();
- assert!(err.contains("--control") && err.contains("taken.json") && err.contains("only you can write"), "{err}");
- // No listener: nothing to publish.
- let err = super::open_control_channel(&path("pc.json"), Err(std::io::Error::other("no loopback"))).unwrap_err();
- assert!(err.contains("no loopback"), "{err}");
- assert!(!dir.join("pc.json").exists());
-
- super::open_control_channel(&path("pc.json"), endpoint()).unwrap();
- let written: serde_json::Value = serde_json::from_str(&std::fs::read_to_string(dir.join("pc.json")).unwrap()).unwrap();
- assert_eq!((written["port"].as_u64(), written["token"].as_str()), (Some(4321), Some("0123456789abcdef")));
- let _ = std::fs::remove_dir_all(&dir);
- }
-
- #[test]
- fn path_from_arg_decodes_file_uris() {
- assert_eq!(super::path_from_arg("file:///home/alice/report.pdf".to_string()), "/home/alice/report.pdf");
- assert_eq!(super::path_from_arg("file:///home/alice/my%20report.pdf".to_string()), "/home/alice/my report.pdf");
- assert_eq!(super::path_from_arg("file://localhost/tmp/a%C3%A9.pdf".to_string()), "/tmp/aé.pdf");
- // Malformed or truncated escapes are kept as written; bytes that aren't UTF-8 keep the URI.
- assert_eq!(super::path_from_arg("file:///tmp/100%.pdf".to_string()), "/tmp/100%.pdf");
- assert_eq!(super::path_from_arg("file:///tmp/a%2".to_string()), "/tmp/a%2");
- assert_eq!(super::path_from_arg("file:///tmp/%zz%".to_string()), "/tmp/%zz%");
- assert_eq!(super::path_from_arg("file:///tmp/%FF.pdf".to_string()), "file:///tmp/%FF.pdf");
- }
-
- #[test]
- fn unquoted_paths_with_spaces_are_rejoined() {
- let have = ["P:\\Archive\\00042A26 Contract.PDF", "C:\\a b c.pdf", "C:\\a.pdf", "C:\\x y.pdf"];
- let exists = |p: &str| have.contains(&p);
- let args = |a: &[&str]| a.iter().map(|s| s.to_string()).collect::>();
- // The case an office program produced: one path, split at its space.
- assert_eq!(
- super::rejoin_split_paths(args(&["P:\\Archive\\00042A26", "Contract.PDF"]), exists),
- args(&["P:\\Archive\\00042A26 Contract.PDF"])
- );
- // The longest run wins; files that exist on their own are left alone.
- assert_eq!(super::rejoin_split_paths(args(&["C:\\a", "b", "c.pdf", "C:\\a.pdf"]), exists), args(&["C:\\a b c.pdf", "C:\\a.pdf"]));
- assert_eq!(super::rejoin_split_paths(args(&["C:\\a.pdf", "C:\\x", "y.pdf"]), exists), args(&["C:\\a.pdf", "C:\\x y.pdf"]));
- // Nothing to join: kept as given, so the missing file is still reported by name.
- assert_eq!(super::rejoin_split_paths(args(&["missing.pdf", "other"]), exists), args(&["missing.pdf", "other"]));
- assert!(super::rejoin_split_paths(Vec::new(), exists).is_empty());
- }
-
- #[test]
- fn path_from_arg_leaves_plain_paths_and_other_schemes_alone() {
- assert_eq!(super::path_from_arg("report.pdf".to_string()), "report.pdf");
- assert_eq!(super::path_from_arg("/home/alice/report.pdf".to_string()), "/home/alice/report.pdf");
- assert_eq!(super::path_from_arg("https://example.com/report.pdf".to_string()), "https://example.com/report.pdf");
- assert_eq!(super::path_from_arg("file://server/share/a.pdf".to_string()), "file://server/share/a.pdf");
- assert_eq!(super::path_from_arg("file://".to_string()), "file://");
- }
-
- #[test]
- fn opengl_is_the_fallback_unless_a_renderer_was_chosen() {
- use super::{RendererChoice, native_options, renderer_choice, retry_with_gl};
- assert_eq!(renderer_choice(None), RendererChoice::Auto);
- assert_eq!(renderer_choice(Some("")), RendererChoice::Auto);
- assert_eq!(renderer_choice(Some("vulkan")), RendererChoice::Auto);
- assert_eq!(renderer_choice(Some(" WGPU ")), RendererChoice::Wgpu);
- for gl in ["gl", "OpenGL", "glow"] {
- assert_eq!(renderer_choice(Some(gl)), RendererChoice::Gl);
- }
- // Retried with OpenGL only when wgpu failed to start by default, never after the app ran.
- assert!(retry_with_gl(RendererChoice::Auto, false));
- assert!(!retry_with_gl(RendererChoice::Auto, true));
- assert!(!retry_with_gl(RendererChoice::Wgpu, false));
- assert!(!retry_with_gl(RendererChoice::Gl, false));
- // Each run states its renderer rather than relying on eframe's default.
- assert_eq!(native_options(false, false, eframe::Renderer::Wgpu).renderer, eframe::Renderer::Wgpu);
- assert_eq!(native_options(false, false, eframe::Renderer::Glow).renderer, eframe::Renderer::Glow);
- }
-
- #[test]
- fn the_desktop_frame_is_turned_off_only_when_the_app_draws_one() {
- use super::native_options;
- assert_eq!(native_options(false, false, eframe::Renderer::Glow).viewport.decorations, None);
- assert_eq!(native_options(false, true, eframe::Renderer::Glow).viewport.decorations, Some(false));
- }
-
- #[test]
- fn a_software_renderer_is_shown_to_the_user() {
- use eframe::wgpu::DeviceType;
- // Issue #519: WARP or llvmpipe is announced in the app, not just in pdfcraft.log.
- let mut app = super::PdfCraftApp::new();
- super::notify_software_renderer(&mut app, DeviceType::Cpu);
- let notice = app.toast.as_ref().map(|(m, _)| m.as_str()).unwrap_or_default();
- assert!(notice.contains("without a graphics processor"), "{notice:?}");
- for gpu in [DeviceType::IntegratedGpu, DeviceType::DiscreteGpu, DeviceType::VirtualGpu, DeviceType::Other] {
- let mut app = super::PdfCraftApp::new();
- super::notify_software_renderer(&mut app, gpu);
- assert!(app.toast.is_none(), "{gpu:?}: {:?}", app.toast);
- }
- }
-
- #[test]
- fn gpu_backends_avoid_vulkan_on_windows_and_prefer_low_power() {
- let mut native = eframe::NativeOptions::default();
- super::configure_gpu(&mut native);
- let eframe::egui_wgpu::WgpuSetup::CreateNew(setup) = &native.wgpu_options.wgpu_setup else {
- panic!("default setup creates its own instance")
- };
- if std::env::var_os("WGPU_POWER_PREF").is_none() {
- assert_eq!(setup.power_preference, eframe::wgpu::PowerPreference::LowPower);
- }
- if std::env::var_os("WGPU_VALIDATION_INDIRECT_CALL").is_none() {
- let flags = setup.instance_descriptor.flags;
- assert!(!flags.contains(eframe::wgpu::InstanceFlags::VALIDATION_INDIRECT_CALL), "Intel Metal can't compile its shader: {flags:?}");
- }
- if cfg!(target_os = "windows") && std::env::var_os("WGPU_BACKEND").is_none() {
- let backends = setup.instance_descriptor.backends;
- assert!(backends.contains(eframe::wgpu::Backends::DX12), "{backends:?}");
- assert!(!backends.contains(eframe::wgpu::Backends::VULKAN), "issue #37: {backends:?}");
- }
- }
-
- #[test]
- fn restored_hidpi_window_fits_the_adapters_surface_limit() {
- // The Outlook launch crash: 3440 x 1369 saved points at 250% DPI requested this surface.
- let (width, height) = (8600, 3423);
- let required = eframe::wgpu::Limits::default();
- assert!(width > required.max_texture_dimension_2d);
- let supported = eframe::wgpu::Limits { max_texture_dimension_2d: 16384, ..required.clone() };
- let enabled = super::surface_texture_limits(required, &supported);
- assert!(width <= enabled.max_texture_dimension_2d);
- assert!(height <= enabled.max_texture_dimension_2d);
- }
-
- #[test]
- fn surface_limits_preserve_other_requirements_and_never_overrequest() {
- for required in [eframe::wgpu::Limits::default(), eframe::wgpu::Limits::downlevel_webgl2_defaults()] {
- for extent in [4096, 8192, 16384] {
- let supported = eframe::wgpu::Limits {
- max_texture_dimension_2d: extent,
- max_bind_groups: 8,
- min_uniform_buffer_offset_alignment: 128,
- min_storage_buffer_offset_alignment: 128,
- ..required.clone()
- };
- let enabled = super::surface_texture_limits(required.clone(), &supported);
- let mut expected = required.clone();
- expected.max_texture_dimension_2d = extent;
- assert_eq!(enabled, expected);
- assert_eq!(enabled.max_texture_dimension_2d, supported.max_texture_dimension_2d);
- assert!(enabled.check_limits(&supported));
- }
- }
- }
-
- #[test]
- fn surface_limits_fit_v3d_shader_variable_limit() {
- // Issue #2383: V3D supports 15 variables, below the default request of 16.
- let required = eframe::wgpu::Limits::default();
- let supported = eframe::wgpu::Limits { max_texture_dimension_2d: 7680, max_inter_stage_shader_variables: 15, ..required.clone() };
- let enabled = super::surface_texture_limits(required.clone(), &supported);
- assert!(enabled.check_limits(&supported), "unsupported device limits: {enabled:?}");
- let expected = eframe::wgpu::Limits { max_texture_dimension_2d: 7680, max_inter_stage_shader_variables: 15, ..required };
- assert_eq!(enabled, expected);
- }
-
- #[test]
- fn surface_limits_fit_downlevel_maxima_and_minimum_alignments() {
- let required = eframe::wgpu::Limits::downlevel_webgl2_defaults();
- let supported = eframe::wgpu::Limits {
- max_texture_dimension_2d: 2048,
- max_bind_groups: 2,
- max_vertex_attributes: 8,
- min_uniform_buffer_offset_alignment: 512,
- min_storage_buffer_offset_alignment: 512,
- ..required.clone()
- };
- let enabled = super::surface_texture_limits(required, &supported);
- assert!(enabled.check_limits(&supported), "unsupported device limits: {enabled:?}");
- // Larger minimum alignments are weaker capabilities, unlike maximum limits.
- assert_eq!(enabled, supported);
- }
-
- #[test]
- fn a_window_larger_than_the_gpu_limit_gets_a_surface_within_it() {
- use eframe::egui_wgpu::winit::surface_fit;
- // Issue #577: 3440 x 1369 points restored at 250% asked an 8192-pixel device for this.
- let (width, height, scale) = surface_fit(8600, 3423, 8192);
- assert_eq!((width, height), (8192, 3260));
- // Both sides shrink alike and egui draws at that factor: the whole window is drawn.
- assert!((8600.0 * scale - 8192.0).abs() < 0.01, "{scale}");
- assert!((3423.0 * scale - height as f32).abs() < 1.0, "{scale}");
- // Within the limit nothing changes, up to and including the limit itself.
- assert_eq!(surface_fit(8600, 3423, 16384), (8600, 3423, 1.0));
- assert_eq!(surface_fit(8192, 8192, 8192), (8192, 8192, 1.0));
- assert_eq!(surface_fit(0, 0, 8192), (0, 0, 1.0));
- // One side over the limit: the window as it would be stretched across monitors.
- assert_eq!(surface_fit(8193, 600, 8192).0, 8192);
- assert_eq!(surface_fit(600, 8193, 8192).1, 8192);
- }
-
- #[test]
- fn surface_fit_never_empties_or_overflows_a_surface() {
- use eframe::egui_wgpu::winit::surface_fit;
- for (w, h, max) in [
- (1, u32::MAX, 8192),
- (u32::MAX, 1, 2048),
- (u32::MAX, u32::MAX, 16384),
- (0, u32::MAX, 8192),
- (u32::MAX, 0, 8192),
- (5, 7, 0),
- (40_000, 3, 1),
- ] {
- let (fw, fh, scale) = surface_fit(w, h, max);
- assert!(fw <= max.max(1) && fh <= max.max(1), "{w} x {h} in {max}: {fw} x {fh}");
- // A side that wasn't zero stays non-zero (`Surface::configure` rejects an empty one),
- // and a zero side stays zero (egui-wgpu skips configuring it).
- assert_eq!((fw == 0, fh == 0), (w == 0, h == 0), "{w} x {h} in {max}: {fw} x {fh}");
- assert!(scale.is_finite() && scale > 0.0 && scale <= 1.0, "{w} x {h} in {max}: {scale}");
- }
- // The longer side lands exactly on the limit (rounding never leaves it a pixel short),
- // and the shorter side keeps the window's proportions to within a pixel.
- for max in [2048, 8192, 16384] {
- for long in (max + 1..=max * 5).step_by(997).chain([max * 2, max * 4, u32::MAX]) {
- for short in [1, 3, 600, max / 3, max - 1, max, long - 1, long] {
- let (fw, fh, scale) = surface_fit(long, short, max);
- assert_eq!(fw, max, "{long} x {short} in {max}");
- let expected = f64::from(short) * f64::from(max) / f64::from(long);
- assert!((f64::from(fh) - expected).abs() <= 1.0, "{long} x {short} in {max}: {fh}");
- assert_eq!(surface_fit(short, long, max), (fh, fw, scale), "transposed");
- }
- }
- }
- }
-
- #[test]
- fn egui_wgpu_carries_the_surface_size_fit() {
- // Issue #577: egui-wgpu 0.36.2 configures a window's surface at the window's size, and
- // `Surface::configure` panics when that's beyond the device's `max_texture_dimension_2d`
- // (emilk/egui#8361). vendor/egui-wgpu fits it within the limit. A dependency bump that
- // resolves egui-wgpu from crates.io again, or a re-vendored copy without the patch, would
- // bring the crash back: re-apply the patch, or drop the copy once an egui release has a
- // fix (vendor/README.md).
- let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../..");
- let lock = std::fs::read_to_string(root.join("Cargo.lock")).unwrap();
- let egui_wgpu = lock.split("[[package]]").find(|p| p.contains("\nname = \"egui-wgpu\"\n")).expect("egui-wgpu is in Cargo.lock");
- assert!(!egui_wgpu.contains("\nsource = "), "egui-wgpu must resolve to vendor/egui-wgpu, not:{egui_wgpu}");
- // `surface_fit` is tested above; these keep it in the paths that size a surface.
- let painter = std::fs::read_to_string(root.join("vendor/egui-wgpu/src/winit.rs")).unwrap().replace("\r\n", "\n");
- for patch in [
- "let (width, height, render_scale) = surface_fit(window_width, window_height, max_side);",
- "let (fit_width, fit_height, _) = surface_fit(width, height, self.max_surface_side());",
- "pixels_per_point: pixels_per_point * surface_state.render_scale,",
- "old_state.window_width,\n old_state.window_height,",
- ] {
- assert!(painter.contains(patch), "vendor/egui-wgpu lost its surface size patch: {patch}");
- }
- }
-
- #[test]
- fn a_gpu_error_while_a_window_is_set_up_is_returned_not_a_panic() {
- // Issue #519: on a 2015 Intel GPU, wgpu's GL backend couldn't configure the window's
- // surface (`GpuWaitTimeout`), and wgpu's default error handler panicked before the app was
- // created, so PdfCraft never got to retry with OpenGL. vendor/egui-wgpu configures a new
- // window's surface inside `catch_errors`. A real device on PdfCraft's own GPU settings,
- // and an error wgpu raises before anything reaches the driver: a texture one pixel wider
- // than the device allows.
- use eframe::wgpu;
- let _gpu = gpu();
- let native = super::native_options(false, false, eframe::Renderer::Wgpu);
- let eframe::egui_wgpu::WgpuSetup::CreateNew(setup) = &native.wgpu_options.wgpu_setup else {
- panic!("default setup creates its own instance")
- };
- let instance = pollster::block_on(native.wgpu_options.wgpu_setup.new_instance());
- let options = wgpu::RequestAdapterOptions { power_preference: setup.power_preference, ..Default::default() };
- // CI runners have a software adapter (WARP, llvmpipe); a machine without any skips.
- let Ok(adapter) = pollster::block_on(instance.request_adapter(&options)) else {
- eprintln!("skipping: no GPU adapter on this machine");
- return;
- };
- let Ok((device, _queue)) = pollster::block_on(adapter.request_device(&(setup.device_descriptor)(&adapter))) else {
- eprintln!("skipping: the adapter gives no device");
- return;
- };
- let texture = |width: u32| {
- device.create_texture(&wgpu::TextureDescriptor {
- label: Some("issue-519"),
- size: wgpu::Extent3d { width, height: 1, depth_or_array_layers: 1 },
- mip_level_count: 1,
- sample_count: 1,
- dimension: wgpu::TextureDimension::D2,
- format: wgpu::TextureFormat::Rgba8Unorm,
- usage: wgpu::TextureUsages::TEXTURE_BINDING,
- view_formats: &[],
- })
- };
- let too_wide = device.limits().max_texture_dimension_2d.saturating_add(1);
- let caught = pollster::block_on(eframe::egui_wgpu::winit::catch_errors(&device, || texture(too_wide)));
- assert!(matches!(caught, Err(wgpu::Error::Validation { .. })), "{caught:?}");
- // The device stays usable, and nothing is left behind to catch later errors by mistake.
- let fine = pollster::block_on(eframe::egui_wgpu::winit::catch_errors(&device, || texture(16)));
- assert!(fine.is_ok(), "{fine:?}");
- assert_eq!(pollster::block_on(eframe::egui_wgpu::winit::catch_errors(&device, || 7)).ok(), Some(7));
- // Nested: the inner call keeps its own error, and the outer one still catches what follows.
- let caught = pollster::block_on(eframe::egui_wgpu::winit::catch_errors(&device, || {
- let inner = pollster::block_on(eframe::egui_wgpu::winit::catch_errors(&device, || texture(too_wide)));
- assert!(inner.is_err(), "inner: {inner:?}");
- texture(too_wide)
- }));
- assert!(matches!(caught, Err(wgpu::Error::Validation { .. })), "outer: {caught:?}");
- }
-
- #[test]
- fn egui_wgpu_returns_a_surface_it_cannot_configure_as_an_error() {
- // Issue #519: `catch_errors` is tested above; this keeps it around the first configure of
- // every new window's surface, and its error reaching eframe as `WgpuError` (eframe then
- // returns `Error::Wgpu`, which `main` retries with OpenGL). Without it a failed configure
- // panics before the app is created, and the OpenGL retry never runs.
- let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../..");
- let lock = std::fs::read_to_string(root.join("Cargo.lock")).unwrap();
- let egui_wgpu = lock.split("[[package]]").find(|p| p.contains("\nname = \"egui-wgpu\"\n")).expect("egui-wgpu is in Cargo.lock");
- assert!(!egui_wgpu.contains("\nsource = "), "egui-wgpu must resolve to vendor/egui-wgpu, not:{egui_wgpu}");
- let painter = std::fs::read_to_string(root.join("vendor/egui-wgpu/src/winit.rs")).unwrap().replace("\r\n", "\n");
- let add_surface = painter.split("async fn add_surface(").nth(1).and_then(|s| s.split("\n fn ").next()).expect("add_surface");
- for patch in [
- "let installed = catch_errors(&device, || {\n self.install_surface(surface, viewport_id, size.width, size.height, false);\n })",
- "return Err(crate::WgpuError::ConfigureSurface(error));",
- ] {
- assert!(add_surface.contains(patch), "vendor/egui-wgpu lost its surface configure patch: {patch}");
- }
- }
-
- #[test]
- fn pipeline_creation_error_is_returned_and_device_remains_usable() {
- use eframe::{egui_wgpu, wgpu};
- let _gpu = gpu();
- let mut native = super::native_options(false, false, eframe::Renderer::Wgpu);
- let egui_wgpu::WgpuSetup::CreateNew(setup) = &native.wgpu_options.wgpu_setup else { panic!("default setup creates its own instance") };
- let instance = pollster::block_on(native.wgpu_options.wgpu_setup.new_instance());
- let options = wgpu::RequestAdapterOptions { power_preference: setup.power_preference, ..Default::default() };
- let Ok(adapter) = pollster::block_on(instance.request_adapter(&options)) else {
- assert!(std::env::var_os("PDFCRAFT_TEST_REQUIRE_GPU").is_none(), "a GPU adapter is required for this run");
- eprintln!("skipping pipeline regression: no GPU adapter");
- return;
- };
- let (device, queue) = pollster::block_on(adapter.request_device(&(setup.device_descriptor)(&adapter)))
- .expect("the selected adapter must provide a test device");
- native.wgpu_options.wgpu_setup =
- egui_wgpu::WgpuSetup::Existing(egui_wgpu::WgpuSetupExisting { instance: instance.clone(), adapter, device, queue });
- // Three samples is invalid on every backend, so this reproduces a pipeline error
- // without depending on a particular driver. The unpatched constructor panics.
- for samples in [3, 1, 3, 1] {
- let options = egui_wgpu::RendererOptions { msaa_samples: samples, ..Default::default() };
- let result = pollster::block_on(egui_wgpu::RenderState::create(&native.wgpu_options, &instance, None, options));
- if samples == 3 {
- let error = result.err().expect("invalid pipeline must return an error");
- assert!(error.to_string().contains("egui_pipeline"), "{error}");
- } else {
- assert!(result.is_ok(), "the device must remain usable: {:?}", result.err());
- }
- }
- }
-
- #[test]
- fn winit_carries_the_windows_11_monitor_scale_fix() {
- // Issue #324: winit 0.30.13 as released nudges a window dragged onto a monitor with another
- // scale factor back onto the one it is leaving, so on Windows 11 it ends up on the wrong
- // monitor, at the wrong size and scale. vendor/winit carries the fix from winit master. A
- // dependency bump that resolves winit from crates.io again, or a re-vendored copy without
- // the patch, would silently bring the bug back: re-apply the patch, or drop the copy once a
- // winit 0.30 release has the fix (vendor/README.md).
- let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../..");
- let lock = std::fs::read_to_string(root.join("Cargo.lock")).unwrap();
- let winit = lock.split("[[package]]").find(|p| p.contains("\nname = \"winit\"\n")).expect("winit is in Cargo.lock");
- assert!(!winit.contains("\nsource = "), "winit must resolve to vendor/winit, not:{winit}");
- let dpi_changed = std::fs::read_to_string(root.join("vendor/winit/src/platform_impl/windows/event_loop.rs")).unwrap();
- let patch = "if !WIN10_BUILD_VERSION.is_some_and(|build| build < 22000) {\n new_outer_rect = suggested_rect;";
- assert!(dpi_changed.contains(patch), "vendor/winit lost its WM_DPICHANGED patch");
- }
-
- const NVIDIA: (u32, u32) = (0x10de, 0x2684);
- const NVIDIA_3090: (u32, u32) = (0x10de, 0x2204);
- const AMD_IGPU: (u32, u32) = (0x1002, 0x164e);
-
- fn adapters() -> Vec<(u32, u32, eframe::wgpu::DeviceType)> {
- use eframe::wgpu::DeviceType;
- vec![(NVIDIA.0, NVIDIA.1, DeviceType::DiscreteGpu), (AMD_IGPU.0, AMD_IGPU.1, DeviceType::IntegratedGpu), (0, 0, DeviceType::Cpu)]
- }
-
- const INTEL_IGPU: (u32, u32) = (0x8086, 0xa780);
-
- fn monitor(pci: (u32, u32)) -> super::DisplayGpu {
- super::DisplayGpu { pci, primary: false }
- }
-
- fn panel(pci: (u32, u32)) -> super::DisplayGpu {
- super::DisplayGpu { pci, primary: true }
- }
-
- #[test]
- fn pick_adapter_prefers_the_gpu_driving_the_monitors() {
- // A desktop whose monitors are all on the discrete GPU: the integrated one shows black.
- assert_eq!(super::pick_adapter(&adapters(), &[monitor(NVIDIA)], None), Some(0));
- // Issue #378: the same on Windows, where the Ryzen integrated GPU without a monitor crashed
- // the display driver. Windows also says which display is the primary one.
- assert_eq!(super::pick_adapter(&adapters(), &[panel(NVIDIA)], None), Some(0));
- }
-
- #[test]
- fn pick_adapter_prefers_the_gpu_driving_the_primary_display() {
- // A Windows desktop with a monitor on each GPU: the window opens on the primary display.
- assert_eq!(super::pick_adapter(&adapters(), &[monitor(AMD_IGPU), panel(NVIDIA)], None), Some(0));
- assert_eq!(super::pick_adapter(&adapters(), &[panel(AMD_IGPU), monitor(NVIDIA)], None), Some(1));
- }
-
- #[test]
- fn pci_ids_come_from_windows_device_ids() {
- assert_eq!(super::pci_ids("PCI\\VEN_10DE&DEV_2204&SUBSYS_40421458&REV_A1"), Some(NVIDIA_3090));
- assert_eq!(super::pci_ids("PCI\\VEN_1002&DEV_164E&SUBSYS_88771043&REV_C1"), Some(AMD_IGPU));
- assert_eq!(super::pci_ids("pci\\ven_1002&dev_164e"), Some(AMD_IGPU));
- // Remote Desktop and other non-PCI display devices, and malformed ids.
- assert_eq!(super::pci_ids("ROOT\\BasicDisplay\\0000"), None);
- assert_eq!(super::pci_ids("PCI\\VEN_10DE&SUBSYS_40421458"), None);
- assert_eq!(super::pci_ids("PCI\\VEN_10DE&DEV_ZZZZ"), None);
- assert_eq!(super::pci_ids("VEN_&DEV_"), None);
- assert_eq!(super::pci_ids(""), None);
- }
-
- #[cfg(target_os = "windows")]
- #[test]
- fn windows_display_gpus_lists_pci_gpus_with_a_monitor() {
- // Whatever this machine has: every entry is a PCI GPU, at most one drives the primary
- // display, and no GPU is listed twice. (A headless CI runner may list none.)
- let gpus = super::windows_display_gpus();
- assert!(gpus.iter().filter(|g| g.primary).count() <= 1, "{gpus:?}");
- for (i, g) in gpus.iter().enumerate() {
- assert!(g.pci.0 != 0, "{gpus:?}");
- assert!(!gpus[..i].iter().any(|h| h.pci == g.pci), "{gpus:?}");
- }
- }
-
- #[test]
- fn pick_adapter_keeps_the_integrated_gpu_on_hybrid_laptops() {
- // Issue #8: the panel is on the integrated GPU, an external monitor on the discrete one.
- assert_eq!(super::pick_adapter(&adapters(), &[monitor(NVIDIA), panel(AMD_IGPU)], None), Some(1));
- assert_eq!(super::pick_adapter(&adapters(), &[panel(AMD_IGPU), monitor(NVIDIA)], None), Some(1));
- assert_eq!(super::pick_adapter(&adapters(), &[panel(AMD_IGPU)], None), Some(1));
- assert_eq!(super::pick_adapter(&adapters(), &[monitor(AMD_IGPU)], None), Some(1));
- }
-
- #[test]
- fn pick_adapter_prefers_the_discrete_gpu_on_desktops_with_a_monitor_on_each() {
- // Issue #445: one monitor on the Intel iGPU, one on the NVIDIA card, no built-in panel.
- use eframe::wgpu::DeviceType;
- let desktop =
- [(INTEL_IGPU.0, INTEL_IGPU.1, DeviceType::IntegratedGpu), (NVIDIA.0, NVIDIA.1, DeviceType::DiscreteGpu), (0, 0, DeviceType::Cpu)];
- assert_eq!(super::pick_adapter(&desktop, &[monitor(INTEL_IGPU), monitor(NVIDIA)], None), Some(1));
- assert_eq!(super::pick_adapter(&desktop, &[monitor(NVIDIA), monitor(INTEL_IGPU)], None), Some(1));
- // A GPU driving no display still loses to one that does.
- assert_eq!(super::pick_adapter(&adapters(), &[monitor(AMD_IGPU), monitor(INTEL_IGPU)], None), Some(1));
- }
-
- #[test]
- fn pick_adapter_prefers_the_gpu_the_compositor_draws_with() {
- // A hybrid laptop whose Wayland compositor runs on the discrete GPU (Hyprland with
- // `AQ_DRM_DEVICES` naming it first): the integrated GPU drives the built-in panel, yet the
- // compositor shows nothing it draws, and reports no error either.
- assert_eq!(super::pick_adapter(&adapters(), &[panel(AMD_IGPU)], Some(NVIDIA)), Some(0));
- assert_eq!(super::pick_adapter(&adapters(), &[panel(AMD_IGPU), monitor(NVIDIA)], Some(NVIDIA)), Some(0));
- // The usual hybrid laptop, with the compositor on the integrated GPU: as before (#8).
- assert_eq!(super::pick_adapter(&adapters(), &[panel(AMD_IGPU), monitor(NVIDIA)], Some(AMD_IGPU)), Some(1));
- // No GPU drives a display as far as sysfs says (a container), but the compositor knows.
- assert_eq!(super::pick_adapter(&adapters(), &[], Some(NVIDIA)), Some(0));
- // A compositor GPU no adapter matches (a virtual one) leaves the displays in charge.
- assert_eq!(super::pick_adapter(&adapters(), &[panel(AMD_IGPU)], Some((0x1af4, 0x1050))), Some(1));
- }
-
- #[test]
- fn pick_adapter_falls_back_to_low_power_without_a_match() {
- assert_eq!(super::pick_adapter(&adapters(), &[monitor((0x8086, 0x1234))], None), Some(1));
- assert_eq!(super::pick_adapter(&adapters(), &[monitor((0x8086, 0x1234)), monitor((0x8086, 0x5678))], None), Some(1));
- assert_eq!(super::pick_adapter(&[], &[monitor(NVIDIA)], None), None);
- }
-
- #[test]
- fn internal_panels_are_edp_lvds_and_dsi_connectors() {
- for name in ["eDP-1", "LVDS-1", "DSI-1"] {
- assert!(super::is_internal_panel(name), "{name}");
- }
- for name in ["DP-3", "HDMI-A-1", "DVI-D-1", "VGA-1", "Writeback-1", ""] {
- assert!(!super::is_internal_panel(name), "{name}");
- }
- }
-
- #[cfg(target_os = "linux")]
- #[test]
- fn linux_display_gpus_reads_connected_connectors() -> std::io::Result<()> {
- let dir = std::env::temp_dir().join(format!("pdfcraft-drm-{}", std::process::id()));
- let _ = std::fs::remove_dir_all(&dir);
- let card = |name: &str, (vendor, device): (u32, u32)| -> std::io::Result<()> {
- std::fs::create_dir_all(dir.join(name).join("device"))?;
- std::fs::write(dir.join(name).join("device/vendor"), format!("{vendor:#06x}\n"))?;
- std::fs::write(dir.join(name).join("device/device"), format!("{device:#06x}\n"))
- };
- let connector = |name: &str, status: &str| -> std::io::Result<()> {
- std::fs::create_dir_all(dir.join(name))?;
- std::fs::write(dir.join(name).join("status"), format!("{status}\n"))
- };
- card("card1", NVIDIA)?;
- card("card2", AMD_IGPU)?;
- connector("card1-DP-3", "connected")?;
- connector("card1-DP-4", "connected")?;
- connector("card2-HDMI-A-1", "disconnected")?;
- connector("card2-Writeback-1", "unknown")?;
- let desktop = super::linux_display_gpus(&dir);
- // Issue #445: a monitor on the integrated GPU too, still no built-in panel.
- connector("card2-HDMI-A-1", "connected")?;
- let mut both = super::linux_display_gpus(&dir);
- // A hybrid laptop: the integrated GPU drives the built-in panel as well.
- connector("card2-eDP-1", "connected")?;
- let mut laptop = super::linux_display_gpus(&dir);
- std::fs::remove_dir_all(&dir)?;
- assert_eq!(desktop, vec![monitor(NVIDIA)]);
- both.sort_by_key(|g| g.pci);
- assert_eq!(both, vec![monitor(AMD_IGPU), monitor(NVIDIA)]);
- laptop.sort_by_key(|g| g.pci);
- assert_eq!(laptop, vec![panel(AMD_IGPU), monitor(NVIDIA)]);
- assert!(super::linux_display_gpus(std::path::Path::new("/nonexistent/drm")).is_empty());
- Ok(())
- }
-
- /// A fresh folder per call: tests run in parallel.
- fn scratch() -> std::path::PathBuf {
- static N: std::sync::atomic::AtomicU32 = std::sync::atomic::AtomicU32::new(0);
- let n = N.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
- let d = std::env::temp_dir().join(format!("pdfcraft-control-file-{}-{n}", std::process::id()));
- let _ = std::fs::remove_dir_all(&d);
- std::fs::create_dir_all(&d).unwrap();
- d
- }
-
- /// The control file's port and token, and every name in its folder.
- fn written(dir: &std::path::Path) -> (serde_json::Value, Vec) {
- let json = serde_json::from_str(&std::fs::read_to_string(dir.join("ctl.json")).unwrap()).unwrap();
- let mut names: Vec = std::fs::read_dir(dir).unwrap().map(|e| e.unwrap().file_name().to_string_lossy().into_owned()).collect();
- names.sort();
- (json, names)
- }
-
- #[test]
- fn the_control_file_replaces_a_hard_link_instead_of_writing_through_it() {
- // The file used to be opened with create + truncate, so a link planted at the path had its
- // target truncated and overwritten with the token.
- let dir = scratch();
- std::fs::write(dir.join("victim.txt"), "keep me").unwrap();
- std::fs::hard_link(dir.join("victim.txt"), dir.join("ctl.json")).unwrap();
- super::write_control_file(&dir.join("ctl.json").to_string_lossy(), 4242, "t0ken").unwrap();
- assert_eq!(std::fs::read_to_string(dir.join("victim.txt")).unwrap(), "keep me");
- let (json, names) = written(&dir);
- assert_eq!((json["port"].as_u64(), json["token"].as_str()), (Some(4242), Some("t0ken")));
- assert_eq!(names, ["ctl.json", "victim.txt"], "no temporary file left behind");
- let _ = std::fs::remove_dir_all(&dir);
- }
-
- #[test]
- fn the_control_file_replaces_a_symlink_instead_of_writing_through_it() {
- let dir = scratch();
- let (link, target) = (dir.join("ctl.json"), dir.join("victim.txt"));
- std::fs::write(&target, "keep me").unwrap();
- #[cfg(unix)]
- let made = std::os::unix::fs::symlink(&target, &link);
- #[cfg(windows)]
- let made = std::os::windows::fs::symlink_file(&target, &link);
- if let Err(e) = made {
- // Windows needs Developer Mode (or admin) for symlinks.
- eprintln!("skipped: can't create a symlink here: {e}");
- let _ = std::fs::remove_dir_all(&dir);
- return;
- }
- super::write_control_file(&link.to_string_lossy(), 4242, "t0ken").unwrap();
- assert_eq!(std::fs::read_to_string(&target).unwrap(), "keep me");
- assert!(!std::fs::symlink_metadata(&link).unwrap().file_type().is_symlink());
- assert_eq!(written(&dir).0["token"].as_str(), Some("t0ken"));
- let _ = std::fs::remove_dir_all(&dir);
- }
-
- #[test]
- fn the_control_file_replaces_the_one_a_previous_run_left() {
- let dir = scratch();
- std::fs::write(dir.join("ctl.json"), r#"{"port":1,"token":"old"}"#).unwrap();
- super::write_control_file(&dir.join("ctl.json").to_string_lossy(), 4242, "t0ken").unwrap();
- let (json, names) = written(&dir);
- assert_eq!(json["token"].as_str(), Some("t0ken"));
- assert_eq!(names, ["ctl.json"]);
- #[cfg(unix)]
- {
- use std::os::unix::fs::PermissionsExt;
- let mode = std::fs::metadata(dir.join("ctl.json")).unwrap().permissions().mode();
- assert_eq!(mode & 0o077, 0, "only the owner can read the token: {mode:o}");
- }
- let _ = std::fs::remove_dir_all(&dir);
- }
-
- /// X11 startup notification and docks match a window to its launcher by `StartupWMClass`,
- /// so it has to be the class the window carries: the app id, on Wayland and X11 alike.
- #[test]
- fn the_desktop_entry_names_the_window_class() {
- let entry = include_str!("../../../packaging/linux/ai.storyteller.pdfcraft.desktop");
- let class = entry.lines().find_map(|line| line.strip_prefix("StartupWMClass="));
- assert_eq!(class, Some(super::APP_ID));
- }
-}
+mod tests;
diff --git a/apps/pdfcraft/src/single_instance.rs b/apps/pdfcraft/src/single_instance.rs
index ecfa12f5c3..751785d0c5 100644
--- a/apps/pdfcraft/src/single_instance.rs
+++ b/apps/pdfcraft/src/single_instance.rs
@@ -153,7 +153,7 @@ pub fn claim(files: &[String], may_hand_off: bool) -> Claim {
/// `%LOCALAPPDATA%` is unset.
#[cfg(windows)]
fn socket_path() -> Option {
- let name = socket_name(std::env::var("SESSIONNAME").ok().as_deref());
+ let name = socket_name(session_id(), std::env::var("SESSIONNAME").ok().as_deref());
// %LOCALAPPDATA% is private to the user's sign-in, so other accounts can't receive this
// user's file paths or send it files to open. A portable copy uses it too (#889), under its
// own name so it never hands files to an installed copy (which keeps its settings elsewhere).
@@ -184,13 +184,31 @@ fn display_socket_name(display: Option<&str>) -> String {
if display.is_empty() { "instance.sock".to_string() } else { format!("instance-{display}.sock") }
}
-/// The socket's file name for a Windows session (`SESSIONNAME`: `Console`, `RDP-Tcp#3`…). The
-/// same user signed in twice (at the console and over Remote Desktop) gets one socket per session,
-/// so a file never opens in a window on the other screen. Only letters, digits and `-` are kept.
+/// The Windows session this process runs in, from its access token: the console, each Remote
+/// Desktop sign-in and session 0 (services) each have their own. `None` if Windows won't say.
+#[cfg(windows)]
+fn session_id() -> Option {
+ use winsafe::{HACCESSTOKEN, TokenInfo, co::TOKEN_INFORMATION_CLASS};
+ match HACCESSTOKEN::GetCurrentProcessToken().GetTokenInformation(TOKEN_INFORMATION_CLASS::SessionId) {
+ Ok(TokenInfo::SessionId(id)) => Some(*id),
+ _ => None,
+ }
+}
+
+/// The socket's file name for a Windows session. The same user signed in twice (at the console
+/// and over Remote Desktop) gets one socket per session, so a file never opens in a window on the
+/// other screen. The session is the token's session id (`session`): a launch from Task Scheduler,
+/// a service or a remote shell has no `SESSIONNAME` but still runs in a session, so it finds the
+/// window of the session it runs in (#3338), and never one in another session (review on #3340).
+/// Only if the id can't be read does `SESSIONNAME` (`Console`, `RDP-Tcp#3`…) name it, letters,
+/// digits and `-` kept.
#[cfg_attr(not(windows), allow(dead_code))]
-fn socket_name(session: Option<&str>) -> String {
- let session: String = session.unwrap_or_default().chars().filter(|c| c.is_ascii_alphanumeric() || *c == '-').take(32).collect();
- if session.is_empty() { "instance.sock".to_string() } else { format!("instance-{session}.sock") }
+fn socket_name(session: Option, session_name: Option<&str>) -> String {
+ if let Some(id) = session {
+ return format!("instance-s{id}.sock");
+ }
+ let name: String = session_name.unwrap_or_default().chars().filter(|c| c.is_ascii_alphanumeric() || *c == '-').take(32).collect();
+ if name.is_empty() { "instance.sock".to_string() } else { format!("instance-{name}.sock") }
}
/// Held while a launch finds out whether it is the first, so launches that start together (several
@@ -359,12 +377,20 @@ mod tests {
#[test]
fn each_windows_session_gets_its_own_socket_name() {
- assert_eq!(socket_name(Some("Console")), "instance-Console.sock");
- assert_eq!(socket_name(Some("RDP-Tcp#3")), "instance-RDP-Tcp3.sock");
- assert_ne!(socket_name(Some("RDP-Tcp#3")), socket_name(Some("RDP-Tcp#4")));
- assert_eq!(socket_name(Some(r"..\x/y:z")), "instance-xyz.sock", "never a path");
- assert_eq!(socket_name(None), "instance.sock");
- assert_eq!(socket_name(Some("")), "instance.sock");
+ // The session id decides: #3338, a launch from Task Scheduler (no SESSIONNAME) in the
+ // console's session finds the window opened from Explorer there...
+ assert_eq!(socket_name(Some(1), None), socket_name(Some(1), Some("Console")));
+ // ...and (review on #3340) one in a Remote Desktop session never reaches the console's.
+ assert_ne!(socket_name(Some(2), None), socket_name(Some(1), Some("Console")));
+ assert_ne!(socket_name(Some(2), Some("RDP-Tcp#3")), socket_name(Some(3), Some("RDP-Tcp#3")));
+ assert_eq!(socket_name(Some(4_000_000_000), None), "instance-s4000000000.sock");
+ // Only without an id does SESSIONNAME name it.
+ assert_eq!(socket_name(None, Some("Console")), "instance-Console.sock");
+ assert_eq!(socket_name(None, Some("RDP-Tcp#3")), "instance-RDP-Tcp3.sock");
+ assert_ne!(socket_name(None, Some("RDP-Tcp#3")), socket_name(None, Some("RDP-Tcp#4")));
+ assert_eq!(socket_name(None, Some(r"..\x/y:z")), "instance-xyz.sock", "never a path");
+ assert_eq!(socket_name(None, None), "instance.sock");
+ assert_eq!(socket_name(None, Some("")), "instance.sock");
}
#[test]
@@ -394,11 +420,21 @@ mod tests {
assert_eq!(display_socket_name(Some("")), "instance.sock");
}
+ /// The socket is named by the session the process runs in, which Windows always gives.
+ #[cfg(windows)]
+ #[test]
+ fn this_process_has_a_session_id() {
+ let id = super::session_id();
+ assert!(id.is_some(), "the access token names the session");
+ let name = socket_name(id, None);
+ assert!(socket_path().is_none_or(|p| p.file_name().is_some_and(|f| f.to_string_lossy().ends_with(&name))), "{name}");
+ }
+
#[cfg(any(windows, all(unix, not(target_os = "macos"))))]
fn socket(test: &str) -> std::path::PathBuf {
let dir = std::env::temp_dir().join(format!("pdfcraft-instance-{}-{test}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
- dir.join(socket_name(None))
+ dir.join(socket_name(None, None))
}
#[cfg(any(windows, all(unix, not(target_os = "macos"))))]
diff --git a/apps/pdfcraft/src/startup.rs b/apps/pdfcraft/src/startup.rs
index 003c6639d4..be25da45b6 100644
--- a/apps/pdfcraft/src/startup.rs
+++ b/apps/pdfcraft/src/startup.rs
@@ -10,6 +10,7 @@ pub(super) struct Arguments {
pub new_window: bool,
pub two_paged: bool,
pub version: bool,
+ pub help: bool,
}
impl Arguments {
@@ -26,6 +27,10 @@ impl Arguments {
parsed.version = true;
break;
}
+ "-h" | "--help" => {
+ parsed.help = true;
+ break;
+ }
"--control" => parsed.control_file = args.next(),
"--create-images" => parsed.create_images = true,
"--new-window" => parsed.new_window = true,
@@ -113,6 +118,14 @@ mod tests {
assert!(version.version && version.files.is_empty());
}
+ #[test]
+ fn help_keeps_the_headless_early_exit() {
+ for flag in ["-h", "--help"] {
+ let args = parse(&["--two-paged", flag, "ignored.pdf"]);
+ assert!(args.help && args.files.is_empty());
+ }
+ }
+
#[test]
fn malformed_boolean_does_not_eat_a_file_or_option() {
let args = parse(&["--two-paged=false", "first.pdf", "--layout", "single", "second.pdf"]);
diff --git a/apps/pdfcraft/src/tests.rs b/apps/pdfcraft/src/tests.rs
new file mode 100644
index 0000000000..c6e6f36518
--- /dev/null
+++ b/apps/pdfcraft/src/tests.rs
@@ -0,0 +1,686 @@
+/// The lockfile package block for `name`, or a panic naming the missing package.
+fn locked_package(root: &std::path::Path, name: &str) -> String {
+ let lock = std::fs::read_to_string(root.join("Cargo.lock")).expect("Cargo.lock");
+ lock.split("[[package]]")
+ .find(|p| p.contains(&format!("\nname = \"{name}\"\n")))
+ .map(str::to_owned)
+ .unwrap_or_else(|| panic!("{name} is not in Cargo.lock"))
+}
+
+/// Interim graph check for the window stack while the reviewed fork revision is not yet
+/// published. The old `vendor/winit` / `vendor/egui-wgpu` path patches were removed from the
+/// published manifest; local integration supplies the reviewed worktrees through an external
+/// Cargo config patch. A git source must name the public storytold fork; a path source is the
+/// local integration graph. Behaviour (surface fitting, error scopes, scroll phases) is tested
+/// directly elsewhere; the retained vendor copies keep provenance until the fork revision is
+/// pinned. No public revision is invented here.
+fn assert_window_stack_graph(root: &std::path::Path, name: &str, version: &str) -> String {
+ let package = locked_package(root, name);
+ assert!(package.contains(&format!("\nversion = \"{version}\"\n")), "{name} must resolve to {version}: {package}");
+ if let Some(source) = package.lines().find_map(|line| line.strip_prefix("source = ")) {
+ assert!(source.contains("github.com/storytold/"), "{name} must come from the reviewed storytold fork once published, not {source}");
+ assert!(
+ source.split(|c: char| !c.is_ascii_hexdigit()).any(|part| part.len() == 40),
+ "{name} must pin the fork's 40-character revision, not {source}"
+ );
+ }
+ // A path patch has no `source =` in Cargo.lock. The local integration config is the only
+ // allowed path source; a machine-local path must never be in the published manifest,
+ // which the root-manifest assertion below checks. A path entry carries no remote identity
+ // to verify, so this test does not claim the resolved code is the published fork.
+ let manifest = std::fs::read_to_string(root.join("Cargo.toml")).expect("root Cargo.toml");
+ assert!(!manifest.contains(&format!("{name} = {{ path = \"vendor/")), "vendor/{name} must not be a published path patch");
+ package
+}
+
+use pdfcraft_ui_egui::control::Endpoint;
+
+fn endpoint() -> std::io::Result {
+ Ok(Endpoint { port: 4321, token: "0123456789abcdef".into() })
+}
+
+/// Tests that open a real GPU device take this first, so only one of them is inside the
+/// graphics libraries at a time. The test threads otherwise run them together, and on
+/// FreeBSD's CI machine the test process then died with SIGSEGV, in about half the runs:
+/// the first device was still being set up when the second test began its own. The UI
+/// tests take turns the same way (`crates/ui-egui/tests/it/main.rs`). Hold it for the whole
+/// test (declared before the instance, so it's released after the device is dropped).
+static GPU: std::sync::Mutex<()> = std::sync::Mutex::new(());
+
+fn gpu() -> std::sync::MutexGuard<'static, ()> {
+ // A test that panicked while holding it leaves nothing to clean up.
+ GPU.lock().unwrap_or_else(std::sync::PoisonError::into_inner)
+}
+
+#[test]
+fn a_control_channel_that_cant_be_published_stops_the_app() {
+ let dir = std::env::temp_dir().join(format!("pdfcraft-app-control-{}", std::process::id()));
+ let _ = std::fs::remove_dir_all(&dir);
+ std::fs::create_dir_all(&dir).unwrap();
+ let path = |name: &str| dir.join(name).to_str().unwrap().to_owned();
+
+ // A folder in the way stands in for a file another user created first: the write fails.
+ std::fs::create_dir(dir.join("taken.json")).unwrap();
+ let err = super::open_control_channel(&path("taken.json"), endpoint()).unwrap_err();
+ assert!(err.contains("--control") && err.contains("taken.json") && err.contains("only you can write"), "{err}");
+ // No listener: nothing to publish.
+ let err = super::open_control_channel(&path("pc.json"), Err(std::io::Error::other("no loopback"))).unwrap_err();
+ assert!(err.contains("no loopback"), "{err}");
+ assert!(!dir.join("pc.json").exists());
+
+ super::open_control_channel(&path("pc.json"), endpoint()).unwrap();
+ let written: serde_json::Value = serde_json::from_str(&std::fs::read_to_string(dir.join("pc.json")).unwrap()).unwrap();
+ assert_eq!((written["port"].as_u64(), written["token"].as_str()), (Some(4321), Some("0123456789abcdef")));
+ let _ = std::fs::remove_dir_all(&dir);
+}
+
+#[test]
+fn path_from_arg_decodes_file_uris() {
+ assert_eq!(super::path_from_arg("file:///home/alice/report.pdf".to_string()), "/home/alice/report.pdf");
+ assert_eq!(super::path_from_arg("file:///home/alice/my%20report.pdf".to_string()), "/home/alice/my report.pdf");
+ assert_eq!(super::path_from_arg("file://localhost/tmp/a%C3%A9.pdf".to_string()), "/tmp/aé.pdf");
+ // Malformed or truncated escapes are kept as written; bytes that aren't UTF-8 keep the URI.
+ assert_eq!(super::path_from_arg("file:///tmp/100%.pdf".to_string()), "/tmp/100%.pdf");
+ assert_eq!(super::path_from_arg("file:///tmp/a%2".to_string()), "/tmp/a%2");
+ assert_eq!(super::path_from_arg("file:///tmp/%zz%".to_string()), "/tmp/%zz%");
+ assert_eq!(super::path_from_arg("file:///tmp/%FF.pdf".to_string()), "file:///tmp/%FF.pdf");
+}
+
+#[test]
+fn unquoted_paths_with_spaces_are_rejoined() {
+ let have = ["P:\\Archive\\00042A26 Contract.PDF", "C:\\a b c.pdf", "C:\\a.pdf", "C:\\x y.pdf"];
+ let exists = |p: &str| have.contains(&p);
+ let args = |a: &[&str]| a.iter().map(|s| s.to_string()).collect::>();
+ // The case an office program produced: one path, split at its space.
+ assert_eq!(super::rejoin_split_paths(args(&["P:\\Archive\\00042A26", "Contract.PDF"]), exists), args(&["P:\\Archive\\00042A26 Contract.PDF"]));
+ // The longest run wins; files that exist on their own are left alone.
+ assert_eq!(super::rejoin_split_paths(args(&["C:\\a", "b", "c.pdf", "C:\\a.pdf"]), exists), args(&["C:\\a b c.pdf", "C:\\a.pdf"]));
+ assert_eq!(super::rejoin_split_paths(args(&["C:\\a.pdf", "C:\\x", "y.pdf"]), exists), args(&["C:\\a.pdf", "C:\\x y.pdf"]));
+ // Nothing to join: kept as given, so the missing file is still reported by name.
+ assert_eq!(super::rejoin_split_paths(args(&["missing.pdf", "other"]), exists), args(&["missing.pdf", "other"]));
+ assert!(super::rejoin_split_paths(Vec::new(), exists).is_empty());
+}
+
+#[test]
+fn path_from_arg_leaves_plain_paths_and_other_schemes_alone() {
+ assert_eq!(super::path_from_arg("report.pdf".to_string()), "report.pdf");
+ assert_eq!(super::path_from_arg("/home/alice/report.pdf".to_string()), "/home/alice/report.pdf");
+ assert_eq!(super::path_from_arg("https://example.com/report.pdf".to_string()), "https://example.com/report.pdf");
+ assert_eq!(super::path_from_arg("file://server/share/a.pdf".to_string()), "file://server/share/a.pdf");
+ assert_eq!(super::path_from_arg("file://".to_string()), "file://");
+}
+
+#[test]
+fn opengl_is_the_fallback_unless_a_renderer_was_chosen() {
+ use super::{RendererChoice, native_options, renderer_choice, retry_with_gl};
+ assert_eq!(renderer_choice(None), RendererChoice::Auto);
+ assert_eq!(renderer_choice(Some("")), RendererChoice::Auto);
+ assert_eq!(renderer_choice(Some("vulkan")), RendererChoice::Auto);
+ assert_eq!(renderer_choice(Some(" WGPU ")), RendererChoice::Wgpu);
+ for gl in ["gl", "OpenGL", "glow"] {
+ assert_eq!(renderer_choice(Some(gl)), RendererChoice::Gl);
+ }
+ // Retried with OpenGL only when wgpu failed to start by default, never after the app ran.
+ assert!(retry_with_gl(RendererChoice::Auto, false));
+ assert!(!retry_with_gl(RendererChoice::Auto, true));
+ assert!(!retry_with_gl(RendererChoice::Wgpu, false));
+ assert!(!retry_with_gl(RendererChoice::Gl, false));
+ // Each run states its renderer rather than relying on eframe's default.
+ assert_eq!(native_options(false, false, eframe::Renderer::Wgpu).renderer, eframe::Renderer::Wgpu);
+ assert_eq!(native_options(false, false, eframe::Renderer::Glow).renderer, eframe::Renderer::Glow);
+}
+
+#[test]
+fn the_desktop_frame_is_turned_off_only_when_the_app_draws_one() {
+ use super::native_options;
+ assert_eq!(native_options(false, false, eframe::Renderer::Glow).viewport.decorations, None);
+ assert_eq!(native_options(false, true, eframe::Renderer::Glow).viewport.decorations, Some(false));
+}
+
+#[test]
+fn a_software_renderer_is_shown_to_the_user() {
+ use eframe::wgpu::DeviceType;
+ // Issue #519: WARP or llvmpipe is announced in the app, not just in pdfcraft.log.
+ let mut app = super::PdfCraftApp::new();
+ super::notify_software_renderer(&mut app, DeviceType::Cpu);
+ let notice = app.toast.as_ref().map(|(m, _)| m.as_str()).unwrap_or_default();
+ assert!(notice.contains("without a graphics processor"), "{notice:?}");
+ for gpu in [DeviceType::IntegratedGpu, DeviceType::DiscreteGpu, DeviceType::VirtualGpu, DeviceType::Other] {
+ let mut app = super::PdfCraftApp::new();
+ super::notify_software_renderer(&mut app, gpu);
+ assert!(app.toast.is_none(), "{gpu:?}: {:?}", app.toast);
+ }
+}
+
+#[test]
+fn gpu_setup_avoids_vulkan_and_stretched_resizes_on_windows_and_prefers_low_power() {
+ let mut native = eframe::NativeOptions::default();
+ super::configure_gpu(&mut native);
+ let eframe::egui_wgpu::WgpuSetup::CreateNew(setup) = &native.wgpu_options.wgpu_setup else { panic!("default setup creates its own instance") };
+ if std::env::var_os("WGPU_POWER_PREF").is_none() {
+ assert_eq!(setup.power_preference, eframe::wgpu::PowerPreference::LowPower);
+ }
+ if std::env::var_os("WGPU_VALIDATION_INDIRECT_CALL").is_none() {
+ let flags = setup.instance_descriptor.flags;
+ assert!(!flags.contains(eframe::wgpu::InstanceFlags::VALIDATION_INDIRECT_CALL), "Intel Metal can't compile its shader: {flags:?}");
+ }
+ if cfg!(target_os = "windows") && std::env::var_os("WGPU_BACKEND").is_none() {
+ let backends = setup.instance_descriptor.backends;
+ assert!(backends.contains(eframe::wgpu::Backends::DX12), "{backends:?}");
+ assert!(!backends.contains(eframe::wgpu::Backends::VULKAN), "issue #37: {backends:?}");
+ }
+ use eframe::wgpu::Dx12SwapchainKind::{DxgiFromHwnd, DxgiFromVisual};
+ assert_eq!(super::dx12_presentation(None), DxgiFromVisual);
+ assert_eq!(super::dx12_presentation(Some(DxgiFromHwnd)), DxgiFromHwnd, "WGPU_DX12_PRESENTATION_SYSTEM still decides");
+ assert_eq!(super::dx12_presentation(Some(DxgiFromVisual)), DxgiFromVisual);
+ let presentation = setup.instance_descriptor.backend_options.dx12.presentation_system;
+ if cfg!(target_os = "windows") {
+ // A composition visual unless WGPU_DX12_PRESENTATION_SYSTEM asks otherwise.
+ let asked = eframe::wgpu::Dx12SwapchainKind::from_env();
+ assert_eq!(presentation, asked.unwrap_or(DxgiFromVisual), "issue #2738: resizing stretches a window swap chain");
+ } else {
+ assert_eq!(presentation, eframe::wgpu::Dx12SwapchainKind::default().with_env(), "only Windows changes it");
+ }
+}
+
+#[test]
+fn restored_hidpi_window_fits_the_adapters_surface_limit() {
+ // The Outlook launch crash: 3440 x 1369 saved points at 250% DPI requested this surface.
+ let (width, height) = (8600, 3423);
+ let required = eframe::wgpu::Limits::default();
+ assert!(width > required.max_texture_dimension_2d);
+ let supported = eframe::wgpu::Limits { max_texture_dimension_2d: 16384, ..required.clone() };
+ let enabled = super::surface_texture_limits(required, &supported);
+ assert!(width <= enabled.max_texture_dimension_2d);
+ assert!(height <= enabled.max_texture_dimension_2d);
+}
+
+#[test]
+fn surface_limits_preserve_other_requirements_and_never_overrequest() {
+ for required in [eframe::wgpu::Limits::default(), eframe::wgpu::Limits::downlevel_webgl2_defaults()] {
+ for extent in [4096, 8192, 16384] {
+ let supported = eframe::wgpu::Limits {
+ max_texture_dimension_2d: extent,
+ max_bind_groups: 8,
+ min_uniform_buffer_offset_alignment: 128,
+ min_storage_buffer_offset_alignment: 128,
+ ..required.clone()
+ };
+ let enabled = super::surface_texture_limits(required.clone(), &supported);
+ let mut expected = required.clone();
+ expected.max_texture_dimension_2d = extent;
+ assert_eq!(enabled, expected);
+ assert_eq!(enabled.max_texture_dimension_2d, supported.max_texture_dimension_2d);
+ assert!(enabled.check_limits(&supported));
+ }
+ }
+}
+
+#[test]
+fn surface_limits_fit_v3d_shader_variable_limit() {
+ // Issue #2383: V3D supports 15 variables, below the default request of 16.
+ let required = eframe::wgpu::Limits::default();
+ let supported = eframe::wgpu::Limits { max_texture_dimension_2d: 7680, max_inter_stage_shader_variables: 15, ..required.clone() };
+ let enabled = super::surface_texture_limits(required.clone(), &supported);
+ assert!(enabled.check_limits(&supported), "unsupported device limits: {enabled:?}");
+ let expected = eframe::wgpu::Limits { max_texture_dimension_2d: 7680, max_inter_stage_shader_variables: 15, ..required };
+ assert_eq!(enabled, expected);
+}
+
+#[test]
+fn surface_limits_fit_downlevel_maxima_and_minimum_alignments() {
+ let required = eframe::wgpu::Limits::downlevel_webgl2_defaults();
+ let supported = eframe::wgpu::Limits {
+ max_texture_dimension_2d: 2048,
+ max_bind_groups: 2,
+ max_vertex_attributes: 8,
+ min_uniform_buffer_offset_alignment: 512,
+ min_storage_buffer_offset_alignment: 512,
+ ..required.clone()
+ };
+ let enabled = super::surface_texture_limits(required, &supported);
+ assert!(enabled.check_limits(&supported), "unsupported device limits: {enabled:?}");
+ // Larger minimum alignments are weaker capabilities, unlike maximum limits.
+ assert_eq!(enabled, supported);
+}
+
+#[test]
+fn a_window_larger_than_the_gpu_limit_gets_a_surface_within_it() {
+ use eframe::egui_wgpu::winit::surface_fit;
+ // Issue #577: 3440 x 1369 points restored at 250% asked an 8192-pixel device for this.
+ let (width, height, scale) = surface_fit(8600, 3423, 8192);
+ assert_eq!((width, height), (8192, 3260));
+ // Both sides shrink alike and egui draws at that factor: the whole window is drawn.
+ assert!((8600.0 * scale - 8192.0).abs() < 0.01, "{scale}");
+ assert!((3423.0 * scale - height as f32).abs() < 1.0, "{scale}");
+ // Within the limit nothing changes, up to and including the limit itself.
+ assert_eq!(surface_fit(8600, 3423, 16384), (8600, 3423, 1.0));
+ assert_eq!(surface_fit(8192, 8192, 8192), (8192, 8192, 1.0));
+ assert_eq!(surface_fit(0, 0, 8192), (0, 0, 1.0));
+ // One side over the limit: the window as it would be stretched across monitors.
+ assert_eq!(surface_fit(8193, 600, 8192).0, 8192);
+ assert_eq!(surface_fit(600, 8193, 8192).1, 8192);
+}
+
+#[test]
+fn surface_fit_never_empties_or_overflows_a_surface() {
+ use eframe::egui_wgpu::winit::surface_fit;
+ for (w, h, max) in
+ [(1, u32::MAX, 8192), (u32::MAX, 1, 2048), (u32::MAX, u32::MAX, 16384), (0, u32::MAX, 8192), (u32::MAX, 0, 8192), (5, 7, 0), (40_000, 3, 1)]
+ {
+ let (fw, fh, scale) = surface_fit(w, h, max);
+ assert!(fw <= max.max(1) && fh <= max.max(1), "{w} x {h} in {max}: {fw} x {fh}");
+ // A side that wasn't zero stays non-zero (`Surface::configure` rejects an empty one),
+ // and a zero side stays zero (egui-wgpu skips configuring it).
+ assert_eq!((fw == 0, fh == 0), (w == 0, h == 0), "{w} x {h} in {max}: {fw} x {fh}");
+ assert!(scale.is_finite() && scale > 0.0 && scale <= 1.0, "{w} x {h} in {max}: {scale}");
+ }
+ // The longer side lands exactly on the limit (rounding never leaves it a pixel short),
+ // and the shorter side keeps the window's proportions to within a pixel.
+ for max in [2048, 8192, 16384] {
+ for long in (max + 1..=max * 5).step_by(997).chain([max * 2, max * 4, u32::MAX]) {
+ for short in [1, 3, 600, max / 3, max - 1, max, long - 1, long] {
+ let (fw, fh, scale) = surface_fit(long, short, max);
+ assert_eq!(fw, max, "{long} x {short} in {max}");
+ let expected = f64::from(short) * f64::from(max) / f64::from(long);
+ assert!((f64::from(fh) - expected).abs() <= 1.0, "{long} x {short} in {max}: {fh}");
+ assert_eq!(surface_fit(short, long, max), (fh, fw, scale), "transposed");
+ }
+ }
+ }
+}
+
+/// Regression test: the lockfile resolves egui-wgpu 0.37.0 through an allowed source, the root manifest
+/// does not path-patch the vendor copy, and that copy stays as provenance for the surface-fit patch.
+#[test]
+fn vendor_provenance_egui_wgpu_surface_fit_identity() {
+ // Issue #577: egui-wgpu configured a window's surface at the window's size, and
+ // `Surface::configure` panics when that's beyond the device's `max_texture_dimension_2d`
+ // (emilk/egui#8361). The reviewed egui 0.37 worktree carries E2 (`surface_fit`); the
+ // behavioural test above exercises it. The old 0.36 vendor copy is retained for
+ // provenance only and is no longer a published path patch, so reading it would not prove
+ // what the app runs.
+ let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../..");
+ assert_window_stack_graph(&root, "egui-wgpu", "0.37.0");
+ assert!(root.join("vendor/egui-wgpu/src/winit.rs").exists(), "the vendor copy and its provenance stay until the fork revision is pinned");
+}
+
+#[test]
+fn a_gpu_error_while_a_window_is_set_up_is_returned_not_a_panic() {
+ // Issue #519: on a 2015 Intel GPU, wgpu's GL backend couldn't configure the window's
+ // surface (`GpuWaitTimeout`), and wgpu's default error handler panicked before the app was
+ // created, so PdfCraft never got to retry with OpenGL. vendor/egui-wgpu configures a new
+ // window's surface inside `catch_errors`. A real device on PdfCraft's own GPU settings,
+ // and an error wgpu raises before anything reaches the driver: a texture one pixel wider
+ // than the device allows.
+ use eframe::wgpu;
+ let _gpu = gpu();
+ let native = super::native_options(false, false, eframe::Renderer::Wgpu);
+ let eframe::egui_wgpu::WgpuSetup::CreateNew(setup) = &native.wgpu_options.wgpu_setup else { panic!("default setup creates its own instance") };
+ let instance = pollster::block_on(native.wgpu_options.wgpu_setup.new_instance());
+ let options = wgpu::RequestAdapterOptions { power_preference: setup.power_preference, ..Default::default() };
+ // CI runners have a software adapter (WARP, llvmpipe); a machine without any skips.
+ let Ok(adapter) = pollster::block_on(instance.request_adapter(&options)) else {
+ eprintln!("skipping: no GPU adapter on this machine");
+ return;
+ };
+ let Ok((device, _queue)) = pollster::block_on(adapter.request_device(&(setup.device_descriptor)(&adapter))) else {
+ eprintln!("skipping: the adapter gives no device");
+ return;
+ };
+ let texture = |width: u32| {
+ device.create_texture(&wgpu::TextureDescriptor {
+ label: Some("issue-519"),
+ size: wgpu::Extent3d { width, height: 1, depth_or_array_layers: 1 },
+ mip_level_count: 1,
+ sample_count: 1,
+ dimension: wgpu::TextureDimension::D2,
+ format: wgpu::TextureFormat::Rgba8Unorm,
+ usage: wgpu::TextureUsages::TEXTURE_BINDING,
+ view_formats: &[],
+ })
+ };
+ let too_wide = device.limits().max_texture_dimension_2d.saturating_add(1);
+ let caught = pollster::block_on(eframe::egui_wgpu::winit::catch_errors(&device, || texture(too_wide)));
+ assert!(matches!(caught, Err(wgpu::Error::Validation { .. })), "{caught:?}");
+ // The device stays usable, and nothing is left behind to catch later errors by mistake.
+ let fine = pollster::block_on(eframe::egui_wgpu::winit::catch_errors(&device, || texture(16)));
+ assert!(fine.is_ok(), "{fine:?}");
+ assert_eq!(pollster::block_on(eframe::egui_wgpu::winit::catch_errors(&device, || 7)).ok(), Some(7));
+ // Nested: the inner call keeps its own error, and the outer one still catches what follows.
+ let caught = pollster::block_on(eframe::egui_wgpu::winit::catch_errors(&device, || {
+ let inner = pollster::block_on(eframe::egui_wgpu::winit::catch_errors(&device, || texture(too_wide)));
+ assert!(inner.is_err(), "inner: {inner:?}");
+ texture(too_wide)
+ }));
+ assert!(matches!(caught, Err(wgpu::Error::Validation { .. })), "outer: {caught:?}");
+}
+
+/// Regression test: the lockfile resolves egui-wgpu 0.37.0 through an allowed source, the root manifest
+/// does not path-patch the vendor copy, and that copy stays as provenance for the catch-errors patch.
+#[test]
+fn vendor_provenance_egui_wgpu_catch_errors_identity() {
+ // Issue #519: E3 (`catch_errors`) is tested above against a real device. The reviewed 0.37
+ // worktree carries it around the first configure of every new window's surface, and its
+ // error reaches eframe as `WgpuError` (eframe then returns `Error::Wgpu`, which `main`
+ // retries with OpenGL). Without it a failed configure panics before the app is created.
+ // `catch_errors` is exercised against a real device in the test above and below; the
+ // graph check makes sure the app runs the reviewed 0.37 worktree (the retained vendor
+ // copy is not read as proof of behaviour).
+ let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../..");
+ assert_window_stack_graph(&root, "egui-wgpu", "0.37.0");
+ assert!(root.join("vendor/egui-wgpu/src/winit.rs").exists(), "the vendor copy and its provenance stay until the fork revision is pinned");
+}
+
+#[test]
+fn pipeline_creation_error_is_returned_and_device_remains_usable() {
+ use eframe::{egui_wgpu, wgpu};
+ let _gpu = gpu();
+ let mut native = super::native_options(false, false, eframe::Renderer::Wgpu);
+ let egui_wgpu::WgpuSetup::CreateNew(setup) = &native.wgpu_options.wgpu_setup else { panic!("default setup creates its own instance") };
+ let instance = pollster::block_on(native.wgpu_options.wgpu_setup.new_instance());
+ let options = wgpu::RequestAdapterOptions { power_preference: setup.power_preference, ..Default::default() };
+ let Ok(adapter) = pollster::block_on(instance.request_adapter(&options)) else {
+ assert!(std::env::var_os("PDFCRAFT_TEST_REQUIRE_GPU").is_none(), "a GPU adapter is required for this run");
+ eprintln!("skipping pipeline regression: no GPU adapter");
+ return;
+ };
+ let (device, queue) =
+ pollster::block_on(adapter.request_device(&(setup.device_descriptor)(&adapter))).expect("the selected adapter must provide a test device");
+ native.wgpu_options.wgpu_setup =
+ egui_wgpu::WgpuSetup::Existing(egui_wgpu::WgpuSetupExisting { instance: instance.clone(), adapter, device, queue });
+ // Three samples is invalid on every backend, so this reproduces a pipeline error
+ // without depending on a particular driver. The unpatched constructor panics.
+ for samples in [3, 1, 3, 1] {
+ let options = egui_wgpu::RendererOptions { msaa_samples: samples, ..Default::default() };
+ let result = pollster::block_on(egui_wgpu::RenderState::create(&native.wgpu_options, &instance, None, options));
+ if samples == 3 {
+ let error = result.err().expect("invalid pipeline must return an error");
+ assert!(error.to_string().contains("egui_pipeline"), "{error}");
+ } else {
+ assert!(result.is_ok(), "the device must remain usable: {:?}", result.err());
+ }
+ }
+}
+
+/// Regression test: winit resolves to 0.30.13 without a published vendor path patch, the retained copy
+/// still carries the W1 Windows 11 monitor-scale fix, and its upstream base is recorded.
+#[test]
+fn winit_carries_the_windows_11_monitor_scale_fix() {
+ // Issue #324: winit 0.30.13 as released nudges a window dragged onto a monitor with another
+ // scale factor back onto the one it is leaving, so on Windows 11 it ends up on the wrong
+ // monitor, at the wrong size and scale. vendor/winit carries the fix from winit master. A
+ // dependency bump that resolves winit from crates.io again, or a re-vendored copy without
+ // the patch, would silently bring the bug back: re-apply the patch, or drop the copy once a
+ // winit 0.30 release has the fix (vendor/README.md).
+ // W1 (and W2–W13) were audited against the reviewed winit worktree: the vendor rows in
+ // `vendor/README.md` match the fork's PATCHES.md entries W1–W13. The vendor source is no
+ // longer a published path patch, so this checks the actual graph, not the old copy.
+ let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../..");
+ assert_window_stack_graph(&root, "winit", "0.30.13");
+ let event_loop = std::fs::read_to_string(root.join("vendor/winit/src/platform_impl/windows/event_loop.rs")).unwrap();
+ // The audited W1 hunk itself, not merely the file's presence: Windows 11 applies the
+ // rect Windows suggests, while Windows 10 keeps the old adjustment.
+ for marker in [
+ "PdfCraft patch: from Windows 11 (build 22000) on, apply the rect Windows suggests",
+ "if !WIN10_BUILD_VERSION.is_some_and(|build| build < 22000) {",
+ "new_outer_rect = suggested_rect;",
+ ] {
+ assert!(event_loop.contains(marker), "vendor/winit lost the W1 monitor-scale patch: {marker}");
+ }
+ let provenance = std::fs::read_to_string(root.join("vendor/winit/.cargo_vcs_info.json")).unwrap();
+ assert!(provenance.contains("e9809ef54b18499bb4f2cac945719ecc2a61061b"), "vendor/winit keeps its upstream base provenance");
+}
+
+const NVIDIA: (u32, u32) = (0x10de, 0x2684);
+const NVIDIA_3090: (u32, u32) = (0x10de, 0x2204);
+const AMD_IGPU: (u32, u32) = (0x1002, 0x164e);
+
+fn adapters() -> Vec<(u32, u32, eframe::wgpu::DeviceType)> {
+ use eframe::wgpu::DeviceType;
+ vec![(NVIDIA.0, NVIDIA.1, DeviceType::DiscreteGpu), (AMD_IGPU.0, AMD_IGPU.1, DeviceType::IntegratedGpu), (0, 0, DeviceType::Cpu)]
+}
+
+const INTEL_IGPU: (u32, u32) = (0x8086, 0xa780);
+
+fn monitor(pci: (u32, u32)) -> super::DisplayGpu {
+ super::DisplayGpu { pci, primary: false }
+}
+
+fn panel(pci: (u32, u32)) -> super::DisplayGpu {
+ super::DisplayGpu { pci, primary: true }
+}
+
+#[test]
+fn pick_adapter_prefers_the_gpu_driving_the_monitors() {
+ // A desktop whose monitors are all on the discrete GPU: the integrated one shows black.
+ assert_eq!(super::pick_adapter(&adapters(), &[monitor(NVIDIA)], None), Some(0));
+ // Issue #378: the same on Windows, where the Ryzen integrated GPU without a monitor crashed
+ // the display driver. Windows also says which display is the primary one.
+ assert_eq!(super::pick_adapter(&adapters(), &[panel(NVIDIA)], None), Some(0));
+}
+
+#[test]
+fn pick_adapter_prefers_the_gpu_driving_the_primary_display() {
+ // A Windows desktop with a monitor on each GPU: the window opens on the primary display.
+ assert_eq!(super::pick_adapter(&adapters(), &[monitor(AMD_IGPU), panel(NVIDIA)], None), Some(0));
+ assert_eq!(super::pick_adapter(&adapters(), &[panel(AMD_IGPU), monitor(NVIDIA)], None), Some(1));
+}
+
+#[test]
+fn pci_ids_come_from_windows_device_ids() {
+ assert_eq!(super::pci_ids("PCI\\VEN_10DE&DEV_2204&SUBSYS_40421458&REV_A1"), Some(NVIDIA_3090));
+ assert_eq!(super::pci_ids("PCI\\VEN_1002&DEV_164E&SUBSYS_88771043&REV_C1"), Some(AMD_IGPU));
+ assert_eq!(super::pci_ids("pci\\ven_1002&dev_164e"), Some(AMD_IGPU));
+ // Remote Desktop and other non-PCI display devices, and malformed ids.
+ assert_eq!(super::pci_ids("ROOT\\BasicDisplay\\0000"), None);
+ assert_eq!(super::pci_ids("PCI\\VEN_10DE&SUBSYS_40421458"), None);
+ assert_eq!(super::pci_ids("PCI\\VEN_10DE&DEV_ZZZZ"), None);
+ assert_eq!(super::pci_ids("VEN_&DEV_"), None);
+ assert_eq!(super::pci_ids(""), None);
+}
+
+#[cfg(target_os = "windows")]
+#[test]
+fn windows_display_gpus_lists_pci_gpus_with_a_monitor() {
+ // Whatever this machine has: every entry is a PCI GPU, at most one drives the primary
+ // display, and no GPU is listed twice. (A headless CI runner may list none.)
+ let gpus = super::windows_display_gpus();
+ assert!(gpus.iter().filter(|g| g.primary).count() <= 1, "{gpus:?}");
+ for (i, g) in gpus.iter().enumerate() {
+ assert!(g.pci.0 != 0, "{gpus:?}");
+ assert!(!gpus[..i].iter().any(|h| h.pci == g.pci), "{gpus:?}");
+ }
+}
+
+#[test]
+fn pick_adapter_keeps_the_integrated_gpu_on_hybrid_laptops() {
+ // Issue #8: the panel is on the integrated GPU, an external monitor on the discrete one.
+ assert_eq!(super::pick_adapter(&adapters(), &[monitor(NVIDIA), panel(AMD_IGPU)], None), Some(1));
+ assert_eq!(super::pick_adapter(&adapters(), &[panel(AMD_IGPU), monitor(NVIDIA)], None), Some(1));
+ assert_eq!(super::pick_adapter(&adapters(), &[panel(AMD_IGPU)], None), Some(1));
+ assert_eq!(super::pick_adapter(&adapters(), &[monitor(AMD_IGPU)], None), Some(1));
+}
+
+#[test]
+fn pick_adapter_prefers_the_discrete_gpu_on_desktops_with_a_monitor_on_each() {
+ // Issue #445: one monitor on the Intel iGPU, one on the NVIDIA card, no built-in panel.
+ use eframe::wgpu::DeviceType;
+ let desktop = [(INTEL_IGPU.0, INTEL_IGPU.1, DeviceType::IntegratedGpu), (NVIDIA.0, NVIDIA.1, DeviceType::DiscreteGpu), (0, 0, DeviceType::Cpu)];
+ assert_eq!(super::pick_adapter(&desktop, &[monitor(INTEL_IGPU), monitor(NVIDIA)], None), Some(1));
+ assert_eq!(super::pick_adapter(&desktop, &[monitor(NVIDIA), monitor(INTEL_IGPU)], None), Some(1));
+ // A GPU driving no display still loses to one that does.
+ assert_eq!(super::pick_adapter(&adapters(), &[monitor(AMD_IGPU), monitor(INTEL_IGPU)], None), Some(1));
+}
+
+#[test]
+fn pick_adapter_prefers_the_gpu_the_compositor_draws_with() {
+ // A hybrid laptop whose Wayland compositor runs on the discrete GPU (Hyprland with
+ // `AQ_DRM_DEVICES` naming it first): the integrated GPU drives the built-in panel, yet the
+ // compositor shows nothing it draws, and reports no error either.
+ assert_eq!(super::pick_adapter(&adapters(), &[panel(AMD_IGPU)], Some(NVIDIA)), Some(0));
+ assert_eq!(super::pick_adapter(&adapters(), &[panel(AMD_IGPU), monitor(NVIDIA)], Some(NVIDIA)), Some(0));
+ // The usual hybrid laptop, with the compositor on the integrated GPU: as before (#8).
+ assert_eq!(super::pick_adapter(&adapters(), &[panel(AMD_IGPU), monitor(NVIDIA)], Some(AMD_IGPU)), Some(1));
+ // No GPU drives a display as far as sysfs says (a container), but the compositor knows.
+ assert_eq!(super::pick_adapter(&adapters(), &[], Some(NVIDIA)), Some(0));
+ // A compositor GPU no adapter matches (a virtual one) leaves the displays in charge.
+ assert_eq!(super::pick_adapter(&adapters(), &[panel(AMD_IGPU)], Some((0x1af4, 0x1050))), Some(1));
+}
+
+#[test]
+fn pick_adapter_falls_back_to_low_power_without_a_match() {
+ assert_eq!(super::pick_adapter(&adapters(), &[monitor((0x8086, 0x1234))], None), Some(1));
+ assert_eq!(super::pick_adapter(&adapters(), &[monitor((0x8086, 0x1234)), monitor((0x8086, 0x5678))], None), Some(1));
+ assert_eq!(super::pick_adapter(&[], &[monitor(NVIDIA)], None), None);
+}
+
+#[test]
+fn internal_panels_are_edp_lvds_and_dsi_connectors() {
+ for name in ["eDP-1", "LVDS-1", "DSI-1"] {
+ assert!(super::is_internal_panel(name), "{name}");
+ }
+ for name in ["DP-3", "HDMI-A-1", "DVI-D-1", "VGA-1", "Writeback-1", ""] {
+ assert!(!super::is_internal_panel(name), "{name}");
+ }
+}
+
+#[cfg(target_os = "linux")]
+#[test]
+fn linux_display_gpus_reads_connected_connectors() -> std::io::Result<()> {
+ let dir = std::env::temp_dir().join(format!("pdfcraft-drm-{}", std::process::id()));
+ let _ = std::fs::remove_dir_all(&dir);
+ let card = |name: &str, (vendor, device): (u32, u32)| -> std::io::Result<()> {
+ std::fs::create_dir_all(dir.join(name).join("device"))?;
+ std::fs::write(dir.join(name).join("device/vendor"), format!("{vendor:#06x}\n"))?;
+ std::fs::write(dir.join(name).join("device/device"), format!("{device:#06x}\n"))
+ };
+ let connector = |name: &str, status: &str| -> std::io::Result<()> {
+ std::fs::create_dir_all(dir.join(name))?;
+ std::fs::write(dir.join(name).join("status"), format!("{status}\n"))
+ };
+ card("card1", NVIDIA)?;
+ card("card2", AMD_IGPU)?;
+ connector("card1-DP-3", "connected")?;
+ connector("card1-DP-4", "connected")?;
+ connector("card2-HDMI-A-1", "disconnected")?;
+ connector("card2-Writeback-1", "unknown")?;
+ let desktop = super::linux_display_gpus(&dir);
+ // Issue #445: a monitor on the integrated GPU too, still no built-in panel.
+ connector("card2-HDMI-A-1", "connected")?;
+ let mut both = super::linux_display_gpus(&dir);
+ // A hybrid laptop: the integrated GPU drives the built-in panel as well.
+ connector("card2-eDP-1", "connected")?;
+ let mut laptop = super::linux_display_gpus(&dir);
+ std::fs::remove_dir_all(&dir)?;
+ assert_eq!(desktop, vec![monitor(NVIDIA)]);
+ both.sort_by_key(|g| g.pci);
+ assert_eq!(both, vec![monitor(AMD_IGPU), monitor(NVIDIA)]);
+ laptop.sort_by_key(|g| g.pci);
+ assert_eq!(laptop, vec![panel(AMD_IGPU), monitor(NVIDIA)]);
+ assert!(super::linux_display_gpus(std::path::Path::new("/nonexistent/drm")).is_empty());
+ Ok(())
+}
+
+/// A fresh folder per call: tests run in parallel.
+fn scratch() -> std::path::PathBuf {
+ static N: std::sync::atomic::AtomicU32 = std::sync::atomic::AtomicU32::new(0);
+ let n = N.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
+ let d = std::env::temp_dir().join(format!("pdfcraft-control-file-{}-{n}", std::process::id()));
+ let _ = std::fs::remove_dir_all(&d);
+ std::fs::create_dir_all(&d).unwrap();
+ d
+}
+
+/// The control file's port and token, and every name in its folder.
+fn written(dir: &std::path::Path) -> (serde_json::Value, Vec) {
+ let json = serde_json::from_str(&std::fs::read_to_string(dir.join("ctl.json")).unwrap()).unwrap();
+ let mut names: Vec = std::fs::read_dir(dir).unwrap().map(|e| e.unwrap().file_name().to_string_lossy().into_owned()).collect();
+ names.sort();
+ (json, names)
+}
+
+#[test]
+fn the_control_file_replaces_a_hard_link_instead_of_writing_through_it() {
+ // The file used to be opened with create + truncate, so a link planted at the path had its
+ // target truncated and overwritten with the token.
+ let dir = scratch();
+ std::fs::write(dir.join("victim.txt"), "keep me").unwrap();
+ std::fs::hard_link(dir.join("victim.txt"), dir.join("ctl.json")).unwrap();
+ super::write_control_file(&dir.join("ctl.json").to_string_lossy(), 4242, "t0ken").unwrap();
+ assert_eq!(std::fs::read_to_string(dir.join("victim.txt")).unwrap(), "keep me");
+ let (json, names) = written(&dir);
+ assert_eq!((json["port"].as_u64(), json["token"].as_str()), (Some(4242), Some("t0ken")));
+ assert_eq!(names, ["ctl.json", "victim.txt"], "no temporary file left behind");
+ let _ = std::fs::remove_dir_all(&dir);
+}
+
+#[test]
+fn the_control_file_replaces_a_symlink_instead_of_writing_through_it() {
+ let dir = scratch();
+ let (link, target) = (dir.join("ctl.json"), dir.join("victim.txt"));
+ std::fs::write(&target, "keep me").unwrap();
+ #[cfg(unix)]
+ let made = std::os::unix::fs::symlink(&target, &link);
+ #[cfg(windows)]
+ let made = std::os::windows::fs::symlink_file(&target, &link);
+ if let Err(e) = made {
+ // Windows needs Developer Mode (or admin) for symlinks.
+ eprintln!("skipped: can't create a symlink here: {e}");
+ let _ = std::fs::remove_dir_all(&dir);
+ return;
+ }
+ super::write_control_file(&link.to_string_lossy(), 4242, "t0ken").unwrap();
+ assert_eq!(std::fs::read_to_string(&target).unwrap(), "keep me");
+ assert!(!std::fs::symlink_metadata(&link).unwrap().file_type().is_symlink());
+ assert_eq!(written(&dir).0["token"].as_str(), Some("t0ken"));
+ let _ = std::fs::remove_dir_all(&dir);
+}
+
+#[test]
+fn the_control_file_replaces_the_one_a_previous_run_left() {
+ let dir = scratch();
+ std::fs::write(dir.join("ctl.json"), r#"{"port":1,"token":"old"}"#).unwrap();
+ super::write_control_file(&dir.join("ctl.json").to_string_lossy(), 4242, "t0ken").unwrap();
+ let (json, names) = written(&dir);
+ assert_eq!(json["token"].as_str(), Some("t0ken"));
+ assert_eq!(names, ["ctl.json"]);
+ #[cfg(unix)]
+ {
+ use std::os::unix::fs::PermissionsExt;
+ let mode = std::fs::metadata(dir.join("ctl.json")).unwrap().permissions().mode();
+ assert_eq!(mode & 0o077, 0, "only the owner can read the token: {mode:o}");
+ }
+ let _ = std::fs::remove_dir_all(&dir);
+}
+
+/// X11 startup notification and docks match a window to its launcher by `StartupWMClass`,
+/// so it has to be the class the window carries: the app id, on Wayland and X11 alike.
+#[test]
+fn the_desktop_entry_names_the_window_class() {
+ let entry = include_str!("../../../packaging/linux/ai.storyteller.pdfcraft.desktop");
+ let class = entry.lines().find_map(|line| line.strip_prefix("StartupWMClass="));
+ assert_eq!(class, Some(super::APP_ID));
+}
+
+// Regression test for #246: `eprintln!` panics when standard error is a broken pipe (a GUI
+// launch on Windows), and a panic inside the panic hook aborts the process. The start-up
+// path and the hook must write to standard error without it.
+#[test]
+fn start_up_and_the_panic_hook_never_use_eprintln() {
+ let source = include_str!("main.rs");
+ // Everything before the top-level test module is product code.
+ let product = source.split("\n#[cfg(test)]\nmod tests;").next().unwrap_or(source);
+ assert!(product.len() < source.len(), "found the end of the product code");
+ let offenders: Vec<_> = product
+ .lines()
+ .enumerate()
+ .filter(|(_, line)| !line.trim_start().starts_with("//") && line.contains("eprintln!"))
+ .map(|(n, line)| format!("main.rs:{}: {}", n + 1, line.trim()))
+ .collect();
+ assert!(
+ offenders.is_empty(),
+ "`eprintln!` panics on a broken standard error pipe; use `log::warn!` or `write_fmt` on `stderr()` and ignore the error:\n{}",
+ offenders.join("\n")
+ );
+}
diff --git a/apps/pdfcraft/src/updates.rs b/apps/pdfcraft/src/updates.rs
deleted file mode 100644
index 686c8cee84..0000000000
--- a/apps/pdfcraft/src/updates.rs
+++ /dev/null
@@ -1,127 +0,0 @@
-//! Asks GitHub for the latest PdfCraft release (Help ▸ Check for updates, issue #28).
-
-use std::time::Duration;
-
-use pdfcraft_ui_egui::updates::{RELEASES_PAGE, Release};
-
-const LATEST: &str = "https://api.github.com/repos/storytold/pdfcraft/releases/latest";
-
-/// The latest release. The answer is untrusted: its size is capped, and only a page under
-/// [`RELEASES_PAGE`] is ever offered for download (anything else falls back to that list).
-pub fn latest_release() -> Result {
- let agent = ureq::Agent::config_builder()
- .timeout_global(Some(Duration::from_secs(10)))
- .tls_config(ureq::tls::TlsConfig::builder().root_certs(os_roots()?).build())
- .build()
- .new_agent();
- let mut response = agent
- .get(LATEST)
- .header("Accept", "application/vnd.github+json")
- .header("User-Agent", concat!("PdfCraft/", env!("CARGO_PKG_VERSION")))
- // Read error statuses ourselves: a 403 from GitHub usually means its anonymous
- // rate limit ran out, and the retry time rides along in a response header.
- .config()
- .http_status_as_error(false)
- .build()
- .call()
- .map_err(|e| format!("couldn't reach GitHub ({e})"))?;
- let status = response.status().as_u16();
- if status == 403 {
- let reset = response.headers().get("x-ratelimit-reset").and_then(|v| v.to_str().ok());
- return Err(rate_limited(reset, now_unix()));
- }
- if !(200..300).contains(&status) {
- return Err(format!("couldn't reach GitHub (http status: {status})"));
- }
- let body = response.body_mut().with_config().limit(1 << 20).read_to_string().map_err(|e| format!("unreadable answer ({e})"))?;
- parse(&body)
-}
-
-/// The certificate authorities the operating system trusts.
-fn os_roots() -> Result {
- let found = rustls_native_certs::load_native_certs();
- let certs: Vec> = found.certs.iter().map(|c| ureq::tls::Certificate::from_der(c.as_ref()).to_owned()).collect();
- if certs.is_empty() {
- return Err("no trusted certificates found on this system".into());
- }
- Ok(ureq::tls::RootCerts::new_with_certs(&certs))
-}
-
-/// The current Unix time in seconds (0 when the clock gives nothing usable).
-fn now_unix() -> u64 {
- std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map(|d| d.as_secs()).unwrap_or(0)
-}
-
-/// What to tell the user when GitHub answers 403: its anonymous quota (60 checks an hour,
-/// shared by everyone behind the same address) ran out. `reset` is the `x-ratelimit-reset`
-/// header, the Unix time the quota refills; `now` is the current Unix time. Anything
-/// surprising (no header, not a number, already past) falls back to asking for patience.
-fn rate_limited(reset: Option<&str>, now: u64) -> String {
- const FALLBACK: &str = "GitHub is limiting update checks for now (shared hourly quota); try again later";
- let wait = reset.and_then(|r| r.trim().parse::().ok()).and_then(|reset| reset.checked_sub(now)).filter(|&wait| wait > 0);
- match wait {
- Some(wait) => {
- let minutes = wait.div_ceil(60).max(1);
- let plural = if minutes == 1 { "" } else { "s" };
- format!("GitHub is limiting update checks for now (shared hourly quota); try again in about {minutes} minute{plural}")
- }
- None => FALLBACK.into(),
- }
-}
-
-fn parse(body: &str) -> Result {
- let v: serde_json::Value = serde_json::from_str(body).map_err(|e| format!("unreadable answer ({e})"))?;
- let version = v["tag_name"].as_str().filter(|t| !t.is_empty() && t.len() <= 64).ok_or("no release found")?.to_string();
- let url = v["html_url"]
- .as_str()
- .filter(|u| u.strip_prefix(RELEASES_PAGE).is_some_and(|rest| rest.starts_with('/') && !rest.contains(['?', '#', '\\'])))
- .unwrap_or(RELEASES_PAGE)
- .to_string();
- Ok(Release { version, url })
-}
-
-#[cfg(test)]
-mod tests {
- use super::*;
-
- #[test]
- fn answers_are_read_and_only_our_release_pages_are_offered() {
- let r = parse(r#"{"tag_name":"v0.2.0","html_url":"https://github.com/storytold/pdfcraft/releases/tag/v0.2.0"}"#).unwrap();
- assert_eq!(r, Release { version: "v0.2.0".into(), url: "https://github.com/storytold/pdfcraft/releases/tag/v0.2.0".into() });
- for elsewhere in ["https://example.com/pdfcraft.exe", "https://github.com/storytold/pdfcraft/releases.evil/x", "javascript:alert(1)"] {
- let r = parse(&format!(r#"{{"tag_name":"v9.9.9","html_url":"{elsewhere}"}}"#)).unwrap();
- assert_eq!(r.url, RELEASES_PAGE, "{elsewhere}");
- }
- assert!(parse(r#"{"message":"Not Found"}"#).is_err());
- assert!(parse("").is_err());
- }
-
- #[test]
- fn rate_limit_messages_name_the_wait() {
- let limited = "GitHub is limiting update checks for now (shared hourly quota); try again in about 20 minutes";
- assert_eq!(rate_limited(Some("1800001200"), 1800000000), limited);
- // Rounds up: 61 seconds out reads as 2 minutes.
- assert_eq!(
- rate_limited(Some("1800000061"), 1800000000),
- "GitHub is limiting update checks for now (shared hourly quota); try again in about 2 minutes"
- );
- assert_eq!(
- rate_limited(Some("1800000060"), 1800000000),
- "GitHub is limiting update checks for now (shared hourly quota); try again in about 1 minute"
- );
- // No header, not a number, or already past: ask for patience instead of a time.
- let later = "GitHub is limiting update checks for now (shared hourly quota); try again later";
- for bad in [None, Some(""), Some("soon"), Some("1799999999"), Some("1800000000")] {
- assert_eq!(rate_limited(bad, 1800000000), later);
- }
- }
-
- /// Live: asks GitHub over TLS with the OS's roots (`cargo test -p pdfcraft -- --ignored`).
- #[test]
- #[ignore = "needs network access"]
- fn github_answers_with_the_latest_release() {
- let r = latest_release().unwrap();
- assert!(pdfcraft_ui_egui::updates::is_newer(&r.version, "0.0.0"), "{r:?}");
- assert!(r.url.starts_with(RELEASES_PAGE), "{r:?}");
- }
-}
diff --git a/apps/pdfcraft/tests/help.rs b/apps/pdfcraft/tests/help.rs
new file mode 100644
index 0000000000..fce9a8dd39
--- /dev/null
+++ b/apps/pdfcraft/tests/help.rs
@@ -0,0 +1,55 @@
+//! `--help`, `-h` and `--version` print and exit without opening a window (#4076), so a script that
+//! calls them returns.
+
+use std::process::{Command, Output, Stdio};
+use std::time::{Duration, Instant};
+
+const BIN: &str = env!("CARGO_BIN_EXE_pdfcraft");
+
+/// Run the app with `args`, failing if it is still running after 60 s (it opened its window).
+fn run(args: &[&str], stdout: Stdio) -> Output {
+ let mut child = Command::new(BIN).args(args).stdin(Stdio::null()).stdout(stdout).stderr(Stdio::piped()).spawn().unwrap();
+ let deadline = Instant::now() + Duration::from_secs(60);
+ while child.try_wait().unwrap().is_none() {
+ if Instant::now() > deadline {
+ let _ = child.kill();
+ panic!("pdfcraft {args:?} did not exit: it started the app instead");
+ }
+ std::thread::sleep(Duration::from_millis(50));
+ }
+ child.wait_with_output().unwrap()
+}
+
+#[test]
+fn help_prints_usage_and_exits() {
+ for flag in ["--help", "-h"] {
+ let out = run(&[flag], Stdio::piped());
+ assert!(out.status.success(), "{flag}: {:?} {}", out.status, String::from_utf8_lossy(&out.stderr));
+ let stdout = String::from_utf8_lossy(&out.stdout);
+ assert!(stdout.starts_with("Usage: pdfcraft [options] [files…]"), "{flag}: {stdout}");
+ assert!(stdout.contains("pdfcraft-cli"), "{flag}: points scripts to the CLI: {stdout}");
+ }
+ // Wherever it is on the command line, and with files named.
+ let out = run(&["missing.pdf", "--help"], Stdio::piped());
+ assert!(out.status.success());
+ assert!(String::from_utf8_lossy(&out.stdout).starts_with("Usage:"));
+}
+
+#[test]
+fn version_prints_the_version_and_exits() {
+ let out = run(&["--version"], Stdio::piped());
+ assert!(out.status.success(), "{:?} {}", out.status, String::from_utf8_lossy(&out.stderr));
+ assert!(String::from_utf8_lossy(&out.stdout).starts_with("pdfcraft "), "{}", String::from_utf8_lossy(&out.stdout));
+}
+
+/// A standard output that can't be written (here a full device) ends the run normally: `println!`
+/// would panic and exit 101.
+#[cfg(target_os = "linux")]
+#[test]
+fn help_and_version_survive_an_unwritable_stdout() {
+ for flag in ["--help", "--version"] {
+ let full = std::fs::OpenOptions::new().write(true).open("/dev/full").unwrap();
+ let out = run(&[flag], Stdio::from(full));
+ assert!(out.status.success(), "{flag}: {:?} {}", out.status, String::from_utf8_lossy(&out.stderr));
+ }
+}
diff --git a/apps/pdfcraft/tests/it/main.rs b/apps/pdfcraft/tests/it/main.rs
new file mode 100644
index 0000000000..b09ace5f40
--- /dev/null
+++ b/apps/pdfcraft/tests/it/main.rs
@@ -0,0 +1,22 @@
+//! The crate's integration tests, linked into one test binary.
+
+// Compile winit's scroll policy against its real event type, without a compositor. The vendored
+// source (`winit_wayland_scroll`) names it as `crate::event`.
+#[cfg(target_os = "linux")]
+mod event {
+ pub use winit::event::TouchPhase;
+}
+
+mod vendor_graph;
+mod winit_macos_emoji_picker;
+mod winit_macos_ime_commit;
+mod winit_macos_ime_discard;
+mod winit_macos_korean_first_key;
+mod winit_macos_korean_space;
+mod winit_macos_title_bar_drag;
+mod winit_wayland_scroll;
+mod winit_wayland_text_input;
+mod winit_windows_hidden_maximized;
+mod winit_windows_ime_cursor;
+mod winit_x11_dpi;
+mod winit_x11_ime_focus;
diff --git a/apps/pdfcraft/tests/it/vendor_graph.rs b/apps/pdfcraft/tests/it/vendor_graph.rs
new file mode 100644
index 0000000000..a6a4d4b292
--- /dev/null
+++ b/apps/pdfcraft/tests/it/vendor_graph.rs
@@ -0,0 +1,81 @@
+//! Dependency-graph verification for the egui 0.37 window stack.
+//!
+//! The twelve `winit_*` modules next to this one read the retained `vendor/winit` /
+//! `vendor/egui-wgpu` copies. They are provenance for W1–W13 and E2+E3, not proof of what the app
+//! runs: the root manifest pins both crates to immutable storytold git revisions, and the retained
+//! copies are not path patches. These tests check the resolved graph and the retained evidence
+//! instead of treating the copies as the runtime source. The winit fork's own W17 tests own patch
+//! behaviour.
+
+/// Return the workspace root above the PdfCraft app crate.
+fn root() -> std::path::PathBuf {
+ std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../..")
+}
+
+/// Every `[[package]]` block in `Cargo.lock` whose `name` is exactly `name`.
+fn locked_packages(name: &str) -> Vec {
+ let lock = std::fs::read_to_string(root().join("Cargo.lock")).expect("Cargo.lock");
+ let needle = format!("name = \"{name}\"");
+ lock.split("[[package]]").filter(|package| package.lines().any(|line| line.trim() == needle.as_str())).map(str::to_owned).collect()
+}
+
+/// The one resolved package called `name`. A duplicate name is a lockfile problem, not something
+/// to resolve by taking the first match.
+fn locked(name: &str) -> String {
+ let mut packages = locked_packages(name);
+ assert_eq!(packages.len(), 1, "Cargo.lock must contain exactly one `{name}` package, found {}", packages.len());
+ packages.remove(0)
+}
+
+/// The resolved package must be the exact canonical storytold git source at the reviewed
+/// 40-character revision, including the `Cargo.lock` URL and fragment. A path source (no `source`
+/// line) is refused: it would make the graph depend on a developer's checkout. A published path
+/// patch is refused as well.
+fn assert_resolved(name: &str, version: &str, url: &str, rev: &str) {
+ let package = locked(name);
+ let expected_version = format!("version = \"{version}\"");
+ assert!(package.lines().any(|line| line.trim() == expected_version.as_str()), "{name} must resolve to {version}: {package}");
+ let sources: Vec<&str> = package.lines().filter_map(|line| line.trim().strip_prefix("source = ")).collect();
+ assert_eq!(sources.len(), 1, "{name} must have exactly one `source` in Cargo.lock; a local path source is not accepted: {package}");
+ let expected_source = format!("\"git+{url}?rev={rev}#{rev}\"");
+ assert_eq!(sources[0], expected_source.as_str(), "{name} must resolve to the exact canonical git URL, revision and fragment, not {}", sources[0]);
+
+ let manifest = std::fs::read_to_string(root().join("Cargo.toml")).expect("root Cargo.toml");
+ assert!(!manifest.contains(&format!("{name} = {{ path = \"vendor/")), "vendor/{name} cannot stay a published path patch");
+}
+
+/// Regression test: the resolved window stack points at the reviewed forks and the retained vendor provenance
+/// files remain.
+#[test]
+fn vendor_provenance_window_stack_graph_is_resolved() {
+ assert_resolved("winit", "0.30.13", "https://github.com/storytold/winit.git", "a517b3846ee8457d84ee4975110bfda6797c44dc");
+ assert_resolved("egui-wgpu", "0.37.0", "https://github.com/storytold/egui.git", "6d518fc799681571963842f95b55a6b68431b15a");
+ // Retained provenance: the audited copies and their recorded W1–W13 / E2+E3 rows.
+ let readme = std::fs::read_to_string(root().join("vendor/README.md")).expect("vendor/README.md");
+ for marker in ["W1–W13", "surface_fit", "catch_errors", "e9809ef54b18499bb4f2cac945719ecc2a61061b"] {
+ assert!(readme.contains(marker), "vendor/README.md lost window-stack provenance: {marker}");
+ }
+ let vcs = std::fs::read_to_string(root().join("vendor/winit/.cargo_vcs_info.json")).expect("vendor/winit provenance");
+ assert!(vcs.contains("e9809ef54b18499bb4f2cac945719ecc2a61061b"), "vendor/winit keeps its upstream base");
+ for path in ["vendor/winit/src/platform_impl", "vendor/egui-wgpu/src/winit.rs"] {
+ assert!(root().join(path).exists(), "retain {path} as provenance for the pinned fork");
+ }
+}
+
+/// Renderer, security and PostScript vendors are unrelated to the UI port and must stay path patches.
+#[test]
+fn renderer_and_security_vendors_remain() {
+ let manifest = std::fs::read_to_string(root().join("Cargo.toml")).expect("root Cargo.toml");
+ for vendor in [
+ "vendor/hayro",
+ "vendor/hayro-interpret",
+ "vendor/hayro-syntax",
+ "vendor/hayro-cmap",
+ "vendor/hayro-jbig2",
+ "vendor/hayro-postscript",
+ "vendor/lopdf",
+ "vendor/vello_common",
+ ] {
+ assert!(manifest.contains(&format!("path = \"{vendor}\"")), "{vendor} must stay a path patch");
+ }
+}
diff --git a/apps/pdfcraft/tests/winit_macos_emoji_picker.rs b/apps/pdfcraft/tests/it/winit_macos_emoji_picker.rs
similarity index 75%
rename from apps/pdfcraft/tests/winit_macos_emoji_picker.rs
rename to apps/pdfcraft/tests/it/winit_macos_emoji_picker.rs
index 6cedf57e33..b95058ba94 100644
--- a/apps/pdfcraft/tests/winit_macos_emoji_picker.rs
+++ b/apps/pdfcraft/tests/it/winit_macos_emoji_picker.rs
@@ -1,7 +1,11 @@
-//! vendor/winit patch (8) (vendor/README.md).
+//! Provenance-only: reads the retained `vendor/` copy, not the resolved dependency, so it is
+//! not runtime coverage. `vendor_graph.rs` checks the resolved graph identity.
+//! Retained vendor provenance for winit patch (8) (vendor/README.md); `vendor_graph.rs` checks the resolved graph.
+/// Regression test: the vendored winit commits text inserted outside a key press and leaves `selectedRange`
+/// unmarked.
#[test]
-fn winit_commits_macos_text_inserted_outside_a_key_press() {
+fn vendor_provenance_winit_macos_text_inserted_outside_a_key_press() {
// The macOS emoji picker inserts its text with `insertText` outside any key press and without
// a composition. winit 0.30.13 reports `insertText` only as part of a key press or a
// composition, so an emoji picked into a PdfCraft text field was dropped. vendor/winit commits
diff --git a/apps/pdfcraft/tests/winit_macos_ime_commit.rs b/apps/pdfcraft/tests/it/winit_macos_ime_commit.rs
similarity index 73%
rename from apps/pdfcraft/tests/winit_macos_ime_commit.rs
rename to apps/pdfcraft/tests/it/winit_macos_ime_commit.rs
index 8701fc20a1..877f9e2a22 100644
--- a/apps/pdfcraft/tests/winit_macos_ime_commit.rs
+++ b/apps/pdfcraft/tests/it/winit_macos_ime_commit.rs
@@ -1,7 +1,11 @@
-//! vendor/winit patch (6) (vendor/README.md).
+//! Provenance-only: reads the retained `vendor/` copy, not the resolved dependency, so it is
+//! not runtime coverage. `vendor_graph.rs` checks the resolved graph identity.
+//! Retained vendor provenance for winit patch (6) (vendor/README.md); `vendor_graph.rs` checks the resolved graph.
+/// Regression test: the vendored winit commits a composition that ended without marked text and keeps the
+/// marked-text underline.
#[test]
-fn winit_commits_macos_compositions_that_end_without_marked_text() {
+fn vendor_provenance_winit_macos_compositions_that_end_without_marked_text() {
// winit 0.30.13 treats `insertText` as an IME commit only while marked text is left. Input
// methods that end a composition with `unmarkText` first lost the committed text, and Apple
// Korean, which clears its marked text before inserting the key typed after a syllable (`한`
diff --git a/apps/pdfcraft/tests/winit_macos_ime_discard.rs b/apps/pdfcraft/tests/it/winit_macos_ime_discard.rs
similarity index 69%
rename from apps/pdfcraft/tests/winit_macos_ime_discard.rs
rename to apps/pdfcraft/tests/it/winit_macos_ime_discard.rs
index 52e65622f6..89efbd3d81 100644
--- a/apps/pdfcraft/tests/winit_macos_ime_discard.rs
+++ b/apps/pdfcraft/tests/it/winit_macos_ime_discard.rs
@@ -1,7 +1,10 @@
-//! vendor/winit patch (7) (vendor/README.md).
+//! Provenance-only: reads the retained `vendor/` copy, not the resolved dependency, so it is
+//! not runtime coverage. `vendor_graph.rs` checks the resolved graph identity.
+//! Retained vendor provenance for winit patch (7) (vendor/README.md); `vendor_graph.rs` checks the resolved graph.
+/// Regression test: disallowing IME in the vendored winit discards the macOS input method's marked text.
#[test]
-fn winit_discards_the_macos_composition_when_ime_is_disallowed() {
+fn vendor_provenance_winit_discards_the_macos_composition_when_ime_is_disallowed() {
// egui disallows IME when a text field loses focus. winit 0.30.13 then forgets the marked text
// but doesn't tell the input method, which keeps the composition and continues it once IME is
// allowed again: the abandoned syllable comes back in the next field. vendor/winit calls
diff --git a/apps/pdfcraft/tests/winit_macos_korean_first_key.rs b/apps/pdfcraft/tests/it/winit_macos_korean_first_key.rs
similarity index 74%
rename from apps/pdfcraft/tests/winit_macos_korean_first_key.rs
rename to apps/pdfcraft/tests/it/winit_macos_korean_first_key.rs
index 76bb32ccbd..f18dde0f2a 100644
--- a/apps/pdfcraft/tests/winit_macos_korean_first_key.rs
+++ b/apps/pdfcraft/tests/it/winit_macos_korean_first_key.rs
@@ -1,7 +1,10 @@
-//! vendor/winit patch (10) (vendor/README.md).
+//! Provenance-only: reads the retained `vendor/` copy, not the resolved dependency, so it is
+//! not runtime coverage. `vendor_graph.rs` checks the resolved graph identity.
+//! Retained vendor provenance for winit patch (10) (vendor/README.md); `vendor_graph.rs` checks the resolved graph.
+/// Regression test: the vendored winit retries the first Apple Korean key once so it starts a composition.
#[test]
-fn winit_retries_the_first_apple_korean_key_once() {
+fn vendor_provenance_winit_retries_the_first_apple_korean_key_once() {
// On the first key in a freshly started app, Apple Korean can answer with `insertText` and the
// raw compatibility jamo instead of starting a composition, so typing g k s in PdfCraft gave
// ㅎㅏㄴ instead of 한. vendor/winit interprets that one key again before sending it to the
diff --git a/apps/pdfcraft/tests/winit_macos_korean_space.rs b/apps/pdfcraft/tests/it/winit_macos_korean_space.rs
similarity index 72%
rename from apps/pdfcraft/tests/winit_macos_korean_space.rs
rename to apps/pdfcraft/tests/it/winit_macos_korean_space.rs
index 30c9d4779c..f5f2aada2f 100644
--- a/apps/pdfcraft/tests/winit_macos_korean_space.rs
+++ b/apps/pdfcraft/tests/it/winit_macos_korean_space.rs
@@ -1,7 +1,10 @@
-//! vendor/winit patch (9) (vendor/README.md).
+//! Provenance-only: reads the retained `vendor/` copy, not the resolved dependency, so it is
+//! not runtime coverage. `vendor_graph.rs` checks the resolved graph identity.
+//! Retained vendor provenance for winit patch (9) (vendor/README.md); `vendor_graph.rs` checks the resolved graph.
+/// Regression test: the vendored winit sends a key committed through macOS IME only once.
#[test]
-fn winit_sends_a_key_committed_through_macos_ime_only_once() {
+fn vendor_provenance_winit_sends_a_key_committed_through_macos_ime_only_once() {
// Apple Korean answers Space with no syllable in progress with `setMarkedText(" ")`,
// `insertText(" ")` and `setMarkedText("")`. The last call resets winit 0.30.13's IME state, so
// the key went to the app both as `Ime::Commit(" ")` and as `KeyboardInput` with text " ", and
diff --git a/apps/pdfcraft/tests/winit_macos_title_bar_drag.rs b/apps/pdfcraft/tests/it/winit_macos_title_bar_drag.rs
similarity index 74%
rename from apps/pdfcraft/tests/winit_macos_title_bar_drag.rs
rename to apps/pdfcraft/tests/it/winit_macos_title_bar_drag.rs
index 5422af59b5..5c8e892f25 100644
--- a/apps/pdfcraft/tests/winit_macos_title_bar_drag.rs
+++ b/apps/pdfcraft/tests/it/winit_macos_title_bar_drag.rs
@@ -1,7 +1,11 @@
-//! vendor/winit patch (12) (vendor/README.md).
+//! Provenance-only: reads the retained `vendor/` copy, not the resolved dependency, so it is
+//! not runtime coverage. `vendor_graph.rs` checks the resolved graph identity.
+//! Retained vendor provenance for winit patch (12) (vendor/README.md); `vendor_graph.rs` checks the resolved graph.
+/// Regression test: the vendored winit lets title-bar drags reach the app while the empty tab strip still
+/// moves the window.
#[test]
-fn winit_lets_macos_title_bar_drags_reach_the_app() {
+fn vendor_provenance_winit_lets_macos_title_bar_drags_reach_the_app() {
// On macOS the tab strip is the title bar: a transparent title bar over a full-size content
// view. NSView answers YES to `mouseDownCanMoveWindow` for a view that isn't opaque, so AppKit
// moved the whole window for a drag that began on a document tab, and tabs could not be
diff --git a/apps/pdfcraft/tests/winit_wayland_scroll.rs b/apps/pdfcraft/tests/it/winit_wayland_scroll.rs
similarity index 67%
rename from apps/pdfcraft/tests/winit_wayland_scroll.rs
rename to apps/pdfcraft/tests/it/winit_wayland_scroll.rs
index ec76d2b4b9..8087219b21 100644
--- a/apps/pdfcraft/tests/winit_wayland_scroll.rs
+++ b/apps/pdfcraft/tests/it/winit_wayland_scroll.rs
@@ -1,13 +1,12 @@
+//! Provenance-only: reads the retained `vendor/` copy, not the resolved dependency, so it is
+//! not runtime coverage. `vendor_graph.rs` checks the resolved graph identity.
#![cfg(target_os = "linux")]
use egui::{Event, Modifiers, MouseWheelUnit, RawInput, TouchPhase, vec2};
use wayland_client::protocol::wl_pointer::AxisSource;
-// Compile the production policy against winit's real event type, without a compositor.
-mod event {
- pub use winit::event::TouchPhase;
-}
-#[path = "../../../vendor/winit/src/platform_impl/linux/wayland/seat/pointer/scroll.rs"]
+use crate::event;
+#[path = "../../../../vendor/winit/src/platform_impl/linux/wayland/seat/pointer/scroll.rs"]
mod scroll;
fn egui_phase(phase: event::TouchPhase) -> TouchPhase {
@@ -19,6 +18,7 @@ fn egui_phase(phase: event::TouchPhase) -> TouchPhase {
}
}
+/// Run three wheel frames through egui and return each frame's zoom and smooth-scroll deltas.
fn wheel_trace(source: AxisSource, discrete: bool) -> Vec<(f32, egui::Vec2)> {
let ctx = egui::Context::default();
let mut previous = event::TouchPhase::Ended;
@@ -30,7 +30,13 @@ fn wheel_trace(source: AxisSource, discrete: bool) -> Vec<(f32, egui::Vec2)> {
let (unit, dy) = if discrete { (MouseWheelUnit::Line, 1.0) } else { (MouseWheelUnit::Point, 24.0) };
let input = RawInput {
time: Some(frame as f64 / 60.0),
- events: vec![Event::MouseWheel { unit, delta: vec2(0.0, dy), phase: egui_phase(phase), modifiers }],
+ events: vec![Event::MouseWheel {
+ unit,
+ delta: vec2(0.0, dy),
+ phase: egui_phase(phase),
+ modifiers,
+ source: egui::MouseWheelSource::Unknown,
+ }],
..Default::default()
};
ctx.run_ui(input, |ui| seen.push(ui.input(|i| (i.zoom_delta(), i.smooth_scroll_delta)))).textures_delta.clear();
@@ -38,8 +44,10 @@ fn wheel_trace(source: AxisSource, discrete: bool) -> Vec<(f32, egui::Vec2)> {
seen
}
+/// Regression test: releasing Ctrl after a pixel-wheel zoom makes the next wheel event scroll instead of
+/// zooming.
#[test]
-fn releasing_ctrl_after_pixel_wheel_zoom_returns_to_scrolling() {
+fn vendor_provenance_releasing_ctrl_after_pixel_wheel_zoom_returns_to_scrolling() {
for source in [AxisSource::Wheel, AxisSource::WheelTilt, AxisSource::Continuous] {
let seen = wheel_trace(source, false);
assert!(seen[1].0 > 1.0, "Ctrl must zoom: {source:?}");
@@ -48,16 +56,18 @@ fn releasing_ctrl_after_pixel_wheel_zoom_returns_to_scrolling() {
}
}
+/// Regression test: discrete wheels follow the modifiers of the current event, just like pixel wheels.
#[test]
-fn discrete_wheels_still_follow_current_modifiers() {
+fn vendor_provenance_discrete_wheels_still_follow_current_modifiers() {
let seen = wheel_trace(AxisSource::Wheel, true);
assert!(seen[1].0 > 1.0);
assert_eq!(seen[2].0, 1.0);
assert!(seen[2].1.y > 0.0);
}
+/// Regression test: a finger gesture keeps its Ctrl zoom through momentum until the gesture ends.
#[test]
-fn finger_gestures_keep_their_modifier_until_the_gesture_ends() {
+fn vendor_provenance_finger_gestures_keep_their_modifier_until_the_gesture_ends() {
// A finger scroll's momentum may arrive after Ctrl is released.
let seen = wheel_trace(AxisSource::Finger, false);
assert!(seen[1].0 > 1.0);
@@ -67,8 +77,9 @@ fn finger_gestures_keep_their_modifier_until_the_gesture_ends() {
assert_eq!(scroll::phase(event::TouchPhase::Ended, Some(AxisSource::Finger), false, false), event::TouchPhase::Started);
}
+/// Regression test: wheel sources without a phase keep the existing start, move and end policy.
#[test]
-fn unknown_sources_keep_the_existing_phase_policy() {
+fn vendor_provenance_unknown_sources_keep_the_existing_phase_policy() {
assert_eq!(scroll::phase(event::TouchPhase::Ended, None, false, false), event::TouchPhase::Started);
assert_eq!(scroll::phase(event::TouchPhase::Started, None, false, false), event::TouchPhase::Moved);
assert_eq!(scroll::phase(event::TouchPhase::Ended, None, true, false), event::TouchPhase::Moved);
diff --git a/apps/pdfcraft/tests/winit_wayland_text_input.rs b/apps/pdfcraft/tests/it/winit_wayland_text_input.rs
similarity index 73%
rename from apps/pdfcraft/tests/winit_wayland_text_input.rs
rename to apps/pdfcraft/tests/it/winit_wayland_text_input.rs
index 691659e5a6..6469354cdc 100644
--- a/apps/pdfcraft/tests/winit_wayland_text_input.rs
+++ b/apps/pdfcraft/tests/it/winit_wayland_text_input.rs
@@ -1,7 +1,10 @@
-//! vendor/winit patch (2) (vendor/README.md).
+//! Provenance-only: reads the retained `vendor/` copy, not the resolved dependency, so it is
+//! not runtime coverage. `vendor_graph.rs` checks the resolved graph identity.
+//! Retained vendor provenance for winit patch (2) (vendor/README.md); `vendor_graph.rs` checks the resolved graph.
+/// Regression test: the vendored winit keeps a Wayland seat's text input until the seat itself is removed.
#[test]
-fn winit_keeps_the_wayland_text_input_until_the_seat_goes() {
+fn vendor_provenance_winit_keeps_the_wayland_text_input_until_the_seat_goes() {
// winit 0.30.13 destroys a Wayland seat's text input whenever the seat loses any capability (a
// touch screen or tablet unplugged, a remote-desktop session ending) and recreates it only when
// a capability is added, so IME stops working in a running PdfCraft until it is restarted.
diff --git a/apps/pdfcraft/tests/winit_windows_hidden_maximized.rs b/apps/pdfcraft/tests/it/winit_windows_hidden_maximized.rs
similarity index 73%
rename from apps/pdfcraft/tests/winit_windows_hidden_maximized.rs
rename to apps/pdfcraft/tests/it/winit_windows_hidden_maximized.rs
index a1bdd11840..a8009591bf 100644
--- a/apps/pdfcraft/tests/winit_windows_hidden_maximized.rs
+++ b/apps/pdfcraft/tests/it/winit_windows_hidden_maximized.rs
@@ -1,7 +1,10 @@
-//! vendor/winit patch (4) (vendor/README.md).
+//! Provenance-only: reads the retained `vendor/` copy, not the resolved dependency, so it is
+//! not runtime coverage. `vendor_graph.rs` checks the resolved graph identity.
+//! Retained vendor provenance for winit patch (4) (vendor/README.md); `vendor_graph.rs` checks the resolved graph.
+/// Regression test: the vendored winit maximizes a hidden Windows window only when it is shown.
#[test]
-fn winit_maximizes_a_hidden_windows_window_only_when_it_is_shown() {
+fn vendor_provenance_winit_maximizes_a_hidden_windows_window_only_when_it_is_shown() {
// eframe creates PdfCraft's window hidden, restores the saved maximized state and shows the
// window once its first frame is drawn. winit 0.30.13 applies the maximized state with
// `ShowWindow(SW_MAXIMIZE)`, which shows a hidden window, so a window closed maximized came back
diff --git a/apps/pdfcraft/tests/winit_windows_ime_cursor.rs b/apps/pdfcraft/tests/it/winit_windows_ime_cursor.rs
similarity index 69%
rename from apps/pdfcraft/tests/winit_windows_ime_cursor.rs
rename to apps/pdfcraft/tests/it/winit_windows_ime_cursor.rs
index aef55877d7..1df11d8a68 100644
--- a/apps/pdfcraft/tests/winit_windows_ime_cursor.rs
+++ b/apps/pdfcraft/tests/it/winit_windows_ime_cursor.rs
@@ -1,7 +1,10 @@
-//! vendor/winit patch (3) (vendor/README.md).
+//! Provenance-only: reads the retained `vendor/` copy, not the resolved dependency, so it is
+//! not runtime coverage. `vendor_graph.rs` checks the resolved graph identity.
+//! Retained vendor provenance for winit patch (3) (vendor/README.md); `vendor_graph.rs` checks the resolved graph.
+/// Regression test: the vendored winit reads the Windows IME cursor only when the composition reports it.
#[test]
-fn winit_reads_the_windows_ime_cursor_only_when_it_is_reported() {
+fn vendor_provenance_winit_reads_the_windows_ime_cursor_only_when_it_is_reported() {
// winit 0.30.13 reads `GCS_CURSORPOS` for every composition without a target clause, whether
// or not `WM_IME_COMPOSITION` says the IME set it. The Microsoft Korean IME never sets it, so
// the composition's cursor read as 0, before the syllable being composed. vendor/winit puts it
diff --git a/apps/pdfcraft/tests/winit_x11_dpi.rs b/apps/pdfcraft/tests/it/winit_x11_dpi.rs
similarity index 71%
rename from apps/pdfcraft/tests/winit_x11_dpi.rs
rename to apps/pdfcraft/tests/it/winit_x11_dpi.rs
index fed0cf3e42..a9bde6a1ee 100644
--- a/apps/pdfcraft/tests/winit_x11_dpi.rs
+++ b/apps/pdfcraft/tests/it/winit_x11_dpi.rs
@@ -1,5 +1,9 @@
+//! Provenance-only: reads the retained `vendor/` copy, not the resolved dependency, so it is
+//! not runtime coverage. `vendor_graph.rs` checks the resolved graph identity.
+/// Regression test: the vendored winit keeps the X11 server-DPI fallback and the desktop file does not
+/// override scaling.
#[test]
-fn winit_carries_the_x11_server_dpi_fallback() {
+fn vendor_provenance_winit_carries_the_x11_server_dpi_fallback() {
// The behavior tests live beside the policy in winit (util::randr::tests).
// This guards PdfCraft's vendored copy, which workspace tests do not test itself.
let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../..");
diff --git a/apps/pdfcraft/tests/winit_x11_ime_focus.rs b/apps/pdfcraft/tests/it/winit_x11_ime_focus.rs
similarity index 75%
rename from apps/pdfcraft/tests/winit_x11_ime_focus.rs
rename to apps/pdfcraft/tests/it/winit_x11_ime_focus.rs
index f244d1387a..61e5c8ebfa 100644
--- a/apps/pdfcraft/tests/winit_x11_ime_focus.rs
+++ b/apps/pdfcraft/tests/it/winit_x11_ime_focus.rs
@@ -1,7 +1,10 @@
-//! vendor/winit patch (5) (vendor/README.md).
+//! Provenance-only: reads the retained `vendor/` copy, not the resolved dependency, so it is
+//! not runtime coverage. `vendor_graph.rs` checks the resolved graph identity.
+//! Retained vendor provenance for winit patch (5) (vendor/README.md); `vendor_graph.rs` checks the resolved graph.
+/// Regression test: the vendored winit focuses the X11 input context it replaces.
#[test]
-fn winit_focuses_the_x11_input_context_it_replaces() {
+fn vendor_provenance_winit_focuses_the_x11_input_context_it_replaces() {
// egui allows IME when a text field gains focus and disallows it when the field loses focus.
// On X11, winit 0.30.13 replaces the window's input context each time and leaves focusing the
// new one to the next focus event, which never comes while PdfCraft's window keeps the focus:
diff --git a/assets/app-icon/README.md b/assets/app-icon/README.md
index 10fb2d4cf3..7c17ad0045 100644
--- a/assets/app-icon/README.md
+++ b/assets/app-icon/README.md
@@ -1,6 +1,6 @@
# PdfCraft app icon
-
+
**Creature:** a lion, in a frontal head-and-shoulders portrait, mane running off the bottom of the tile.
@@ -13,15 +13,14 @@ a full-bleed colour field, no frame or roundel, the animal looking at the viewer
|---|---|---|
| Ink | `#0b0b0c` | line work and the figure's contour |
| Paper | `#efe9dc` | the figure (the lion's silhouette) |
-| PdfCraft green (app colour) | `#12a58a` | the full-bleed field |
+| PdfCraft red (app colour) | `#b70228` | the full-bleed field |
**Tile:** `viewBox="0 0 512 512"`, a rounded square with `rx=112` that clips everything. Windows and Linux
icons use the full-bleed tile. macOS icons put it on Apple's grid (an 824 px body centred on a transparent
1024 px canvas).
**Provenance:** the project owner's original drawing, made in ArtCraft (2880 px, keyed to the palette), then
-vectorised with craftrules `assets/logo-options/_tools/vectorize_tile.py` (potrace; no filtering or
-warping). The source drawing is kept in craftrules at `assets/app-icons/pdfcraft/source.png`, not here.
+vectorised (potrace; no filtering or warping).
Licence: [LICENSE.txt](LICENSE.txt) (`MIT OR Apache-2.0`, like the repo).
## Files
@@ -33,7 +32,7 @@ Licence: [LICENSE.txt](LICENSE.txt) (`MIT OR Apache-2.0`, like the repo).
| `pdfcraft-1024.png` | 1024 px on Apple's grid; also the runtime Dock icon on macOS |
| `pdfcraft.icns` | macOS icon (16–1024 px) |
| `pdfcraft.ico` | Windows icon (16–256 px), embedded in `pdfcraft.exe` by `apps/pdfcraft/build.rs` |
-| `pdfcraft-document.svg` | Windows PDF file icon master: a Lucide document outline, green badge and outlined PDF lettering; no font dependency |
+| `pdfcraft-document.svg` | Windows PDF file icon master: a Lucide document outline, red badge and outlined PDF lettering; no font dependency |
| `pdfcraft-document.ico` | Windows PDF file icon (16–256 px), embedded as resource 2 in `pdfcraft.exe` |
| `hicolor/x/apps/ai.storyteller.pdfcraft.png` | Linux hicolor theme, 16–512 px; the 256 px one is the runtime icon on Windows and Linux |
| `hicolor/scalable/apps/ai.storyteller.pdfcraft.svg` | Linux scalable icon (copy of the master) |
diff --git a/assets/app-icon/hicolor/128x128/apps/ai.storyteller.pdfcraft.png b/assets/app-icon/hicolor/128x128/apps/ai.storyteller.pdfcraft.png
index 20f4ca4393..b4ac96bc47 100644
Binary files a/assets/app-icon/hicolor/128x128/apps/ai.storyteller.pdfcraft.png and b/assets/app-icon/hicolor/128x128/apps/ai.storyteller.pdfcraft.png differ
diff --git a/assets/app-icon/hicolor/16x16/apps/ai.storyteller.pdfcraft.png b/assets/app-icon/hicolor/16x16/apps/ai.storyteller.pdfcraft.png
index be76e69b86..c3f4315212 100644
Binary files a/assets/app-icon/hicolor/16x16/apps/ai.storyteller.pdfcraft.png and b/assets/app-icon/hicolor/16x16/apps/ai.storyteller.pdfcraft.png differ
diff --git a/assets/app-icon/hicolor/24x24/apps/ai.storyteller.pdfcraft.png b/assets/app-icon/hicolor/24x24/apps/ai.storyteller.pdfcraft.png
index a00f8f9501..5ae21c7ded 100644
Binary files a/assets/app-icon/hicolor/24x24/apps/ai.storyteller.pdfcraft.png and b/assets/app-icon/hicolor/24x24/apps/ai.storyteller.pdfcraft.png differ
diff --git a/assets/app-icon/hicolor/256x256/apps/ai.storyteller.pdfcraft.png b/assets/app-icon/hicolor/256x256/apps/ai.storyteller.pdfcraft.png
index be413a715a..766624ec8c 100644
Binary files a/assets/app-icon/hicolor/256x256/apps/ai.storyteller.pdfcraft.png and b/assets/app-icon/hicolor/256x256/apps/ai.storyteller.pdfcraft.png differ
diff --git a/assets/app-icon/hicolor/32x32/apps/ai.storyteller.pdfcraft.png b/assets/app-icon/hicolor/32x32/apps/ai.storyteller.pdfcraft.png
index f9e2ef2b6e..ab6e8e9312 100644
Binary files a/assets/app-icon/hicolor/32x32/apps/ai.storyteller.pdfcraft.png and b/assets/app-icon/hicolor/32x32/apps/ai.storyteller.pdfcraft.png differ
diff --git a/assets/app-icon/hicolor/48x48/apps/ai.storyteller.pdfcraft.png b/assets/app-icon/hicolor/48x48/apps/ai.storyteller.pdfcraft.png
index 73971ee5f9..55256b293d 100644
Binary files a/assets/app-icon/hicolor/48x48/apps/ai.storyteller.pdfcraft.png and b/assets/app-icon/hicolor/48x48/apps/ai.storyteller.pdfcraft.png differ
diff --git a/assets/app-icon/hicolor/512x512/apps/ai.storyteller.pdfcraft.png b/assets/app-icon/hicolor/512x512/apps/ai.storyteller.pdfcraft.png
index 1d9ff4d11c..eb3699f9b8 100644
Binary files a/assets/app-icon/hicolor/512x512/apps/ai.storyteller.pdfcraft.png and b/assets/app-icon/hicolor/512x512/apps/ai.storyteller.pdfcraft.png differ
diff --git a/assets/app-icon/hicolor/64x64/apps/ai.storyteller.pdfcraft.png b/assets/app-icon/hicolor/64x64/apps/ai.storyteller.pdfcraft.png
index 14444f01bc..9bba411e39 100644
Binary files a/assets/app-icon/hicolor/64x64/apps/ai.storyteller.pdfcraft.png and b/assets/app-icon/hicolor/64x64/apps/ai.storyteller.pdfcraft.png differ
diff --git a/assets/app-icon/hicolor/scalable/apps/ai.storyteller.pdfcraft.svg b/assets/app-icon/hicolor/scalable/apps/ai.storyteller.pdfcraft.svg
index 3a1de9f076..5a8017ec4f 100644
--- a/assets/app-icon/hicolor/scalable/apps/ai.storyteller.pdfcraft.svg
+++ b/assets/app-icon/hicolor/scalable/apps/ai.storyteller.pdfcraft.svg
@@ -1 +1 @@
-
\ No newline at end of file
+
\ No newline at end of file
diff --git a/assets/app-icon/pdfcraft-1024.png b/assets/app-icon/pdfcraft-1024.png
index 3c79b60279..d6cf213765 100644
Binary files a/assets/app-icon/pdfcraft-1024.png and b/assets/app-icon/pdfcraft-1024.png differ
diff --git a/assets/app-icon/pdfcraft-document.ico b/assets/app-icon/pdfcraft-document.ico
index 79967a800a..ec1ea4568e 100644
Binary files a/assets/app-icon/pdfcraft-document.ico and b/assets/app-icon/pdfcraft-document.ico differ
diff --git a/assets/app-icon/pdfcraft-document.svg b/assets/app-icon/pdfcraft-document.svg
index 603a740157..b9d2fbf3c9 100644
--- a/assets/app-icon/pdfcraft-document.svg
+++ b/assets/app-icon/pdfcraft-document.svg
@@ -4,10 +4,10 @@