diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000000..1910ea4bd3 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,32 @@ +version: 2 +updates: + # Maintain Rust / Cargo dependencies + - package-ecosystem: "cargo" + directories: + - "/" + - "/crates/filters/fuzz" + schedule: + interval: "daily" + time: "06:00" + timezone: "America/New_York" + # Consolidate updates into a single PR rather than one PR per crate + groups: + cargo-dependencies: + patterns: + - "*" + commit-message: + prefix: "chore(deps)" + + # Maintain GitHub Actions workflows + - package-ecosystem: "github-actions" + directory: "/" + schedule: + interval: "daily" + time: "06:00" + timezone: "America/New_York" + groups: + actions: + patterns: + - "*" + commit-message: + prefix: "ci(deps)" diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md new file mode 100644 index 0000000000..50ff3e96c4 --- /dev/null +++ b/.github/pull_request_template.md @@ -0,0 +1,76 @@ + + +## What this changes + + + +## How you verified it + + + +- [ ] `cargo fmt --check` +- [ ] clippy with `-D warnings` on the crates I changed +- [ ] tests pass on the crates I changed +- [ ] `cargo xtask layers` and `cargo xtask assets` (if you touched crate boundaries or any asset) + +**Tests.** New behaviour needs a test at the right scale — a property test, a unit test, a +crate integration test or an end-to-end one. For a bug fix, **please check that the test +fails before your fix** by reverting the fix and watching it go red. A test nobody has seen +fail is not evidence that it pins anything. + +## Never-crash + +Every PDF, script, MCP call, settings file and keystroke is untrusted input (`AGENTS.md` §4). + +- [ ] No new `unwrap`/`expect`/`panic!`/`unreachable!`/`todo!` outside tests, unless it is + provably infallible with a comment saying why +- [ ] No indexing or slicing with input-derived positions (`get`, and strings only at char + boundaries); checked or saturating arithmetic; allocations sized by input are capped; + recursion is bounded +- [ ] No `unsafe` +- [ ] Any crash this fixes has a small synthetic regression test + +## Provenance + +This is the part that cannot be undone after a merge, and the part a new contributor is +least likely to know about. Please read `AGENTS.md` §1 before adding any asset. + +- [ ] No asset from an Adobe product, and no Adobe icon recreated, traced or imitated +- [ ] No font files committed here (they live in + [storytold/craft-fonts](https://github.com/storytold/craft-fonts)) +- [ ] No GPL/AGPL code copied in — MuPDF, Ghostscript, Poppler, veraPDF and DSS may only be + run as external oracle processes +- [ ] Behaviour comes from the specification or black-box observation, **not** from reading + another implementation's source +- [ ] Every new asset has an `ATTRIBUTION.toml` entry and `cargo xtask assets` passes + +## Security + +- [ ] `--root` confinement still holds on every path, including ones reached by nested + tool dispatch +- [ ] MCP and the UI control channel stay opt-in, loopback-only and token-authenticated +- [ ] No new outbound network access (that decision is the owner's) + +## Parity and reach + +- [ ] `parity/acrobat-features.toml` updated in this PR, with `notes` saying what is *still* + missing — `shipped` means "exists and tested", not "as good as Acrobat" +- [ ] Reachable headlessly through an `automation` tool where that applies (`AGENTS.md` §3), + so agents can drive it and not just the UI +- [ ] Docs updated if this changes a documented behaviour (`docs/`, crate README) + +## Anything a reviewer should look at first + + diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e0c0596447..388b44f032 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -20,6 +20,9 @@ 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: @@ -27,28 +30,43 @@ jobs: # and release.yml builds macOS (a failing release is fixed and rebuilt). os: [ubuntu-latest, windows-latest] steps: - - uses: actions/checkout@v4 + - 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@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: repository: storytold/craft-fonts - ref: 8dcdacd5153e64560d109541a47d806f26f048c0 + ref: 3d9ab3a132ed0eff457e424226a435a901da30d3 path: craft-fonts - - name: Install Linux system dependencies (egui/winit/rfd) + - 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 + libgl1-mesa-dev libgtk-3-dev \ + qpdf poppler-utils - - uses: dtolnay/rust-toolchain@stable + # 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 @@ -56,7 +74,7 @@ jobs: run: Add-Content -Path $env:GITHUB_ENV -Value 'PDFCRAFT_REQUIRE_WINRES=1' shell: pwsh - - uses: Swatinem/rust-cache@v2 + - uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2 - name: Format if: runner.os == 'Linux' @@ -71,12 +89,10 @@ jobs: - name: Test (with craft-fonts) if: runner.os == 'Linux' - env: - CRAFT_FONTS_DIR: ${{ github.workspace }}/craft-fonts - CRAFT_FONTS_REQUIRED: "1" run: cargo test --workspace - name: Layering + if: runner.os == 'Linux' run: cargo xtask layers - name: Assets (licences and attribution) @@ -91,11 +107,12 @@ jobs: name: wasm32 check runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 - - uses: dtolnay/rust-toolchain@stable + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - uses: dtolnay/rust-toolchain@686976e191b89faba57d3206551f0f330d8cb249 # stable with: + toolchain: stable targets: wasm32-unknown-unknown - - uses: Swatinem/rust-cache@v2 + - uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2 with: key: wasm32 - name: Check library crates for wasm32 @@ -105,7 +122,7 @@ jobs: name: dependency licences and advisories runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - uses: EmbarkStudios/cargo-deny-action@v2 with: command: check @@ -115,9 +132,11 @@ jobs: if: github.event_name == 'schedule' || github.event_name == 'workflow_dispatch' runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 - - uses: dtolnay/rust-toolchain@stable - - uses: Swatinem/rust-cache@v2 + - 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) @@ -126,7 +145,7 @@ jobs: run: cargo xtask fuzz --time 900 --seed ${{ github.run_number }} - name: Upload findings if: always() - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 with: name: fuzz-findings path: fuzz-out/findings diff --git a/.github/workflows/freebsd.yml b/.github/workflows/freebsd.yml index 37e9fbdecb..fba20b1cc7 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: 8dcdacd5153e64560d109541a47d806f26f048c0 + craft-fonts-ref: 3d9ab3a132ed0eff457e424226a435a901da30d3 diff --git a/.github/workflows/nix.yml b/.github/workflows/nix.yml new file mode 100644 index 0000000000..ce817da129 --- /dev/null +++ b/.github/workflows/nix.yml @@ -0,0 +1,33 @@ +# Evaluates the Nix flake (no build, about a minute): `nix build` / `nix develop` stop at evaluation +# when Cargo.lock gains something nix/package.nix can't vendor, and nothing else would notice. +name: Nix + +on: + push: + branches: [main] + paths: ["flake.nix", "flake.lock", "nix/**", "Cargo.lock", ".github/workflows/nix.yml"] + pull_request: + paths: ["flake.nix", "flake.lock", "nix/**", "Cargo.lock", ".github/workflows/nix.yml"] + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: nix-${{ github.ref }} + 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 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index b5929d067e..c6ee0e1c31 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: 8dcdacd5153e64560d109541a47d806f26f048c0 + craft-fonts-ref: 3d9ab3a132ed0eff457e424226a435a901da30d3 version: ${{ inputs.version }} secrets: APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }} @@ -35,3 +35,9 @@ jobs: APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }} WINDOWS_CERTIFICATE: ${{ secrets.WINDOWS_CERTIFICATE }} WINDOWS_CERTIFICATE_PASSWORD: ${{ secrets.WINDOWS_CERTIFICATE_PASSWORD }} + AZURE_TENANT_ID: ${{ secrets.AZURE_TENANT_ID }} + AZURE_CLIENT_ID: ${{ secrets.AZURE_CLIENT_ID }} + AZURE_CLIENT_SECRET: ${{ secrets.AZURE_CLIENT_SECRET }} + AZURE_SIGNING_ENDPOINT: ${{ secrets.AZURE_SIGNING_ENDPOINT }} + AZURE_SIGNING_ACCOUNT: ${{ secrets.AZURE_SIGNING_ACCOUNT }} + AZURE_CERT_PROFILE: ${{ secrets.AZURE_CERT_PROFILE }} diff --git a/AGENTS.md b/AGENTS.md index 039cd6a8b1..0665ad1d3b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -25,7 +25,7 @@ An **asset** is any non-code material that the project includes, ships, embeds, - 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: - 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, and are embedded as code in hayro. + - 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. Adding anything to this list needs the user's explicit approval. diff --git a/ATTRIBUTION.md b/ATTRIBUTION.md index f4974b09a0..5cfee8d55a 100644 --- a/ATTRIBUTION.md +++ b/ATTRIBUTION.md @@ -4,10 +4,11 @@ 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). -## In this repository (265) +## In this repository (272) | 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 | @@ -21,9 +22,12 @@ Every asset PdfCraft includes, bundles or uses to build its published material, | `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 | | `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 | @@ -218,7 +222,7 @@ Every asset PdfCraft includes, bundles or uses to build its published material, | `vendor/hayro-interpret/assets/FoxitSerifItalic.pfb` | FoxitSerifItalic (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/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) | -| `vendor/hayro-interpret/assets/LAB.icc` | Lab abstract ICC profile | The Hayro Authors (generated with LittleCMS 2) | MIT OR Apache-2.0 | https://github.com/LaurenzV/hayro (hayro-interpret 0.7.0 assets/) | Lab colour space 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. | | `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. | @@ -273,6 +277,9 @@ Every asset PdfCraft includes, bundles or uses to build its published material, | `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 | | `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 | +| `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 | ## Compiled in through dependencies (6) diff --git a/ATTRIBUTION.toml b/ATTRIBUTION.toml index b32a25d4b6..db3dd6124f 100644 --- a/ATTRIBUTION.toml +++ b/ATTRIBUTION.toml @@ -1,3 +1,14 @@ +[[asset]] +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" +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" + # 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`). # @@ -19,7 +30,7 @@ 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 = "a562bce9a9ba1be6bc464ef74104bbf53f88291a5ec522d18ba3f607e8490cf6" +sha256 = "9870c0fb9f7198155be16adee7f45d36cb9a254af90b545a3ce5b04cbbbf24d4" [[asset]] path = "crates/ui-egui/src/i18n/zh-hant.tsv" @@ -30,7 +41,7 @@ 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 = "3d9dde3978c43dea4ad461288c859d466520888aa4fce0ea43514f8206bc084f" +sha256 = "fc31e40877ce115cfb7033c57dd6acabcae3725b249364387e3fe23e04d94e5e" [[asset]] path = "crates/ui-egui/src/i18n/cs.tsv" @@ -41,7 +52,7 @@ 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 = "a84c17c7a8f297bef07a613e63116f9a8bb05aa02fd8442a684648df3a768224" +sha256 = "5de19834f293efaea68cb6b7f51a71d320391f53345f56cfebcd73dab828b665" [[asset]] path = "crates/ui-egui/src/i18n/pt-br.tsv" @@ -52,7 +63,7 @@ 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 = "59fbeb2f5c912f7b77c1f117e0f68b8806bb015e0d187721bc77b6708cb3e088" +sha256 = "cef0d22341d0722f029c6543fc846fce164331b7ad8f693a6754b7567b9d9e95" [[asset]] path = "crates/ui-egui/src/i18n/es.tsv" @@ -63,7 +74,7 @@ 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 = "47a294326663323e42e92b4286d68b42741d0f6c8bb5df2023ed6c5697595332" +sha256 = "c0ca7761cbabd8096bc9af54cdf6b88b3ae0cd8210003db8b3356e2706e44fc8" [[asset]] path = "crates/ui-egui/src/i18n/fr.tsv" @@ -74,7 +85,7 @@ 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 = "15aada11b884a55869b2080f79a6902134a4cdeea1c2c011484b22b5c5f53747" +sha256 = "a8afd51c3296f44b0446d6b06492b1348444b3e3c448a326ad213d18ce7af714" [[asset]] path = "crates/ui-egui/src/i18n/de.tsv" @@ -85,7 +96,7 @@ 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 = "6945b0d3b6b6bcd8cad936b8b440484903fa71ce439a5da6fddf595ffa9f0233" +sha256 = "7676a2dfaa7d66e2e9ef608d2d9de7f863557dcecbef6d94bafa981097405e6d" [[asset]] path = "crates/ui-egui/src/i18n/te.tsv" @@ -96,7 +107,7 @@ 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 = "8ee5d4a658dd29e41e171103343eb223cc360956fb533788cec8676dcdf1c6ec" +sha256 = "9a22996c0308ea5b20ec5e328504cd83cbb560147763faaf0e6987f974844c29" [[asset]] path = "crates/ui-egui/src/i18n/it.tsv" @@ -107,7 +118,7 @@ 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 = "884adac1fca5f8a4bb14d947e9feabdee2ca18e86c3a1dbc2e7a042512c37ec2" +sha256 = "eacc972d65a2d951e94e1a8cf944638e8328f73f6c05759de6059a425f159cd7" [[asset]] path = "crates/ui-egui/src/i18n/pl.tsv" @@ -118,7 +129,7 @@ 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 = "5e794e7ab841956a509ecdf1701d3e61ced122d10e962d20ce14068cdd8d4df4" +sha256 = "dfa4aa540cc7d61fa22a27a949e6eec0f78dcc5b883d0e4e004d695e460b95aa" [[asset]] path = "crates/ui-egui/src/i18n/ru.tsv" @@ -129,7 +140,7 @@ 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 = "f83db805a29b5ac05bea6c89b7542d7afdb8fab8edf578a8d976aca7e18cd348" +sha256 = "27e02e7bb3d378bceca76988a9dceaa68d974f57cbee962954deca6184cf62aa" [[asset]] path = "crates/ui-egui/src/i18n/hu.tsv" @@ -140,7 +151,7 @@ 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 = "ad66bf99115990740d7a65ced3495d3dc954cd7c229ac35e3f136c20c439420b" +sha256 = "9675edd36c5ae54d9893fd10c4325aab664941bf714c08fbfeef9134fa42056c" [[asset]] path = "crates/ui-egui/src/i18n/bg.tsv" @@ -151,7 +162,18 @@ 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 = "f0f5451f8602e2d9ac27386e3fabde563bcc69d357c235db7babfeee8cac6cb6" +sha256 = "ce18fc96f8516aa917e4a8bcfd290edec13a9800b3d1ee102e4b2a71b8467d2b" + +[[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" +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" [[asset]] path = "crates/ui-egui/src/i18n/ja.tsv" @@ -162,7 +184,7 @@ 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 = "ca0f9ea68fa5df68c468dd535baf85aa11e5e7bcd704149f7d2df44c284a0cff" +sha256 = "77593b7497c67f4a79cc31fa43fdd6eae11601dd87c079a8e666b25cdd9117ba" [[asset]] path = "crates/ui-egui/src/i18n/uk.tsv" @@ -173,7 +195,7 @@ 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 = "d5eb9db7c07041b0dca02dbe5ea27ca10de9ce2d3a519d7b8e2dc8dedf5078c4" +sha256 = "d7d93a03f22ab380696f69b20b12104bf30e78c4440b683df63594af716894e2" [[asset]] path = "crates/ui-egui/src/i18n/ar.tsv" @@ -184,7 +206,30 @@ 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 = "df5deac92b0ff5b7b40251d3eee21c6d3c345fa62a6b726001a730c16fabbfb8" +sha256 = "889a0d0a9ea53ca0a818c2ba672c053c232d2cca140f8d63127353e6f36505fe" + +[[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" +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" + +[[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" +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" + [[asset]] path = "assets/icons/accessibility.svg" @@ -2321,15 +2366,16 @@ usage = "Default CMYK colour conversion (vendored hayro-interpret)" sha256 = "73e1ba37d2bad5bab2a964f40a9eed96209666efc067c3322626214bbef234a0" [[asset]] -path = "vendor/hayro-interpret/assets/LAB.icc" -title = "Lab abstract ICC profile" -author = "The Hayro Authors (generated with LittleCMS 2)" -source = "https://github.com/LaurenzV/hayro (hayro-interpret 0.7.0 assets/)" +path = "crates/fonts/src/std14_metrics.rs" +title = "Standard-14 font advance widths and encodings (as Rust tables)" +author = "Adobe (Core14 AFM metrics, PDF specification encodings); tables generated by the PdfCraft contributors" +source = "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." licence = "MIT OR Apache-2.0" -licence_file = "vendor/hayro-interpret/LICENSE-MIT" +licence_file = "LICENSE-MIT" kind = "data" -usage = "Lab colour space conversion (vendored hayro-interpret)" -sha256 = "a6efc5c30ece826eba2a83ddf5aa7026289b0c5ffa90acb129bc59ab8d7f5c59" +adobe_data = true +usage = "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." +sha256 = "f225e0b044875b8fa6375b6b5ad935f0d99fcc1ad03ecf9f0baae2cedbfca4d2" [[bundled]] crate = "epaint_default_fonts" @@ -3296,3 +3342,37 @@ licence_file = "assets/icons/LICENSE-lucide.txt" kind = "icon" usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs" sha256 = "d1a443233345724859de8e4fb48968ba91e885e12ddbd710701548bd91b39428" + +[[asset]] +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" +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" + +[[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" +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" + +[[asset]] + +path = "vendor/vello_common/assets/probe.png" +title = "vello_common probe reference image (rendered from probe.rgba)" +author = "Linebender vello contributors" +source = "vello_common 0.0.8 (crates.io), vendored in vendor/vello_common; regenerated by its vello_sparse_tests regenerate_probe_reference binary" +licence = "MIT OR Apache-2.0" +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" diff --git a/CLAUDE.md b/CLAUDE.md index 77d6445b4e..12779b1e35 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -31,8 +31,15 @@ PdfCraft is a clean-room, open-source, Rust-native PDF application targeting Ado - 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`). - **Rust only** in the product and build (`xtask`). No handwritten JS/TS. -- **Quality gates** before every commit: `cargo fmt --check`, `cargo clippy --workspace -- -D warnings`, `cargo test --workspace`, and the wasm check once `xtask ci` exists (M0). -- **Commits:** one task id per commit (e.g. `M1.4: xref stream reader`). Commit only green states. End messages with the attribution line required by the environment. +- **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). + - **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. +- **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. ## Running and looking at the app - `cargo run -p pdfcraft -- ` opens the desktop app. diff --git a/Cargo.lock b/Cargo.lock index 60d3a33c78..68a758c2fb 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -131,7 +131,7 @@ checksum = "35f0f96ce78e38c3dc6d8948aa8163d06385be74000f3c7a95bf1eef35d3ea32" dependencies = [ "cipher", "cpubits", - "cpufeatures", + "cpufeatures 0.3.1", ] [[package]] @@ -452,6 +452,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b281d307588d634de920874890732659e2e7672f72b5e10e81badc1a8a83621e" dependencies = [ "aws-lc-sys", + "untrusted 0.7.1", "zeroize", ] @@ -537,6 +538,15 @@ dependencies = [ "serde_core", ] +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + [[package]] name = "block-buffer" version = "0.12.1" @@ -913,7 +923,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "65c35e4b699c7e15ccbe7ee35c005e4fc0a278d22238a2857e6ce2dadeda1b06" dependencies = [ "cfg-if", - "cpufeatures", + "cpufeatures 0.3.1", "rand_core 0.10.1", ] @@ -924,7 +934,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1aa79e62e7697b8e29b513a68abacf485adcd1fe8284a4316c5ae868e6633327" dependencies = [ "iana-time-zone", + "js-sys", "num-traits", + "serde", + "wasm-bindgen", "windows-link", ] @@ -934,7 +947,7 @@ version = "0.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e8cf2a2c93cd704877c0858356ed03480ff301ee950b43f1cbe4573b088bfa6c" dependencies = [ - "crypto-common", + "crypto-common 0.2.2", "inout", ] @@ -962,6 +975,30 @@ version = "0.5.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0c9ea0ac24bc397ab3c98583a3c9ba74fa56b09a4449bbe172b9b1ddb016027a" +[[package]] +name = "cmpv2" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "961b955a666e25ee5a1091d219128d6e6401e3dab84efb1a2bf6b4035d797b39" +dependencies = [ + "crmf", + "der 0.7.10", + "spki 0.7.3", + "x509-cert", +] + +[[package]] +name = "cms" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b77c319abfd5219629c45c34c89ba945ed3c5e49fcde9d16b6c3885f118a730" +dependencies = [ + "const-oid 0.9.6", + "der 0.7.10", + "spki 0.7.3", + "x509-cert", +] + [[package]] name = "codespan-reporting" version = "0.13.1" @@ -1007,6 +1044,12 @@ dependencies = [ "crossbeam-utils", ] +[[package]] +name = "const-oid" +version = "0.9.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8" + [[package]] name = "const-oid" version = "0.10.2" @@ -1081,6 +1124,15 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "15b85f9c39137c3a891689859392b1bd49812121d0d61c9caf00d46ed5ce06ae" +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + [[package]] name = "cpufeatures" version = "0.3.1" @@ -1090,6 +1142,35 @@ dependencies = [ "libc", ] +[[package]] +name = "craft-docpath" +version = "0.1.0" +source = "git+https://github.com/storytold/craft-libs?rev=82a24b006eefa036b43c09a67fb9a3fbd17ffe63#82a24b006eefa036b43c09a67fb9a3fbd17ffe63" + +[[package]] +name = "craft-print" +version = "0.1.0" +source = "git+https://github.com/storytold/craft-libs?rev=666c358065261a2816b376fa3bb10538dbf53040#666c358065261a2816b376fa3bb10538dbf53040" +dependencies = [ + "miniz_oxide 0.8.9", + "roxmltree", + "tempfile", + "winprint", + "winsafe", +] + +[[package]] +name = "craft-quit" +version = "0.1.0" +source = "git+https://github.com/storytold/craft-libs?rev=f58bc1a3df490992d633393d0898af0588ce882c#f58bc1a3df490992d633393d0898af0588ce882c" +dependencies = [ + "dispatch2", + "egui", + "muda", + "objc2 0.6.4", + "objc2-app-kit 0.3.2", +] + [[package]] name = "craft-ui" version = "0.1.0" @@ -1108,6 +1189,27 @@ dependencies = [ "cfg-if", ] +[[package]] +name = "crmf" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "36fe21b96d5b87f5de4b5b7202ec41c00110ac817ce6728fe75fb2fe5962ed92" +dependencies = [ + "cms", + "der 0.7.10", + "spki 0.7.3", + "x509-cert", +] + +[[package]] +name = "crossbeam-channel" +version = "0.5.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "98b0cc327b5bc766e7fda9c9260cc0fa81b43a8e240440422dff70788e3f9ef1" +dependencies = [ + "crossbeam-utils", +] + [[package]] name = "crossbeam-deque" version = "0.8.8" @@ -1156,6 +1258,16 @@ dependencies = [ "zeroize", ] +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "typenum", +] + [[package]] name = "crypto-common" version = "0.2.2" @@ -1200,9 +1312,9 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b5eed333089e2e1c1ac8c6c0398e5e2497b4c9926ca6d0365ed1e099afa5bc23" dependencies = [ "cfg-if", - "cpufeatures", + "cpufeatures 0.3.1", "curve25519-dalek-derive", - "digest", + "digest 0.11.3", "fiat-crypto", "rustc_version", "subtle", @@ -1240,17 +1352,41 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "be1e0bca6c3637f992fc1cc7cbc52a78c1ef6db076dbf1059c4323d6a2048376" +[[package]] +name = "der" +version = "0.7.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb" +dependencies = [ + "const-oid 0.9.6", + "der_derive", + "flagset", + "pem-rfc7468 0.7.0", + "zeroize", +] + [[package]] name = "der" version = "0.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a878c850e9e421b20262e9b41f9c860e4785fa07541c266b62ff9d1ef998a80a" dependencies = [ - "const-oid", - "pem-rfc7468", + "const-oid 0.10.2", + "pem-rfc7468 1.0.0", "zeroize", ] +[[package]] +name = "der_derive" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8034092389675178f570469e6c3b0465d3d30b4505c294a6550db47f3c17ad18" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + [[package]] name = "deranged" version = "0.5.8" @@ -1266,15 +1402,25 @@ dependencies = [ "cipher", ] +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer 0.10.4", + "crypto-common 0.1.7", +] + [[package]] name = "digest" version = "0.11.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f1dd6dbb5841937940781866fa1281a1ff7bd3bf827091440879f9994983d5c2" dependencies = [ - "block-buffer", - "const-oid", - "crypto-common", + "block-buffer 0.12.1", + "const-oid 0.10.2", + "crypto-common 0.2.2", "ctutils", ] @@ -1378,12 +1524,12 @@ version = "0.17.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c0681a4fc24c767085329728d8dfba959af91228aa4610cca4f8ce317ba46ae0" dependencies = [ - "der", - "digest", + "der 0.8.2", + "digest 0.11.3", "elliptic-curve", "rfc6979", - "signature", - "spki", + "signature 3.0.0", + "spki 0.8.0", "zeroize", ] @@ -1404,7 +1550,7 @@ version = "3.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "29fcf32e6c73d1079f83ab4d782de2d81620346a5f38c6237a86a22f8368980a" dependencies = [ - "signature", + "signature 3.0.0", ] [[package]] @@ -1415,7 +1561,7 @@ checksum = "6ebaa1a2bf1290ab3bfe5a7b771d050ebffab2711c19a81691c683a5144a25de" dependencies = [ "curve25519-dalek", "ed25519", - "sha2", + "sha2 0.11.0", "subtle", "zeroize", ] @@ -1582,8 +1728,8 @@ checksum = "9d65aa39b3a5c1c9c1b745c9a019234bb7a21b77abcb4f4d266d706e2d577d65" dependencies = [ "base16ct", "crypto-bigint", - "crypto-common", - "digest", + "crypto-common 0.2.2", + "digest 0.11.3", "ff", "group", "hybrid-array", @@ -1741,7 +1887,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.61.2", + "windows-sys 0.52.0", ] [[package]] @@ -1828,6 +1974,16 @@ version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "64cd1e32ddd350061ae6edb1b082d7c54915b5c672c389143b9a63403a109f24" +[[package]] +name = "filetime" +version = "0.2.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c287a33c7f0a620c38e641e7f60827713987b3c0f26e8ddc9462cc69cf75759" +dependencies = [ + "cfg-if", + "libc", +] + [[package]] name = "find-msvc-tools" version = "0.1.14" @@ -1840,6 +1996,12 @@ version = "0.5.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1d674e81391d1e1ab681a28d99df07927c6d4aa5b027d7da16ba32d1d21ecd99" +[[package]] +name = "flagset" +version = "0.4.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7ac824320a75a52197e8f2d787f6a38b6718bb6897a35142d749af3c0e8f4fe" + [[package]] name = "flatbuffers" version = "24.12.23" @@ -1867,6 +2029,28 @@ version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "98de4bbd547a563b716d8dfa9aad1cb19bfab00f4fa09a6a4ed21dbcf44ce9c4" +[[package]] +name = "fmt-derive" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1cb4a4b45c26455885f8b462db12978e51f60cc914337bccb9a36faf93410169" +dependencies = [ + "fmt-derive-proc", +] + +[[package]] +name = "fmt-derive-proc" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7704da0760734344ce8753f15b21e97c6be85dc5a8f2c3a963fbd042b3380d23" +dependencies = [ + "proc-macro-crate", + "proc-macro-error", + "proc-macro2", + "quote", + "syn 2.0.119", +] + [[package]] name = "fmv-macos-events" version = "0.1.0" @@ -1909,6 +2093,15 @@ dependencies = [ "serde", ] +[[package]] +name = "fontconfig-parser" +version = "0.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbc773e24e02d4ddd8395fd30dc147524273a83e54e0f312d986ea30de5f5646" +dependencies = [ + "roxmltree", +] + [[package]] name = "foreign-types" version = "0.5.0" @@ -1951,6 +2144,16 @@ version = "1.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c" +[[package]] +name = "futures-channel" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1f9e3d69d39e4862ffed03ed071a76f9a13ba1d9109d355b0f0aa6b15e393c4" +dependencies = [ + "futures-core", + "futures-sink", +] + [[package]] name = "futures-concurrency" version = "7.7.1" @@ -2000,6 +2203,12 @@ dependencies = [ "syn 3.0.6", ] +[[package]] +name = "futures-sink" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1944426bf7d03f1d14f708785e4b33efd750b36d48a157b836b3efc15ede8e1d" + [[package]] name = "futures-task" version = "0.3.34" @@ -2013,12 +2222,25 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc" dependencies = [ "futures-core", + "futures-io", "futures-macro", + "futures-sink", "futures-task", + "memchr", "pin-project-lite", "slab", ] +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + [[package]] name = "gethostname" version = "1.1.0" @@ -2036,8 +2258,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" dependencies = [ "cfg-if", + "js-sys", "libc", "wasi", + "wasm-bindgen", ] [[package]] @@ -2232,6 +2456,25 @@ dependencies = [ "euclid", ] +[[package]] +name = "h2" +version = "0.4.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d29020232d6aa3fb1daca64c1127cf662cf97f254ae16c18c05b8ab635fc118" +dependencies = [ + "atomic-waker", + "bytes", + "fnv", + "futures-core", + "futures-sink", + "http", + "indexmap", + "slab", + "tokio", + "tokio-util", + "tracing", +] + [[package]] name = "half" version = "2.7.1" @@ -2364,6 +2607,7 @@ dependencies = [ "memchr", "rustc-hash 2.1.3", "smallvec", + "stringprep", "zune-jpeg", ] @@ -2391,7 +2635,7 @@ version = "0.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6303bc9732ae41b04cb554b844a762b4115a61bfaa81e3e83050991eeb56863f" dependencies = [ - "digest", + "digest 0.11.3", ] [[package]] @@ -2404,6 +2648,29 @@ dependencies = [ "itoa", ] +[[package]] +name = "http-body" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca2a8f2913ee65f60facd6a5905613afaa448497a0230cc41ce022d93290bc2c" +dependencies = [ + "bytes", + "http", +] + +[[package]] +name = "http-body-util" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23169fe34a5fbcdd3f3862e78fb9b6fccd5f02a6dc6f732547005d45631ce71c" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "pin-project-lite", +] + [[package]] name = "httparse" version = "1.10.1" @@ -2421,6 +2688,68 @@ dependencies = [ "zeroize", ] +[[package]] +name = "hyper" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2c3e324da4c95177d6291d4c8730197c0d1822f8a9766814a4a44fa5ab797c9c" +dependencies = [ + "atomic-waker", + "bytes", + "futures-channel", + "futures-core", + "h2", + "http", + "http-body", + "httparse", + "itoa", + "pin-project-lite", + "smallvec", + "tokio", + "want", +] + +[[package]] +name = "hyper-rustls" +version = "0.27.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dfa8e654703247911e29c23fbeaa261834bd9bb74efba2f9acddc37bfb127f53" +dependencies = [ + "http", + "hyper", + "hyper-util", + "rustls", + "tokio", + "tokio-rustls", + "tower-service", +] + +[[package]] +name = "hyper-util" +version = "0.1.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddc03d96684f9226b8a787cdb71488417b53ab5ea8fdb1dac946cb9431cc8bff" +dependencies = [ + "base64 0.23.1", + "bytes", + "futures-channel", + "futures-util", + "http", + "http-body", + "httparse", + "hyper", + "ipnet", + "libc", + "percent-encoding", + "pin-project-lite", + "socket2", + "system-configuration", + "tokio", + "tower-service", + "tracing", + "windows-registry", +] + [[package]] name = "iana-time-zone" version = "0.1.65" @@ -2601,6 +2930,12 @@ version = "0.10.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4275b20e6057cd7733fd8df8a5a31701e4fe44497dad0f3fa0e1c4fb971506be" +[[package]] +name = "ipnet" +version = "2.12.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "791930b43c0d5973160d90a8f3894509f2b273430f5c5c73b668636d0287c5c0" + [[package]] name = "itertools" version = "0.15.0" @@ -2702,7 +3037,17 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d8f198d1db720e4940b5a493201d199d9f24f568f8f746bd13706243a2f71598" dependencies = [ "cfg-if", - "cpufeatures", + "cpufeatures 0.3.1", +] + +[[package]] +name = "keyboard-types" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fbe853b403ae61a04233030ae8a79d94975281ed9770a1f9e246732b534b28d" +dependencies = [ + "bitflags 2.13.2", + "serde", ] [[package]] @@ -2855,12 +3200,18 @@ dependencies = [ "rand 0.10.3", "rangemap", "rayon", - "sha2", + "sha2 0.11.0", "stringprep", "thiserror 2.0.21", "weezl 0.2.1", ] +[[package]] +name = "lru-slab" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4050469837a6ff301cd14c1f8f24f88549e6d548f24f64e2148eb0f72cebc51f" + [[package]] name = "md-5" version = "0.11.0" @@ -2868,7 +3219,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "69b6441f590336821bb897fb28fc622898ccceb1d6cea3fde5ea86b090c4de98" dependencies = [ "cfg-if", - "digest", + "digest 0.11.3", ] [[package]] @@ -2895,6 +3246,12 @@ dependencies = [ "autocfg", ] +[[package]] +name = "mime" +version = "0.3.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" + [[package]] name = "miniz_oxide" version = "0.8.9" @@ -2915,6 +3272,17 @@ dependencies = [ "simd-adler32", ] +[[package]] +name = "mio" +version = "1.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1788edb87fdc09c7e26304471e2f5be8cdefb1b6930d6e3985fc02ff53bf86ee" +dependencies = [ + "libc", + "wasi", + "windows-sys 0.61.2", +] + [[package]] name = "moxcms" version = "0.8.1" @@ -2925,6 +3293,24 @@ dependencies = [ "pxfm", ] +[[package]] +name = "muda" +version = "0.21.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4340e89c178916e62879c5c36b7f699019369be413e496178a6983ce91b8bc03" +dependencies = [ + "crossbeam-channel", + "dpi", + "keyboard-types", + "objc2 0.6.4", + "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", +] + [[package]] name = "multiversion_no_op" version = "1.0.0" @@ -3474,7 +3860,7 @@ dependencies = [ "elliptic-curve", "primefield", "primeorder", - "sha2", + "sha2 0.11.0", ] [[package]] @@ -3488,7 +3874,7 @@ dependencies = [ "fiat-crypto", "primefield", "primeorder", - "sha2", + "sha2 0.11.0", ] [[package]] @@ -3502,7 +3888,7 @@ dependencies = [ "elliptic-curve", "primefield", "primeorder", - "sha2", + "sha2 0.11.0", ] [[package]] @@ -3546,36 +3932,42 @@ version = "0.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "112d82ceb8c5bf524d9af484d4e4970c9fd5a0cc15ba14ad93dccd28873b0629" dependencies = [ - "digest", + "digest 0.11.3", "hmac", ] [[package]] name = "pdfcraft" -version = "0.6.0" +version = "0.9.0" dependencies = [ + "craft-quit", "eframe", "egui", "fmv-macos-events", "log", + "pdfcraft-engine", "pdfcraft-ui-egui", "pollster 1.0.1", + "raw-window-handle", "rustls-native-certs", "serde_json", "uds_windows", "ureq", "wayland-client", "wayland-protocols", + "windows", + "winit", "winresource", "winsafe", ] [[package]] name = "pdfcraft-a11y" -version = "0.6.0" +version = "0.9.0" dependencies = [ "pdfcraft-content", "pdfcraft-cos", + "pdfcraft-filters", "pdfcraft-fonts", "pdfcraft-model", "thiserror 2.0.21", @@ -3583,7 +3975,7 @@ dependencies = [ [[package]] name = "pdfcraft-annot" -version = "0.6.0" +version = "0.9.0" dependencies = [ "hayro-syntax", "pdfcraft-cos", @@ -3592,14 +3984,27 @@ dependencies = [ "thiserror 2.0.21", ] +[[package]] +name = "pdfcraft-audit" +version = "0.9.0" +dependencies = [ + "pdfcraft-content", + "pdfcraft-cos", + "pdfcraft-edit", + "pdfcraft-model", + "thiserror 2.0.21", +] + [[package]] name = "pdfcraft-automation" -version = "0.6.0" +version = "0.9.0" dependencies = [ "base64 0.22.1", + "flate2", "image", "pdfcraft-cos", "pdfcraft-engine", + "pdfcraft-fonts", "pdfcraft-model", "pdfcraft-organize", "pdfcraft-platform", @@ -3607,16 +4012,21 @@ dependencies = [ "pdfcraft-xfa", "png 0.18.1", "serde_json", + "sha2 0.11.0", + "tempfile", ] [[package]] name = "pdfcraft-cli" -version = "0.6.0" +version = "0.9.0" dependencies = [ "base64 0.22.1", + "pdfcraft-audit", "pdfcraft-automation", "pdfcraft-cos", "pdfcraft-engine", + "pdfcraft-platform", + "pdfcraft-preflight", "pdfcraft-render", "rustix 1.1.5", "serde_json", @@ -3625,22 +4035,24 @@ dependencies = [ [[package]] name = "pdfcraft-compare" -version = "0.6.0" +version = "0.9.0" [[package]] name = "pdfcraft-content" -version = "0.6.0" +version = "0.9.0" dependencies = [ "pdfcraft-cos", ] [[package]] name = "pdfcraft-cos" -version = "0.6.0" +version = "0.9.0" dependencies = [ "hayro-syntax", "pdfcraft-crypt", "pdfcraft-filters", + "pdfcraft-oracle", + "pdfcraft-testkit", "proptest", "serde_json", "thiserror 2.0.21", @@ -3648,7 +4060,7 @@ dependencies = [ [[package]] name = "pdfcraft-create" -version = "0.6.0" +version = "0.9.0" dependencies = [ "encoding_rs", "hayro-syntax", @@ -3659,25 +4071,27 @@ dependencies = [ "png 0.18.1", "thiserror 2.0.21", "tiff", + "zune-jpeg", ] [[package]] name = "pdfcraft-crypt" -version = "0.6.0" +version = "0.9.0" dependencies = [ "aes", "md-5", "proptest", - "sha2", + "sha2 0.11.0", "stringprep", "thiserror 2.0.21", ] [[package]] name = "pdfcraft-edit" -version = "0.6.0" +version = "0.9.0" dependencies = [ "hayro-syntax", + "kurbo 0.13.1", "pdfcraft-annot", "pdfcraft-content", "pdfcraft-cos", @@ -3689,14 +4103,16 @@ dependencies = [ [[package]] name = "pdfcraft-engine" -version = "0.6.0" +version = "0.9.0" dependencies = [ "chrono", + "craft-docpath", "icu_properties", "idna", "image", "pdfcraft-a11y", "pdfcraft-annot", + "pdfcraft-audit", "pdfcraft-compare", "pdfcraft-cos", "pdfcraft-create", @@ -3720,19 +4136,21 @@ dependencies = [ "pdfcraft-xfdf", "png 0.18.1", "thiserror 2.0.21", + "tiff", + "unicode-segmentation", "url", ] [[package]] name = "pdfcraft-export" -version = "0.6.0" +version = "0.9.0" dependencies = [ "flate2", ] [[package]] name = "pdfcraft-filters" -version = "0.6.0" +version = "0.9.0" dependencies = [ "flate2", "proptest", @@ -3742,16 +4160,17 @@ dependencies = [ [[package]] name = "pdfcraft-fonts" -version = "0.6.0" +version = "0.9.0" dependencies = [ "harfrust", "pdfcraft-cos", "skrifa 0.44.0", + "unicode-bidi", ] [[package]] name = "pdfcraft-forms" -version = "0.6.0" +version = "0.9.0" dependencies = [ "hayro-syntax", "pdfcraft-content", @@ -3763,37 +4182,38 @@ dependencies = [ [[package]] name = "pdfcraft-geom" -version = "0.6.0" +version = "0.9.0" [[package]] name = "pdfcraft-js" -version = "0.6.0" +version = "0.9.0" dependencies = [ "boa_engine", ] [[package]] name = "pdfcraft-measure" -version = "0.6.0" +version = "0.9.0" dependencies = [ "pdfcraft-annot", "pdfcraft-content", "pdfcraft-cos", "pdfcraft-model", + "pdfcraft-testkit", "serde", "thiserror 2.0.21", ] [[package]] name = "pdfcraft-model" -version = "0.6.0" +version = "0.9.0" dependencies = [ "pdfcraft-cos", ] [[package]] name = "pdfcraft-ocr" -version = "0.6.0" +version = "0.9.0" dependencies = [ "ocrs", "pdfcraft-fonts", @@ -3803,7 +4223,7 @@ dependencies = [ [[package]] name = "pdfcraft-optimize" -version = "0.6.0" +version = "0.9.0" dependencies = [ "image", "pdfcraft-annot", @@ -3814,35 +4234,47 @@ dependencies = [ "thiserror 2.0.21", ] +[[package]] +name = "pdfcraft-oracle" +version = "0.9.0" + [[package]] name = "pdfcraft-organize" -version = "0.6.0" +version = "0.9.0" dependencies = [ "hayro-syntax", "pdfcraft-content", "pdfcraft-cos", + "pdfcraft-oracle", "thiserror 2.0.21", ] [[package]] name = "pdfcraft-platform" -version = "0.6.0" +version = "0.9.0" dependencies = [ + "async-channel", + "futures-lite", + "rustix 1.1.5", "zbus", ] [[package]] name = "pdfcraft-preflight" -version = "0.6.0" +version = "0.9.0" dependencies = [ + "flate2", + "pdfcraft-content", "pdfcraft-cos", "pdfcraft-model", + "roxmltree", ] [[package]] name = "pdfcraft-print" -version = "0.6.0" +version = "0.9.0" dependencies = [ + "craft-print", "pdfcraft-content", "pdfcraft-cos", "pdfcraft-model", @@ -3851,7 +4283,7 @@ dependencies = [ [[package]] name = "pdfcraft-redact" -version = "0.6.0" +version = "0.9.0" dependencies = [ "pdfcraft-annot", "pdfcraft-content", @@ -3860,12 +4292,14 @@ dependencies = [ "pdfcraft-fonts", "pdfcraft-forms", "pdfcraft-model", + "pdfcraft-testkit", + "pdfcraft-xfa", "thiserror 2.0.21", ] [[package]] name = "pdfcraft-render" -version = "0.6.0" +version = "0.9.0" dependencies = [ "bytemuck", "flate2", @@ -3877,13 +4311,14 @@ dependencies = [ "lopdf", "pdfcraft-cos", "pdfcraft-fonts", + "serde", "skrifa 0.44.0", "thiserror 2.0.21", ] [[package]] name = "pdfcraft-sign" -version = "0.6.0" +version = "0.9.0" dependencies = [ "aes", "aws-lc-rs", @@ -3909,24 +4344,30 @@ dependencies = [ "rsa", "rustls-cng", "security-framework", - "sha1", - "sha2", + "sha1 0.11.0", + "sha2 0.11.0", "sha3", "thiserror 2.0.21", ] +[[package]] +name = "pdfcraft-testkit" +version = "0.9.0" + [[package]] name = "pdfcraft-ui-egui" -version = "0.6.0" +version = "0.9.0" dependencies = [ "arboard", "base64 0.22.1", "bytemuck", + "craft-quit", "craft-ui", "eframe", "egui", "egui_extras", "egui_kittest", + "fontconfig-parser", "getrandom 0.4.3", "image", "js-sys", @@ -3944,6 +4385,7 @@ dependencies = [ "png 0.18.1", "pollster 1.0.1", "rfd", + "rustix 1.1.5", "serde", "serde_json", "skrifa 0.44.0", @@ -3953,11 +4395,12 @@ dependencies = [ "wasm-bindgen", "wasm-bindgen-futures", "web-sys", + "winsafe", ] [[package]] name = "pdfcraft-web" -version = "0.6.0" +version = "0.9.0" dependencies = [ "eframe", "egui", @@ -3969,7 +4412,7 @@ dependencies = [ [[package]] name = "pdfcraft-xfa" -version = "0.6.0" +version = "0.9.0" dependencies = [ "base64 0.22.1", "image", @@ -3977,13 +4420,13 @@ dependencies = [ "pdfcraft-fonts", "png 0.18.1", "roxmltree", - "sha2", + "sha2 0.11.0", "thiserror 2.0.21", ] [[package]] name = "pdfcraft-xfdf" -version = "0.6.0" +version = "0.9.0" dependencies = [ "pdfcraft-annot", "pdfcraft-cos", @@ -3993,6 +4436,25 @@ dependencies = [ "thiserror 2.0.21", ] +[[package]] +name = "pem" +version = "3.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d30c53c26bc5b31a98cd02d20f25a7c8567146caf63ed593a9d87b2775291be" +dependencies = [ + "base64 0.22.1", + "serde_core", +] + +[[package]] +name = "pem-rfc7468" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88b39c9bfcfc231068454382784bb460aae594343fb030d46e9f50a645418412" +dependencies = [ + "base64ct", +] + [[package]] name = "pem-rfc7468" version = "1.0.0" @@ -4165,8 +4627,8 @@ version = "0.8.0-rc.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "986d2e952779af96ea048f160fd9194e1751b4faea78bcf3ceb456efe008088e" dependencies = [ - "der", - "spki", + "der 0.8.2", + "spki 0.8.0", ] [[package]] @@ -4175,8 +4637,8 @@ version = "0.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "451913da69c775a56034ea8d9003d27ee8948e12443eae7c038ba100a4f21cb7" dependencies = [ - "der", - "spki", + "der 0.8.2", + "spki 0.8.0", ] [[package]] @@ -4304,7 +4766,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c555a6e4eb7d4e158fcb028c835c3b8642206ddc279b5c6b202ef9a8bdb592f4" dependencies = [ "crypto-bigint", - "crypto-common", + "crypto-common 0.2.2", "ff", "rand_core 0.10.1", "subtle", @@ -4333,6 +4795,30 @@ dependencies = [ "toml_edit", ] +[[package]] +name = "proc-macro-error" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da25490ff9892aab3fcf7c36f08cfb902dd3e71ca0f9f9517bea02a73a5ce38c" +dependencies = [ + "proc-macro-error-attr", + "proc-macro2", + "quote", + "syn 1.0.109", + "version_check", +] + +[[package]] +name = "proc-macro-error-attr" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1be40180e52ecc98ad80b184934baf3d0d29f979574e439af5a55274b35f869" +dependencies = [ + "proc-macro2", + "quote", + "version_check", +] + [[package]] name = "proc-macro2" version = "1.0.107" @@ -4394,6 +4880,63 @@ dependencies = [ "memchr", ] +[[package]] +name = "quinn" +version = "0.11.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4051e23e9185c255a7e33ef59cdbca87a22d359052eecd22fc6b901fb37d9d11" +dependencies = [ + "bytes", + "cfg_aliases", + "pin-project-lite", + "quinn-proto", + "quinn-udp", + "rustc-hash 2.1.3", + "rustls", + "socket2", + "thiserror 2.0.21", + "tokio", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-proto" +version = "0.11.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0e750cca55fe4f0439a15d0bb529da9651e79993e8e72c61a899a36d462befbe" +dependencies = [ + "aws-lc-rs", + "bytes", + "getrandom 0.4.3", + "lru-slab", + "rand 0.10.3", + "rand_pcg", + "ring", + "rustc-hash 2.1.3", + "rustls", + "rustls-pki-types", + "slab", + "thiserror 2.0.21", + "tinyvec", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-udp" +version = "0.5.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af66907df18639dcf4db56ca65490cabc4b27a97dbadd96f2926cca73298f016" +dependencies = [ + "cfg_aliases", + "libc", + "once_cell", + "socket2", + "tracing", + "windows-sys 0.52.0", +] + [[package]] name = "quote" version = "1.0.47" @@ -4446,6 +4989,12 @@ dependencies = [ "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" @@ -4461,6 +5010,15 @@ version = "0.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69" +[[package]] +name = "rand_pcg" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "caa0f4137e1c0a72f4c651489402276c8e8e1cf081f3b0ba156d2cbeef09e86a" +dependencies = [ + "rand_core 0.10.1", +] + [[package]] name = "rand_xorshift" version = "0.4.0" @@ -4609,6 +5167,49 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "19b30a45b0cd0bcca8037f3d0dc3421eaf95327a17cad11964fb8179b4fc4832" +[[package]] +name = "reqwest" +version = "0.13.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "16a1cfa75cc186dd73d5818e510e042e40927bccc9c236b061cea97e1eb08029" +dependencies = [ + "base64 0.23.1", + "bytes", + "encoding_rs", + "futures-channel", + "futures-core", + "futures-util", + "h2", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-rustls", + "hyper-util", + "js-sys", + "log", + "mime", + "percent-encoding", + "pin-project-lite", + "quinn", + "rustls", + "rustls-pki-types", + "rustls-platform-verifier", + "serde", + "serde_json", + "serde_urlencoded", + "sync_wrapper", + "tokio", + "tokio-rustls", + "tower", + "tower-http", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + [[package]] name = "resvg" version = "0.45.1" @@ -4679,7 +5280,7 @@ dependencies = [ "cfg-if", "getrandom 0.2.17", "libc", - "untrusted", + "untrusted 0.9.0", "windows-sys 0.52.0", ] @@ -4689,7 +5290,7 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4dd4211456b4172d7e44261920c25acf07367c4f04bb5f5d54fc21b090d9b159" dependencies = [ - "digest", + "digest 0.11.3", ] [[package]] @@ -4718,14 +5319,14 @@ version = "0.10.0-rc.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "30b2aa4ba0d89f73d1e332df05be0eeab8840351c36ca5654341dfdb57bb3caf" dependencies = [ - "const-oid", + "const-oid 0.10.2", "crypto-bigint", "crypto-primes", - "digest", + "digest 0.11.3", "pkcs1", "pkcs8", "rand_core 0.10.1", - "signature", + "signature 3.0.0", "zeroize", ] @@ -4870,7 +5471,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys 0.4.15", - "windows-sys 0.59.0", + "windows-sys 0.52.0", ] [[package]] @@ -4883,15 +5484,16 @@ dependencies = [ "errno", "libc", "linux-raw-sys 0.12.1", - "windows-sys 0.61.2", + "windows-sys 0.52.0", ] [[package]] name = "rustls" -version = "0.23.45" +version = "0.23.46" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0d41d731c7d2f962d1ccc364cec258de3c0e93b38c2fb3ba97ac74513048d634" +checksum = "48e13bd8c0e9365c43cfa5c9e8f9ad49d3c8444926c9aac819e0e4dc503c8fdf" dependencies = [ + "aws-lc-rs", "log", "once_cell", "ring", @@ -4930,18 +5532,47 @@ version = "1.15.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2f4925028c7eb5d1fcdaf196971378ed9d2c1c4efc7dc5d011256f76c99c0a96" dependencies = [ + "web-time", "zeroize", ] +[[package]] +name = "rustls-platform-verifier" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1167586491e2b18b8bfbb293e8180ec17c201c4f076d7cb3070ca964e7598f98" +dependencies = [ + "core-foundation 0.10.1", + "core-foundation-sys", + "jni", + "log", + "once_cell", + "rustls", + "rustls-native-certs", + "rustls-platform-verifier-android", + "rustls-webpki", + "security-framework", + "security-framework-sys", + "webpki-root-certs", + "windows-sys 0.52.0", +] + +[[package]] +name = "rustls-platform-verifier-android" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eec689c0bc40ff2458a5977b6619cb718087084a18e02a131c599b62d05e1a5f" + [[package]] name = "rustls-webpki" version = "0.103.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f3c3cf1d8b1e7d4927e2d154c3fcb02979afb9939629c62cd9048d4f07b60ac2" dependencies = [ + "aws-lc-rs", "ring", "rustls-pki-types", - "untrusted", + "untrusted 0.9.0", ] [[package]] @@ -4962,6 +5593,12 @@ dependencies = [ "wait-timeout", ] +[[package]] +name = "ryu" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" + [[package]] name = "ryu-js" version = "1.0.3" @@ -5006,7 +5643,7 @@ checksum = "d56d437c2f19203ce5f7122e507831de96f3d2d4d3be5af44a0b0a09d8a80e4d" dependencies = [ "base16ct", "ctutils", - "der", + "der 0.8.2", "hybrid-array", "subtle", "zeroize", @@ -5090,6 +5727,17 @@ dependencies = [ "zmij", ] +[[package]] +name = "serde_json_canonicalizer" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fe52319a927259afbfa5180c5157cd8167edfd3e8c254f9558c7fef44c5649f2" +dependencies = [ + "ryu-js", + "serde", + "serde_json", +] + [[package]] name = "serde_repr" version = "0.1.21" @@ -5110,6 +5758,18 @@ dependencies = [ "serde_core", ] +[[package]] +name = "serde_urlencoded" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd" +dependencies = [ + "form_urlencoded", + "itoa", + "ryu", + "serde", +] + [[package]] name = "serdect" version = "0.4.3" @@ -5120,6 +5780,17 @@ dependencies = [ "serde", ] +[[package]] +name = "sha1" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a978451301f4db1d02937a4ab3ccce137717b81826e79b7d49ffe3244a13c3b8" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "digest 0.10.7", +] + [[package]] name = "sha1" version = "0.11.0" @@ -5127,8 +5798,19 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "aacc4cc499359472b4abe1bf11d0b12e688af9a805fa5e3016f9a386dc2d0214" dependencies = [ "cfg-if", - "cpufeatures", - "digest", + "cpufeatures 0.3.1", + "digest 0.11.3", +] + +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "digest 0.10.7", ] [[package]] @@ -5138,8 +5820,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "446ba717509524cb3f22f17ecc096f10f4822d76ab5c0b9822c5f9c284e825f4" dependencies = [ "cfg-if", - "cpufeatures", - "digest", + "cpufeatures 0.3.1", + "digest 0.11.3", ] [[package]] @@ -5148,7 +5830,7 @@ version = "0.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bc9bad02c26382724b2d2692c6f179285e4b54eeecd7968f52a50059c3c11759" dependencies = [ - "digest", + "digest 0.11.3", "keccak", "sponge-cursor", ] @@ -5169,16 +5851,185 @@ dependencies = [ "libc", ] +[[package]] +name = "signature" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de" +dependencies = [ + "rand_core 0.6.4", +] + [[package]] name = "signature" version = "3.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "28d567dcbaf0049cb8ac2608a76cd95ff9e4412e1899d389ee400918ca7537f5" dependencies = [ - "digest", + "digest 0.11.3", "rand_core 0.10.1", ] +[[package]] +name = "sigstore-bundle" +version = "0.6.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0e7dc53c8a941858d01479622dbb7650aaa2ee0ca1fb58fb6cdddbfc3064abbb" +dependencies = [ + "base64 0.22.1", + "hex", + "serde", + "serde_json", + "sigstore-crypto", + "sigstore-merkle", + "sigstore-rekor", + "sigstore-tsa", + "sigstore-types", + "thiserror 2.0.21", +] + +[[package]] +name = "sigstore-crypto" +version = "0.6.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da90c3d9af638898a5fdc347479b18f950bb0dde11ecf2244f94cd28135da53b" +dependencies = [ + "aws-lc-rs", + "base64 0.22.1", + "const-oid 0.9.6", + "der 0.7.10", + "digest 0.10.7", + "pem", + "rand_core 0.10.1", + "sha2 0.10.9", + "signature 2.2.0", + "sigstore-types", + "spki 0.7.3", + "thiserror 2.0.21", + "tracing", + "x509-cert", +] + +[[package]] +name = "sigstore-merkle" +version = "0.6.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c0d53558825c77716855c13794306fff766854c1bf92948e77d7890da3f2455" +dependencies = [ + "base64 0.22.1", + "hex", + "sigstore-crypto", + "sigstore-types", + "thiserror 2.0.21", +] + +[[package]] +name = "sigstore-rekor" +version = "0.6.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "641fdaaa40d0cd6e249cf1671c2240beff1a3ece578062cf43e750779e8db2b3" +dependencies = [ + "base64 0.22.1", + "hex", + "reqwest", + "serde", + "serde_json", + "sigstore-crypto", + "sigstore-merkle", + "sigstore-types", + "thiserror 2.0.21", + "url", +] + +[[package]] +name = "sigstore-trust-root" +version = "0.6.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6b0d2550f05c096400066e858db13d91f9de3b750a7541fb1291c1dc80ca290" +dependencies = [ + "base64 0.22.1", + "chrono", + "hex", + "rustls-pki-types", + "serde", + "serde_json", + "sigstore-crypto", + "sigstore-types", + "thiserror 2.0.21", + "x509-cert", +] + +[[package]] +name = "sigstore-tsa" +version = "0.6.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bc86c8abc1ece6832236e7e34e9baebb5d4d40490c0332b814a0d8624ca7255" +dependencies = [ + "aws-lc-rs", + "base64 0.22.1", + "chrono", + "cmpv2", + "cms", + "const-oid 0.9.6", + "der 0.7.10", + "hex", + "rand 0.10.3", + "reqwest", + "rustls-pki-types", + "rustls-webpki", + "sigstore-crypto", + "sigstore-types", + "thiserror 2.0.21", + "tracing", + "x509-cert", + "x509-tsp", +] + +[[package]] +name = "sigstore-types" +version = "0.6.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8d870c9bcfdf83396ac2bd2d6a23c5d09ec02cc9fda50fa1cbb3dd71a9bee53" +dependencies = [ + "base64 0.22.1", + "chrono", + "hex", + "pem", + "serde", + "serde_json", + "thiserror 2.0.21", +] + +[[package]] +name = "sigstore-verify" +version = "0.6.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d30aff69036e4579b416956e9ba68cec45b793116f78f10842d04c7f8b924bab" +dependencies = [ + "base64 0.22.1", + "chrono", + "cms", + "const-oid 0.9.6", + "hex", + "pem", + "rustls-pki-types", + "rustls-webpki", + "serde", + "serde_json", + "serde_json_canonicalizer", + "sigstore-bundle", + "sigstore-crypto", + "sigstore-merkle", + "sigstore-rekor", + "sigstore-trust-root", + "sigstore-tsa", + "sigstore-types", + "thiserror 2.0.21", + "tls_codec", + "tracing", + "x509-cert", +] + [[package]] name = "simd-adler32" version = "0.3.10" @@ -5351,6 +6202,16 @@ dependencies = [ "serde", ] +[[package]] +name = "socket2" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4" +dependencies = [ + "libc", + "windows-sys 0.60.2", +] + [[package]] name = "spirv" version = "0.4.0+sdk-1.4.341.0" @@ -5360,6 +6221,16 @@ dependencies = [ "bitflags 2.13.2", ] +[[package]] +name = "spki" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d91ed6c858b01f942cd56b37a94b3e0a1798290327d1236e4d9cf4eaca44d29d" +dependencies = [ + "base64ct", + "der 0.7.10", +] + [[package]] name = "spki" version = "0.8.0" @@ -5367,7 +6238,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1d9efca8738c78ee9484207732f728b1ef517bbb1833d6fc0879ca898a522f6f" dependencies = [ "base64ct", - "der", + "der 0.8.2", ] [[package]] @@ -5445,6 +6316,16 @@ dependencies = [ "siphasher", ] +[[package]] +name = "syn" +version = "1.0.109" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b64191b275b66ffe2469e8af2c1cfe3bafa67b529ead792a6d0160888b4237" +dependencies = [ + "proc-macro2", + "unicode-ident", +] + [[package]] name = "syn" version = "2.0.119" @@ -5467,6 +6348,15 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "sync_wrapper" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" +dependencies = [ + "futures-core", +] + [[package]] name = "synstructure" version = "0.14.0" @@ -5487,6 +6377,27 @@ dependencies = [ "libc", ] +[[package]] +name = "system-configuration" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a13f3d0daba03132c0aa9767f98351b3488edc2c100cda2d2ec2b04f3d8d3c8b" +dependencies = [ + "bitflags 2.13.2", + "core-foundation 0.9.4", + "system-configuration-sys", +] + +[[package]] +name = "system-configuration-sys" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e1d1b10ced5ca923a1fcb8d03e96b8d3268065d724548c0211415ff6ac6bac4" +dependencies = [ + "core-foundation-sys", + "libc", +] + [[package]] name = "tag_ptr" version = "0.1.0" @@ -5499,6 +6410,17 @@ version = "1.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "55937e1799185b12863d447f42597ed69d9928686b8d88a1df17376a097d8369" +[[package]] +name = "tar" +version = "0.4.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f6221d9a6003c78398e3b239969f352578258df48c8eb051caadae0015bc840" +dependencies = [ + "filetime", + "libc", + "xattr", +] + [[package]] name = "tempfile" version = "3.27.0" @@ -5506,10 +6428,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" dependencies = [ "fastrand", - "getrandom 0.4.3", + "getrandom 0.3.4", "once_cell", "rustix 1.1.5", - "windows-sys 0.61.2", + "windows-sys 0.52.0", ] [[package]] @@ -5656,13 +6578,63 @@ version = "1.13.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fd3ca314f692efd6c868f8408f53fe444634a845f96c028b97d35f6a1f79f0ee" +[[package]] +name = "tls_codec" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0de2e01245e2bb89d6f05801c564fa27624dbd7b1846859876c7dad82e90bf6b" +dependencies = [ + "tls_codec_derive", + "zeroize", +] + +[[package]] +name = "tls_codec_derive" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2d2e76690929402faae40aebdda620a2c0e25dd6d3b9afe48867dfd95991f4bd" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + [[package]] name = "tokio" version = "1.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "202caea871b69668250d242070849eb495be178ed697a3e98aebce5bc81a0bed" dependencies = [ + "bytes", + "libc", + "mio", "pin-project-lite", + "socket2", + "windows-sys 0.61.2", +] + +[[package]] +name = "tokio-rustls" +version = "0.26.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c9cc2678c2cdd569ef8215e2afd7954ada2ae20b4fdd2c5fe6139a3b02d105db" +dependencies = [ + "rustls", + "tokio", +] + +[[package]] +name = "tokio-util" +version = "0.7.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e464cf451ba96ebfc6f9b6542f17ee8b8956e33f1e40d9690624e59d7a7f8a4b" +dependencies = [ + "bytes", + "futures-core", + "futures-sink", + "libc", + "pin-project-lite", + "tokio", ] [[package]] @@ -5716,6 +6688,51 @@ version = "1.1.2+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7d56353a2a665ad0f41a421187180aab746c8c325620617ad883a99a1cbe66d2" +[[package]] +name = "tower" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4" +dependencies = [ + "futures-core", + "futures-util", + "pin-project-lite", + "sync_wrapper", + "tokio", + "tower-layer", + "tower-service", +] + +[[package]] +name = "tower-http" +version = "0.6.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840" +dependencies = [ + "bitflags 2.13.2", + "bytes", + "futures-util", + "http", + "http-body", + "pin-project-lite", + "tower", + "tower-layer", + "tower-service", + "url", +] + +[[package]] +name = "tower-layer" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e" + +[[package]] +name = "tower-service" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" + [[package]] name = "tracing" version = "0.1.44" @@ -5748,6 +6765,12 @@ dependencies = [ "once_cell", ] +[[package]] +name = "try-lock" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" + [[package]] name = "type-map" version = "0.5.1" @@ -5777,7 +6800,7 @@ checksum = "f2f6fb2847f6742cd76af783a2a2c49e9375d0a111c7bef6f71cd9e738c72d6e" dependencies = [ "memoffset", "tempfile", - "windows-sys 0.61.2", + "windows-sys 0.60.2", ] [[package]] @@ -5831,6 +6854,12 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254" +[[package]] +name = "untrusted" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a156c684c91ea7d62626509bce3cb4e1d9ed5c4d978f7b4352658f96a4c26b4a" + [[package]] name = "untrusted" version = "0.9.0" @@ -5930,8 +6959,6 @@ dependencies = [ [[package]] name = "vello_common" version = "0.0.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3361bff7f7d82c0c496b92048db83846691f0e844cc28dee92b1c824291b55ee" dependencies = [ "bytemuck", "fearless_simd", @@ -6026,6 +7053,15 @@ dependencies = [ "winapi-util", ] +[[package]] +name = "want" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec4cdd0dd910afe868b7ef477227d8d538b46b3075031afee8a9f2acb0a2ed0b" +dependencies = [ + "try-lock", +] + [[package]] name = "wasi" version = "0.11.1+wasi-snapshot-preview1" @@ -6268,6 +7304,15 @@ dependencies = [ "web-sys", ] +[[package]] +name = "webpki-root-certs" +version = "1.0.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b96554aa2acc8ccdb7e1c9a58a7a68dd5d13bccc69cd124cb09406db612a1c9b" +dependencies = [ + "rustls-pki-types", +] + [[package]] name = "weezl" version = "0.1.12" @@ -6469,7 +7514,7 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.61.2", + "windows-sys 0.52.0", ] [[package]] @@ -6555,6 +7600,17 @@ dependencies = [ "windows-link", ] +[[package]] +name = "windows-registry" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "02752bf7fbdcce7f2a27a742f798510f3e5ad88dbe84871e5168e2120c3d5720" +dependencies = [ + "windows-link", + "windows-result", + "windows-strings", +] + [[package]] name = "windows-result" version = "0.4.1" @@ -6582,15 +7638,6 @@ dependencies = [ "windows-targets 0.52.6", ] -[[package]] -name = "windows-sys" -version = "0.59.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" -dependencies = [ - "windows-targets 0.52.6", -] - [[package]] name = "windows-sys" version = "0.60.2" @@ -6805,6 +7852,27 @@ dependencies = [ "memchr", ] +[[package]] +name = "winprint" +version = "0.2.1" +source = "git+https://github.com/storytold/winprint.rs?rev=5f80b9baf6b58937c401656634e42d33f00ddf49#5f80b9baf6b58937c401656634e42d33f00ddf49" +dependencies = [ + "flate2", + "fmt-derive", + "reqwest", + "scopeguard", + "sigstore-trust-root", + "sigstore-types", + "sigstore-verify", + "strum", + "tar", + "thiserror 2.0.21", + "windows", + "windows-core", + "windows-implement", + "xml", +] + [[package]] name = "winresource" version = "0.1.31" @@ -6883,6 +7951,41 @@ version = "0.13.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ea6fc2961e4ef194dcbfe56bb845534d0dc8098940c7e5c012a258bfec6701bd" +[[package]] +name = "x509-cert" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1301e935010a701ae5f8655edc0ad17c44bad3ac5ce8c39185f75453b720ae94" +dependencies = [ + "const-oid 0.9.6", + "der 0.7.10", + "sha1 0.10.7", + "signature 2.2.0", + "spki 0.7.3", + "tls_codec", +] + +[[package]] +name = "x509-tsp" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f5ceece934a21607055b7ac5c25adb56a2ff559804b10705dc674d1d838c15e1" +dependencies = [ + "cmpv2", + "cms", + "der 0.7.10", +] + +[[package]] +name = "xattr" +version = "1.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32e45ad4206f6d2479085147f02bc2ef834ac85886624a23575ae137c8aa8156" +dependencies = [ + "libc", + "rustix 1.1.5", +] + [[package]] name = "xcursor" version = "0.3.11" @@ -6908,6 +8011,12 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b9cc00251562a284751c9973bace760d86c0276c471b4be569fe6b068ee97a56" +[[package]] +name = "xml" +version = "1.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f45bb2c13fec6a6cb4c0f76a7e94839e110a14ec803ec2940777a94c347bc52" + [[package]] name = "xml-rs" version = "0.8.29" @@ -6926,10 +8035,12 @@ version = "0.1.0" dependencies = [ "anyhow", "lopdf", + "pdfcraft-cos", + "pdfcraft-oracle", "png 0.18.1", "serde", "serde_json", - "sha2", + "sha2 0.11.0", "toml", ] @@ -7108,6 +8219,20 @@ name = "zeroize" version = "1.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e" +dependencies = [ + "zeroize_derive", +] + +[[package]] +name = "zeroize_derive" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c50655cbb0fe3fc43170059e702f1ce5e19b84cec58dc87b037a09935c2f328" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] [[package]] name = "zerotrie" diff --git a/Cargo.toml b/Cargo.toml index 1312fedc07..ec89098021 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -5,46 +5,55 @@ exclude = ["vendor"] default-members = ["crates/*", "apps/pdfcraft", "apps/pdfcraft-cli", "xtask"] [workspace.package] -version = "0.6.0" +version = "0.9.0" edition = "2024" license = "MIT OR Apache-2.0" -rust-version = "1.90" +rust-version = "1.95" 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"] } +# 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" } +# Shared system spooler; document composition remains in pdfcraft-print. +craft-print = { git = "https://github.com/storytold/craft-libs", version = "0.1.0", rev = "666c358065261a2816b376fa3bb10538dbf53040" } +# 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" } # internal -pdfcraft-geom = { path = "crates/geom", version = "0.6.0" } -pdfcraft-filters = { path = "crates/filters", version = "0.6.0" } -pdfcraft-cos = { path = "crates/cos", version = "0.6.0" } -pdfcraft-crypt = { path = "crates/crypt", version = "0.6.0" } -pdfcraft-organize = { path = "crates/organize", version = "0.6.0" } -pdfcraft-measure = { path = "crates/measure", version = "0.6.0" } -pdfcraft-annot = { path = "crates/annot", version = "0.6.0" } -pdfcraft-fonts = { path = "crates/fonts", version = "0.6.0" } -pdfcraft-model = { path = "crates/model", version = "0.6.0" } -pdfcraft-content = { path = "crates/content", version = "0.6.0" } -pdfcraft-redact = { path = "crates/redact", version = "0.6.0" } -pdfcraft-print = { path = "crates/print", version = "0.6.0" } -pdfcraft-xfdf = { path = "crates/xfdf", version = "0.6.0" } -pdfcraft-sign = { path = "crates/sign", version = "0.6.0" } -pdfcraft-optimize = { path = "crates/optimize", version = "0.6.0" } -pdfcraft-a11y = { path = "crates/a11y", version = "0.6.0" } -pdfcraft-ocr = { path = "crates/ocr", version = "0.6.0" } -pdfcraft-js = { path = "crates/js", version = "0.6.0" } -pdfcraft-compare = { path = "crates/compare", version = "0.6.0" } -pdfcraft-preflight = { path = "crates/preflight", version = "0.6.0" } -pdfcraft-export = { path = "crates/export", version = "0.6.0" } -pdfcraft-edit = { path = "crates/edit", version = "0.6.0" } -pdfcraft-create = { path = "crates/create", version = "0.6.0" } -pdfcraft-forms = { path = "crates/forms", version = "0.6.0" } -pdfcraft-xfa = { path = "crates/xfa", version = "0.6.0" } -pdfcraft-render = { path = "crates/render", version = "0.6.0" } -pdfcraft-engine = { path = "crates/engine", version = "0.6.0" } -pdfcraft-platform = { path = "crates/platform", version = "0.6.0" } -pdfcraft-ui-egui = { path = "crates/ui-egui", version = "0.6.0" } -pdfcraft-automation = { path = "crates/automation", version = "0.6.0", default-features = false } +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 } # pdf hayro = "0.7.1" @@ -107,3 +116,4 @@ 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" } diff --git a/NOTICE b/NOTICE index 7218477937..8cacb0b585 100644 --- a/NOTICE +++ b/NOTICE @@ -20,20 +20,22 @@ list (author, source, license) is in ATTRIBUTION.md. - Inter typeface (assets/fonts/Inter-*.ttf) — SIL Open Font License 1.1. See assets/fonts/OFL-Inter.txt. - JetBrains Mono typeface (assets/fonts/JetBrainsMono-*.ttf) — SIL Open Font License 1.1. See assets/fonts/OFL-JetBrainsMono.txt. - Dancing Script typeface (assets/fonts/DancingScript.ttf), Copyright 2016 The Dancing Script Project Authors — SIL Open Font License 1.1 (Reserved Font Name "Dancing Script"). See assets/fonts/OFL-DancingScript.txt. -- Japanese fonts from craft-fonts (https://github.com/storytold/craft-fonts), an optional build input +- Fonts from craft-fonts (https://github.com/storytold/craft-fonts), an optional build input (CRAFT_FONTS_DIR) that is not part of this repository. Builds made with it, which includes every official release, embed the fonts listed in https://github.com/storytold/craft-fonts/blob/main/ATTRIBUTION.md (BIZ UDPGothic, Shippori Mincho, BIZ UDMincho; the web build only BIZ UDPGothic Regular) under the SIL Open Font License 1.1. Those - packages carry each font's licence as OFL-.txt. + packages carry each font's licence as OFL-.txt. Builds made with the craft-fonts revision + pinned in .github/workflows/ also include Noto Sans Thai (in the web build too), Copyright 2022 + The Noto Project Authors (https://github.com/notofonts/thai), under SIL OFL 1.1; its licence is + fonts/noto-sans-thai/OFL.txt in the craft-fonts build input. - hayro (rendering, via crates.io) — Apache-2.0 OR MIT. Its `embed-fonts` feature bundles the Foxit standard-14 Type 1 fonts under BSD-3-Clause ("Copyright 2014 PDFium Authors"), and hayro-cmap bundles Adobe CMap resources under BSD-3-Clause. - lopdf (bootstrap document inspection, via crates.io) — MIT. - egui default fonts (epaint_default_fonts, via eframe) — Ubuntu Light (Ubuntu Font Licence 1.0), Hack (MIT / Bitstream Vera licence), Noto Emoji (SIL OFL 1.1), emoji-icon-font (MIT). -- Colour profiles in hayro-interpret — CGATS001Compat-v2-micro.icc (CC0-1.0, Clinton Ingram) and - LAB.icc (generated by the hayro authors with LittleCMS 2; MIT OR Apache-2.0). +- Colour profile in hayro-interpret — CGATS001Compat-v2-micro.icc (CC0-1.0, Clinton Ingram). Required notices: diff --git a/README.md b/README.md index a0e985b1af..2a65b66457 100644 --- a/README.md +++ b/README.md @@ -11,7 +11,7 @@

PdfCraft

- The PDF workbench; an open-source, clean-room reimplementation of Adobe Acrobat, rebuilt in pure Rust.
+ The PDF workbench; an open-source, modern PDF reader and editor, built in pure Rust.
Read, organize, combine, split and secure PDFs in a fast, native app, written in Rust from the ground up.
macOS · Windows · Linux · FreeBSD · the web

@@ -133,6 +133,8 @@ Search the whole document as you type, step through matches with ⌘G ## Navigate long documents +Right-click a PDF tab to close it, reopen the last closed file-backed tab, or reveal its file. Closing still asks about unsaved changes. Reopening reads the saved file from disk; it does not restore discarded edits. Reveal selects the file in Explorer or Finder. On Linux and FreeBSD it requests `org.freedesktop.FileManager1.ShowItems`, falling back to opening the containing directory with `xdg-open` when that service is unavailable. Reopen and Reveal are unavailable on the web; Reveal is also unavailable for pathless documents. + 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. @@ -170,7 +172,7 @@ Open **Organize pages** to see every page at once: **Saves you can trust:** - *Incremental:* the original bytes stay byte-for-byte intact. - *Atomic:* the file is written to a temporary copy, then swapped in. -- *Verified:* independently checked with qpdf. +- *Verified:* independently checked with qpdf, on every CI run. When open documents exceed the window width, scroll over the tab strip with the mouse wheel or trackpad, or use its horizontal scrollbar. Opening or switching to a document brings its tab into view. @@ -197,7 +199,7 @@ Every page of a combined document renders pixel-identical to its source. ```sh pdfcraft-cli combine report.pdf appendix.pdf --out combined.pdf -pdfcraft-cli extract report.pdf --pages 1,3,5 --out highlights.pdf +pdfcraft-cli extract report.pdf --pages 1,3,5-8 --out highlights.pdf pdfcraft-cli split report.pdf --every 10 --out-dir parts/ ``` @@ -283,6 +285,7 @@ Every engine feature is reachable without the GUI, through one table of JSON-Sch pdfcraft-cli run text_find doc=1 query=invoice # key=value; values parse as JSON pdfcraft-cli run --script review.json # e.g. comment_add {"type": "highlight", "find": "total due"} pdfcraft-cli run --script steps.json --root ./work # several steps in one session + pdfcraft-cli run --script steps.json --report # one JSON document: per step its number, tool, status, result or error ``` With `--root`, every file the script's steps read or write stays in that directory, including the PNG a step saves with `"out"` (the script itself is read from wherever you name it). @@ -363,12 +366,15 @@ On top of those, two corpus sweeps run over real-world files: - **Opening and rendering:** of the 983 pdf.js test files, 963 open and render cleanly, with 0 crashes. - **Open, edit and save round trips:** 958 succeed. -The output is verified with independent tools: hayro, qpdf and poppler. +The output is verified against independent implementations: hayro and qpdf on every CI run, and +poppler over the corpus on demand (`cargo xtask text-oracle`). PdfCraft is a clean-room implementation. Its behaviour comes from the ISO 32000 specification and black-box observation, never from anyone else's code. Every icon, font and image is openly licensed and listed in [ATTRIBUTION.md](ATTRIBUTION.md). ## Get started +Building needs Rust 1.95 or later. + ```sh git clone https://github.com/storytold/pdfcraft cd pdfcraft @@ -411,16 +417,16 @@ Logs, environment variables and other development notes are in [docs/development ## What's next -PdfCraft is young and moving fast. The aim is a workbench where you can view, organize, annotate, fill, sign and edit PDFs, at parity with Acrobat Pro. +PdfCraft is young and moving fast. The aim is a workbench where you can view, organize, annotate, fill, sign and edit PDFs, for professional and everyday workflows. -**Where it stands (October 2026), honestly:** PdfCraft is **alpha**. Most of Acrobat Pro's offline features exist in some form (57% of all tracked features, 94% of the must-haves). For everyday work (viewing, commenting, filling and signing forms, organizing pages) it is about 55% of the way to replacing Acrobat; against all of Acrobat Pro, about 49%; for someone who only needs the essentials, about 66%. Every everyday workflow can be completed, but converting and sharing is weak: no Office-to-PDF, and Word export that loses layout. +**Where it stands (October 2026), honestly:** PdfCraft is **alpha**. Most essential offline PDF features exist in some form (57% of the features tracked in [docs/target-app-parity.md](docs/target-app-parity.md), 94% of core essentials). For everyday work (viewing, commenting, filling and signing forms, organizing pages) readiness is about 55%; across all professional workflows, about 49%; for someone who only needs the essentials, about 66%. Every everyday workflow can be completed, but converting and sharing is weak: no office-document-to-PDF, and .docx export that loses layout. - **Good today:** viewing and search; organizing, combining and splitting; most kinds of comment; filling and authoring forms (with sandboxed JavaScript); passwords, redaction and sanitizing; basic digital signatures; printing; the Accessibility Checker; agent control through the CLI and MCP. - **Still borrowed:** pages are drawn by the `hayro` crate while our own renderer is built. -- **Thin or missing:** reliable editing of existing text (especially CJK and right-to-left), OCR beyond Latin script, Office import/export, signature tokens and online long-term validation, Preflight and PDF/X/UA, printing from the web build, and full XFA forms. -- **Hardening:** fuzzing still turns up crashes and hangs on hostile files; each one is fixed with a regression test. Quality has not yet been compared with Acrobat side by side. +- **Thin or missing:** reliable editing of existing text (especially CJK and right-to-left), OCR beyond Latin script, office document import/export, signature tokens and online long-term validation, Preflight and PDF/X/UA, printing from the web build, physical printer fidelity, and full XFA forms. +- **Hardening:** fuzzing still turns up crashes and hangs on hostile files; each one is fixed with a regression test. Quality has not yet been benchmarked side by side. -**Next, in order:** Office-to-PDF and a Word export that keeps layout, the reported bugs, web printing and the Chinese interface font, a fidelity harness against Acrobat and the renderer, editing existing content, then the Pro workflows (OCR, Office, Preflight, signatures, accessibility tagging) and 1.0 polish. +**Next, in order:** office-document-to-PDF and a .docx export that keeps layout, the reported bugs and the Chinese interface font, a fidelity harness against a reference renderer, editing existing content, then professional workflows (OCR, office formats, Preflight, signatures, accessibility tagging) and 1.0 polish. The summary is in **[ROADMAP.md](ROADMAP.md)**; the full assessment by dimension and area is in [docs/target-app-parity.md](docs/target-app-parity.md), the ranked gaps in [docs/gaps.md](docs/gaps.md), and the milestones in [docs/roadmap.md](docs/roadmap.md). @@ -446,14 +452,41 @@ 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. For unattended deployment +The MSI installs for all users and requires administrator privileges. 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: ```powershell msiexec /i "pdfcraft--windows-x64.msi" /qn /norestart INSTALLDESKTOPSHORTCUT=0 ``` -Use the MSI for your architecture. Per-user installation overrides are not supported. +| Property | `=0` leaves out | +|---|---| +| `INSTALLDESKTOPSHORTCUT` | the all-users desktop shortcut | +| `INSTALLPDFCONTEXTMENU` | "Edit with PdfCraft" on PDF files | +| `INSTALLIMAGECONTEXTMENU` | "Create PDF with PdfCraft…" on images | + +All three are on for a first install. An upgrade keeps what the installed version has: a shortcut +or menu entry that is there stays, and one that is missing (left out at install, or a desktop +shortcut someone deleted) stays missing, so you don't need to pass them again. A value on the +command line always wins: `=1` puts one back and `=0` removes it. Upgrading from a release that +didn't have a menu yet (0.6.0 and earlier for the PDF menu, 0.2.1 and earlier for the image menu) +adds it, as a first install would. Use the MSI for your architecture. Per-user installation +overrides are not supported. + +**Pin the checksum, not just the version.** Release candidates of a version share its MSI version +(MSI versions are numbers only, so 1.2.3-rc.1 and 1.2.3 are both 1.2.3), and the installer lets an +MSI of the same version replace the installed one without asking. Two different `0.7.1` MSIs can't +be told apart by version, so if your deployment tool checks the file, pin its SHA-256 from +`SHA256SUMS.txt` (`Get-FileHash -Algorithm SHA256`). + +**Signing identities.** The Windows MSIs and both executables are signed through Azure Trusted +Signing with a certificate issued to `Learning Machines Inc` (Atlanta, Georgia, US). The macOS app +and command-line tool are signed with the Developer ID `Learning Machines LLC (DJ6XS33FX8)` and +notarized. The MSI's manufacturer, shown as the publisher in Installed apps, is +`Learning Machines LLC`. ### macOS @@ -548,8 +581,6 @@ ArtCraft Team and are not covered by this license. They may be used only unmodif part of this repository and PdfCraft, under [`docs/brand/LICENSE-brand.txt`](docs/brand/LICENSE-brand.txt). Forks and modified versions must remove them. -Adobe, Photoshop, Illustrator, Premiere Pro, Lightroom, Acrobat, After Effects and InDesign are trademarks or registered trademarks of Adobe Inc. in the United States and/or other countries. PdfCraft is an independent, open-source project and is not affiliated with, sponsored by or endorsed by Adobe Inc.; these names are used only to describe the workflows it is compatible with. -

ArtCraft
Made by the ArtCraft team and community. diff --git a/ROADMAP.md b/ROADMAP.md index a53036fce4..b1283e7751 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -1,9 +1,46 @@ # PdfCraft roadmap -**Stage: alpha** · next: beta, ~26% (ready for real work 49% → 75%) and ~400–700 h away +**Stage: alpha** · next: beta, ~25% (ready for real work 50% → 75%) and ~400–700 h away -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** M14 #307 Plan 3 uses one marker traversal on open and full refresh, and the combined scanner borrows metadata instead of cloning scan-only containers; includes a behavior-preserving `organize` binding for the workspace Clippy gate · earlier: minor (localized shortcut labels, #496; readiness table with hours per audience; full number as weighted sum 49%; 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 (M5, PR #1971: bounded nested FDF export excludes source page/page-tree dictionaries; refreshed against upstream 4db6ac4a; estimates unchanged) +> **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) +> **Last reviewed:** 2026-10-18 · **Last updated:** 2026-10-18 · **Change:** minor (M4, #1919: repeated page indices accumulate rotations; M3, #3149: discard render demand after page geometry changes and check the GPU texture limit before upload; estimates unchanged) +> **Last reviewed:** 2026-10-18 · **Last updated:** 2026-10-18 · **Change:** minor (M8, #1350: named replacement descriptions retired during Sanitize and across shared resources; estimates unchanged) +> **Last reviewed:** 2026-10-17 · **Last updated:** 2026-10-17 · **Change:** minor (M4 #2997: Ctrl-drag (Option on macOS) in the Organize grid copies the pages into the drop gap; `page_duplicate` takes `at`; 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: AcroForm XML text; estimates unchanged) +> **Last reviewed:** 2026-10-15 · **Last updated:** 2026-10-15 · **Change:** minor (M2/gap 12: the Annex D.2 alias codes read as the ink they draw in both tables; the text oracle scores reading order; 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 (M14/#2383 GPU-limit candidate; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS) +> **Last reviewed:** 2026-10-12 · **Last updated:** 2026-10-13 · **Change:** minor (M4: indirect page rotations; M13: CLI check visits each directory once through aliases and cycles; 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 (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-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-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) +> **Last reviewed:** 2026-10-12 · **Last updated:** 2026-10-12 · **Change:** minor (M13: CLI check visits each directory once through aliases and cycles; 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: Extract and Split preserve shared Type 3 glyph resources; 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 (M2: packed TIFF-predictor RGB rows retain their byte boundaries; 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 (M3 #1364 candidate: consent before absolute local GoToR access; estimates unchanged); earlier: minor (M1 #1339: an unchanged incremental save of a classic cross-reference table writes a zero-length subsection; 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 (M5 #1398: stamps read upright on rotated pages; 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 (gap 6: cloudy comments stay when flatten cannot draw them, a refused text-box edit keeps its properties, arrow defaults stay off lines, a custom match stops at a separator, and the vertical-text redaction error no longer claims an overlap; #1317, #1327, #1302, #1315, #1340; 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 (M7 #1316: replacing a line keeps a neighbouring line whose glyph box overlaps; 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 (M5: comment submission shortcuts; 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 (M1 #1339: an unchanged incremental save of a classic cross-reference table writes a zero-length subsection; 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 (M14 Windows low-DPI font-hinting candidate measured on macOS; no Windows runtime claim; 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 (M3: Windows' Animation effects setting is followed, and Preferences has Animation effects: system, on or off); earlier: exclusive image-crop resize follow-up; M2 short same-line redraws split before placement; 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 (M7 #1200 candidate: local GoToR links and confined automation; 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:** exclusive image-crop resize follow-up; minor (M2 short same-line redraws split before placement; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (M10, #1028: OCR reads pictures on pages that have text, on request); earlier: M14 #307 Plan 3 uses one marker traversal on open and full refresh, and the combined scanner borrows metadata instead of cloning scan-only containers; includes a behavior-preserving `organize` binding for the workspace Clippy gate · earlier: minor (localized shortcut labels, #496; readiness table with hours per audience; full number as weighted sum 49%; 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 (#594 paragraph drags validated in the Linux test environment; 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 (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) 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 @@ -32,7 +69,7 @@ and add a line to the [progress log](#progress-log); when a gap closes, remove i |---|---|---| | **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)) | -| **Ready for real work**, full Acrobat Pro | **≈ 49%** (45–53%) | estimated, weighted sum over [dimensions](#by-dimension) | +| **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)) | | Remaining to **beta** | ≈ 400–700 Opus 5.5 agent-hours | estimated | @@ -43,13 +80,13 @@ and add a line to the [progress log](#progress-log); when a gap closes, remove i of 0.4–0.5, switching reports such as #179). Two are only partial: editing (no CJK/RTL) and convert and share (no in-app Office→PDF; Word export loses layout, #773; password-protected files open in Acrobat since #836, #774). Those, unmeasured quality against Acrobat, the borrowed `hayro` renderer, -no printing on the web and 245 open issues keep it far from beta. +and 245 open issues keep it far from beta. ### Readiness by audience | Audience | Ready | Opus 5.5 agent-hours to ≈ 95% | Work that dominates | |---|---|---|---| -| Full Acrobat Pro (ready for real work) | ≈ 49% | ≈ 780–1,400 h (≈ 300–550 h wall clock with 3–5 agents; 60–70% parallelizes) | Own renderer and fidelity harness, editing existing content, OCR and Office conversion, Preflight and PDF/A/X/UA, accessibility tagging, signatures for institutions | +| Full Acrobat Pro (ready for real work) | ≈ 50% | ≈ 780–1,400 h (≈ 300–550 h wall clock with 3–5 agents; 60–70% parallelizes) | Own renderer and fidelity harness, editing existing content, OCR and Office conversion, Preflight and PDF/A/X/UA, accessibility tagging, signatures for institutions | | Mainstream practitioner | ≈ 55% | ≈ 500–900 h (≈ 200–400 h wall clock; ≈ 60% parallelizes) | Rendering fidelity, editing existing text (CJK, RTL, subsets), conversion (Word layout, Excel, PowerPoint, Office→PDF), OCR languages, the bug backlog | | Essentials user | ≈ 66% | ≈ 180–330 h (≈ 90–180 h wall clock; ≈ 50% parallelizes) | Rendering fidelity on everyday files, launch and GPU stability, simple text fixes, Word export layout, Chinese UI glyphs | @@ -64,13 +101,13 @@ Hours are calibrated as in [Effort and calibration](docs/target-app-parity.md#ef | File formats and conversion | ≈ 40% | 12% | 120–220 (within features) | [file-format-parity.md](docs/file-format-parity.md) | | PDF specification and standards | ≈ 60% | 8% | 150–260 (within features) | [pdf-spec-parity.md](docs/pdf-spec-parity.md) | | Hardware | ≈ 35% | 4% | 25–50 | [hardware-parity.md](docs/hardware-parity.md) | -| Localization | ≈ 59% | 5% | 35–60 | [localization-parity.md](docs/localization-parity.md) | +| Localization | ≈ 68% | 5% | 35–60 | [localization-parity.md](docs/localization-parity.md) | | Performance | ≈ 40% | 6% | 25–45 | [gaps.md](docs/gaps.md) gap 13 | | Stability | ≈ 50% | 8% | 45–80 | [gaps.md](docs/gaps.md) gap 6 | | Platforms | ≈ 70% | 5% | 15–30 | [hardware-parity.md](docs/hardware-parity.md#platforms) | | Ecosystem and plug-ins | ≈ 30% | 1% | 10–20 (within features) | [gaps.md](docs/gaps.md) | | AI features | ≈ 0% (in scope: local providers) | 1% | 20–40 (within features) | [gaps.md](docs/gaps.md) gap 23 | -| **Total (weighted sum)** | **≈ 49%** | 100% | **≈ 780–1,400** | | +| **Total (weighted sum)** | **≈ 50%** | 100% | **≈ 780–1,400** | | ## Features @@ -95,8 +132,8 @@ What's strong and missing in each: [target-app-parity.md](docs/target-app-parity ## Languages -Measured from `crates/ui-egui/src/i18n/*.tsv` (2,143 keys). Detail and script notes: -[localization-parity.md](docs/localization-parity.md). +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). | Language | Code | Strings | Status | |---|---|---|---| @@ -111,10 +148,11 @@ Measured from `crates/ui-egui/src/i18n/*.tsv` (2,143 keys). Detail and script no | Japanese | `ja` | 99.1% | partial | | German | `de` | 99.3% | partial | | Korean | `ko` | 0% | none | -| Vietnamese | `vi` | 0% | none | +| Vietnamese | `vi` | 100% of current union | partial (dialogs / tooltips; no help) | -Also shipped: Traditional Chinese, Russian, Bulgarian, Telugu, Hungarian, Ukrainian, Italian -(94–100%) and Czech (menus only): **8 more languages**. +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). ## Upcoming @@ -123,7 +161,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, web printing, Chinese UI face (#826) | 65–120 | +| 1 | Backlog and platform: crash and wrong-result reports, Chinese 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 | @@ -134,14 +172,182 @@ Ranked; detail and the full milestone table in [docs/roadmap.md](docs/roadmap.md ## Progress log +- **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 (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 (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-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. + +- **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-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-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 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-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-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-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-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-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. +- **2026-10-12 (M6, XML field names):** AcroForm XML data export constrains generated element names to XML 1.0 NCNames, so Latin-1 names such as `Area²` and `Aµ` produce readable data files. Original PDF field names travel through the existing `xfdf:original` attribute; previously valid tags, including Greek and CJK, keep their spelling. Saved-PDF regressions cover invalid prefixes, combining letters and normalized-name collisions. Estimates unchanged. +- **2026-10-12 (M6, CSV carriage returns):** Form-data CSV export quotes carriage returns in field names and values, so a trailing carriage return cannot turn into a record separator and disappear on reimport. Saved-PDF round-trip regressions cover CR, LF, CRLF, commas and quotes; estimates unchanged. +- **2026-10-13 (community, M14, #1553 review):** Relative Fontconfig includes resolve beside each including config, including nested configuration selected through `FONTCONFIG_PATH`. Directory includes accept only digit-prefixed `*.conf` in lexical order. Isolated regressions reproduce both review findings; configuration size/count/depth limits, cycles, symlinked config and valid reset ordering are covered. Estimates unchanged. +- **2026-10-12 (community, M14, NixOS UI fonts):** Linux/Unix interface fallback fonts are also discovered in Fontconfig's configured directories, including Nix store and user font paths. Existing candidate names, glyph checks, size limits and the screenshot opt-out are retained. Synthetic regressions cover included configuration, symlinks, cycles, depth limits and candidate ordering. Estimates unchanged. +- **2026-10-12 (M7, local text-box candidate):** Add Text opens source text for editing and both text tools retype added items on one click. Draft boxes grow toward the page edge and downwards, with the grown width saved. Single visual rows grow across style boundaries, while multi-row paragraph widths stay fixed. Added-text previews remove only the selected item on a clone, with UI and read-only tool regressions. Arabic shaping, ordering and alignment are outside this candidate. Estimates unchanged. +- **2026-10-12 (M14, #1395):** XDG desktops use the configuration base for app.ron, safely copying existing preferences without overwriting a destination. Original preferences, logs, recovery and digital IDs are retained; failed migration falls back to the original location. Portable and non-XDG locations remain unchanged; estimates unchanged. + +- **2026-10-12 (M10, Windows print progress, follow-up to #944):** While a job spools, the progress card counts its sheets ("Sending sheet 2 of 3 to Office…") as craft-print reports them (`submit_with_progress`, storytold/craft-libs#27), and steps aside for another long job's card. Pins craft-libs 666c358. Estimates unchanged. +- **2026-10-15 (M2, renderer speed):** Three vendored hayro-interpret patches (31–33), pixel for pixel the same over the first three pages of every pdf.js corpus file. A sampled (Type 0) function keeps its samples in one flat array instead of a hash map rebuilt every time a shading using it is painted: `bug1721218` (InDesign) draws in 0.49 s (was 1.34 s), and a 2,097,152-sample ramp painted 30 times in under a second (was 38 s). One decode of a JPEG or Flate image serves every size it is drawn at (only JPX decodes for a size), so zooming a 300-dpi scan in and out costs 35–40% less render work. Coons and tensor patch meshes copy their vertices' colours less: mesh-gradient pages draw 10–16% faster (page 8 of #1584's annual report 773 → 696 ms). Estimates unchanged. +- 2026-10-12 · M3: selected page text can be copied from Edit and the canvas context menu (#1225). Five focused regressions, full CI and native Unicode clipboard checks pass; floating Copy shares the document permission gate. Estimates unchanged. + +- **2026-10-13 (M2, page-label prefixes):** Preserve literal leading and trailing whitespace in page-label prefixes without changing generic metadata normalization. Nine original public-inspection cases cover prefix spaces, encodings and empty-prefix controls. Existing empty-prefix fallback remains a compatibility policy, not a conformance claim. Estimates unchanged. +- **2026-10-14 (M14, Italian catalog):** Italian gains the 201 entries the other catalogs had and the code looks up: the sentences the accessibility checker, preflight, signature validation and page-range parser word, certificate and calendar labels, digital-ID dialog messages and five plurals. It now has 2,528 entries. 64 keys other catalogs carry but no code looks up (tooltip texts with a fixed shortcut, older wordings, generic labels only Lithuanian has) were left out. AI-assisted, written from the meaning of the English; native review welcome. Estimates unchanged. +- **2026-10-15 (M3, #2166 part 1):** Save asks before writing over a version of the file the document did not come from (something else wrote it since the open or the last save), with Save as… to keep it, and the changed-on-disk notice says what Reload and Save each do; a file that is open already comes to the front instead of opening twice; a running instance's autosaves are locked, so a second instance neither offers nor discards them. Part 2 (every writer checked and atomic) is #2182. +- **2026-10-14 (M1, #2166):** No PDF leaves the engine unchecked and no writer truncates: new files from Combine, Extract, Split and Create, signed, timestamped and LTV-embedded files and the OCR batch output reopen with their pages before anyone writes them, as saves already did; the desktop app, the automation tools (with or without `--root`) and the CLI's edit, combine, extract and split all write through the one staging-file writer in `pdfcraft-platform`, so a failed write or `--out` naming the input never leaves a half-written file. Special files such as `/dev/stdout` are still written as given. +- **2026-10-14 (M3 #2080 candidate):** Existing per-tab page history gains explicit search jumps, rail buttons and control-channel commands. Continuous scrolling and automatic search refresh stay outside history; page-level restoration and estimates unchanged. + +- **2026-10-13 (M2, page-unit transform precision):** Preserve small transform coefficients in page marks, added content and print output. Regression cases cover accepted page units of 4096 and 75000; existing page-unit interpretation and estimates are unchanged. +- **2026-10-14 (M5, PR #1457 branch refresh):** Merged upstream main and reconciled roadmap entries while preserving comment submission shortcuts. Estimates unchanged. + +- **2026-10-12 (M3, page-label navigation):** The page box displays logical labels and keeps the current page when its unchanged value is submitted, including duplicate labels. Editable-control regressions also cover numeric fallback, invalid input, active typing and focused same-document replacement with changed labels or fewer pages. Clean file-watcher reload preserves the chosen scrolling mode, preventing a Single Page reader from jumping to page 1 during replacement. Estimates unchanged. +- **2026-10-13 (community, M2/M6, form-heavy documents):** An image whose pixels hold at most 16 bits of samples (an 8-bit Separation or DeviceN spot-colour image, a 1-4-bit gray or indexed one) is converted once per possible pixel value instead of once per pixel (vendored hayro-interpret patch 28). A fillable character sheet whose 3304 × 4105 DeviceN background ran a PostScript tint transform per pixel drew each page in 3.8 s and 430 MB, on opening and again after every field was filled; it now draws in 0.11 s and 100 MB, pixel for pixel the same. The inspector places widgets without `/P` in one pass over the pages (a form of 8,000 such fields opened in 1.17 s), and the Fields panel pairs its rows with the form through one map a frame and draws only the rows in view (8,000 fields: about 15× less CPU a frame with the panel open). Regressions fail before each fix. +- **2026-10-14 (M9, signature appearance labels):** A visible signature's labels ("Digitally signed by", "Reason: ", …) follow the interface language when Helvetica can draw all of them, and fall back to English as a set otherwise, with a note under the preview; the preview shows the same labels and the signing time. A drawn label the font can't draw is refused naming the label and character, never written as `?`. `sign_document` takes `labels` and `appearance`. Sign, automation and kittest regressions cover German, Chinese and the refusals. Estimates unchanged. +- **2026-10-13 (M4, #1910 Organize on the current page):** Opening Organize Pages from the toolbar, tool panel, command or `--organize` selects the page being read and scrolls its cell into view, replacing a stale grid selection that did not include it. One UI regression covers a middle page, the last page and reopening after an earlier selection. Estimates unchanged. +- **2026-10-13 (M5, signature pad undo):** The Draw tab of Create signature/initials has an Undo button and Ctrl/Cmd+Z that take back the last stroke (#1963). Pad is UI-only, so no automation tool. Estimates unchanged. +- **2026-10-13 (M2, raw crop and page-unit floor):** The renderer applies its existing one-point minimum in physical points for non-nearly-zero raw crop dimensions on scaled pages. Five synthetic regressions cover inspection, crop origins and rotations, reciprocal rounding and bounded raster controls. Existing UserUnit policy, nearly-zero fallback, feature statuses and estimates are unchanged. +- **2026-10-13 (M5, text box font size, #1908):** Comment Properties has a Font size control for text boxes, typewriter text and callouts (1-400 pt); it writes `/DA` and the rich-text strings, redraws the appearance and grows the box to fit. `comment_edit` takes `font_size`. Annot, tool and UI regressions. Estimates unchanged. +- **2026-10-13 (M3, Single Page edge paging, #1924):** In Single Page view a page taller than the window pans with the wheel, and one more notch (or a fresh trackpad swipe) at the bottom/top edge shows the next page at its top / the previous page at its bottom. A swipe that panned to the edge, momentum included, does not turn. Estimates unchanged. +- **2026-10-13 (M3, #1980):** Ctrl+Alt+Q in the digital ID email field no longer requests application exit. Quit requires exact Ctrl+Q/Cmd+Q modifiers and uses the existing unsaved-document confirmation. Synthetic keyboard regressions cover email input and clean/dirty quit requests; estimates unchanged. + +- **2026-10-14 (M4, page transitions read, §12.4.4):** `/Trans` and `/Dur` are read into `DocInfo::transitions` and listed by `doc_info`, the data a full-screen presentation needs to play transitions and auto-advance; playing and editing them stay planned. +- **2026-10-14 (M4, page transitions set, §12.4.4):** `page_transition_set` (one undoable edit) writes or removes `/Trans` and `/Dur` on chosen pages with the values checked first, the counterpart of reading them into `DocInfo::transitions`; playing them and the dialog stay planned. +- **2026-10-14 (M12, transition geometry):** `pdfcraft_render::transition` gives the picture of any of the twelve page transitions at a moment of its run (which parts of the old and new page to paint, where, how opaque), in page fractions, tested for every style and direction; the full-screen presentation will paint from it once it plays transitions. +- **2026-10-14 (M3, Open in Full Screen mode honoured):** a document with `/PageMode /FullScreen` now asks (Full Screen / Not now) before the app enters full screen, as Acrobat does; before, the setting was written and read but never acted on when opening. +- **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-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. +- **2026-10-13 (M6, choice indices):** Multi-select choice fields write ascending `/I` option indices while preserving the caller's `/V` order. Synthetic forms and headless engine regressions cover reverse selection and save/reopen. Estimates unchanged. + +- **2026-10-12 (M10, Windows printing without PowerShell, #1107):** craft-print now prints on Windows in-process: printers from EnumPrinters/GetDefaultPrinter, jobs drawn as vectors by Windows.Data.Pdf into the XPS print API with a driver-validated PrintTicket (exact paper, orientation, no scaling), so printing works where App Control for Business or AppLocker run PowerShell in Constrained Language Mode. Portrait and landscape sheets still print as one job (turned with /Rotate); a sheet size the driver lacks is refused rather than shrunk. Pins craft-libs f03ec27 (craft-libs#28); deny.toml allows the storytold/winprint.rs fork and adds a webpki-root-certs licence exception and a RUSTSEC-2024-0370 ignore for winprint's build-dependencies. An earlier craft-print revision was checked in Windows Sandbox under enforced WDAC: printers listed, CLI and desktop jobs spooled, and no powershell.exe in the 4688 audit. The final pinned revision passed craft-libs Windows CI, including a real paused-queue job; that Sandbox run has not been repeated. Estimates unchanged. +- **2026-10-12 (M1, #1338):** A full rewrite used to build a new trailer from `/Root`, `/Info`, `/Encrypt` and `/ID`, so accepted unknown entries such as a custom string or `/Version` disappeared, including when an incremental save was forced to rewrite. Those entries are copied, and objects reachable only from them are written and renumbered with the rest of the file. Ordinary incremental saves are unchanged. Estimates unchanged. +- **2026-10-12 (M4, #1310, #1311, #1312):** Undo back to the opened file left it marked unsaved, so recovery and close treated unchanged work as dirty. After a save, Revert followed the restored snapshot's older base and the next save dropped the latest saved revision. The last saved file is remembered on its own: undo and redo are clean only when they land on it, Revert opens it, and a later save appends to it. Estimates unchanged. +- **2026-10-12 (M13, CLI robustness scans):** `pdfcraft-cli check` visits a directory once even when input roots overlap or directory symlinks lead back into the tree. PDFs reached through file symlinks remain accepted. Three real CLI regressions cover overlapping roots, a directory cycle and directory/file aliases. Estimates unchanged. +- **2026-10-12 (M4, Type 3 extraction fidelity):** Extract and Split preserve shared image/resource dictionaries reached through Type 3 glyphs, including nested forms, patterns and soft masks. A bounded object-graph walk protects unknown glyph content; unreadable pages/forms retain their glyph resources, and shared explicit fonts are visited once. Separate page dictionaries still prune unused images. Synthetic save/reopen regressions and an opt-in Poppler pixel oracle cover resource ownership, nested forms, cycles, limits and repeated fonts. Estimates unchanged. +- **2026-10-12 (M2, TIFF predictor rows):** Packed RGB images using TIFF horizontal differencing keep every row when samples do not fill the last byte. The renderer aligns each decoded row before moving to the next. Synthetic decoding tests cover 1/2/4-bit samples, horizontal differencing and an 8-bit control; a red/green page-render regression verifies visible output. Estimates unchanged. +- **2026-10-12 (M3, #1364 candidate):** Absolute local GoToR links wait for Allow/Cancel before target-file access. Pending requests preserve their source and page/view destination; relative links retain their existing behavior. Headless `link_open` requires explicit `allow_absolute: true`, preserving existing root, symlink, file and password validation. Estimates unchanged. + +- **2026-10-12 (community, M7, inline Arabic ActualText):** A single-glyph lam-alef `/ActualText` span joins its surrounding word in Edit text instead of splitting it into separate lines. Pure RTL ligature text is put in visual order before line assembly and recovered to typing order with its neighbours. Multi-glyph and mixed-direction replacements retain their authoritative order. Synthetic regressions cover lam-alef variants, marks, two-byte glyph codes, nested spans, replacement and save/reopen. Estimates unchanged. +- **2026-10-12 (M5, #1398):** A stamp placed on a page with `/Rotate` reads upright as that page is displayed. Rubber stamps (including Sign Here), natural-size and explicit-rectangle images, and PDF-page stamps counterrotate their appearance; restyling a picture stamp keeps that correction, and a saved file reopens the same way. Estimates unchanged. +- **2026-10-12 (M10, file formats):** Create PDF reads BigTIFF scans; a malformed GIF header is no longer detected as an image only to fail conversion. file-format-parity.md now lists XDP data import/export for XFA forms (already shipped under `form.xfa-data`) separately from opening `.xdp` as a document. Estimates unchanged. +- **2026-10-12 (gap 6, #1317, #1327, #1302, #1315, #1340):** Flattening keeps a visible comment it cannot draw, such as a cloudy square with no stored appearance, and still draws a solid square, a hidden comment and a cloudy square that already has an appearance. A text box another program drew is left unchanged when a restyle or resize is refused. Make Current Properties Default on an arrow updates the Arrow tool and leaves plain lines alone. A custom text match that ends on a reconstructed word or line separator no longer includes the next glyph. The vertical-writing redaction refusal still fails closed, and its message no longer says the text was under the mark. Estimates unchanged. +- **2026-10-12 (M12, text-only auto-tag):** Automatically tag PDF now writes marked text objects, paragraph tags, a Document element and a parent tree for untagged text-only PDFs. The result is undoable and opens the Tags panel for review. Mixed paint, forms, annotations, existing tags and malformed streams are refused before mutation. Synthetic two-page engine and tool tests check the tree, checker, unchanged rendering and save/reopen. Richer content and role classification remain open. Estimates unchanged. +- **2026-10-12 (M7, #1316):** Replacing a line or paragraph no longer removes a distinct neighbour whose glyph box overlaps the edited line. A second drawing is removed only when it also sits on the same baseline, so fake-bold and invisible copies still go. Synthetic regressions cover 6 pt leading, the same word on the next line, ordinary spacing, a separate paragraph, same-position copies, and incremental and full save/reopen. Estimates unchanged. +- **2026-10-12 (M5, comment shortcuts):** Ctrl+Enter posts highlight comment text and replies; Shift+Enter adds a newline. Replies keep Enter-to-post, and floating comment composers only submit from their focused editor. Estimates unchanged. + +- **2026-10-12 (M7, #1443):** Adding a word to a multi-line paragraph no longer reflows or merges its lines. Two causes: in Chrome- and Skia-made subset fonts, `/W` leaves out glyphs of the `/DW` width, which were treated as missing, so unchanged rows measured too wide and rewrapped (and new letters fell back to Helvetica); and lines drawn turned on the page (a landscape page drawn a quarter turn round) were compared along the page's x axis, so all of them became one line. Lines and rows are now found along each text's own baseline, a turned paragraph is rewritten in its own text space, and that rewrite keeps explicit line breaks. Formatting part of turned text is refused rather than misplaced. Synthetic regressions cover three turns and a default-width subset. Estimates unchanged. +- **2026-10-12 (M1, #1339):** An incremental save of an unchanged classic-table file used to append `xref` and then `trailer` with no subsection, which qpdf reports as a damaged file. The section now has a zero-length subsection, so no object entry changes and a trailer-only edit is still written. Estimates unchanged. +- **2026-10-12 (M12, auto-tag preview):** The engine and `accessibility_autotag_preview` propose paragraph and large-heading roles for bounded text blocks on one page, with locations and reasons. Every proposal needs review; images remain unclassified. No PDF tags are written yet. Synthetic tool tests cover normal text, a larger heading, bounds and unchanged source bytes. Estimates unchanged. +- **2026-10-12 (M10, editing OCR text on scans):** Editing a recognised word on a scanned page now changes what the page shows. Before, the rewrite stayed invisible OCR text, so the scan still showed the old word; on a form, the whole OCR layer also grouped into one paragraph that rewrapped when one word changed. OCR words now group into phrases (a table cell or a tab stop splits them, and lines stay apart); an edit draws the phrase visibly in the scan's sampled ink colour over a cover in the sampled paper colour, keeping each word's width and growing rightwards. The editor covers the scanned word while typing. OCR words are now sized from their letters (a capitals-only word's box spans the cap height, not the full ascender-descender span), so recognised and rewritten text is as tall as the scanned word and sits on its baseline. Five engine tests on synthetic scans and a kittest UI test; `ocr.editable-text-output` is partial. +- **2026-10-12 (M2, Lab Bradford follow-up):** Lab white-point adaptation scales Bradford LMS cone responses using LMS white-point ratios. Independent D50 and equal-energy neutral/chromatic render oracles, D65 identity, direct/Indexed consistency with asymmetric chromatic Range, and degenerate-cone fallback cover the correction. CalRGB and the merged Indexed/Range scaling retain their behavior. Estimates unchanged. +- **2026-10-12 (community, M14, recovery metadata):** A recovery snapshot whose metadata is missing, damaged or names another snapshot is no longer deleted when the folder is listed: it is offered for recovery without an original path (Save asks for a destination), and encrypted snapshots still ask for their password. Recovering one writes fresh metadata in place of the damaged file, and clearing the folder also removes metadata left without a snapshot. Before, a failed metadata write meant the next start deleted the finished snapshot. Synthetic regressions cover the failed write, missing, corrupt, mismatched and encrypted cases. Estimates unchanged. +- **2026-10-12 (M7, Arabic ActualText):** Add text records each shaped line in typing order with `/ActualText`, so copy and search preserve mixed Arabic, Latin, digits and brackets. Edit text keeps the logical order supplied by `/ActualText` instead of reversing Arabic or Hebrew a second time. Adjacent visual glyph runs retain their own bidi recovery. Font-independent tests cover Arabic marks, numbers, Latin words, reopen and complete replacement; automation checks saved editor text and unchanged rendering. Font fallback and bidi typing remain separate gaps; estimates unchanged. +- **2026-10-12 (M5, Fill & Sign text size):** The active Fill & Sign toolbar now chooses 4–300 pt for new text and dates; the in-place editor and saved annotation use that size, which persists in local settings. `fill_sign_add` accepts the same `font_size`. Feature and readiness estimates unchanged. +- **2026-10-12 (community, M3, Greek search):** Case-insensitive Find treats normal, final and capital sigma (`σ`, `ς`, `Σ`) as the same letter, so `κόσμος` and `ΚΌΣΜΟΣ` find each other. Case-sensitive and whole-word options, glyph ranges and match limits retain their behavior. Synthetic text-search regressions cover both directions. The tab-menu regression selects the platform's Reveal label (Finder on macOS), restoring the macOS test without changing the menu. Estimates unchanged. +- **2026-10-12 (M2, soft scans):** A shrunk, interpolated, axis-aligned image (a 300 dpi scan on a screen) is snapped to whole device pixels and shrunk once, not resampled a second time at a fractional scale and offset. On a synthetic scan page the error against an area-average reference fell from 3.4 to 1.6 grey levels at 96 dpi, and edge sharpness is within 2% of the reference (was 10% soft). Rotated, skewed, flipped and non-interpolated images are unchanged; downscaling of those is still open. +- **2026-10-12 (M14, Lithuanian localization):** Added Lithuanian as another interface and date language alongside the existing languages, with a 2,578-entry catalog and three-form plurals. Calendar, inspector and password-dialog regressions check localized labels and unchanged document values. The contributor has reviewed the translation as a native Lithuanian speaker. Estimates unchanged. +- **2026-10-12 (M12, XFA scripts turned off):** When a runaway script turns an XFA form's scripts off, the app now says so: a toast as it happens (at open or after a click), and the notice bar from then on, ahead of its other notices, with a JavaScript Console button. Before, the error was only in the console, so calculations and buttons just stopped. A notice bar the user had closed comes back for it; closed again, it stays closed. The engine reports the state (`Document::xfa_scripts_off`, and `xfa_scripts_off` in `doc_info`) instead of the UI reading error text. Two kittest UI tests (a runaway at open; a runaway click after the bar was closed), engine assertions and a `doc_info` tool test. Overall unchanged. +- **2026-10-12 (M14, Windows low-DPI font-hinting candidate):** The Windows policy disables symmetric rendering for embedded JetBrains Mono only. On the locked-font macOS probe, horizontal alpha-edge energy per ink improves 2.78%, 3.87% and 1.59% at 100%, 125% and 150% scaling, and is down 0.79% at 200%; Inter is pixel-identical. Logical layout hashes and 8 MiB atlas size match, repeated fractional offsets add no atlas dimensions, and baseline/candidate time and RSS ranges overlap. Windows runtime and hardware remain unverified; estimates unchanged. +- **2026-10-12 (M7, #1200 candidate):** Bounded local GoToR file specifications open in desktop tabs; explicit numeric page views apply, while named destinations open at the default view with a warning. The automation tool resolves relative to the source and uses existing `--root` confinement. Appearance-only Link Properties edits preserve the existing action. URL/network paths remain refused; `/NewWindow` is a tab hint. Estimates unchanged. +- **2026-10-12 (M1, #1337):** COS opening repairs exhausted cross-reference entries and records the skipped row; allocation safely falls back to unused numbers for exhausted /Size hints and high edits. Classic-table and stream regressions reproduce the former panic; ordinary, zero-offset and repaired-range controls preserve opening and allocation. Estimates unchanged. +- **2026-10-12 (M7, private image crops):** Exclusive image crops resize with their pixels across nested states, intervening operators and content streams; shared clips remain fixed. Synthetic engine/tool regressions and private image save/reopen checks; estimates unchanged. + +- **2026-10-12 (M12, accessibility finding locations):** Checker findings for figures, annotations and form widgets carry verified page bounds. Selecting one flashes its location; the headless check returns the same PDF-space rectangle. Malformed annotation rectangles remain page-only findings. Guided review and tagging estimates unchanged. + +- **2026-10-12 (M14, gap 13, large-file memory):** Measured on synthetic 1 GiB and 100,000-page files. Edits let go of the previous working file (working bytes, display bytes, renderer) before writing the next, so a 1 GiB file peaks at two copies while editing instead of three (five edits: 3.1 → 2.1 GiB, 16 → 8.5 s). Saving still peaks at three; reading from disk on demand is blocked on the renderer (gap 2). Estimates unchanged. +- **2026-10-12 (M2, short text redraws):** Same-line redraws that restart a matching three-glyph prefix after more than one glyph-height of progress split before placement, so short doubled words no longer interleave. The existing redraw-position tolerance is reused; controls cover negative TJ movement, combining marks, RTL, CJK, vertical and turned text. Estimates unchanged. +- **2026-10-11 (M5, #1191):** Quick-bar colour changes now restyle the selected comment when it matches the active comment tool, while continuing to update that tool's default; added an accessible label to the swatch controls and a UI regression proving the saved style and undo step. Estimates unchanged. +- **2026-10-11 (M10.5, focused follow-up to #599):** Carry the architectural/ANSI/large ISO paper presets, current-page matching at 100%, and previewed PDF scale into the current print dialog and `doc_print`. Retains the #847/#1007 spooler and background discovery. Driver substitution, physical calibration, vector Windows printing and managed-endpoint restrictions remain separate gaps. Overall estimates unchanged. + +- **2026-10-11 (contributor fork, M7):** Full matching font import completes source-subset glyphs; installed/file font selection, TrueType/OpenType-CFF mapping, disjoint range styles, marked paragraph boundaries and single-face contextual shaping/bidi added. Private Chinese keyboard edits on all four pages preserve source anchors and save/reopen renders. Curved clips exclude holes from hit tests; shared path scopes are isolated for image transforms without moving neighbouring paint. No font binary or private fixture is published; release font inputs and readiness estimates unchanged. + +- **2026-10-11 (M7, cropped image resize follow-up):** Resize from the visible (clipped) frame; an image's own clip moves with it (#1049) and shared clips stay fixed. Selected corner grips keep control over overlapping text; horizontal/vertical-only shrinking responds without freezing; one drag frame is previewed and one edit is applied on release. Synthetic pointer/tool regressions and private grow/shrink/save/reopen/undo rendering checks cover the regression. Shared clipping scopes containing unrelated painted content are refused. Estimates unchanged. + +- **2026-10-11 (M7, cropped artwork selection):** Image/Form inventories now expose page/path-clipped visible bounds while retaining full placement geometry for transforms. Edit clicks prioritize text over background artwork; image frames stay within their own page and fixed clips remain fixed during movement. Engine/tool/UI regressions and an opt-in four-page private-file test cover direct access to 73 text regions and five cropped placements. Rectangular clips are exact; curved/complex clips use conservative bounds. Estimates unchanged. + +- **2026-10-11 (M7, live text editor follow-up):** Reuse validated embedded sfnt source faces; adapt Mac format-6 subset cmaps through PDF Unicode mappings in memory without changing or installing source fonts. Preserve source baseline positions on entry. Show draft text and a single frame that grows/shrinks as rows are added or removed; single rows can grow toward the page edge. `text_edit_layout` exposes read-only draft bounds through the engine and automation. Licensed synthetic font/frame regressions and an opt-in all-page private-file check cover source font usage, stable positions, typing and deletion. Unsupported source programs, shaping and installed-font selection remain limited; estimates unchanged. + +- **2026-10-11 (M7, mixed-style regions follow-up):** Group adjacent source runs into visual rows and aligned rows into regions across subset fonts, colours, weights and text-matrix sizes. One inline TextEdit uses styled spans and source row breaks; rewriting preserves surviving source-run styles, source subset encodings and centered mixed-size title alignment. Added engine, automation and UI regressions for edit/delete, read-only preview, save/reopen and undo; privately verified the supplied real PDF's three-row title and six-row body, including selecting both in one editor without extra rows. `text_paragraphs` exposes `editor_text` and `rows`. Installed-font selection, exact editor glyphs, complex shaping and arbitrary multi-range style edits remain open. Readiness estimates unchanged. + +- **2026-10-11 (M7, contributor fork):** Join compatible words across text objects and subset-font spaces; preserve source fonts that lack a space glyph using text positioning; recognize component colour operators. Inline editing uses one editor frame, resets its available width when switching blocks, keeps source line breaks, and renders a COS-clone background without the original glyphs, preserving unselected text advances. `page_render.hide_text_block` exposes the same read-only preview. Mixed-style paragraph grouping, installed-font selection and exact source-font editor rendering remain open. + Newest first: the date, what moved, and the new overall estimate. +- **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. +- 2026-10-11: Organize Pages shows an editable, synchronized physical page-range selection (#1092), with bounded validation and UI/operation regression coverage; overall estimate unchanged. + +- **2026-10-11 (M5.7, scanned signatures, #1110):** Fill & Sign ▸ Add signature has a Scan tab: a phone photo of a signature on paper is framed automatically (all words; table, sheet edge and dust ignored; a sideways signature turned upright), the frame can be moved, resized or redrawn, the photo turned, and an ink-sensitivity slider picks up faint ink. A morphological paper estimate removes shadows and uneven light, and the trimmed ink is saved and reused like an image signature. Photos and images can also be dropped on the Image and Scan tabs. Photos keep the image-signature limits (#1124: a 4 MiB file, up to 32 megapixels decoded, scaled down to 4 megapixels), so a scan never processes more than 4 megapixels. `fill_sign_add` takes `scan` and `sensitivity`. Synthetic engine, UI and automation tests; checked by hand on real phone photos. Milestone and effort estimates unchanged. +- **2026-10-12 (M10.5, hosted CI):** Integrated current main, including its date-table formatting and static-storage fixes. The Arabic synthetic drawing regression now checks the original serialized-output golden with and without the optional font face: the face-dependent FontName and FontFamily are checked separately and normalized only in the fixture. The hosted FreeBSD fingerprint is reproduced by those two metadata fields alone. Drawing bytes and all other fields retain the original golden; no printing behavior or readiness estimate changes. +- **2026-10-11 (M3, animation effects):** PdfCraft follows Windows' Settings ▸ Accessibility ▸ Visual effects ▸ Animation effects, and Preferences has its own Animation effects: Use system setting (the default), On or Off. With animation effects off, a wheel notch scrolls its whole distance, and Ctrl with the wheel zooms its whole step, in the frame it arrives. Both were eased over about a quarter of a second whatever the setting: egui spreads a coarse wheel step over later frames and has no switch for it, so `crates/ui-egui/src/motion.rs` reads each frame's wheel input the way egui does, minus the easing, and hands that to the scroll areas and the views (a test sums what egui eases and compares it with what is read at once, and fails if egui stops easing). egui's own animations (menus, sections, scrolling to a row) take no time, the chrome's hover and press washes are simply there, and the progress card's bar steps without its sweep. The desktop app asks WinRT's `UISettings.AnimationsEnabled` at launch and every two seconds of use, so a change applies without a restart. How far a notch scrolls or zooms is unchanged. The preference set to On or Off decides all of this whatever the system says; it is saved, set as `animation_effects` (`system`, `on`, `off`) on the control channel, and translated in all 20 catalogs. Not done: macOS (Reduce motion), Linux (GNOME's animations switch) and the browser (`prefers-reduced-motion`) aren't asked yet, so Use system setting animates there as before; Off works everywhere. Estimates unchanged. +- **2026-10-11 (M10, gap 4):** The web build prints. Its Print dialog offers "Browser's print dialog" (the default there), which reaches every printer the operating system knows. PdfCraft draws the print-ready PDF's sheets itself with its own renderer, a sheet a frame because the web build has no threads: 300 dpi up to 100 sheets, 200 up to 300, 150 up to 1,000, at most 64 megapixels a sheet and 1 GiB of pictures a job. It then prints them from a hidden iframe, one sheet per printed page with no margin. A landscape sheet among portrait ones is turned onto the paper, as a driver does, because Chromium prints every page at the default `@page` size. Copies, two-sided and colour are set in the browser's dialog, so the dialog greys them out. If the browser refuses to print (a sandboxed frame, for example), the print-ready PDF downloads instead. Verified in headless Chromium: the right sheets at 300 dpi, the landscape sheet turned, no blank trailing page, and the download fallback. Not yet tested in Firefox or Safari. Gap 4 closed; rank 1 is 65–120 → 50–90 h; other estimates unchanged. +- **2026-10-11 (M10.5):** Extracted printer discovery, options and PDF submission into craft-libs/craft-print, shared with DesignCraft. Windows preserves composed sheet size and orientation without fitting into default margins; duplex binding edges are corrected. PdfCraft retains permission checks and imposition. Shared transport and local composition, engine, automation and UI coverage validate the boundary; native Windows CI passes, including paused-queue submission and cleanup; physical printer fidelity remains a hardware check. Rust 1.95 is required by the shared crate. No readiness percentage change. +- **2026-10-11 (M10, #1028):** Recognize Text can read pages that already have text: "Also recognize pages that already have text" in the dialog (remembered with the other choices, and used by Recognize text in multiple files) and `skip_text_pages: false` on `ocr_recognize` and, newly, `ocr_recognize_files`. A picture on such a page becomes searchable, and a recognised word at least half covered by the page's own text (an earlier OCR layer included) is left out, so nothing is found twice; `ocr_recognize` reports how many were left out as `already_on_page`. Before, the option existed only for agents and added the page's text a second time. The default still skips pages with text. Synthetic tests (a picture of a sentence plus real text) cover the engine, the tool and the dialog, and fail with the filter turned off. Still missing: reading only the picture areas (the whole page is read, as for a scan). Separately, `recognize_text_makes_a_scanned_page_searchable` fails on main wherever the models are installed (the model reads "quick" as "auick"). Estimates unchanged. +- **2026-10-11 (M2, page-label prefixes):** The page-label readers bound the total size of decoded prefixes. Each range decodes its own prefix, and ranges may share one prefix object, so a small file could ask for a copy per range: within the 200,000-entry tree limit, about 200 MiB of copies of one 1,024-byte prefix. Inspection and Organize now refuse a tree whose decoded prefixes pass 4 MiB in total (`MAX_PREFIX_TOTAL_BYTES`), before any label is made, as the other tree limits do; relabelling such a tree changes nothing. Synthetic tests share one 1,000-byte prefix across 5,000 ranges and keep exactly 4 MiB of prefixes readable. Overall estimate unchanged. +- **2026-10-11 (UI, checkboxes and radio buttons):** Checkboxes no longer look like radio buttons, and on the light theme neither is invisible. With the theme's 6 px widget radius egui drew a checkbox's 14 px box as a circle, so View ▸ Page display's Continuous scrolling and Show cover page read as two more layout choices; on the light theme an unticked box or an unselected radio button was white on white with no edge. Every checkbox (81) and radio button (79) now goes through `widgets::checkbox` / `widgets::radio` / `widgets::radio_value`: a 1 px outline (darker on hover), the plain fill on the light theme as dialogs already had, and for checkboxes a rounded-square box (3 px), keeping what the caller set (dialogs, the search panel). `clippy.toml` disallows `ui.checkbox`, `ui.radio`, `ui.radio_value`, `egui::Checkbox` and `egui::RadioButton` so new ones use them too. Estimates unchanged. +- **2026-10-11 (M13, #971):** `text_find` now bounds each page's temporary match-range list to the remaining result limit plus one, which is enough to detect and return a continuation without allocating every match on a dense page. Added limited-search coverage; the existing end-to-end continuation test passes. The text cache byte budget and per-page extraction warnings remain open. +- **2026-10-11 (M7, #594):** Ordinary paragraph body drags now use the existing source-preserving object translation path; edge drags still rewrap. An in-progress drag is discarded if the document generation changes, and added-content gestures keep their own edit handler. Added real-shell source regressions for overlapping and identical labels, history, save/reopen, partial overlap, kerning, rotation/crop/UserUnit and atomic refusal. Linux validation passed: focused UI/engine regressions, exact-base source-loss failures, native drag/undo/redo/save/reopen with independent Poppler renders, and all eight CI gates. Text replacement and resizing over unrelated overlapping content remain outside this repair. Estimates unchanged. + +- **2026-10-10 (community, M7, Thai Add Text):** Optional craft-fonts Thai face (Noto Sans Thai) for the UI and shaped Add Text through the engine, CLI and MCP. Stacked marks and Sara Am are kept through Type 3 clusters with ToUnicode; regular style, and wrapping at cluster boundaries (no dictionary word segmentation). The craft-fonts pin for CI and releases moves to the revision with the Thai face. Forms and Fill & Sign are outside this change. Estimates unchanged. +- **2026-10-11 (#995, #1077, permissions password after opening):** A PDF with only a permissions password opens without asking, restricted, and nothing let the user enter that password afterwards: the notice and the hint pointed at reopening with it. Now the security notice and Properties ▸ Security offer Unlock…, and Change settings / Remove security on a restricted document ask for the permissions password first and then carry on (as Acrobat does). The open password is refused there. `Session::unlock` upgrades the open document in place (owner and user passwords unlock the same file key, so nothing is re-read), unlocks the undo and redo states and reopens saves as owner; it is not an edit and doesn't mark the document unsaved. New command `protect.unlock`; the new strings are in all 15 full catalogs. Tests cover crypt, cos, engine (undo, redo, save, separate open password) and the UI. Still missing: an automation tool for it. Estimates unchanged. - **2026-10-11 (M14, #307 Plan 3):** Engine open and full refresh use one page/content traversal to populate marks and added content. The independent public scans remain available; the combined scanner borrows direct objects and holds referenced objects through `Arc` instead of cloning scan-only `/Contents` arrays or `/PCAdded` dictionaries. Differential tests cover an indirect `/PCAdded` dictionary as well as ordering, malformed/direct/indirect/repeated entries and `/UserUnit`. Exact `bbbe600`→final release CLI measurements used 10 alternating pairs: wall-time medians were 183.645→174.440 ms on 10,000 marker-free pages (10/10 wins), 33.587→32.752 ms on 1,500 mixed pages (8/10), and 24.331→20.133 ms on the 100,000-entry array stress fixture (10/10). The stress fixture's maximum-RSS median fell 28,655,616→19,038,208 B; peak-footprint medians were 14,747,136→14,673,408 B. From P0, the follow-up stress run was 20.886→20.201 ms (10/10), with RSS 23,953,408→19,087,360 B and peak footprint 14,706,144→14,697,952 B. `doc_open` stdout hashes matched every pair. The ancillary `organize` binding preserves the prior `prune_legacy` update condition. Estimates unchanged. - **2026-10-11 (M4, Organize overview):** Opening Organize pages from All tools, Home, the tool palette or `ui.set tool=organize` opens the thumbnail grid directly. Checkboxes toggle individual pages without modifier keys; page controls rotate either way or delete one page, while the toolbar acts on the selection. Portrait and landscape previews retain their aspect ratio. UI and control regressions cover entry, selection, single-page versus batch actions, undo, permissions and retaining the last page. Milestone and effort estimates unchanged. +- **2026-10-11 (M5, partial comment imports):** Replacing an editable named comment through XFDF or FDF (including an indirect name in the source PDF) preserves its object identity, so existing replies and review statuses remain attached after saving, reopening and exporting again. Replaced pop-ups are still removed. Locked comments, redaction marks and their pop-ups stay unchanged, including when their names are indirect; imported redactions remain visible. Synthetic round-trip tests cover both formats, incremental and full saves; milestone and effort estimates unchanged. + +- **2026-10-15 (M9, #1895):** Preserve original signature artwork and show validation through a separate page overlay. Generic in-place appearance rewriting is deferred: neither a caption substring nor a unique drawing establishes an icon role. Signed regressions cover chamfered borders, mixed signer/caption text, image/text exports, both print modes and saves. Snapshot reuse and trust/cache refresh remain. Baked Not Verified captions are not replaced; #79 remains open. Estimates unchanged. - **2026-10-10 (M9, signature graphics):** A visible digital signature can show a graphic where the signer's name is large, as Acrobat's Configure Signature Appearance does: the saved Fill & Sign signature (drawn strokes smoothed as ink, typed outlines in the script font, or an image) or an imported PNG/JPEG. Sign as previews it and remembers the choice for the next signing; `sign_document` takes `graphic` (`image`, `text` or `strokes`). Graphics are checked and capped before drawing (non-finite or unbounded points, mismatched pixel buffers, oversized images are refused with an error the caller can act on); images keep their alpha as a soft mask. `sign.signature-appearance` stays partial (no named saved appearances or PDF-page graphics). Overall estimates unchanged. - **2026-10-11 (M14, absolute interface sizes):** A chosen Interface size is now absolute, as PhotoCraft's UI Scale is: "100%" is one screen pixel per point whatever scale the display reports, so on a 1080p screen whose system reports 200 % it draws at a true 100 % (#456, reported after the first change). Auto still keeps the display's scale and adds GNOME's text scaling, and its menu entry shows the size it draws at. The menu offers PhotoCraft's sizes (75–300 %). - **2026-10-11 (automation, #971):** `text_find` reads pages in growing batches (8, doubling to 128) and stops once `limit` is reached, so a bounded search on a long document no longer extracts and caches every page first. Results say `complete`, and a capped one gives `next` (`start_page`, `skip`) to continue. Still open from #971: a byte budget for the automation text cache and per-page warnings for pages whose text can't be extracted. +- **2026-10-11 (M10, #773 integration):** Merged current upstream updates into the Word export fix and adopted the shared Windows symlink test setup. Word layout scope and estimates unchanged. +- **2026-10-11 (M10, #773):** Word export no longer invents visible borders for tables inferred from text alignment. Rotated text, and pages where text overlaps an image or a watermark-sized text block, use positioned, editable shapes so invoice watermarks do not reserve body space (pages whose logo or title sits clear of the text keep flowing paragraphs, tables and inline images); source pages have separate sizes and section breaks. Synthetic export and automation regressions cover the invoice failure. Exact font matching, vector rules and general layout fidelity remain partial; estimates unchanged. - **2026-10-11 (docs, closed issues):** The progress docs caught up with issues closed on 2026-10-10: encrypted output opens in Acrobat (#774, #836; gap 1 closed, rank 0 is 32–55 h), Windows prints (#756, #847), column select (#740), 1:1 touchpad scrolling (#759), same-page multi-object move (#844), and the #799–#821 batch of wrong-result fixes including the #816 panic. Scores and readiness not re-judged; overall estimate unchanged. - **2026-10-11 (M7, watermark placement):** A watermark can be moved from the page centre by a horizontal and a vertical offset in points: Add/Update Watermark has an "Offset from centre" row (the preview follows it, for text and for pictures) and `doc_watermark` takes `offset_x` and `offset_y`. A non-finite offset is refused instead of written into the page. `edit.watermark-appearance` stays partial: no offset from the page edge or in page units, no print/screen-only variants, no repeat. Image and PDF watermarks, already shipped, no longer appear as missing. The overall estimate is unchanged. - **2026-10-11 (M12, XFA script time limits):** An XFA script's time limit (1 s for scripts that run unasked, on open and on every change) counted the engine's start-up too: the script's thread, a new context and, for the first script in a process, the engine's own set-up (≈ 60–170 ms when idle). On a busy machine a trivial initialize script could pass the limit, be abandoned and turn the form's scripts off; that made `xfa_buttons_run_their_scripts_in_the_app` and `messages_from_scripts_run_on_open_show_right_away` flaky (under full CPU load 25 of 40 runs of the test binary failed, with "ran longer than 1.0 s and was abandoned" in the console). The limit now runs from when the engine is ready; start-up has its own 10 s cap, so nothing waits forever, and a trivial warm-up script takes the first-in-process set-up. Under the same load none of 40 runs fails, and runaway scripts are abandoned as before. The two UI tests print the script console when they fail. Overall unchanged. @@ -157,6 +363,8 @@ Newest first: the date, what moved, and the new overall estimate. - **2026-10-11 (M14, #307, Plan 2):** Forms now return terminal fields and the page-widget adoption count from one enumeration, and engine open reuses that result while retaining XFA script-button marking. The 12,000-page/no-form PDF (1,442,052 bytes) and 2,500-orphan-widget PDF (673,432 bytes) produce byte-identical `doc_open`/`doc_info` outputs against upstream `33c14e07cfd742a82bf03baa6b103c01008fa68c`, including the orphan repair note. After one warm-up per binary and three interleaved base/candidate rounds, median open times were 0.16/0.15 s and 0.08/0.07 s, with median peak RSS 62.69/59.98 MiB and 33.28/32.20 MiB. Workspace tests, Clippy, formatting, focused forms/engine/organize tests, and the WASM check pass. A behavior-preserving local binding in `organize` also lets the workspace Clippy gate pass. No estimate change. - **2026-10-11 (M14, Traditional Chinese semantics):** Corrected accessibility pass/fail labels, the inverted script-accessibility rule, Taiwan row/column terminology and form choice/read-only labels; added the missing table-row explanation. Catalog regressions cover the rule metadata and preserve field names. Milestone and effort estimates unchanged. + +- **2026-10-11 (M14, no silent `?`):** Text PdfCraft's fonts can't draw (Chinese, Greek, Czech's ř in Helvetica) is never written into a PDF as `?` without a word. Form values and button captions, text boxes, callouts, Fill & Sign text, measurement labels and dynamic stamps' by-lines are kept as typed, as Acrobat keeps them: the appearance draws `?` there, the annotation is marked (`PCUndrawn`), and the edit says so (the interface shows a translated notice; tools return `warnings`). A form also sets `/NeedAppearances`, so other viewers redraw the field from its value (not in a PDF/A document, which forbids it); a restyle doesn't replace a comment another program drew with those characters. Where `?` would be permanent, the edit is refused with the character and what to do instead: flattening what PdfCraft drew with `?`, redaction overlay text (also checked before anything is redacted), typed signatures through `fill_sign_add`, visible signature details, and new documents from text (with the line). Reports PdfCraft writes from documents (comment summaries, compare reports) write such characters as `[U+XXXX]` and say so; a dynamic XFA form's own text is laid out and reported. One end-to-end test drives every path through the tools. Not yet: drawing those characters (embedding a font that has them). Overall estimate unchanged. - **2026-10-10 (M14, interface size):** Preferences ▸ Interface size scales the whole interface (50–250 %, setting `ui_scale`, control option `ui-scale`); 50 % brings an interface the system draws at 200 % back to 100 % (#456). Auto, the default, follows the desktop's text scaling: on Linux and the BSDs the desktop app reads GNOME's `text-scaling-factor` once at launch, so with Large Text (1.25) on a 1× display the interface no longer draws 13 px labels that show their pixels next to the system's text. The size is egui's zoom factor, so `pixels_per_point` grows with it and page rasters stay one texel per screen pixel; egui's own ⌘+/⌘− interface zoom is turned off so those keys stay page zoom. Still missing: KDE's font DPI and Windows' "Make text bigger" aren't read (both scale only text there), and egui's text is still greyscale-antialiased without the system's gamma, so small text looks rougher than GTK's at 100 %. - **2026-10-10 (M14, #496):** Shortcut displays share a UI formatter across toolbar search, registry menus, command palette, Help and tooltips. Modifier and key names use a dedicated catalog context in every bundled translation; missing entries keep the English key name rather than a command translation. German shows Strg/Umschalt/Entf, macOS retains its symbols, and runtime language changes refresh the display. Regression coverage checks localized layout widths, English/German switching, platform conventions and unchanged command execution. Milestone and effort estimates unchanged. @@ -165,6 +373,8 @@ Newest first: the date, what moved, and the new overall estimate. - **2026-10-10 (M12, XFA rows and areas):** Dynamic XFA forms lay out a repeating subform inside a top-to-bottom container in positioned content (a table in an area) with all its instances, drawn and measured, instead of one; and areas no longer appear in data paths (`pdfcraft_xfa::data_path`), since they are not data scopes. Before, a form whose fields sit in an area read none of the values Adobe's viewers saved, wrote what was filled here under a new element named after the area, which Adobe's viewers would most likely not read, and a FormCalc total looping over the table's rows failed on the rows that were missing. Layout, the script tree, reading and writing the data and the engine's value and row operations share the one path rule. Synthetic tests cover the layout, the path rule and the whole round trip (rows, Adobe-written values, a FormCalc total over all rows, the written data, reopening). Still open for such forms: XFA script objects (`.pdf", "sha256": "a", "summary": { "passed": 0, "failed": 1, "manual": 0, "skipped": 0 } }, + "after": { "name": "safe.pdf", "sha256": "b", "summary": { "passed": 1, "failed": 0, "manual": 0, "skipped": 0 } }, + "rules": [{ "rule": "primary-language", "before": { "status": "failed", "findings": 1 }, "after": { "status": "passed", "findings": 0 } }], + "review_required": [], + "note": "Review independently.", + }); + let html = review_html(&record).unwrap(); + assert!(html.contains("<script>alert('x')</script>.pdf")); + assert!(!html.contains(" + + + + "#, + ); + let bytes = Arc::new(pdfcraft_xfa::fixtures::shell(&tpl)); + let mut s = Session::new().with_clock(|| 1_700_000_000); + let id = s.open("iso.pdf", None, bytes, None).expect("opens"); + let value = |s: &Session, n: &str| s.get(id).unwrap().form.iter().find(|f| f.name == n).map(|f| f.value.clone()).unwrap_or_default(); + let out = s.take_js_output(id); + assert!(out.errors.iter().any(|e| e.contains("boom")), "{out:?}"); + assert_eq!(value(&s, "a"), vec!["1".to_string()], "the failed script's write reached nothing"); + assert_eq!(value(&s, "c"), vec!["2".to_string()]); + assert_eq!(value(&s, "country"), vec!["CA".to_string()], "shown text mapped to the saved value"); + assert_eq!(value(&s, "e"), vec!["CA".to_string()], "the next script reads the saved value"); + assert_eq!(value(&s, "summary"), vec!["Country: CA".to_string()]); +} + #[test] fn xfa_scripts_initialize_calculate_validate_toggle_and_add_rows() { let bytes = Arc::new(pdfcraft_xfa::fixtures::shell(&pdfcraft_xfa::fixtures::scripted_template())); @@ -2523,6 +4484,7 @@ fn xfa_scripts_initialize_calculate_validate_toggle_and_add_rows() { assert_eq!(value(&s, "qty"), vec!["2".to_string()]); assert_eq!(value(&s, "total"), vec!["10".to_string()], "{:?}", s.take_js_output(id)); assert_eq!(value(&s, "grand"), vec!["0".to_string()]); + assert!(!s.get(id).unwrap().xfa_scripts_off()); assert!(!s.get(id).unwrap().dirty); assert!(!names(&s).iter().any(|n| n == "note"), "details is hidden"); // A change recalculates. @@ -2613,6 +4575,175 @@ fn xfa_rows_in_areas_are_laid_out_read_calculated_and_written_where_adobe_binds_ assert_eq!((v2("days_2"), v2("rest")), ("2".into(), "29".into())); } +#[test] +fn a_value_a_script_sets_reaches_every_field_bound_to_the_same_data_node() { + // `carried` inside the area and a second `carried` outside it bind to one data node (areas + // are not data scopes). In document order: `setter` writes the inner one, `echo` reads the + // outer one in the same event, and the saved data holds the one value. + let tpl = pdfcraft_xfa::fixtures::leave_template("
5") + .replace( + r#" "#, + r#" + "#, + ) + .replace( + " \n \n", + r#" + + + +"#, + ); + assert!(tpl.contains("name=\"echo\"") && tpl.contains("name=\"setter\""), "the fixture was reshaped"); + let bytes = Arc::new(pdfcraft_xfa::fixtures::shell(&tpl)); + let mut s = Session::new().with_clock(|| 1_700_000_000); + let id = s.open("shared.pdf", None, bytes, None).expect("opens"); + let out = s.take_js_output(id); + assert!(out.errors.is_empty(), "{out:?}"); + let values = |s: &Session, id: DocId, n: &str| { + s.get(id).unwrap().form.iter().filter(|f| f.name.starts_with(n)).map(|f| f.value.join("")).collect::>() + }; + assert_eq!(values(&s, id, "carried"), vec!["9".to_string(), "9".to_string()], "both fields show the node's value"); + assert_eq!(values(&s, id, "echo"), vec!["9".to_string()], "read in the same event the setter ran in"); + let cos = pdfcraft_cos::Document::open(s.get(id).unwrap().bytes.clone()).unwrap(); + let d = pdfcraft_xfa::data_of(&cos).unwrap(); + assert_eq!(d.text_at(&pdfcraft_xfa::som_to_path("form[0].page[0].carried[0]")), Some("9")); + assert_eq!(d.count(&pdfcraft_xfa::som_to_path("form[0].page[0]"), "carried"), 1, "one data node"); + // The inner field hidden (no widget): the outer widget still takes the node's value, and + // an unrelated edit does not write the old value back over it. + let hidden = Arc::new(pdfcraft_xfa::fixtures::shell( + &tpl.replace(r#""#, + r#"90"#, + ))); + let id = s.open("check.pdf", None, checkbox, None).expect("opens"); + let carried: Vec<(pdfcraft_forms::FieldKind, Vec)> = + s.get(id).unwrap().form.iter().filter(|f| f.name.starts_with("carried")).map(|f| (f.kind, f.value.clone())).collect(); + assert_eq!( + carried, + vec![(pdfcraft_forms::FieldKind::CheckBox, vec!["9".to_string()]), (pdfcraft_forms::FieldKind::Text, vec!["9".to_string()])], + "{carried:?}" + ); +} + +#[test] +fn flattening_an_xfa_form_bakes_its_pages_and_takes_the_xfa_out() { + let data = "
JPEnglish\nSpanish"; + let bytes = Arc::new(pdfcraft_xfa::fixtures::shell(&pdfcraft_xfa::fixtures::fields_template(data))); + let mut s = Session::new().with_clock(|| 1_700_000_000); + let id = s.open("fields.pdf", None, bytes, None).expect("opens"); + assert_eq!(s.get(id).unwrap().info.xfa, Some(pdfcraft_render::Xfa::Dynamic)); + s.apply(id, Edit::SetFieldValue { name: "other".into(), value: FieldValue::Text("typed here".into()) }).unwrap(); + s.apply(id, Edit::Flatten { comments: false, fields: true }).unwrap(); + let d = s.get(id).unwrap(); + assert!(d.form.is_empty(), "no fields remain"); + assert_eq!(d.info.xfa, None, "no longer an XFA form: Adobe's viewers show the baked pages"); + assert!(d.xfa.is_none()); + let texts = page_texts(&s, id).join("\n"); + for want in ["Japan", "typed here", "Country: JP", "12345"] { + assert!(texts.contains(want), "{want} is page content now: {texts}"); + } + // Saved and reopened: a plain PDF, the same page content, nothing of the XFA left. + let saved = s.save_bytes(id).unwrap(); + let cos = pdfcraft_cos::Document::open(saved.clone()).unwrap(); + assert!(pdfcraft_xfa::read_packets(&cos).unwrap().is_none()); + let id2 = s.open("flat.pdf", None, saved, None).unwrap(); + let d2 = s.get(id2).unwrap(); + assert_eq!((d2.info.xfa, d2.form.len()), (None, 0)); + assert!(page_texts(&s, id2).join("\n").contains("typed here")); + // Undone, the form is a laid-out XFA form again. + s.undo(id).unwrap(); + let d = s.get(id).unwrap(); + assert_eq!(d.info.xfa, Some(pdfcraft_render::Xfa::Dynamic)); + assert_eq!(d.form.iter().find(|f| f.name == "other").unwrap().value, vec!["typed here".to_string()]); + // Flattening comments alone leaves the XFA form as it is. + s.apply(id, Edit::Flatten { comments: true, fields: false }).unwrap(); + assert_eq!(s.get(id).unwrap().info.xfa, Some(pdfcraft_render::Xfa::Dynamic)); +} + +#[test] +fn a_dynamic_xfa_form_that_was_never_laid_out_keeps_its_xfa_and_the_user_is_told_what_went() { + // A template that can't be read: the form keeps its placeholder page and its XFA. + let broken = "", ""); + let mut doc = Document::open(Arc::new(shell(&empty))).unwrap(); + render_into(&mut doc).unwrap(); + import_data(&mut doc, "
FR", Some("form"), None).unwrap(); + assert_eq!(crate::data_of(&doc).unwrap().text_at(&som_to_path("form[0].page1[0].country[0]")), Some("FR")); +} + +const XFA_NS: &str = "http://www.xfa.org/schema/xfa-data/1.0/"; + +#[test] +fn a_picture_chosen_for_an_image_field_is_kept_in_the_data_with_its_content_type() { + use crate::data::write_data_image; + let mut doc = Document::open(Arc::new(shell(&fields_template("")))).unwrap(); + render_into(&mut doc).unwrap(); + let path = som_to_path("form[0].page1[0].photo[0]"); + // No data node yet: it is made. + let r = write_data_image(&mut doc, &path, "image/gif", TINY_GIF, None).unwrap(); + assert!(r.written, "{:?}", r.warnings); + let xdp = crate::read_packets(&doc).unwrap().unwrap().xdp; + assert!(xdp.contains("R0lGODlh"), "{xdp}"); + // Written again: replaced, not duplicated; a hostile content type is cleaned. + let r = write_data_image(&mut doc, &path, "image/png\">iVBORw0KGgo"), "{xdp}"); + assert!(!xdp.contains("")); + // The form shows it: laid out again, the page's picture is the PNG from the data. + let (t, _) = parse(&fields_template("")).unwrap(); + rerender(&mut doc, &t).unwrap(); + let d = crate::data_of(&doc).unwrap(); + assert!(d.text_at(&path).unwrap().starts_with("iVBORw0KGgo")); + // Too big is refused before anything is written. + assert!(write_data_image(&mut doc, &path, "image/png", &vec![0u8; (8 << 20) + 1], None).is_err()); + assert!(write_data_image(&mut doc, &Vec::new(), "image/png", TINY_GIF, None).is_err()); + // The image field is a push button marked as one, with no action and nothing drawn. + let bytes = write_incremental(&doc, &SaveOptions::default()).unwrap(); + let doc = Document::open(Arc::new(bytes)).unwrap(); + let fields = pdfcraft_forms_free_fields(&doc); + let photo = &fields.iter().find(|(n, _)| n == "photo").unwrap().1; + assert_eq!(photo.name(b"FT"), Some(b"Btn".as_slice())); + assert_eq!(photo.int(b"Ff"), Some(1 << 16)); + assert_eq!(photo.get(b"PCXfaImage"), Some(&Object::Bool(true))); + assert!(!photo.contains(b"A") && !photo.contains(b"MK")); +} + +#[test] +fn imported_data_is_written_with_the_packets_own_prefix_and_never_leaves_it_unreadable() { + use crate::data::{export_data, import_data, write_data_image}; + // A file binding the data namespace to another prefix, with an attribute in it. + let file = format!( + "
CA
" + ); + let mut doc = Document::open(Arc::new(shell(&fields_template("
JP")))).unwrap(); + render_into(&mut doc).unwrap(); + import_data(&mut doc, &file, Some("form"), None).unwrap(); + let xdp = crate::read_packets(&doc).unwrap().unwrap().xdp; + assert!(xdp.contains("
"), "{xdp}"); + assert_eq!(crate::data_of(&doc).unwrap().text_at(&som_to_path("form[0].page1[0].country[0]")), Some("CA")); + // An xsi:nil declared on an envelope the file's data element doesn't carry. + let file = "1"; + import_data(&mut doc, file, Some("form"), None).unwrap(); + let xdp = crate::read_packets(&doc).unwrap().unwrap().xdp; + assert!(xdp.contains("xmlns:n0=\"http://www.w3.org/2001/XMLSchema-instance\" n0:nil=\"true\""), "{xdp}"); + assert!(crate::data_of(&doc).is_some(), "the packet still parses"); + // Our own export, from a packet using another prefix, imports back. + let other = fields_template("
7") + .replace("xfa:datasets", "xd:datasets") + .replace("xfa:data>", "xd:data>") + .replace("xmlns:xfa=", "xmlns:xd="); + let mut doc2 = Document::open(Arc::new(shell(&other))).unwrap(); + render_into(&mut doc2).unwrap(); + let exported = export_data(&doc2, true, "o.pdf").unwrap(); + import_data(&mut doc, &exported, Some("form"), None).unwrap(); + assert_eq!(crate::data_of(&doc).unwrap().text_at(&som_to_path("form[0].page1[0].pin[0]")), Some("7")); + // A picture into that other-prefix packet uses its prefix. + let path = som_to_path("form[0].page1[0].photo[0]"); + write_data_image(&mut doc2, &path, "image/gif", TINY_GIF, None).unwrap(); + let xdp = crate::read_packets(&doc2).unwrap().unwrap().xdp; + assert!(xdp.contains(""), "{xdp}"); + assert!(crate::data_of(&doc2).is_some()); + // A picture where the data node is a group is refused, the group kept. + let grouped = fields_template("
keep me"); + let mut doc3 = Document::open(Arc::new(shell(&grouped))).unwrap(); + render_into(&mut doc3).unwrap(); + let r = write_data_image(&mut doc3, &path, "image/gif", TINY_GIF, None).unwrap(); + assert!(!r.written && r.warnings.iter().any(|w| w.contains("structured")), "{r:?}"); + assert!(crate::read_packets(&doc3).unwrap().unwrap().xdp.contains("keep me")); + // A field name XML can't hold is written under the same cleaned name the values use. + let odd = fields_template("").replace("name=\"photo\"", "name=\"Foto_Straße\""); + let mut doc4 = Document::open(Arc::new(shell(&odd))).unwrap(); + render_into(&mut doc4).unwrap(); + let r = write_data_image(&mut doc4, &som_to_path("form[0].page1[0].Foto_Straße[0]"), "image/gif", TINY_GIF, None).unwrap(); + assert!(r.written, "{r:?}"); + let xdp = crate::read_packets(&doc4).unwrap().unwrap().xdp; + assert_eq!(xdp.matches("R0lG"), "{xdp}"); + // What is not this form's data is refused: an SVG, an XDP without datasets, an XDP whose + // config has a element before the datasets, a data document with another root. + let mut doc = Document::open(Arc::new(shell(&fields_template("
JP")))).unwrap(); + render_into(&mut doc).unwrap(); + for bad in [ + "".to_string(), + "", + &format!( + "

{rows}
" + ), + ); + let form = layout_xml(&many).unwrap(); + assert!(form.warnings.iter().any(|w| w.contains("60 rows") && w.contains("first 50")), "{:?}", form.warnings); +} + +#[test] +fn an_anchor_type_places_the_named_point_of_the_box_at_its_x_and_y() { + // The page label of IRCC IMM 5532E: x is the centre of the box, so its right edge sits + // 10.795mm right of x, inside the page, not 21.59mm. + let xml = r##" + + + +"##; + let form = layout_xml(xml).unwrap(); + let page = &form.pages[0]; + let span = |t: &str| { + page.items + .iter() + .find_map(|i| { + if let Item::Text(s) = i + && s.text == t + { + Some(s.clone()) + } else { + None + } + }) + .unwrap() + }; + let label = span("PAGE 1 OF 8"); + let right = (197.485 + 21.59 / 2.0) * 72.0 / 25.4; + assert!( + (label.x + label.face.width(&label.text) - right).abs() < 0.5, + "label ends at {}, box at {right}", + label.x + label.face.width(&label.text) + ); + let foot = span("Footer"); + assert!((foot.x - 7.0 * 72.0).abs() < 0.01, "footer at x {}", foot.x); + assert!(foot.baseline > 10.25 * 72.0 && foot.baseline < 10.5 * 72.0, "footer baseline {}", foot.baseline); + let field = widgets(page).into_iter().find(|w| w.name == "ref").unwrap(); + assert!((field.rect.x - 6.0 * 72.0).abs() < 0.01 && (field.rect.y - 72.0).abs() < 0.01, "{:?}", field.rect); +} + +#[test] +fn stripping_takes_the_xfa_out_and_leaves_an_ordinary_document() { + use crate::pdf::strip; + let mut doc = Document::open(Arc::new(shell(&fields_template("
JP
")))).unwrap(); + render_into(&mut doc).unwrap(); + assert!(crate::read_packets(&doc).unwrap().is_some()); + // Reader usage rights on the catalog go too (changing the form voids them). + let root = doc.root().unwrap(); + let mut ur = pdfcraft_cos::Dict::new(); + ur.set(b"UR3".to_vec(), Object::Dict(pdfcraft_cos::Dict::new())); + doc.update_dict(root, |d| d.set(b"Perms".to_vec(), Object::Dict(ur))).unwrap(); + let r = strip(&mut doc).unwrap(); + assert_eq!(r.packets, vec!["template".to_string(), "datasets".to_string()]); + assert_eq!(r.unbound_data, Some(0)); + assert_eq!((r.script_buttons, r.image_fields), (0, 1), "{r:?}"); + assert!(r.usage_rights); + assert!(r.field_keys >= 7, "{r:?}"); + assert!( + r.notes().iter().any(|n| n.contains("template, datasets")) && r.notes().iter().any(|n| n.contains("extended features")), + "{:?}", + r.notes() + ); + let bytes = write_incremental(&doc, &SaveOptions::default()).unwrap(); + let doc = Document::open(Arc::new(bytes)).unwrap(); + assert!(crate::read_packets(&doc).unwrap().is_none(), "no XFA entry"); + assert!(!is_dynamic(&doc)); + assert!(crate::existing_layout(&doc).is_none(), "the layout mark is gone"); + let root = doc.get(doc.root().unwrap()); + let catalog = root.as_dict().unwrap(); + assert!(!catalog.contains(b"NeedsRendering") && !catalog.contains(b"Perms")); + let acro = doc.resolve(catalog.get(b"AcroForm").unwrap()); + let acro = acro.as_dict().unwrap(); + assert!(!acro.contains(b"XFA") && !acro.contains(b"PCXfaLayout") && !acro.contains(b"PCXfaOverrides")); + // The fields stay, with their values, and without the private keys. + let fields = pdfcraft_forms_free_fields(&doc); + assert_eq!(fields.len(), 7); + let country = &fields.iter().find(|(n, _)| n == "country").unwrap().1; + assert_eq!(country.get(b"V").and_then(|v| v.as_string()).map(|s| s.to_text()).as_deref(), Some("JP")); + for (name, d) in &fields { + for k in [b"PCSom".as_slice(), b"PCXfaClick", b"PCXfaImage", b"PCItems"] { + assert!(!d.contains(k), "{name} still has {}", String::from_utf8_lossy(k)); + } + } + // The pages are untouched. + let pages = doc.resolve(catalog.get(b"Pages").unwrap()); + assert_eq!(pages.as_dict().unwrap().int(b"Count"), Some(1)); + // Twice is nothing: a document without XFA is left as it is. + let mut doc = doc; + let r = strip(&mut doc).unwrap(); + assert_eq!(r, crate::Stripped::default()); + assert!(!doc.is_modified()); + // Data no field holds is counted; a form whose click buttons ran scripts too; the shape + // check needs no packet decoded and tells a never-laid-out dynamic form from one to strip. + let mut doc = Document::open(Arc::new(shell(&scripted_template().replace("", "
3kept nowherex
")))).unwrap(); + let before = crate::xfa_shape(&doc).unwrap(); + assert_eq!(before, crate::XfaShape { needs_rendering: true, has_fields: false, laid_out: false }); + assert!(!before.strippable(), "nothing laid out yet: stripping would leave the placeholder page"); + render_into(&mut doc).unwrap(); + assert!(crate::xfa_shape(&doc).unwrap().strippable()); + let r = strip(&mut doc).unwrap(); + assert_eq!(r.unbound_data, Some(2), "{r:?}"); + assert_eq!(r.script_buttons, 4, "addRow, removeRow, hello, legacy: {r:?}"); + assert!(crate::xfa_shape(&doc).is_none()); + // A field tree whose dictionaries share one Kids array (every kid listing every other) is + // walked once per dictionary, not once per listing. + let mut doc = Document::open(Arc::new(shell(&fields_template("")))).unwrap(); + render_into(&mut doc).unwrap(); + let root = doc.root().unwrap(); + let acro_ref = doc.get(root).as_dict().unwrap().get(b"AcroForm").and_then(Object::as_ref); + let fields: Vec = (0..2000).map(|_| Object::Ref(doc.add(Object::Dict(pdfcraft_cos::Dict::new())))).collect(); + let kids = doc.add(Object::Array(fields.clone())); + for f in &fields { + if let Some(r) = f.as_ref() { + doc.update_dict(r, |d| { + d.set(b"Kids".to_vec(), Object::Ref(kids)); + d.set(b"PCSom".to_vec(), Object::String(pdfcraft_cos::PdfString::text("x"))); + }) + .unwrap(); + } + } + let update = |d: &mut pdfcraft_cos::Dict| d.set(b"Fields".to_vec(), Object::Array(fields.clone())); + match acro_ref { + Some(r) => doc.update_dict(r, update).unwrap(), + None => doc + .update_dict(root, |c| { + let mut a = c.get(b"AcroForm").and_then(|a| a.as_dict().cloned()).unwrap_or_default(); + update(&mut a); + c.set(b"AcroForm".to_vec(), Object::Dict(a)); + }) + .unwrap(), + } + let started = std::time::Instant::now(); + let r = strip(&mut doc).unwrap(); + assert_eq!(r.field_keys, 2000); + assert!(started.elapsed() < std::time::Duration::from_secs(5), "{:?}", started.elapsed()); +} + +#[test] +fn som_indices_count_earlier_same_named_siblings() { + let draw = |name: Option<&str>| { + Node::Draw(Box::new(Draw { common: Common { name: name.map(str::to_string), ..Default::default() }, ..Default::default() })) + }; + let children = [draw(Some("a")), draw(Some("b")), draw(Some("a")), draw(None), draw(Some("a")), draw(Some("b"))]; + let sibs = SomIndices::of(&children); + assert_eq!((0..=children.len()).map(|i| sibs.at(i)).collect::>(), [0, 0, 1, 0, 2, 1, 0]); +} diff --git a/crates/xfdf/README.md b/crates/xfdf/README.md index fd2852584b..c137aabd93 100644 --- a/crates/xfdf/README.md +++ b/crates/xfdf/README.md @@ -4,7 +4,7 @@ Layer L4: comment and form data exchange (execution plan M5.6, M6.7). ```rust let xml = export_xfdf(&doc, true, true, "form.pdf"); // comments and/or field values -let fdf = export_fdf(&doc, false, true, "form.pdf"); +let fdf = export_fdf(&doc, false, true, "form.pdf")?; let txt = export_data(&doc, Format::Txt); // or Xml, Csv let report = import(&mut doc, &bytes)?; // XFDF, FDF, XML, CSV or text, detected ``` @@ -13,11 +13,35 @@ let report = import(&mut doc, &bytes)?; // XFDF, FDF, XML, CSV o circle, polygon, polyline, markup, stamp, caret, ink, attachments, sound, redact) with rect, name, author, subject, dates, flags, colours, opacity, width, icon, quads, line ends, ink, default appearance, callouts (`callout`, `fringe`, `intent`, `head`), pop-ups and reply threads - (`inreplyto`); field values nested by name. -- **FDF**: fields (with `/Kids` hierarchies) and comments as direct objects. + (`inreplyto`); field values nested by name, with text preserved across XML comments and processing instructions. +- **FDF**: fields (with `/Kids` hierarchies) and indirect comments. Nested annotation values + are copied under FDF object numbers; shared references and cycles remain shared. Document + links and appearances are excluded. Source `/Page` and `/Pages` dictionaries become null + at every copy depth, so nested destination actions cannot carry the source page tree or content. + Type names may be indirect; lookup checks each reference generation, is iterative, stops at + cycles and refuses chains beyond + 64 references. Discovery borrows direct arrays and dictionaries, + retains array owners lazily and records annotation positions instead of resolved bodies. + Retained entries are budgeted before their keys or values are cloned, including when + saved-PDF cache eviction causes annotation bodies to be parsed again. Export returns + `Result` and refuses copied graphs above 100,000 comments/objects/values, 64 direct nesting + levels or a conservative 64 MiB serialization budget instead of exporting partial data. + These are copied-graph limits: source parsing and page discovery retain their existing + COS/parser limits, with no total source-memory bound. Unknown indirect streams retain their + encoded bytes. FDF import semantics are unchanged. - **Form data**: Acrobat's XML (`xfdf:original` keeps names that aren't XML names), CSV and tab-delimited text (a row of names, a row of values). +XML and XFDF exports preserve tabs and line breaks in field names and comment metadata with +character references, so XML attribute normalization cannot change their identities. + +Plain AcroForm XML imports retain all direct text around comments and processing instructions, +including CDATA and character references. Nested-element and XFA interpretation are unchanged. + +CSV export and spreadsheet merging quote tabs, and import distinguishes quoted tabs from TSV +delimiters without changing field identities. Automatic detection of externally authored, ambiguous +tables remains heuristic. + Import replaces comments with the same `/NM` (and drops the replaced comment's pop-up), draws appearances PdfCraft can draw, and sets values through the form's own checks (formats, validation, recalculation); read-only fields take imported values, as in Acrobat. XML is read diff --git a/crates/xfdf/src/lib.rs b/crates/xfdf/src/lib.rs index 7bec9c5f41..35bc781541 100644 --- a/crates/xfdf/src/lib.rs +++ b/crates/xfdf/src/lib.rs @@ -11,7 +11,7 @@ #![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] -use std::collections::HashMap; +use std::collections::{HashMap, HashSet}; use std::fmt::Write as _; use std::io::Write as _; @@ -29,6 +29,8 @@ pub enum DataError { Malformed(String), #[error("nothing in the file matches this document")] NothingImported, + #[error("FDF export exceeds the {0} limit; no data was exported")] + ExportLimit(&'static str), #[error(transparent)] Cos(#[from] pdfcraft_cos::CosError), } @@ -41,6 +43,9 @@ pub enum Format { Xml, Csv, Txt, + /// An XFA form's data as an XML Data Package (the engine writes it; for a form without + /// XFA packets it is the XML format). + Xdp, } impl Format { @@ -49,6 +54,7 @@ impl Format { "xfdf" => Format::Xfdf, "fdf" => Format::Fdf, "xml" => Format::Xml, + "xdp" => Format::Xdp, "csv" => Format::Csv, "txt" | "tab" | "tsv" => Format::Txt, _ => return None, @@ -193,8 +199,13 @@ fn esc(s: &str) -> String { '<' => o.push_str("<"), '>' => o.push_str(">"), '"' => o.push_str("""), + // XML 1.0 §3.3.3 normalizes literal attribute whitespace, but not references. + '\t' => o.push_str(" "), + '\n' => o.push_str(" "), '\r' => o.push_str(" "), c if (c as u32) < 0x20 && c != '\n' && c != '\t' => {} + // XML 1.0 noncharacters are not allowed even as raw text. + '\u{FFFE}' | '\u{FFFF}' => {} c => o.push(c), } } @@ -458,19 +469,223 @@ fn pdf_string(s: &str) -> Vec { v } +// Keep aliases as aliases instead of expanding a shared graph once per use. References are +// queued, not followed recursively, so cycles and long indirect chains cost linear work. +const MAX_FDF_OBJECTS: usize = 100_000; +const MAX_FDF_BYTES: usize = 64 * 1024 * 1024; + +/// Direct values stay borrowed: `Document::resolve` would deep-clone them. Only +/// indirect values need an owner, and annotation bodies are never retained in discovery. +enum FdfValue<'a> { + Borrowed(&'a Object), + Indirect(std::sync::Arc), +} + +impl<'a> FdfValue<'a> { + fn resolve(doc: &Document, value: &'a Object) -> Self { + match value { + Object::Ref(_) => Self::Indirect(doc.resolve(value)), + _ => Self::Borrowed(value), + } + } + + fn object(&self) -> &Object { + match self { + Self::Borrowed(value) => value, + Self::Indirect(value) => value.as_ref(), + } + } +} + +struct FdfCopy<'a> { + doc: &'a Document, + objects: Vec, + numbers: HashMap, + pending: Vec<(ObjRef, usize)>, + nodes: usize, + bytes: usize, +} + +impl<'a> FdfCopy<'a> { + fn new(doc: &'a Document) -> Self { + Self { doc, objects: Vec::new(), numbers: HashMap::new(), pending: Vec::new(), nodes: MAX_FDF_OBJECTS, bytes: MAX_FDF_BYTES } + } + + fn charge(&mut self, bytes: usize) -> Result<(), DataError> { + self.bytes = self.bytes.checked_sub(bytes).ok_or(DataError::ExportLimit("64 MiB data"))?; + Ok(()) + } + + fn slot(&mut self) -> Result<(usize, u32), DataError> { + if self.objects.len() >= MAX_FDF_OBJECTS { + return Err(DataError::ExportLimit("100,000 objects")); + } + // Object 1 is the FDF root; arena indices start at object 2. The arena is capped above. + let index = self.objects.len(); + let number = index as u32 + 2; + self.charge(100)?; // indirect-object and xref overhead + self.objects.push(Object::Null); + Ok((index, number)) + } + + fn dict(&mut self, dict: &Dict, depth: usize) -> Result { + if dict.len() > self.nodes { + return Err(DataError::ExportLimit("100,000 values")); + } + let mut out = Dict::new(); + for (key, value) in dict.iter() { + self.dict_entry(&mut out, key, value, depth + 1)?; + } + Ok(out) + } + + fn dict_entry(&mut self, out: &mut Dict, key: &[u8], value: &Object, depth: usize) -> Result<(), DataError> { + self.charge(key.len().saturating_mul(3).saturating_add(2))?; + let value = self.copy(value, depth)?; + out.set(key.to_vec(), value); + Ok(()) + } + + /// Skip source-document links and appearances without cloning their values. The + /// dictionary and every retained entry are charged before any owned copy is made. + fn annotation_dict(&mut self, dict: &Dict) -> Result { + self.charge_value(0)?; + let mut out = Dict::new(); + for (key, value) in dict.iter() { + if matches!(key.as_slice(), b"P" | b"AP" | b"Popup" | b"IRT" | b"Parent" | b"FS" | b"Sound") { + continue; + } + self.dict_entry(&mut out, key, value, 1)?; + } + Ok(out) + } + + fn charge_value(&mut self, depth: usize) -> Result<(), DataError> { + if depth > MAX_DEPTH { + return Err(DataError::ExportLimit("64 direct nesting levels")); + } + self.nodes = self.nodes.checked_sub(1).ok_or(DataError::ExportLimit("100,000 values"))?; + // Includes delimiters, spacing and even the longest finite f64 in plain decimal form. + self.charge(330)?; + Ok(()) + } + + fn is_source_page(&self, dict: &Dict) -> Result { + let Some(value) = dict.get(b"Type") else { return Ok(false) }; + let mut value = FdfValue::Borrowed(value); + let mut seen = HashSet::new(); + for hops in 0..=MAX_DEPTH { + match value.object() { + Object::Name(name) => return Ok(matches!(name.as_slice(), b"Page" | b"Pages")), + Object::Ref(reference) => { + if self.doc.generation(reference.num) != reference.generation || !seen.insert(*reference) { + return Ok(false); + } + if hops == MAX_DEPTH { + return Err(DataError::ExportLimit("64 Type reference levels")); + } + value = FdfValue::Indirect(self.doc.get(*reference)); + } + _ => return Ok(false), + } + } + Err(DataError::ExportLimit("64 Type reference levels")) + } + + fn copy(&mut self, value: &Object, depth: usize) -> Result { + self.charge_value(depth)?; + Ok(match value { + Object::Ref(source) => { + let number = if let Some(number) = self.numbers.get(source) { + *number + } else { + let (index, number) = self.slot()?; + self.numbers.insert(*source, number); + self.pending.push((*source, index)); + number + }; + Object::Ref(ObjRef::new(number, 0)) + } + Object::Array(values) => { + if values.len() > self.nodes { + return Err(DataError::ExportLimit("100,000 values")); + } + let mut out = Vec::new(); + for value in values { + out.push(self.copy(value, depth + 1)?); + } + Object::Array(out) + } + // Source page links must not drag page contents or the page tree into an FDF. + Object::Dict(dict) if self.is_source_page(dict)? => Object::Null, + Object::Dict(dict) => Object::Dict(self.dict(dict, depth)?), + Object::String(s) => { + self.charge(s.bytes.len().saturating_mul(4))?; + value.clone() + } + Object::Name(name) => { + self.charge(name.len().saturating_mul(3))?; + value.clone() + } + // A PDF stream can only be an indirect object, never a dictionary/array value. + Object::Stream(_) => return Err(DataError::Malformed("FDF export encountered a direct stream".into())), + other => other.clone(), + }) + } + + fn finish(&mut self, doc: &Document) -> Result<(), DataError> { + let mut cursor = 0; + while let Some((source, index)) = self.pending.get(cursor).copied() { + // ISO 32000-2 §7.3.10: an absent or stale reference denotes null. Keep reader + // errors distinct so damaged objects cannot be silently lost during export. + let value = if doc.generation(source.num) != source.generation { + std::sync::Arc::new(Object::Null) + } else { + match doc.try_get(source.num) { + Ok(value) => value, + Err(pdfcraft_cos::CosError::MissingObject(_)) => std::sync::Arc::new(Object::Null), + Err(error) => return Err(error.into()), + } + }; + let copied = match &*value { + Object::Stream(stream) => { + self.charge(stream.raw.len())?; + Object::Stream(pdfcraft_cos::Stream { dict: self.dict(&stream.dict, 0)?, raw: stream.raw.clone() }) + } + other => self.copy(other, 0)?, + }; + let slot = self.objects.get_mut(index).ok_or_else(|| DataError::Malformed("FDF object slot is missing".into()))?; + *slot = copied; + cursor += 1; + } + Ok(()) + } +} + /// FDF with field values and/or comments (appearances are not carried). Each comment is an /// indirect object of the FDF, so a reply's `/IRT` can point at its parent's object there -/// (§12.7.8.3.1); nothing else refers back into the source document. -pub fn export_fdf(doc: &Document, comments: bool, fields: bool, file: &str) -> Vec { +/// (§12.7.8.3.1). Nested values are copied and renumbered, including shared or cyclic graphs. +/// Source `/Page` and `/Pages` dictionaries become null at every copy depth. +/// Refuses exports above the object, value, direct-depth or data budget instead of losing data. +pub fn export_fdf(doc: &Document, comments: bool, fields: bool, file: &str) -> Result, DataError> { + let mut copy = FdfCopy::new(doc); + copy.charge(file.len().saturating_mul(8))?; let mut body: Vec = b"<< /FDF << ".to_vec(); // Comment dictionaries, written after the root as objects 2, 3, … - let mut annots: Vec = Vec::new(); if fields { body.extend_from_slice(b"/Fields ["); for f in pdfcraft_forms::fields(doc) { if matches!(f.kind, FieldKind::PushButton | FieldKind::Signature) { continue; } + // Field dictionary, /T string and /V scalar/array, plus every emitted list + // entry. Other field kinds emit only their first value (or a default). + let field_nodes = if matches!(f.kind, FieldKind::List) && f.value.len() > 1 { f.value.len().saturating_add(3) } else { 3 }; + copy.nodes = copy.nodes.checked_sub(field_nodes).ok_or(DataError::ExportLimit("100,000 values"))?; + copy.charge(f.name.len().saturating_mul(8).saturating_add(100))?; + for value in &f.value { + copy.charge(value.len().saturating_mul(8).saturating_add(100))?; + } body.extend_from_slice(b"<< /T "); body.extend(pdf_string(&f.name)); body.extend_from_slice(b" /V "); @@ -489,44 +704,61 @@ pub fn export_fdf(doc: &Document, comments: bool, fields: bool, file: &str) -> V body.extend_from_slice(b"] "); } if comments { - // Every exported comment with its source reference, numbered in export order. - let mut picked: Vec<(Option, Dict, usize)> = Vec::new(); - for (pi, p) in pdfcraft_model::pages(doc).iter().enumerate() { - for a in p.dict.get(b"Annots").map(|a| doc.resolve(a)).and_then(|a| a.as_array().cloned()).unwrap_or_default() { - let Some(d) = doc.resolve(&a).as_dict().cloned() else { continue }; - if d.name(b"Subtype").is_some_and(|sub| SUBTYPES.iter().any(|(s, _)| s.as_bytes() == sub)) { - picked.push((a.as_ref(), d, pi)); + // Keep the page/array owners once. Handles retain positions, never annotation + // bodies: the document cache can evict and reparse even a repeated source reference. + let pages = pdfcraft_model::pages(doc); + let mut arrays = Vec::new(); + let mut picked: Vec<(Option, usize, usize)> = Vec::new(); + for (pi, page) in pages.iter().enumerate() { + let owner = page.dict.get(b"Annots").map(|value| FdfValue::resolve(doc, value)); + let before = picked.len(); + if let Some(values) = owner.as_ref().and_then(|owner| owner.object().as_array()) { + for (ai, a) in values.iter().enumerate() { + let value = FdfValue::resolve(doc, a); + let Some(d) = value.object().as_dict() else { continue }; + if d.name(b"Subtype").is_some_and(|sub| SUBTYPES.iter().any(|(s, _)| s.as_bytes() == sub)) { + if picked.len() >= MAX_FDF_OBJECTS { + return Err(DataError::ExportLimit("100,000 comments")); + } + picked.push((a.as_ref(), pi, ai)); + } } } + // Empty/unselected arrays need no owner. Resolve subsequent pages only after + // this page passes the cap, rather than loading them all before discovery. + arrays.push(if picked.len() > before { owner } else { None }); } - let numbers: HashMap = - picked.iter().enumerate().filter_map(|(i, (r, _, _))| Some(((*r)?, u32::try_from(i).ok()?.checked_add(2)?))).collect(); - for (_, mut d, pi) in picked { + let mut numbers = HashMap::new(); + for (source, _, _) in &picked { + let (_, number) = copy.slot()?; + if let Some(source) = source { + numbers.insert(*source, number); + } + } + copy.numbers = numbers.clone(); + let count = picked.len(); + for (index, (_, pi, ai)) in picked.into_iter().enumerate() { + let annotation = arrays + .get(pi) + .and_then(Option::as_ref) + .and_then(|owner| owner.object().as_array()) + .and_then(|values| values.get(ai)) + .ok_or_else(|| DataError::Malformed("FDF annotation slot is missing".into()))?; + let value = FdfValue::resolve(doc, annotation); + let d = value.object().as_dict().ok_or_else(|| DataError::Malformed("FDF comment is not a dictionary".into()))?; // A reply points at its parent's object in this FDF; a parent that isn't exported // leaves the reply unthreaded. let irt = d.get(b"IRT").and_then(Object::as_ref).and_then(|r| numbers.get(&r)).copied(); - // Only direct values travel: references into this file would dangle. - for k in [&b"P"[..], b"AP", b"Popup", b"IRT", b"Parent", b"FS", b"Sound"] { - d.remove(k); - } - let keys: Vec> = d.iter().filter(|(_, v)| matches!(v, Object::Ref(_))).map(|(k, _)| k.clone()).collect(); - for k in keys { - let Some(o) = d.get(&k) else { continue }; - let v = (*doc.resolve(o)).clone(); - if matches!(v, Object::Stream(_)) { - d.remove(&k); - } else { - d.set(k, v); - } - } + let mut d = copy.annotation_dict(d)?; if let Some(num) = irt { d.set(b"IRT".to_vec(), Object::Ref(ObjRef::new(num, 0))); } d.set(b"Page".to_vec(), Object::Int(pi as i64)); - annots.push(d); + let slot = copy.objects.get_mut(index).ok_or_else(|| DataError::Malformed("FDF comment slot is missing".into()))?; + *slot = Object::Dict(d); } body.extend_from_slice(b"/Annots ["); - for i in 0..annots.len() { + for i in 0..count { let _ = write!(body, "{} 0 R ", i + 2); } body.extend_from_slice(b"] "); @@ -534,16 +766,17 @@ pub fn export_fdf(doc: &Document, comments: bool, fields: bool, file: &str) -> V body.extend_from_slice(b"/F "); body.extend(pdf_string(file)); body.extend_from_slice(b" >> >>"); + copy.finish(doc)?; let mut out = b"%FDF-1.2\n%\xE2\xE3\xCF\xD3\n".to_vec(); - let mut offsets = Vec::with_capacity(annots.len() + 1); + let mut offsets = Vec::with_capacity(copy.objects.len() + 1); offsets.push(out.len()); out.extend_from_slice(b"1 0 obj\n"); out.extend(body); out.extend_from_slice(b"\nendobj\n"); - for (i, d) in annots.into_iter().enumerate() { + for (i, object) in copy.objects.into_iter().enumerate() { offsets.push(out.len()); let _ = writeln!(out, "{} 0 obj", i + 2); - pdfcraft_cos::serialize(&Object::Dict(d), &mut out); + pdfcraft_cos::serialize(&object, &mut out); out.extend_from_slice(b"\nendobj\n"); } let xref = out.len(); @@ -552,12 +785,32 @@ pub fn export_fdf(doc: &Document, comments: bool, fields: bool, file: &str) -> V let _ = writeln!(out, "{o:010} 00000 n "); } let _ = write!(out, "trailer\n<< /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n"); - out + Ok(out) } -/// The XML element name for a field name (letters, digits, `_`, `-`, `.`). +/// XML 1.0 fifth-edition NameStartChar, excluding `:` for an unprefixed NCName. +/// https://www.w3.org/TR/2008/REC-xml-20081126/#NT-NameStartChar +fn xml_name_start(c: char) -> bool { + matches!( + c, + '_' | 'A'..='Z' | 'a'..='z' + | '\u{C0}'..='\u{D6}' | '\u{D8}'..='\u{F6}' | '\u{F8}'..='\u{2FF}' + | '\u{370}'..='\u{37D}' | '\u{37F}'..='\u{1FFF}' + | '\u{200C}'..='\u{200D}' | '\u{2070}'..='\u{218F}' + | '\u{2C00}'..='\u{2FEF}' | '\u{3001}'..='\u{D7FF}' + | '\u{F900}'..='\u{FDCF}' | '\u{FDF0}'..='\u{FFFD}' + | '\u{10000}'..='\u{EFFFF}' + ) +} + +fn xml_name_char(c: char) -> bool { + xml_name_start(c) || matches!(c, '-' | '.' | '0'..='9' | '\u{B7}' | '\u{300}'..='\u{36F}' | '\u{203F}'..='\u{2040}') +} + +/// Keep the existing letters/digits/`_`/`-`/`.` normalization, constrained to valid NCNames. fn xml_name(name: &str) -> String { - let mut s: String = name.chars().map(|c| if c.is_alphanumeric() || matches!(c, '_' | '-' | '.') { c } else { '_' }).collect(); - if !s.chars().next().is_some_and(|c| c.is_alphabetic() || c == '_') { + let mut s: String = + name.chars().map(|c| if (c.is_alphanumeric() && xml_name_char(c)) || matches!(c, '_' | '-' | '.') { c } else { '_' }).collect(); + if !s.chars().next().is_some_and(|c| (c.is_alphabetic() && xml_name_start(c)) || c == '_') { s.insert(0, '_'); } s @@ -577,7 +830,7 @@ pub fn export_data(doc: &Document, format: Format) -> String { }) .collect(); match format { - Format::Xml => { + Format::Xml | Format::Xdp => { let mut out = String::from("\n\n"); for (k, v) in &fields { let tag = xml_name(k); @@ -588,7 +841,7 @@ pub fn export_data(doc: &Document, format: Format) -> String { out } Format::Csv => { - let q = |s: &str| if s.contains([',', '"', '\n']) { format!("\"{}\"", s.replace('"', "\"\"")) } else { s.to_string() }; + let q = |s: &str| if s.contains([',', '"', '\n', '\r', '\t']) { format!("\"{}\"", s.replace('"', "\"\"")) } else { s.to_string() }; format!( "{}\n{}\n", fields.iter().map(|f| q(&f.0)).collect::>().join(","), @@ -754,7 +1007,27 @@ fn annot_from_xml(node: roxmltree::Node, subtype: &str, page_ref: ObjRef) -> Opt /// Put an annotation dictionary on a page, replacing one with the same `/NM`. Returns its /// reference. -fn place(doc: &mut Document, page_ref: ObjRef, d: Dict) -> Result { +/// `/F` bits an imported file may not set on a redaction mark: Invisible, Hidden, NoView — so a +/// mark it brings in is always drawn for the user to review before content is removed. +const REDACT_UNSEEN: i64 = 1 | 2 | 32; +/// `/F` Locked (ISO 32000-2 Table 167): the user protected this annotation from change. +const LOCKED: i64 = 128; + +/// An existing annotation an import must never replace, move or unlock: one the user locked, or a +/// redaction mark (replacing it would drop the region the user marked to remove). +fn import_protected(x: &Dict) -> bool { + x.int(b"F").unwrap_or(0) & LOCKED != 0 || x.name(b"Subtype") == Some(b"Redact".as_slice()) +} + +fn place(doc: &mut Document, page_ref: ObjRef, mut d: Dict) -> Result { + // A redaction mark the import carries is always made visible: clear any Invisible/Hidden/NoView + // flag so it cannot remove content the user never sees. + if d.name(b"Subtype") == Some(b"Redact".as_slice()) { + let f = d.int(b"F").unwrap_or(0); + if f & REDACT_UNSEEN != 0 { + d.set(b"F".to_vec(), Object::Int(f & !REDACT_UNSEEN)); + } + } let nm = d.get(b"NM").and_then(|n| n.as_string().map(|s| s.to_text())); let annots: Vec = doc .get(page_ref) @@ -765,21 +1038,39 @@ fn place(doc: &mut Document, page_ref: ObjRef, d: Dict) -> Result = Vec::new(); + // Never let a same-name import replace a locked comment or a redaction mark: keep it, and add + // the imported dict beside it under no name of its own. + let mut clash = false; for a in annots { - let same = nm.is_some() - && a.as_ref().and_then(|r| doc.get(r).as_dict().and_then(|x| x.get(b"NM").and_then(|n| n.as_string().map(|s| s.to_text())))) == nm; - if same { + let existing = a.as_ref().and_then(|r| doc.get(r).as_dict().cloned()); + let same = nm.is_some() && existing.as_ref().and_then(|x| x.get(b"NM").and_then(|n| doc.resolve(n).as_string().map(|s| s.to_text()))) == nm; + if same && existing.as_ref().is_some_and(import_protected) { + clash = true; + kept.push(a); + } else if same { removed.extend(a.as_ref()); } else { kept.push(a); } } + if clash { + d.remove(b"NM"); + } // The replaced note's pop-up goes with it; otherwise it stays on the page pointing at a note that is no longer there. kept.retain(|a| { let Some(x) = a.as_ref().map(|r| doc.get(r)).and_then(|o| o.as_dict().cloned()) else { return true }; x.name(b"Subtype") != Some(b"Popup".as_slice()) || x.reference(b"Parent").is_none_or(|p| !removed.contains(&p)) }); - let r = doc.add(Object::Dict(d)); + // Keep the replaced comment's identity: replies and review statuses not included in a + // partial import still point at it through /IRT. Allocating a new object would detach + // those comments and leave their parent outside the page's annotation list. + let r = match removed.first().copied() { + Some(r) => { + doc.set(r, Object::Dict(d)); + r + } + None => doc.add(Object::Dict(d)), + }; // Our own appearance where we can draw one; others keep none (viewers draw a default). let _ = pdfcraft_annot::set_appearance(doc, r); kept.push(Object::Ref(r)); @@ -798,7 +1089,7 @@ fn import_xfdf(doc: &mut Document, text: &str) -> Result { let pages: Vec = pdfcraft_model::pages(doc).iter().map(|p| p.obj).collect(); // Comments: parents first, then replies (whose /IRT needs the parent's reference). let mut by_name: HashMap = HashMap::new(); - let mut later: Vec<(ObjRef, String)> = Vec::new(); + let mut later: HashMap = HashMap::new(); if let Some(annots) = root.children().find(|c| c.has_tag_name("annots")) { for node in annots.children().filter(|c| c.is_element()) { let tag = node.tag_name().name().to_ascii_lowercase(); @@ -806,11 +1097,13 @@ fn import_xfdf(doc: &mut Document, text: &str) -> Result { let Some(page) = node.attribute("page").and_then(|p| p.parse::().ok()).and_then(|p| pages.get(p).copied()) else { continue }; let Some(d) = annot_from_xml(node, subtype, page) else { continue }; let r = place(doc, page, d)?; + // A later same-name record replaces this comment, including its reply relation. + later.remove(&r); if let Some(nm) = node.attribute("name") { by_name.insert(nm.to_string(), r); } if let Some(irt) = node.attribute("inreplyto") { - later.push((r, irt.to_string())); + later.insert(r, irt.to_string()); } // A pop-up window. if let Some(pop) = node.children().find(|c| c.has_tag_name("popup")) @@ -881,7 +1174,12 @@ fn walk_xfdf(node: roxmltree::Node, prefix: &str, out: &mut Vec<(String, Vec = f.children().filter(|c| c.has_tag_name("value")).map(|v| v.text().unwrap_or("").to_string()).collect(); + let vals: Vec = f + .children() + .filter(|c| c.has_tag_name("value")) + // Comments and processing instructions can split a value into several text nodes. + .map(|v| v.children().filter(|n| n.is_text()).filter_map(|n| n.text()).collect()) + .collect(); if !vals.is_empty() { out.push((full.clone(), vals)); } @@ -972,7 +1270,7 @@ pub fn merge_csv(files: &[Vec<(String, Vec)>]) -> String { } } } - let q = |s: &str| if s.contains([',', '"', '\n', '\r']) { format!("\"{}\"", s.replace('"', "\"\"")) } else { s.to_string() }; + let q = |s: &str| if s.contains([',', '"', '\n', '\r', '\t']) { format!("\"{}\"", s.replace('"', "\"\"")) } else { s.to_string() }; let mut out = columns.iter().map(|c| q(c)).collect::>().join(","); out.push('\n'); for f in files { @@ -995,7 +1293,7 @@ fn import_fdf(doc: &mut Document, bytes: &[u8]) -> Result { // Comments: parents first, then replies (whose /IRT needs the parent's new reference). let pages: Vec = pdfcraft_model::pages(doc).iter().map(|p| p.obj).collect(); let mut placed: HashMap = HashMap::new(); - let mut later: Vec<(ObjRef, Option, Option)> = Vec::new(); + let mut later: HashMap, Option)> = HashMap::new(); for a in body.get(b"Annots").map(|a| fdf.resolve(a)).and_then(|a| a.as_array().cloned()).unwrap_or_default() { let Some(mut src) = fdf.resolve(&a).as_dict().cloned() else { continue }; // The parent is another comment: link it below rather than copying it in. @@ -1011,16 +1309,17 @@ fn import_fdf(doc: &mut Document, bytes: &[u8]) -> Result { d.remove(b"Page"); d.set(b"P".to_vec(), Object::Ref(page)); let r = place(doc, page, d)?; + later.remove(&r); if let Some(fr) = a.as_ref() { placed.insert(fr, r); } if let Some(irt) = irt { let parent_name = fdf.resolve(&irt).as_dict().and_then(|p| text_of(&fdf, p, b"NM")); - later.push((r, irt.as_ref(), parent_name)); + later.insert(r, (irt.as_ref(), parent_name)); } report.comments += 1; } - for (r, parent_obj, parent_name) in later { + for (r, (parent_obj, parent_name)) in later { // The parent imported from this file, else one already in the document with its name. let parent = parent_obj.and_then(|p| placed.get(&p).copied()).or_else(|| parent_name.and_then(|nm| find_by_name(doc, &nm))); if let Some(parent) = parent.filter(|p| *p != r) { @@ -1031,6 +1330,31 @@ fn import_fdf(doc: &mut Document, bytes: &[u8]) -> Result { Ok(report) } +// Inspect the first logical record: tabs within quoted CSV cells are data. A quote +// in the middle of an unquoted TSV name stays literal, as in the TSV reader. +fn has_tab_delimiter(text: &str) -> bool { + let mut quoted = false; + let mut cell_start = true; + let mut chars = text.chars().peekable(); + while let Some(c) = chars.next() { + match c { + '"' if quoted && chars.peek() == Some(&'"') => { + chars.next(); + } + '"' if quoted || cell_start => { + quoted = !quoted; + cell_start = false; + } + '\t' if !quoted => return true, + '\r' | '\n' if !quoted => return false, + ',' if !quoted => cell_start = true, + _ => cell_start = false, + } + } + // An unclosed quote can be a literal TSV field-name character. + quoted && text.lines().next().is_some_and(|line| line.contains('\t')) +} + /// Form data from XML, CSV or tab-delimited text. fn import_table(doc: &mut Document, text: &str) -> Result { let mut report = Report::default(); @@ -1044,19 +1368,19 @@ fn import_table(doc: &mut Document, text: &str) -> Result { .map(|c| { let name = c.attributes().find(|a| a.name() == "original").map(|a| a.value().to_string()).unwrap_or_else(|| c.tag_name().name().to_string()); - (name, vec![c.text().unwrap_or("").to_string()]) + // Comments and processing instructions do not end a field's character data. + (name, vec![c.children().filter(|node| node.is_text()).filter_map(|node| node.text()).collect()]) }) .collect() - } else if t.lines().next().is_some_and(|l| l.contains('\t')) { - let mut lines = t.lines(); - let (Some(head), Some(row)) = (lines.next(), lines.next()) else { return Err(DataError::UnknownFormat) }; - head.split('\t').map(str::to_string).zip(row.split('\t')).map(|(k, v)| (k, vec![v.to_string()])).collect() + } else if has_tab_delimiter(t) { + tab_delimited(t)? } else { // CSV with quotes: a quoted value may hold commas, doubled quotes and line breaks. - let mut records: Vec> = Vec::new(); + let mut records: Vec<(Vec, bool)> = Vec::new(); let mut record = Vec::new(); let mut cur = String::new(); let mut quoted = false; + let mut has_quotes = false; let mut chars = t.chars().peekable(); while let Some(c) = chars.next() { match c { @@ -1064,27 +1388,41 @@ fn import_table(doc: &mut Document, text: &str) -> Result { cur.push('"'); chars.next(); } - '"' => quoted = !quoted, + '"' => { + quoted = !quoted; + has_quotes = true; + } ',' if !quoted => record.push(std::mem::take(&mut cur)), '\r' if !quoted && chars.peek() == Some(&'\n') => {} '\n' if !quoted => { record.push(std::mem::take(&mut cur)); - records.push(std::mem::take(&mut record)); + records.push((std::mem::take(&mut record), std::mem::take(&mut has_quotes))); } c => cur.push(c), } } record.push(cur); - records.push(record); - // Blank lines (such as the one a trailing line break leaves) are not records. - let mut records = records.into_iter().filter(|r| !matches!(r.as_slice(), [only] if only.is_empty())); + records.push((record, has_quotes)); + // Blank lines (such as the one a trailing line break leaves) are not records, but a + // quoted empty value is a record that clears a field. + let mut records = + records.into_iter().filter(|(r, has_quotes)| *has_quotes || !matches!(r.as_slice(), [only] if only.is_empty())).map(|(r, _)| r); let (Some(head), Some(row)) = (records.next(), records.next()) else { return Err(DataError::UnknownFormat) }; - head.into_iter().zip(row).map(|(k, v)| (k, vec![v])).collect() + // Text exported from a one-field form has no tab to show for it (#1321). CSV quotes a + // value with a comma, so one name over several unquoted cells is that text, not CSV. + if head.len() == 1 && row.len() > 1 { tab_delimited(t)? } else { head.into_iter().zip(row).map(|(k, v)| (k, vec![v])).collect() } }; apply_values(doc, &values, &mut report); Ok(report) } +/// Tab-delimited text: a row of names, a row of values. +fn tab_delimited(t: &str) -> Result)>, DataError> { + let mut lines = t.lines(); + let (Some(head), Some(row)) = (lines.next(), lines.next()) else { return Err(DataError::UnknownFormat) }; + Ok(head.split('\t').map(str::to_string).zip(row.split('\t')).map(|(k, v)| (k, vec![v.to_string()])).collect()) +} + /// Import comments and/or field values from XFDF, FDF, XML, CSV or tab-delimited text /// (detected from the content). pub fn import(doc: &mut Document, bytes: &[u8]) -> Result { diff --git a/crates/xfdf/src/tests.rs b/crates/xfdf/src/tests.rs index 0f32cced8a..c33c115e75 100644 --- a/crates/xfdf/src/tests.rs +++ b/crates/xfdf/src/tests.rs @@ -1,7 +1,7 @@ use std::sync::Arc; -use pdfcraft_annot::{Markup, Meta, NewAnnotation, Shape, Style, add_annotation, add_reply, summaries}; -use pdfcraft_cos::{SaveOptions, write_full}; +use pdfcraft_annot::{Markup, Meta, NewAnnotation, Shape, Style, add_annotation, add_reply, add_text_replacement, summaries}; +use pdfcraft_cos::{SaveOptions, write_full, write_incremental}; use pdfcraft_forms::{NewField, add_field, fields, set_value}; use super::*; @@ -142,6 +142,150 @@ fn values(doc: &Document) -> Vec<(String, Vec)> { fields(doc).into_iter().map(|f| (f.name, f.value)).collect() } +/// #2666: Replace Text writes a StrikeOut and a Caret that replies to it. XFDF names a reply by +/// the `/NM` of what it replies to, so when both carried one id the caret's `inreplyto` was its +/// own `name`: importing it found a comment replying to itself and the pair was lost. +#[test] +fn a_replace_text_pair_survives_an_xfdf_round_trip() { + let mut src = blank(); + let quads = [[100.0, 720.0, 200.0, 720.0, 100.0, 706.0, 200.0, 706.0]]; + let style = Style { color: [0.89, 0.13, 0.13], opacity: 1.0, width: 1.0, fill: None }; + let meta = |id: &str| Meta { date: Some("D:20261001120000Z".into()), id: id.into() }; + add_text_replacement(&mut src, 0, &quads, "new words", "Ada", &style, &style, &meta("strike"), &meta("caret")).unwrap(); + assert_eq!(summaries(&src).len(), 2, "a strikeout and a caret"); + + let xfdf = export_xfdf(&src, true, false, "a.pdf"); + assert!(xfdf.contains("name=\"strike\"") && xfdf.contains("name=\"caret\""), "each gets its own name: {xfdf}"); + assert!(xfdf.contains("inreplyto=\"strike\""), "the caret replies to the strikeout: {xfdf}"); + assert!(!xfdf.contains("inreplyto=\"caret\""), "never a reply to itself"); + + let mut dst = blank(); + let r = import(&mut dst, xfdf.as_bytes()).unwrap(); + assert_eq!(r.comments, 2, "both comments import: {r:?}"); + let found = summaries(&dst); + let kinds: Vec<&str> = found.iter().map(|s| s.subtype.as_str()).collect(); + assert!(kinds.contains(&"StrikeOut") && kinds.contains(&"Caret"), "{kinds:?}"); + assert_eq!(comment_view(&dst), comment_view(&src), "the pair and its thread survive"); +} + +#[test] +fn xml_exports_drop_xml_noncharacters() { + let mut src = filled(); + set_value(&mut src, "Name", &FieldValue::Text("a\u{FFFE}b\u{FFFF}c".into())).unwrap(); + let xfdf = export_xfdf(&src, false, true, "f.pdf"); + let doc = roxmltree::Document::parse(&xfdf).unwrap_or_else(|e| panic!("XFDF is not well-formed: {e}: {xfdf:?}")); + let value = doc + .descendants() + .find(|n| n.has_tag_name("field") && n.attribute("name") == Some("Name")) + .and_then(|n| n.children().find(|c| c.has_tag_name("value"))) + .and_then(|n| n.text()); + assert_eq!(value, Some("abc")); + let data = export_data(&src, Format::Xml); + roxmltree::Document::parse(&data).unwrap_or_else(|e| panic!("XML is not well-formed: {e}: {data:?}")); +} + +#[test] +fn xml_exports_preserve_whitespace_in_field_names() { + let names = ["Line\nName", "Line\tName", "Line\rName", "Line\r\nName", "\t Leading\nTrailing \t", "Line Name"]; + let value = "value\twith\nlines\r\nand & \"quotes\""; + let mut src = one_field(); + pdfcraft_forms::delete_field(&mut src, "Name").unwrap(); + for (index, name) in names.iter().enumerate() { + let placeholder = format!("field{index}"); + add_field(&mut src, 0, [50.0, 700.0, 250.0, 720.0], &NewField::Text { multiline: true }, Some(&placeholder)).unwrap(); + // Existing PDFs can have leading/trailing whitespace even though add_field trims it. + let field = fields(&src).into_iter().find(|f| f.name == placeholder).unwrap(); + src.update_dict(field.obj, |d| d.set(b"T".to_vec(), PdfString::text(name))).unwrap(); + set_value(&mut src, name, &FieldValue::Text(format!("{index}: {value}"))).unwrap(); + } + let source_bytes = write_full(&src, &SaveOptions::default()).unwrap(); + let src = Document::open(Arc::new(source_bytes.clone())).unwrap(); + for format in [Format::Xml, Format::Xfdf] { + let exported = if format == Format::Xfdf { export_xfdf(&src, false, true, "form.pdf") } else { export_data(&src, format) }; + let xml = roxmltree::Document::parse(&exported).unwrap(); + let exported_names: Vec<_> = if format == Format::Xfdf { + xml.descendants().filter(|n| n.has_tag_name("field")).map(|n| n.attribute("name").unwrap()).collect() + } else { + xml.root_element() + .children() + .filter(|n| n.is_element()) + .map(|n| n.attribute(("http://ns.adobe.com/xfdf-transition/", "original")).unwrap()) + .collect() + }; + assert_eq!(exported_names, names, "{format:?}: {exported}"); + let mut dst = Document::open(Arc::new(source_bytes.clone())).unwrap(); + for name in names { + set_value(&mut dst, name, &FieldValue::Text("replace me".into())).unwrap(); + } + let report = import(&mut dst, exported.as_bytes()).unwrap(); + assert_eq!(report.fields, names.len(), "{format:?}: {report:?}"); + assert!(report.rejected.is_empty(), "{report:?}"); + let saved = write_full(&dst, &SaveOptions::default()).unwrap(); + let reopened = Document::open(Arc::new(saved)).unwrap(); + assert_eq!(values(&reopened), values(&src), "{format:?}"); + } + assert_eq!(write_full(&src, &SaveOptions::default()).unwrap(), source_bytes, "export must not change the source PDF"); +} + +#[test] +fn xfdf_exports_preserve_whitespace_in_comment_attributes() { + let mut src = one_field(); + let shape = Shape::Note { at: [100.0, 500.0], icon: pdfcraft_annot::NoteIcon::Comment }; + let names = ["note\tline\nidentity", "note line identity"]; + let author = "Ada\tLovelace\nReview"; + let subject = "Subject\twith\nlines\r\nand & quotes\""; + for name in names { + add_annotation( + &mut src, + &NewAnnotation { page: 0, style: Style::default_for(&shape), shape: shape.clone(), contents: name.into(), author: author.into() }, + &Meta { id: name.into(), date: None }, + ) + .unwrap(); + } + let annotation_refs = |doc: &Document| { + let page = pdfcraft_model::pages(doc)[0].obj; + doc.get(page) + .as_dict() + .unwrap() + .get(b"Annots") + .map(|a| doc.resolve(a)) + .unwrap() + .as_array() + .unwrap() + .iter() + .filter_map(Object::as_ref) + .filter(|r| doc.get(*r).as_dict().unwrap().name(b"Subtype") == Some(b"Text")) + .collect::>() + }; + for annotation in annotation_refs(&src) { + src.update_dict(annotation, |d| d.set(b"Subj".to_vec(), PdfString::text(subject))).unwrap(); + } + let source_bytes = write_full(&src, &SaveOptions::default()).unwrap(); + let mut dst = Document::open(Arc::new(source_bytes)).unwrap(); + let file = "form\twith\nlines.pdf"; + let xfdf = export_xfdf(&dst, true, false, file); + let xml = roxmltree::Document::parse(&xfdf).unwrap(); + let notes: Vec<_> = xml.descendants().filter(|n| n.has_tag_name("text")).collect(); + assert_eq!(notes.iter().map(|n| n.attribute("name").unwrap()).collect::>(), names); + for note in notes { + assert_eq!(note.attribute("title"), Some(author)); + assert_eq!(note.attribute("subject"), Some(subject)); + } + assert_eq!(xml.descendants().find(|n| n.has_tag_name("f")).unwrap().attribute("href"), Some(file)); + let before = annotation_refs(&dst); + let report = import(&mut dst, xfdf.as_bytes()).unwrap(); + assert_eq!(report.comments, names.len()); + assert_eq!(annotation_refs(&dst), before, "reimport must replace the original comments"); + let saved = write_full(&dst, &SaveOptions::default()).unwrap(); + let reopened = Document::open(Arc::new(saved)).unwrap(); + for (annotation, name) in annotation_refs(&reopened).iter().zip(names) { + let dict = reopened.get(*annotation); + assert_eq!(text_of(&reopened, dict.as_dict().unwrap(), b"NM").as_deref(), Some(name)); + assert_eq!(text_of(&reopened, dict.as_dict().unwrap(), b"T").as_deref(), Some(author)); + assert_eq!(text_of(&reopened, dict.as_dict().unwrap(), b"Subj").as_deref(), Some(subject)); + } +} + #[test] fn xfdf_round_trips_comments_and_values() { let src = filled(); @@ -167,7 +311,7 @@ fn xfdf_round_trips_comments_and_values() { #[test] fn fdf_round_trips_values_and_comments() { let src = filled(); - let fdf = export_fdf(&src, true, true, "form.pdf"); + let fdf = export_fdf(&src, true, true, "form.pdf").unwrap(); assert!(fdf.starts_with(b"%FDF-1.2")); let mut dst = blank(); let r = import(&mut dst, &fdf).unwrap(); @@ -209,7 +353,7 @@ fn non_utf8_check_box_states_round_trip() { let mut src = shift_jis_check_box(); set_value(&mut src, "agree", &FieldValue::Check(true)).unwrap(); // FDF writes the state's own bytes, not its #XX text. - let fdf = export_fdf(&src, false, true, "form.pdf"); + let fdf = export_fdf(&src, false, true, "form.pdf").unwrap(); let name: &[u8] = b"/#82#CD#82#A2"; assert!(fdf.windows(name.len()).any(|w| w == name), "{}", String::from_utf8_lossy(&fdf)); for data in [fdf, export_xfdf(&src, false, true, "form.pdf").into_bytes()] { @@ -245,7 +389,7 @@ fn fdf_keeps_replies_and_review_status_on_their_note() { let want = threads(&src); assert_eq!(want.iter().filter(|t| t.1.as_deref() == Some("note")).count(), 3, "{want:?}"); - let fdf = export_fdf(&src, true, false, "notes.pdf"); + let fdf = export_fdf(&src, true, false, "notes.pdf").unwrap(); // Every cross-reference entry points at its object (other readers trust the table). let tail = std::str::from_utf8(&fdf[fdf.windows(5).position(|w| w == b"xref\n").unwrap()..]).unwrap(); let xref: usize = tail.lines().skip_while(|l| *l != "startxref").nth(1).unwrap().parse().unwrap(); @@ -292,6 +436,60 @@ trailer\n<< /Root 1 0 R >>\n%%EOF\n"; assert_eq!((parent("cycle-a").as_deref(), parent("cycle-b").as_deref()), (Some("cycle-b"), Some("cycle-a"))); } +#[test] +fn xml_field_names_remain_valid_and_round_trip_without_collisions() { + assert_eq!(xml_name(".Value"), "_.Value"); + let names = [ + ("Area²", "Area_"), + ("Area_", "Area_"), + ("Aµ", "A_"), + ("µRate", "_Rate"), + ("²Rate", "_Rate"), + ("½Amount", "_Amount"), + ("1Rate", "_1Rate"), + ("Café", "Café"), + ("Δείγμα", "Δείγμα"), + ("姓名", "姓名"), + ("٣Value", "_٣Value"), + ("\u{345}Rate", "_\u{345}Rate"), + ("A\u{345}", "A\u{345}"), + ("Alpha_1-2", "Alpha_1-2"), + ("ns:value", "ns_value"), + ("-Value", "_-Value"), + ]; + let mut src = one_field(); + pdfcraft_forms::delete_field(&mut src, "Name").unwrap(); + for (index, (name, _)) in names.iter().enumerate() { + add_field(&mut src, 0, [50.0, 700.0, 250.0, 720.0], &NewField::Text { multiline: false }, Some(name)).unwrap(); + set_value(&mut src, name, &FieldValue::Text(format!("value-{index}"))).unwrap(); + } + let source = Arc::new(write_full(&src, &SaveOptions::default()).unwrap()); + let src = Document::open(source.clone()).unwrap(); + let want = values(&src); + for format in [Format::Xml, Format::Xdp] { + let text = export_data(&src, format); + let xml = roxmltree::Document::parse(&text).unwrap(); + let children: Vec<_> = xml.root_element().children().filter(|n| n.is_element()).collect(); + assert_eq!(children.len(), names.len()); + for (child, (original, tag)) in children.iter().zip(names) { + assert_eq!(child.tag_name().name(), tag); + assert_eq!(child.attribute(("http://ns.adobe.com/xfdf-transition/", "original")), (original != tag).then_some(original)); + } + assert!(!src.is_modified(), "export must not change the source PDF"); + assert_eq!(src.bytes().as_slice(), source.as_slice()); + + let mut dst = Document::open(source.clone()).unwrap(); + for (name, _) in names { + set_value(&mut dst, name, &FieldValue::Text("replaced".into())).unwrap(); + } + let report = import(&mut dst, text.as_bytes()).unwrap(); + assert_eq!(report.fields, names.len()); + assert!(report.rejected.is_empty(), "{report:?}"); + let reopened = Document::open(Arc::new(write_full(&dst, &SaveOptions::default()).unwrap())).unwrap(); + assert_eq!(values(&reopened), want, "{format:?}: sanitized names must preserve distinct original fields"); + } +} + #[test] fn form_data_as_xml_csv_and_text() { let src = filled(); @@ -328,6 +526,104 @@ fn csv_values_may_span_lines() { assert!(import(&mut blank(), b"Name,Agree\r\n\"Ada, Yes\r\n").is_ok()); } +/// One page with one text field, `Name`. +fn one_field() -> Document { + let mut doc = Document::new_empty(); + let pages = doc.get(doc.root().unwrap()).as_dict().unwrap().reference(b"Pages").unwrap(); + let mut p = Dict::new(); + p.set(b"Type".to_vec(), Object::name("Page")); + p.set(b"Parent".to_vec(), Object::Ref(pages)); + p.set(b"MediaBox".to_vec(), Object::Array(vec![0.into(), 0.into(), 600.into(), 800.into()])); + let page = doc.add(p); + doc.update_dict(pages, |d| { + d.set(b"Count".to_vec(), Object::Int(1)); + d.set(b"Kids".to_vec(), Object::Array(vec![Object::Ref(page)])); + }) + .unwrap(); + add_field(&mut doc, 0, [50.0, 700.0, 250.0, 720.0], &NewField::Text { multiline: false }, Some("Name")).unwrap(); + Document::open(Arc::new(write_full(&doc, &SaveOptions::default()).unwrap())).unwrap() +} + +#[test] +fn csv_exports_preserve_carriage_returns_in_names_and_values() { + for (name, value, expected) in [ + ("Name", "plain", "Name\nplain\n"), + ("Name", "Tail\r", "Name\n\"Tail\r\"\n"), + ("Name", "Alpha\rBeta", "Name\n\"Alpha\rBeta\"\n"), + ("Name", "\rLead", "Name\n\"\rLead\"\n"), + ("Name", "\r", "Name\n\"\r\"\n"), + ("Name", "Alpha\r\nBeta", "Name\n\"Alpha\r\nBeta\"\n"), + ("Name", "Alpha\nBeta", "Name\n\"Alpha\nBeta\"\n"), + ("Name", "comma,\r", "Name\n\"comma,\r\"\n"), + ("Name", "\"quoted\"\r", "Name\n\"\"\"quoted\"\"\r\"\n"), + ("Name", "before\r,\"after\"", "Name\n\"before\r,\"\"after\"\"\"\n"), + ("Na\rme", "both\r", "\"Na\rme\"\n\"both\r\"\n"), + ] { + let mut src = one_field(); + let field = fields(&src)[0].obj; + src.update_dict(field, |d| d.set(b"T".to_vec(), PdfString::text(name))).unwrap(); + set_value(&mut src, name, &FieldValue::Text(value.into())).unwrap(); + let source = Arc::new(write_full(&src, &SaveOptions::default()).unwrap()); + let src = Document::open(source.clone()).unwrap(); + assert_eq!(values(&src), [(name.to_string(), vec![value.to_string()])]); + let csv = export_data(&src, Format::Csv); + + let mut dst = Document::open(source).unwrap(); + set_value(&mut dst, name, &FieldValue::Text("replaced".into())).unwrap(); + let report = import(&mut dst, csv.as_bytes()).unwrap(); + assert_eq!(report.fields, 1); + assert!(report.rejected.is_empty(), "{report:?}"); + let reopened = Document::open(Arc::new(write_full(&dst, &SaveOptions::default()).unwrap())).unwrap(); + assert_eq!(values(&reopened), [(name.to_string(), vec![value.to_string()])]); + assert_eq!(csv, expected, "CSV must quote CR as well as LF, commas and quotes"); + } +} + +/// #1321: the text export of a one-field form has no tab to tell it from CSV, so a comma in the +/// value used to split it and only the text before the comma was imported. +#[test] +fn one_field_text_data_keeps_its_commas() { + for value in ["Hello, world", "Say \"hi\", twice"] { + let mut src = one_field(); + set_value(&mut src, "Name", &FieldValue::Text(value.into())).unwrap(); + let txt = export_data(&src, Format::Txt); + assert_eq!(txt, format!("Name\n{value}\n")); + let mut dst = one_field(); + let r = import(&mut dst, txt.as_bytes()).unwrap(); + assert_eq!(r.fields, 1, "{r:?}"); + assert!(r.rejected.is_empty(), "{r:?}"); + assert_eq!(values(&dst)[0].1, [value]); + } +} + +/// A one-column CSV is still CSV: quotes come off, and a quoted value keeps its commas and line +/// breaks. +#[test] +fn one_column_csv_still_reads_its_quotes() { + let mut src = one_field(); + set_value(&mut src, "Name", &FieldValue::Text("Hello, world".into())).unwrap(); + let csv = export_data(&src, Format::Csv); + assert_eq!(csv, "Name\n\"Hello, world\"\n"); + let mut dst = one_field(); + assert_eq!(import(&mut dst, csv.as_bytes()).unwrap().fields, 1); + assert_eq!(values(&dst)[0].1, ["Hello, world"]); + let mut dst = one_field(); + assert_eq!(import(&mut dst, b"Name\r\n\"Ada \"\"the\"\", \r\nCountess\"\r\n").unwrap().fields, 1); + assert!(values(&dst)[0].1[0].starts_with("Ada \"the\", ") && values(&dst)[0].1[0].ends_with("Countess"), "{:?}", values(&dst)); +} + +#[test] +fn quoted_empty_csv_value_clears_a_field() { + for data in ["Name\n\"\"\n", "Name\r\n\"\"\r\n", "Name\n\"\"", "Name\n\n\"\"\n", "Name\n\"\"\nGrace\n"] { + let mut dst = one_field(); + set_value(&mut dst, "Name", &FieldValue::Text("Ada".into())).unwrap(); + let report = import(&mut dst, data.as_bytes()).unwrap(); + assert_eq!(report.fields, 1, "{data:?}"); + assert!(report.rejected.is_empty(), "{report:?}"); + assert!(values(&dst)[0].1.is_empty(), "{data:?}"); + } +} + #[test] fn multi_select_lists_round_trip_through_xml_csv_and_text() { let src = filled(); @@ -352,7 +648,7 @@ fn bad_data_is_refused_with_a_reason() { fn data_files_merge_into_a_spreadsheet() { let src = filled(); let xfdf = export_xfdf(&src, false, true, "form.pdf"); - let fdf = export_fdf(&src, false, true, "form.pdf"); + let fdf = export_fdf(&src, false, true, "form.pdf").unwrap(); let mut other = blank(); set_value(&mut other, "Name", &FieldValue::Text("Grace \"Amazing\" Hopper, RADM".into())).unwrap(); let pdf = pdfcraft_cos::write_full(&other, &Default::default()).unwrap(); @@ -399,6 +695,286 @@ fn reimporting_a_note_replaces_its_popup() { assert!(refs.contains(&parent), "the pop-up belongs to a note that is still on the page"); } +/// A partial import updates the parent without detaching replies and review statuses that +/// weren't included in the data file. FDF export only carries parents still on a page. +#[test] +fn replacing_a_note_keeps_existing_replies_and_review_status() { + let xfdf = concat!( + r#""#, + r#"Updated note"#, + r#""#, + ); + let mut incoming = blank(); + import(&mut incoming, xfdf.as_bytes()).unwrap(); + let mut original = filled(); + let index = summaries(&original).iter().find(|s| s.name.as_deref() == Some("n1")).unwrap().index; + pdfcraft_annot::set_review_state( + &mut original, + 0, + index, + pdfcraft_annot::ReviewState::Accepted, + "Reviewer", + &Meta { date: None, id: "status".into() }, + ) + .unwrap(); + let original = Arc::new(write_full(&original, &SaveOptions::default()).unwrap()); + for data in [xfdf.as_bytes().to_vec(), export_fdf(&incoming, true, false, "notes.pdf").unwrap()] { + // Start with the parent, reply and review status in the existing PDF revision. + let mut doc = Document::open(original.clone()).unwrap(); + let mut want = threads(&doc); + want.sort(); + assert_eq!(want.iter().filter(|(_, parent, _)| parent.as_deref() == Some("n1")).count(), 2); + assert_eq!(import(&mut doc, &data).unwrap().comments, 1); + let note = summaries(&doc).into_iter().find(|s| s.name.as_deref() == Some("n1")).unwrap(); + assert_eq!(note.contents.as_deref(), Some("Updated note")); + + // Check the saved object graph and a subsequent export/import, rather than relying on + // summaries resolving an obsolete parent's name from an object outside /Annots. + for incremental in [true, false] { + let saved = if incremental { + let saved = write_incremental(&doc, &SaveOptions::default()).unwrap(); + assert!(saved.starts_with(original.as_slice()), "an incremental save preserves the original revision"); + saved + } else { + write_full(&doc, &SaveOptions::default()).unwrap() + }; + let reopened = Document::open(Arc::new(saved)).unwrap(); + let note = summaries(&reopened).into_iter().find(|s| s.name.as_deref() == Some("n1")).unwrap(); + assert_eq!(note.contents.as_deref(), Some("Updated note")); + let fdf = export_fdf(&reopened, true, false, "notes.pdf").unwrap(); + let mut dst = blank(); + import(&mut dst, &fdf).unwrap(); + let mut got = threads(&dst); + got.sort(); + assert_eq!(got, want, "partial comment import must preserve reply and status threads (incremental={incremental})"); + } + } +} + +#[test] +fn replacing_an_indirectly_named_note_preserves_its_identity_and_thread() { + let initial = concat!( + r#"Original"#, + r#"Reply"#, + r#""#, + r#""#, + ); + let update = br#"Updated"#; + let mut original = blank(); + for name in ["Name", "Agree", "Pick"] { + pdfcraft_forms::delete_field(&mut original, name).unwrap(); + } + import(&mut original, initial.as_bytes()).unwrap(); + let annotation_refs = |doc: &Document| { + let page = pdfcraft_model::pages(doc)[0].obj; + doc.get(page) + .as_dict() + .unwrap() + .get(b"Annots") + .map(|a| doc.resolve(a)) + .unwrap() + .as_array() + .unwrap() + .iter() + .filter_map(Object::as_ref) + .collect::>() + }; + let parent = annotation_refs(&original)[0]; + let indirect_name = original.add(PdfString::text("n1")); + original.update_dict(parent, |d| d.set(b"NM".to_vec(), Object::Ref(indirect_name))).unwrap(); + let original = Arc::new(write_full(&original, &SaveOptions::default()).unwrap()); + let mut incoming = blank(); + import(&mut incoming, update).unwrap(); + for data in [update.to_vec(), export_fdf(&incoming, true, false, "notes.pdf").unwrap()] { + let mut doc = Document::open(original.clone()).unwrap(); + let refs = annotation_refs(&doc); + assert_eq!(refs.len(), 3); + let parent = refs.iter().copied().find(|r| text_of(&doc, doc.get(*r).as_dict().unwrap(), b"NM").as_deref() == Some("n1")).unwrap(); + assert!(doc.get(parent).as_dict().unwrap().get(b"NM").unwrap().as_ref().is_some(), "the saved source name is indirect"); + let mut want = threads(&doc); + want.sort(); + assert_eq!(import(&mut doc, &data).unwrap().comments, 1); + let updated = annotation_refs(&doc); + assert_eq!(updated.len(), 3, "a valid indirect name must replace its comment instead of duplicating it"); + assert!(updated.contains(&parent), "replacement preserves the source object's identity"); + assert_eq!(text_of(&doc, doc.get(parent).as_dict().unwrap(), b"Contents").as_deref(), Some("Updated")); + for child in refs.into_iter().filter(|r| *r != parent) { + assert_eq!(doc.get(child).as_dict().unwrap().reference(b"IRT"), Some(parent)); + } + let saved = write_incremental(&doc, &SaveOptions::default()).unwrap(); + assert!(saved.starts_with(original.as_slice())); + let reopened = Document::open(Arc::new(saved)).unwrap(); + let mut got = threads(&reopened); + got.sort(); + assert_eq!(got, want); + assert_eq!(summaries(&reopened).iter().find(|s| s.name.as_deref() == Some("n1")).unwrap().contents.as_deref(), Some("Updated")); + } +} + +#[test] +fn replacement_matches_named_comments_only_on_their_page() { + let xfdf = concat!( + r#""#, + r#"First page"#, + r#"Second page"#, + r#"Unnamed"#, + r#""#, + ); + let mut doc = blank(); + import(&mut doc, xfdf.as_bytes()).unwrap(); + import(&mut doc, xfdf.as_bytes()).unwrap(); + let notes = summaries(&doc); + assert_eq!(notes.len(), 4, "named comments are replaced, unnamed comments are added"); + for (page, text) in [(0, "First page"), (1, "Second page")] { + let note = notes.iter().find(|s| s.page == page && s.name.as_deref() == Some("same")).unwrap(); + assert_eq!(note.contents.as_deref(), Some(text)); + } + assert_eq!(notes.iter().filter(|s| s.name.is_none()).count(), 2); +} + +#[test] +fn the_last_same_named_record_controls_its_reply_parent() { + let mut incoming = blank(); + let page = pdfcraft_model::pages(&incoming)[0].obj; + let mut refs = Vec::new(); + for name in ["parent", "duplicate", "duplicate"] { + let mut d = Dict::new(); + d.set(b"Subtype".to_vec(), Object::name("Text")); + d.set(b"NM".to_vec(), PdfString::text(name)); + d.set(b"Rect".to_vec(), arr(&[10.0, 10.0, 30.0, 30.0])); + if refs.len() == 1 { + d.set(b"IRT".to_vec(), Object::Ref(refs[0])); + } + refs.push(incoming.add(d)); + } + incoming.update_dict(page, |p| p.set(b"Annots".to_vec(), Object::Array(refs.into_iter().map(Object::Ref).collect()))).unwrap(); + for data in [export_xfdf(&incoming, true, false, "notes.pdf").into_bytes(), export_fdf(&incoming, true, false, "notes.pdf").unwrap()] { + let mut doc = blank(); + import(&mut doc, &data).unwrap(); + let notes = summaries(&doc); + assert_eq!(notes.len(), 2); + let last = notes.iter().find(|s| s.name.as_deref() == Some("duplicate")).unwrap(); + assert_eq!(last.in_reply_to, None, "a replaced record's deferred /IRT must not attach the final standalone comment"); + } +} + +#[test] +fn indirectly_named_protected_comments_and_popups_are_not_replaced() { + let xfdf = br#"Updated"#; + let fdf = b"%FDF-1.2\n1 0 obj\n<< /FDF << /Annots [<< /Subtype /Redact /NM (n1) /Page 0 /Rect [0 0 1 1] /F 39 /Contents (Updated) >>] >> >>\nendobj\ntrailer\n<< /Root 1 0 R >>\n%%EOF\n"; + let annotation_refs = |doc: &Document| { + pdfcraft_model::pages(doc)[0] + .dict + .get(b"Annots") + .map(|a| doc.resolve(a)) + .unwrap() + .as_array() + .unwrap() + .iter() + .filter_map(Object::as_ref) + .collect::>() + }; + for (subtype, flags) in [("Text", LOCKED), ("Redact", 0)] { + let mut original = blank(); + let page = pdfcraft_model::pages(&original)[0].obj; + let name = original.add(PdfString::text("n1")); + let mut protected = Dict::new(); + protected.set(b"Type".to_vec(), Object::name("Annot")); + protected.set(b"Subtype".to_vec(), Object::name(subtype)); + protected.set(b"NM".to_vec(), Object::Ref(name)); + protected.set(b"Rect".to_vec(), arr(&[10.0, 10.0, 30.0, 30.0])); + protected.set(b"F".to_vec(), Object::Int(flags)); + protected.set(b"Contents".to_vec(), PdfString::text("Original")); + protected.set(b"CustomData".to_vec(), PdfString::text("preserved")); + let parent = original.add(protected); + let mut popup = Dict::new(); + popup.set(b"Subtype".to_vec(), Object::name("Popup")); + popup.set(b"Rect".to_vec(), arr(&[40.0, 10.0, 140.0, 60.0])); + popup.set(b"Parent".to_vec(), Object::Ref(parent)); + let popup = original.add(popup); + original.update_dict(parent, |d| d.set(b"Popup".to_vec(), Object::Ref(popup))).unwrap(); + let mut annots = annotation_refs(&original); + annots.extend([parent, popup]); + original.update_dict(page, |d| d.set(b"Annots".to_vec(), Object::Array(annots.into_iter().map(Object::Ref).collect()))).unwrap(); + let original = Arc::new(write_full(&original, &SaveOptions::default()).unwrap()); + for data in [xfdf.as_slice(), fdf.as_slice()] { + let mut doc = Document::open(original.clone()).unwrap(); + let before = annotation_refs(&doc); + let parent = before.iter().copied().find(|r| text_of(&doc, doc.get(*r).as_dict().unwrap(), b"NM").as_deref() == Some("n1")).unwrap(); + let protected = doc.get(parent); + let popup_ref = protected.as_dict().unwrap().reference(b"Popup").unwrap(); + let popup = doc.get(popup_ref); + assert!(protected.as_dict().unwrap().get(b"NM").unwrap().as_ref().is_some()); + assert_eq!(import(&mut doc, data).unwrap().comments, 1); + let after = annotation_refs(&doc); + assert_eq!(after.len(), before.len() + 1); + assert!(after.contains(&parent) && after.contains(&popup_ref)); + assert_eq!(doc.get(parent), protected, "the protected {subtype} must not change, including its unknown data"); + assert_eq!(doc.get(popup_ref), popup, "the protected comment's popup must not change"); + let added = after.into_iter().find(|r| !before.contains(r)).unwrap(); + let added = doc.get(added); + let added = added.as_dict().unwrap(); + assert!(added.get(b"NM").is_none(), "the imported comment must relinquish the protected name"); + assert_eq!(added.int(b"F"), Some(4), "an imported redaction stays printable with Invisible/Hidden/NoView removed"); + let reopened = Document::open(Arc::new(write_incremental(&doc, &SaveOptions::default()).unwrap())).unwrap(); + assert_eq!(reopened.get(parent), protected); + assert_eq!(reopened.get(popup_ref), popup); + } + } +} + +#[test] +fn imported_redact_marks_cannot_replace_a_locked_mark_or_hide_themselves() { + let mut doc = blank(); + let page = pdfcraft_model::pages(&doc)[0].obj; + // A locked redaction mark the user placed, with a known /NM. + let mut mark = Dict::new(); + mark.set(b"Type".to_vec(), Object::name("Annot")); + mark.set(b"Subtype".to_vec(), Object::name("Redact")); + mark.set(b"NM".to_vec(), PdfString::text("mark1")); + mark.set(b"Rect".to_vec(), Object::Array(vec![60.into(), 660.into(), 560.into(), 678.into()])); + mark.set(b"F".to_vec(), Object::Int(128)); // Locked + let mark_ref = doc.add(Object::Dict(mark)); + let mut annots = + doc.get(page).as_dict().and_then(|p| p.get(b"Annots").cloned()).and_then(|a| doc.resolve(&a).as_array().cloned()).unwrap_or_default(); + annots.push(Object::Ref(mark_ref)); + doc.update_dict(page, |p| p.set(b"Annots".to_vec(), Object::Array(annots))).unwrap(); + + // A reviewer's XFDF: shrink the locked mark to a 1×1 corner by reusing its /NM, and add a + // redaction mark imported as Hidden that would remove other content unseen. + let xfdf = concat!( + r#""#, + r#""#, + r#""#, + r#""#, + ); + import(&mut doc, xfdf.as_bytes()).unwrap(); + + let refs: Vec = doc + .get(page) + .as_dict() + .and_then(|p| p.get(b"Annots").cloned()) + .and_then(|a| doc.resolve(&a).as_array().cloned()) + .unwrap_or_default() + .iter() + .filter_map(|a| a.as_ref()) + .collect(); + // The locked mark is still there, unchanged (same object, same geometry, still its /NM). + assert!(refs.contains(&mark_ref), "the locked redaction mark was replaced"); + let locked = doc.get(mark_ref).as_dict().cloned().unwrap(); + assert_eq!(nums_of(&doc, locked.get(b"Rect")), vec![60.0, 660.0, 560.0, 678.0], "the locked mark's geometry was changed"); + assert_eq!(locked.get(b"NM").and_then(|n| n.as_string().map(|s| s.to_text())).as_deref(), Some("mark1")); + assert_eq!(locked.int(b"F"), Some(128), "the locked mark lost its Locked flag"); + // Every redaction mark on the page is visible (none Invisible/Hidden/NoView): the import's two + // marks were added beside the protected one, both drawable for review. + let redacts: Vec = + refs.iter().map(|r| doc.get(*r).as_dict().cloned().unwrap()).filter(|d| d.name(b"Subtype") == Some(b"Redact".as_slice())).collect(); + assert_eq!(redacts.len(), 3, "the imported marks should be added, not merged away"); + for d in &redacts { + assert_eq!(d.int(b"F").unwrap_or(0) & (1 | 2 | 32), 0, "a redaction mark is hidden from review: {d:?}"); + } +} + /// The `/Vertices` of every annotation on the document's pages, by subtype. fn vertices(doc: &Document) -> Vec<(String, Vec)> { let mut out = Vec::new(); @@ -492,6 +1068,42 @@ fn xfdf_keeps_callout_leader_inset_and_ending() { assert_eq!(callout_keys(&dst), want, "{xfdf}"); } +/// Run `f` on a thread with a small stack, as a deep recursion would overflow it. +fn on_small_stack(f: impl FnOnce() -> T + Send + 'static) -> T { + std::thread::Builder::new().stack_size(256 << 10).spawn(f).unwrap().join().unwrap() +} + +#[test] +fn deeply_nested_xml_is_refused_before_parsing() { + // A file nested far deeper than any stack holds used to overflow roxmltree's recursive + // parser, which aborts the process. It must be refused without recursing. + let deep = |root: &str| format!("<{root}>{}{}", "".repeat(100_000), "".repeat(100_000)); + let xfdf = deep("xfdf"); + let xml = deep("fields"); + let (imported, table, values) = on_small_stack(move || { + let mut doc = blank(); + (import(&mut doc, xfdf.as_bytes()), import(&mut doc, xml.as_bytes()), data_values(xfdf.as_bytes())) + }); + for r in [imported.map(|_| ()), table.map(|_| ()), values.map(|_| ())] { + assert!(matches!(&r, Err(DataError::Malformed(m)) if m.contains("deep")), "{r:?}"); + } +} + +#[test] +fn wide_or_commented_xml_is_not_mistaken_for_deep() { + // Thousands of siblings, self-closing tags, `>` in attribute values, and tags inside + // comments, CDATA and processing instructions add no depth. + let mut x = String::from(""); + for i in 0..5_000 { + x.push_str(&format!(" g\">v]]> ?>")); + } + x.push_str(""); + assert_eq!(check_form_xml_depth(&x), Ok(())); + let nested = |n: usize| format!("{}{}", "".repeat(n), "".repeat(n)); + assert_eq!(check_form_xml_depth(&nested(MAX_XML_DEPTH)), Ok(())); + assert!(check_form_xml_depth(&nested(MAX_XML_DEPTH + 1)).is_err()); +} + #[test] fn duplicate_xfdf_annotation_flags_do_not_turn_on_unrelated_bits() { // Flags are a set of bit masks, not additive integers. A repeated "print" flag @@ -508,3 +1120,473 @@ fn duplicate_xfdf_annotation_flags_do_not_turn_on_unrelated_bits() { assert_eq!(square.get(b"F").and_then(Object::as_int), Some(expected), "flags={flags}"); } } + +// XML comments and processing instructions are not field text (XML 1.0 §§2.5–2.6). +fn split_text_values() -> [(&'static str, &'static str); 5] { + [ + ("Ada Lovelace", "Ada Lovelace"), + ("Ada", "Ada"), + ("Ada Lovelace", "Ada Lovelace"), + (" Å]]>Ω ", " Å<&>Ω "), + ("", ""), + ] +} + +#[test] +fn xfdf_data_values_join_text_around_xml_comments_and_instructions() { + for (xml_text, expected) in split_text_values() { + let xml = format!(r#"{xml_text}"#); + assert_eq!(data_values(xml.as_bytes()).unwrap(), vec![("Name".into(), vec![expected.to_string()])], "{xml_text}"); + } +} + +#[test] +fn xfdf_import_keeps_text_around_xml_comments_and_instructions() { + for (xml_text, expected) in split_text_values() { + let xml = format!(r#"{xml_text}"#); + let mut doc = one_field(); + set_value(&mut doc, "Name", &FieldValue::Text("old value".into())).unwrap(); + let report = import(&mut doc, xml.as_bytes()).unwrap(); + assert_eq!(report.fields, 1); + assert!(report.rejected.is_empty(), "{report:?}"); + let saved = write_full(&doc, &SaveOptions::default()).unwrap(); + let reopened = Document::open(Arc::new(saved)).unwrap(); + let want: Vec<&str> = if expected.is_empty() { vec![] } else { vec![expected] }; + assert_eq!(values(&reopened)[0].1, want, "{xml_text}"); + } +} + +#[test] +fn fdf_export_keeps_indirect_values_nested_in_annotation_properties() { + let mut src = blank(); + let page = pdfcraft_model::pages(&src)[0].obj; + let mut annotation = Dict::new(); + annotation.set(b"Type".to_vec(), Object::name("Annot")); + annotation.set(b"Subtype".to_vec(), Object::name("Square")); + annotation.set(b"NM".to_vec(), PdfString::text("indirect-values")); + let rect = [50.0, 60.0, 100.0, 120.0].map(|v| Object::Ref(src.add(Object::Real(v)))); + annotation.set(b"Rect".to_vec(), Object::Array(rect.into())); + let color = [0.25, 0.5, 0.75].map(|v| Object::Ref(src.add(Object::Real(v)))); + annotation.set(b"C".to_vec(), Object::Array(color.into())); + let mut border = Dict::new(); + border.set(b"W".to_vec(), Object::Ref(src.add(Object::Real(2.5)))); + border.set(b"S".to_vec(), Object::name("D")); + let dash = [3.0, 2.0].map(|v| Object::Ref(src.add(Object::Real(v)))); + border.set(b"D".to_vec(), Object::Ref(src.add(Object::Array(dash.into())))); + annotation.set(b"BS".to_vec(), Object::Ref(src.add(border))); + let note = src.add(annotation); + src.update_dict(page, |p| p.set(b"Annots".to_vec(), Object::Array(vec![Object::Ref(note)]))).unwrap(); + + // This is an actual saved PDF: indirect scalar numbers, arrays and dictionaries are legal + // PDF objects, and source object numbers have no meaning in an exported FDF. + let src = Document::open(Arc::new(write_full(&src, &SaveOptions::default()).unwrap())).unwrap(); + assert_eq!(summaries(&src)[0].rect, [50.0, 60.0, 100.0, 120.0]); + assert_eq!(summaries(&src)[0].color, Some([0.25, 0.5, 0.75])); + let fdf = export_fdf(&src, true, false, "source.pdf").unwrap(); + let exported = Document::open(Arc::new(fdf.clone())).unwrap(); + let body = fdf_body(&exported).unwrap(); + let annots = exported.resolve(body.get(b"Annots").unwrap()); + let note = exported.resolve(&annots.as_array().unwrap()[0]); + let d = note.as_dict().unwrap(); + assert_eq!(nums_of(&exported, d.get(b"Rect")), [50.0, 60.0, 100.0, 120.0], "{}", String::from_utf8_lossy(&fdf)); + assert_eq!(nums_of(&exported, d.get(b"C")), [0.25, 0.5, 0.75]); + let bs = exported.resolve(d.get(b"BS").unwrap()); + let bs = bs.as_dict().unwrap(); + assert_eq!(exported.resolve(bs.get(b"W").unwrap()).as_f64(), Some(2.5)); + assert_eq!(nums_of(&exported, bs.get(b"D")), [3.0, 2.0]); + + let mut dst = blank(); + import(&mut dst, &fdf).unwrap(); + let dst = Document::open(Arc::new(write_full(&dst, &SaveOptions::default()).unwrap())).unwrap(); + let notes = summaries(&dst); + assert_eq!(notes[0].rect, [50.0, 60.0, 100.0, 120.0]); + assert_eq!(notes[0].color, Some([0.25, 0.5, 0.75])); +} + +/// Put extension data on a real exported comment without changing its normal properties. +fn fdf_note_with_data(doc: &mut Document, data: Object) { + let page = pdfcraft_model::pages(doc)[0].obj; + let mut note = Dict::new(); + note.set(b"Type".to_vec(), Object::name("Annot")); + note.set(b"Subtype".to_vec(), Object::name("Square")); + note.set(b"Rect".to_vec(), Object::Array(vec![10.into(), 20.into(), 30.into(), 40.into()])); + note.set(b"PCData".to_vec(), data); + let note = doc.add(note); + doc.update_dict(page, |dict| dict.set(b"Annots".to_vec(), Object::Array(vec![Object::Ref(note)]))).unwrap(); +} + +fn exported_note_data(doc: &Document) -> Object { + let body = fdf_body(doc).unwrap(); + let annots = doc.resolve(body.get(b"Annots").unwrap()); + let note = doc.resolve(&annots.as_array().unwrap()[0]); + note.as_dict().unwrap().get(b"PCData").unwrap().clone() +} + +#[test] +fn fdf_export_preserves_shared_cycles_and_nested_streams_without_expansion() { + let mut src = blank(); + let leaf = src.add(PdfString::text("shared")); + let mut shared = leaf; + for _ in 0..35 { + shared = src.add(Object::Array(vec![Object::Ref(shared), Object::Ref(shared)])); + } + let cycle = src.add(Object::Null); + src.set(cycle, Object::Array(vec![Object::Ref(cycle)])); + let mut stream_dict = Dict::new(); + stream_dict.set(b"PCMeta".to_vec(), Object::Ref(leaf)); + let stream = src.add(pdfcraft_cos::Stream::from_raw(stream_dict, b"original bytes".to_vec())); + let data = Object::Array(vec![Object::Ref(shared), Object::Ref(shared), Object::Ref(cycle), Object::Ref(stream)]); + fdf_note_with_data(&mut src, data); + let src = Document::open(Arc::new(write_full(&src, &SaveOptions::default()).unwrap())).unwrap(); + let bytes = export_fdf(&src, true, false, "source.pdf").unwrap(); + assert!(bytes.len() < 10_000, "a shared graph must not expand"); + let fdf = Document::open(Arc::new(bytes)).unwrap(); + let data = exported_note_data(&fdf); + let data = data.as_array().unwrap(); + assert_eq!(data[0], data[1]); + let mut current = data[0].clone(); + for _ in 0..35 { + let next = fdf.resolve(¤t); + let values = next.as_array().unwrap(); + assert_eq!(values[0], values[1]); + current = values[0].clone(); + } + assert_eq!(fdf.resolve(¤t).as_string().unwrap().to_text(), "shared"); + let cycle = fdf.resolve(&data[2]); + assert_eq!(cycle.as_array().unwrap()[0], data[2]); + let stream = fdf.resolve(&data[3]); + let Object::Stream(stream) = &*stream else { panic!("copied stream") }; + assert_eq!(&*stream.raw, b"original bytes"); + assert_eq!(fdf.resolve(stream.dict.get(b"PCMeta").unwrap()).as_string().unwrap().to_text(), "shared"); +} + +#[test] +fn fdf_export_walks_long_indirect_chains_and_nulls_absent_or_stale_refs() { + let mut src = blank(); + let live = src.add(PdfString::text("live")); + let mut head = live; + for _ in 0..3_000 { + head = src.add(Object::Array(vec![Object::Ref(head)])); + } + fdf_note_with_data(&mut src, Object::Array(vec![Object::Ref(head), Object::Ref(ObjRef::new(999_999, 0)), Object::Ref(ObjRef::new(live.num, 1))])); + let fdf = Document::open(Arc::new(export_fdf(&src, true, false, "source.pdf").unwrap())).unwrap(); + let data = exported_note_data(&fdf); + let data = data.as_array().unwrap(); + assert_eq!(*fdf.resolve(&data[1]), Object::Null); + assert_eq!(*fdf.resolve(&data[2]), Object::Null); + let mut current = data[0].clone(); + for _ in 0..3_000 { + current = fdf.resolve(¤t).as_array().unwrap()[0].clone(); + } + assert_eq!(fdf.resolve(¤t).as_string().unwrap().to_text(), "live"); +} + +#[test] +fn fdf_export_refuses_excessive_direct_depth_values_and_payload() { + let mut src = blank(); + let mut deep = Object::Null; + for _ in 0..70 { + deep = Object::Array(vec![deep]); + } + fdf_note_with_data(&mut src, deep); + assert_eq!(export_fdf(&src, true, false, "source.pdf"), Err(DataError::ExportLimit("64 direct nesting levels"))); + // Not exporting comments must not inspect their extension data. + assert!(export_fdf(&src, false, true, "source.pdf").is_ok()); + + let mut src = blank(); + fdf_note_with_data(&mut src, Object::Array(vec![Object::Null; MAX_FDF_OBJECTS + 1])); + assert_eq!(export_fdf(&src, true, false, "source.pdf"), Err(DataError::ExportLimit("100,000 values"))); + + let mut src = blank(); + let stream = src.add(pdfcraft_cos::Stream::from_raw(Dict::new(), vec![0; MAX_FDF_BYTES + 1])); + fdf_note_with_data(&mut src, Object::Ref(stream)); + assert_eq!(export_fdf(&src, true, false, "source.pdf"), Err(DataError::ExportLimit("64 MiB data"))); + + let mut src = blank(); + // Strings need up to four serialized bytes per source byte. Refuse before copying + // the direct payload, just as the indirect stream case above refuses its raw data. + fdf_note_with_data(&mut src, Object::String(PdfString::literal(vec![b'x'; MAX_FDF_BYTES / 4 + 1]))); + assert_eq!(export_fdf(&src, true, false, "source.pdf"), Err(DataError::ExportLimit("64 MiB data"))); +} + +#[test] +fn fdf_export_refuses_aliases_before_cloning_large_annotation_values() { + let mut src = blank(); + fdf_note_with_data(&mut src, Object::Array(vec![Object::Null; 1_024])); + let page = pdfcraft_model::pages(&src)[0].obj; + let annots = src.get(page).as_dict().unwrap().get(b"Annots").unwrap().clone(); + let note = annots.as_array().unwrap()[0].as_ref().unwrap(); + // The source holds one 1,024-value dictionary. The aliases must not clone its + // values a hundred thousand times before the comment-count refusal is reached. + src.update_dict(page, |dict| dict.set(b"Annots".to_vec(), Object::Array(vec![Object::Ref(note); MAX_FDF_OBJECTS + 1]))).unwrap(); + assert_eq!(export_fdf(&src, true, false, "source.pdf"), Err(DataError::ExportLimit("100,000 comments"))); +} + +#[test] +fn fdf_export_refuses_repeated_saved_annotations_after_cache_eviction() { + let bytes = { + let mut src = blank(); + let page = pdfcraft_model::pages(&src)[0].obj; + let mut notes = Vec::new(); + // More than the editor cache's 1,024 objects. Four passes force reparsing; + // retaining each resolved body during discovery would pin all four copies. + for _ in 0..1_025 { + let mut note = Dict::new(); + note.set(b"Type".to_vec(), Object::name("Annot")); + note.set(b"Subtype".to_vec(), Object::name("Square")); + note.set(b"Rect".to_vec(), Object::Array(vec![10.into(), 20.into(), 30.into(), 40.into()])); + note.set(b"PCData".to_vec(), Object::Array(vec![Object::Null; 1_024])); + notes.push(Object::Ref(src.add(note))); + } + let aliases = (0..4).flat_map(|_| notes.iter().cloned()).collect(); + src.update_dict(page, |dict| dict.set(b"Annots".to_vec(), Object::Array(aliases))).unwrap(); + write_full(&src, &SaveOptions { object_streams: false, ..SaveOptions::default() }).unwrap() + }; + // Drop the overlay-backed source before reopening: its persistent Arc objects + // would hide the parsed-object cache eviction exercised by this fixture. + let src = Document::open(Arc::new(bytes)).unwrap(); + assert!(src.repair_log().is_empty()); + assert_eq!(export_fdf(&src, true, false, "source.pdf"), Err(DataError::ExportLimit("100,000 values"))); +} + +#[test] +fn fdf_export_counts_emitted_field_values_before_allocating_lists() { + let field_document = |kind: &str, count: usize| { + let mut doc = Document::new_empty(); + let mut field = Dict::new(); + field.set(b"FT".to_vec(), Object::name(kind)); + field.set(b"T".to_vec(), PdfString::text("Pick")); + field.set(b"Ff".to_vec(), Object::Int(1 << 21)); // multi-select for a /Ch list + field.set(b"V".to_vec(), Object::Array(vec![Object::String(PdfString::text("")); count])); + let field = doc.add(field); + let mut form = Dict::new(); + form.set(b"Fields".to_vec(), Object::Array(vec![Object::Ref(field)])); + doc.update_dict(doc.root().unwrap(), |root| root.set(b"AcroForm".to_vec(), Object::Dict(form))).unwrap(); + assert_eq!(fields(&doc)[0].value.len(), count); + doc + }; + + // A field dictionary, /T string and /V array use three nodes before its entries. + let doc = field_document("Ch", MAX_FDF_OBJECTS - 3); + assert_eq!(fields(&doc)[0].kind, FieldKind::List); + let bytes = export_fdf(&doc, false, true, "field.pdf").unwrap(); + assert_eq!(data_values(&bytes).unwrap()[0].1.len(), MAX_FDF_OBJECTS - 3); + for count in [MAX_FDF_OBJECTS - 2, MAX_FDF_OBJECTS + 1] { + let doc = field_document("Ch", count); + assert!(matches!(export_fdf(&doc, false, true, "field.pdf"), Err(DataError::ExportLimit("100,000 values")))); + } + + // A hostile array on a scalar field still emits just its first string. The node + // budget counts output values, rather than charging entries that are not copied. + let doc = field_document("Tx", MAX_FDF_OBJECTS + 1); + assert_eq!(fields(&doc)[0].kind, FieldKind::Text); + let bytes = export_fdf(&doc, false, true, "field.pdf").unwrap(); + let fdf = Document::open(Arc::new(bytes)).unwrap(); + let body = fdf_body(&fdf).unwrap(); + let exported = fdf.resolve(body.get(b"Fields").unwrap()); + let exported = fdf.resolve(&exported.as_array().unwrap()[0]); + assert!(matches!(exported.as_dict().unwrap().get(b"V"), Some(Object::String(_)))); +} + +#[test] +fn xml_form_import_preserves_text_around_comments_and_instructions() { + for (body, expected) in [ + ("leftright", "leftright"), + ("leftmiddleright", "leftmiddleright"), + ("]]>&right", "&right"), + (" left right ", " left right "), + ("", ""), + ] { + let mut original = one_field(); + let root = original.root().unwrap(); + original.update_dict(root, |dict| dict.set(b"PCUnknown".to_vec(), PdfString::text("retained"))).unwrap(); + set_value(&mut original, "Name", &FieldValue::Text("replace me".into())).unwrap(); + let source = write_full(&original, &SaveOptions::default()).unwrap(); + let mut doc = Document::open(Arc::new(source.clone())).unwrap(); + let xml = format!("{body}"); + let report = import(&mut doc, xml.as_bytes()).unwrap(); + assert_eq!(report.fields, 1); + assert!(report.rejected.is_empty()); + for saved in [write_full(&doc, &SaveOptions::default()).unwrap(), write_incremental(&doc, &SaveOptions::default()).unwrap()] { + let reopened = Document::open(Arc::new(saved)).unwrap(); + let name = fields(&reopened).into_iter().find(|f| f.name == "Name").unwrap(); + let want = if expected.is_empty() { Vec::new() } else { vec![expected.to_string()] }; + assert_eq!(name.value, want, "XML body: {body}"); + let root = reopened.get(reopened.root().unwrap()); + assert_eq!(root.as_dict().unwrap().get(b"PCUnknown").unwrap().as_string().unwrap().to_text(), "retained"); + } + assert_eq!(fields(&original).into_iter().find(|f| f.name == "Name").unwrap().value, vec!["replace me"]); + } +} + +#[test] +fn fdf_export_nulls_nested_page_links_without_copying_the_source_page_tree() { + for indirect_type in [false, true] { + let source = { + let mut doc = Document::new_empty(); + let root = doc.root().unwrap(); + let tree = doc.get(root).as_dict().unwrap().reference(b"Pages").unwrap(); + let mut pages = Vec::new(); + for _ in 0..3 { + let contents = doc.add(pdfcraft_cos::Stream::from_raw( + Dict::new(), + b"% source-page-content-must-stay-in-the-PDF\nq 0 g 0 0 10 10 re f Q\n".repeat(1_024), + )); + let mut page = Dict::new(); + let page_type = if indirect_type { Object::Ref(doc.add(Object::name("Page"))) } else { Object::name("Page") }; + page.set(b"Type".to_vec(), page_type); + page.set(b"Parent".to_vec(), Object::Ref(tree)); + page.set(b"MediaBox".to_vec(), Object::Array(vec![0.into(), 0.into(), 600.into(), 800.into()])); + page.set(b"Contents".to_vec(), Object::Ref(contents)); + pages.push(doc.add(page)); + } + let tree_type = if indirect_type { Object::Ref(doc.add(Object::name("Pages"))) } else { Object::name("Pages") }; + doc.update_dict(tree, |dict| { + dict.set(b"Type".to_vec(), tree_type); + dict.set(b"Count".to_vec(), Object::Int(3)); + dict.set(b"Kids".to_vec(), Object::Array(pages.iter().copied().map(Object::Ref).collect())); + }) + .unwrap(); + let mut action = Dict::new(); + action.set(b"S".to_vec(), Object::name("GoTo")); + action.set(b"D".to_vec(), Object::Array(vec![Object::Ref(pages[1]), Object::name("Fit")])); + let mut extension = Dict::new(); + extension.set(b"Type".to_vec(), Object::name("PCMetadata")); + extension.set(b"Target".to_vec(), Object::Ref(pages[1])); + extension.set(b"Label".to_vec(), PdfString::text("retained metadata")); + let extension = doc.add(extension); + let type_cycle = doc.add(Object::Null); + doc.set(type_cycle, Object::Ref(type_cycle)); + let mut cyclic_type = Dict::new(); + cyclic_type.set(b"Type".to_vec(), Object::Ref(type_cycle)); + cyclic_type.set(b"Label".to_vec(), PdfString::text("cyclic type is not a page")); + fdf_note_with_data( + &mut doc, + Object::Array(vec![Object::Dict(action), Object::Ref(extension), Object::Array(vec![Object::Ref(tree)]), Object::Dict(cyclic_type)]), + ); + write_full(&doc, &SaveOptions::default()).unwrap() + }; + let source = Document::open(Arc::new(source)).unwrap(); + assert!(source.repair_log().is_empty()); + assert_eq!(pdfcraft_model::pages(&source).len(), 3); + let bytes = export_fdf(&source, true, false, "source.pdf").unwrap(); + assert!(bytes.len() < 4_096, "a comment export must not carry source pages or their content ({} bytes)", bytes.len()); + let fdf = Document::open(Arc::new(bytes)).unwrap(); + let data = exported_note_data(&fdf); + let data = data.as_array().unwrap(); + let action = data[0].as_dict().unwrap(); + assert_eq!(action.name(b"S"), Some(b"GoTo".as_slice())); + let destination = action.get(b"D").unwrap().as_array().unwrap(); + assert_eq!(*fdf.resolve(&destination[0]), Object::Null); + assert_eq!(destination[1], Object::name("Fit")); + let extension = fdf.resolve(&data[1]); + let extension = extension.as_dict().unwrap(); + assert_eq!(extension.name(b"Type"), Some(b"PCMetadata".as_slice())); + assert_eq!(extension.get(b"Target"), Some(&destination[0]), "the excluded page reference remains aliased"); + assert_eq!(extension.get(b"Label").unwrap().as_string().unwrap().to_text(), "retained metadata"); + let tree = data[2].as_array().unwrap(); + assert_eq!(*fdf.resolve(&tree[0]), Object::Null); + + let cyclic_type = data[3].as_dict().unwrap(); + assert_eq!(cyclic_type.get(b"Label").unwrap().as_string().unwrap().to_text(), "cyclic type is not a page"); + let type_cycle = cyclic_type.get(b"Type").unwrap(); + assert_eq!(&*fdf.resolve(type_cycle), type_cycle); + } +} + +#[test] +fn fdf_export_bounds_indirect_type_alias_lookups() { + for extra_aliases in [MAX_DEPTH - 1, MAX_DEPTH] { + let mut doc = blank(); + let mut page_type = doc.add(Object::name("Page")); + for _ in 0..extra_aliases { + page_type = doc.add(Object::Ref(page_type)); + } + let mut page = Dict::new(); + page.set(b"Type".to_vec(), Object::Ref(page_type)); + fdf_note_with_data(&mut doc, Object::Dict(page)); + let exported = export_fdf(&doc, true, false, ""); + if extra_aliases < MAX_DEPTH { + let fdf = Document::open(Arc::new(exported.unwrap())).unwrap(); + assert_eq!(exported_note_data(&fdf), Object::Null); + } else { + assert!(matches!(exported, Err(DataError::ExportLimit("64 Type reference levels")))); + } + } +} + +#[test] +fn fdf_export_preserves_metadata_with_stale_type_references() { + for alias in [false, true] { + let mut doc = blank(); + let current = doc.add(Object::name("Page")); + doc.update_dict(doc.root().unwrap(), |dict| dict.set(b"PCPageType".to_vec(), Object::Ref(current))).unwrap(); + let mut doc = Document::open(Arc::new(write_full(&doc, &SaveOptions::default()).unwrap())).unwrap(); + let current = doc.get(doc.root().unwrap()).as_dict().unwrap().reference(b"PCPageType").unwrap(); + let stale = ObjRef::new(current.num, current.generation + 1); + let page_type = if alias { doc.add(Object::Ref(stale)) } else { stale }; + let mut metadata = Dict::new(); + metadata.set(b"Type".to_vec(), Object::Ref(page_type)); + metadata.set(b"Label".to_vec(), PdfString::text("preserved despite stale type")); + fdf_note_with_data(&mut doc, Object::Dict(metadata)); + assert_eq!(doc.generation(stale.num), current.generation); + // A warm number-keyed cache must not make the stale Type reference live. + assert_eq!(*doc.try_get(stale.num).unwrap(), Object::name("Page")); + let bytes = export_fdf(&doc, true, false, "").unwrap(); + let fdf = Document::open(Arc::new(bytes)).unwrap(); + let data = exported_note_data(&fdf); + let metadata = data.as_dict().expect("stale Type must not classify unrelated metadata as a source page"); + assert_eq!(metadata.get(b"Label").unwrap().as_string().unwrap().to_text(), "preserved despite stale type"); + let mut page_type = fdf.resolve(metadata.get(b"Type").unwrap()); + if alias { + page_type = fdf.resolve(&page_type); + } + assert_eq!(*page_type, Object::Null); + } +} + +#[test] +fn csv_headers_with_tabs_survive_export_import_and_save() { + for name in ["First\tName", "\tName", "Name\t", "Name\t\"quoted\"", "Name\nwith\ta tab"] { + let mut source = one_field(); + add_field(&mut source, 0, [50.0, 650.0, 250.0, 670.0], &NewField::Text { multiline: false }, Some("Other")).unwrap(); + set_value(&mut source, "Other", &FieldValue::Text("other field".into())).unwrap(); + let field = fields(&source).into_iter().find(|f| f.name == "Name").unwrap().obj; + source.update_dict(field, |dict| dict.set(b"T".to_vec(), PdfString::text(name))).unwrap(); + set_value(&mut source, name, &FieldValue::Text("original, \"value\"".into())).unwrap(); + let bytes = write_full(&source, &SaveOptions::default()).unwrap(); + let source = Document::open(Arc::new(bytes.clone())).unwrap(); + let row = data_values(&bytes).unwrap(); + for csv in [export_data(&source, Format::Csv), merge_csv(&[row])] { + let mut doc = Document::open(Arc::new(bytes.clone())).unwrap(); + set_value(&mut doc, name, &FieldValue::Text("replacement".into())).unwrap(); + set_value(&mut doc, "Other", &FieldValue::Text("other replacement".into())).unwrap(); + let report = import(&mut doc, csv.as_bytes()).unwrap(); + assert_eq!(report.fields, 2); + assert!(report.rejected.is_empty(), "{:?}", report.rejected); + for saved in [write_full(&doc, &SaveOptions::default()).unwrap(), write_incremental(&doc, &SaveOptions::default()).unwrap()] { + let reopened = Document::open(Arc::new(saved)).unwrap(); + assert_eq!(fields(&reopened).into_iter().find(|f| f.name == name).unwrap().value, vec!["original, \"value\""], "{csv:?}"); + assert_eq!(values(&reopened), values(&source), "{csv:?}"); + } + } + } +} + +#[test] +fn tabular_import_keeps_tsv_quotes_literal_and_csv_value_tabs() { + for name in ["Name", "Na\"me", "\"Name\"", "\"Name"] { + let mut doc = one_field(); + add_field(&mut doc, 0, [50.0, 650.0, 250.0, 670.0], &NewField::Text { multiline: false }, Some("Other")).unwrap(); + let field = fields(&doc).into_iter().find(|f| f.name == "Name").unwrap().obj; + doc.update_dict(field, |dict| dict.set(b"T".to_vec(), PdfString::text(name))).unwrap(); + let text = format!("{name}\tOther\r\nAda\tother field\r\n"); + let report = import(&mut doc, text.as_bytes()).unwrap(); + assert_eq!(report.fields, 2, "{text:?}"); + assert_eq!(fields(&doc).into_iter().find(|f| f.name == name).unwrap().value, vec!["Ada"]); + } + let mut doc = one_field(); + add_field(&mut doc, 0, [50.0, 650.0, 250.0, 670.0], &NewField::Text { multiline: false }, Some("Other")).unwrap(); + import(&mut doc, b"Name,Other\r\n\"Ada\tLovelace\",other field\r\n").unwrap(); + assert_eq!(fields(&doc).into_iter().find(|f| f.name == "Name").unwrap().value, vec!["Ada\tLovelace"]); +} diff --git a/deny.toml b/deny.toml index cb39d68046..ed9c26c206 100644 --- a/deny.toml +++ b/deny.toml @@ -32,8 +32,9 @@ allow-wildcard-paths = true [sources] unknown-registry = "deny" unknown-git = "deny" -# Shared UI components; Cargo.toml pins the immutable revision. -allow-git = ["https://github.com/storytold/craft-libs"] +# Shared UI components and spooler; Cargo.toml pins the immutable revision. craft-print pins +# storytold's winprint fork (Windows printing, #1107) to an immutable revision of its own. +allow-git = ["https://github.com/storytold/craft-libs", "https://github.com/storytold/winprint.rs"] [advisories] yanked = "warn" @@ -43,6 +44,17 @@ ignore = [ # private-key operations use aws-lc-rs on native targets and are refused in the browser # (ADR-0009). Revisit when a fixed `rsa` ships. { id = "RUSTSEC-2023-0071", reason = "rsa is used for verification only (ADR-0009)" }, + # proc-macro-error is unmaintained (no vulnerability). It reaches the tree as a proc macro of + # winprint's fmt-derive (craft-print's Windows spooler), so it runs at compile time on Windows + # and is not in the binary. + { id = "RUSTSEC-2024-0370", reason = "compile-time proc macro of winprint's fmt-derive (craft-print Windows printing, #1107)" }, + # zvariant, zbus_names and zvariant_derive are unmaintained (no vulnerability, and no fixed + # release to move to). They reach the tree as the D-Bus stack behind `accesskit_unix` -> + # `atspi`, which is Linux accessibility only, and as a dev-dependency of pdfcraft-platform. + # Revisit when the accesskit/zbus chain moves to a maintained successor. + { id = "RUSTSEC-2026-0370", reason = "zvariant unmaintained; Linux a11y stack via accesskit_unix" }, + { id = "RUSTSEC-2026-0371", reason = "zbus_names unmaintained; Linux a11y stack via accesskit_unix" }, + { id = "RUSTSEC-2026-0372", reason = "zvariant_derive unmaintained; Linux a11y stack via accesskit_unix" }, ] # egui's default UI fonts (Ubuntu-Light, Hack, emoji-icon-font): font licences on the AGENTS.md @@ -50,3 +62,11 @@ ignore = [ [[licenses.exceptions]] crate = "epaint_default_fonts" allow = ["OFL-1.1", "Ubuntu-font-1.0"] + +# winprint (craft-print's Windows spooler, #1107) lists reqwest as a build-dependency for its +# PDFium download, which only runs with its `pdfium` feature; craft-print turns that off. The CA +# bundle reqwest brings is CDLA-Permissive-2.0 data and is compiled into winprint's build script +# only, never into PdfCraft. Remove when winprint makes those build-dependencies optional. +[[licenses.exceptions]] +crate = "webpki-root-certs" +allow = ["CDLA-Permissive-2.0"] diff --git a/docs/architecture.md b/docs/architecture.md index eb56cdb3ed..6b44817e15 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -1,6 +1,6 @@ # PdfCraft architecture -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-10 · **Change:** major (first public architecture document, written from the code on main) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (shared print spooler; Windows CI passes) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS) How PdfCraft is built today. The design rationale and the planned crates live in `plan/architecture.md` and `plan/adr/` (local-only); this file describes what is on `main`. Each @@ -8,7 +8,7 @@ crate's `README.md` has the detail. ## Shape -One Cargo workspace (`Cargo.toml`, edition 2024, Rust ≥ 1.90, `MIT OR Apache-2.0`), 31 library +One Cargo workspace (`Cargo.toml`, edition 2024, Rust ≥ 1.95, `MIT OR Apache-2.0`), 31 library crates in `crates/`, three binaries in `apps/`, build tooling in `xtask/`, and patched third-party crates in `vendor/`. About 152k lines of Rust and 1,765 tests (2026-10-10). Rust only: no handwritten JS/TS, in the product or the build. @@ -49,7 +49,8 @@ The PDF object graph *is* the model (`crates/engine/README.md` §The editing mod 3. After every edit an incremental write produces the working file (original bytes plus one revision); the view is refreshed from those bytes, so what you see is what Save writes. 4. Saves are incremental and atomic. A full rewrite happens only where it must: redaction, PDF/A - conversion, print imposition, a changed password, Save As with garbage collection. + conversion, print imposition, a changed password, Save As to another file (garbage-collected, + unless the document is signed). 5. Autosave and crash recovery keep encrypted documents encrypted. Signed documents are protected from rewrites. @@ -101,7 +102,7 @@ Every user-facing feature is reachable headlessly (`AGENTS.md` §3): `cargo xtask ci` runs fmt, clippy (`-D warnings`), tests, the layering check, the WASM check, `xtask assets` (asset licences and hashes), `xtask parity` (checklist claims must cite real commands, tools and tests) and `xtask deny` (dependency licences). Other tasks: `xtask fuzz`, `xtask corpus` and `xtask check` (robustness -sweep over the pinned pdf.js corpus), `xtask text-oracle` (word F1 against `pdftotext`), +sweep over the pinned pdf.js corpus), `xtask text-oracle` (word F1 and reading order against `pdftotext`), `xtask demo-pdf`, `xtask screenshots`, `xtask models` (OCR models), `xtask version`. Releases are built by `.github/workflows/release.yml` ([releasing.md](releasing.md)). diff --git a/docs/cli-batch.md b/docs/cli-batch.md new file mode 100644 index 0000000000..ffe0356cf5 --- /dev/null +++ b/docs/cli-batch.md @@ -0,0 +1,86 @@ +# Batch conversion, OCR and compression + +`pdfcraft-cli batch` runs the same steps over files or folders, one file at a time. +It writes separate PDF outputs and a JSON report to stdout. It does not change +source files or documents open in the editor. + +```sh +# Compress all PDFs, including subfolders. +pdfcraft-cli batch scans --root . --recursive --steps optimize --out-dir compressed + +# Convert images and text, then compress. Existing PDFs are accepted too. +pdfcraft-cli batch inputs --root . --recursive --steps convert,optimize --out-dir pdfs --ppi 150 --quality 80 + +# Add searchable text before compression. Requires the installed OCR models. +pdfcraft-cli batch scans --root . --recursive --steps ocr,optimize --out-dir searchable +``` + +Multiple input files or folders can be listed before the options. Directories are +read only one level deep unless `--recursive` is present. Paths within each input +folder are retained inside the output folder. Files encountered more than once +through overlapping selections run once; the first selection determines their +relative output path. Processing order is sorted by input path. + +Steps are `convert`, `ocr` and `optimize`, each at most once. `convert`, when used, +must come first. Conversion uses the same supported extensions as Create PDF; +new engine importers become available here automatically. Without `convert`, +only `.pdf` inputs are processed. Unsupported extensions are reported as skipped. + +- `optimize` uses Reduce File Size settings: JPEG quality 60, resampling to 150 ppi + above 225 ppi, plus the existing document cleanup. `--ppi` (9–1600) sets the target + and a threshold of 1.5 times that value; `--quality` (1–100) sets JPEG quality. + These settings can lose image detail. A candidate that is not smaller retains + the bytes entering that step. This is not an images-only or lossless preset. +- `ocr` uses the installed engine and its supported languages. `--dpi` (72–600, + default 300), `--language` (default `en`) and `--ocr-all-pages` configure it. + By default pages already containing text are skipped. Missing models are + reported as file errors; nothing is downloaded or installed by the batch. +- `--stop-on-error` stops processing after the first file error. By default the + remaining files continue. Earlier successful outputs stay, and unprocessed + files are listed as skipped. A file is published only after all its steps succeed. + +Existing output names are never replaced, even by concurrent batch writers: +`book.pdf`, `book (2).pdf`, and so on. A temporary file in the destination folder +is published without overwriting. Sources are never moved or deleted. An output +folder inside an input folder is excluded from discovery, including on reruns. +The output folder cannot equal or contain a selected input folder. + +The report contains `completed`, `failed`, `skipped`, `stopped` and `files`. +Each file records its status, output and step results, or the error/skip reason. +The `batch` command exits nonzero on any file failure while still printing the +report. Redirect stdout to retain it; keep the report outside the input folders. + +## MCP and JSON scripts + +The same implementation is available as `doc_batch`: + +```json +{ + "paths": ["inputs"], + "steps": ["convert", "optimize"], + "recursive": true, + "out_dir": "pdfs", + "ppi": 150, + "quality": 80, + "stop_on_error": false +} +``` + +In `pdfcraft-cli run --script`, put this object in the `args` of a `doc_batch` +step. MCP marks a report with failed files as `isError: true` and retains the +structured report. Tool names and OCR settings match the existing automation API +(`skip_text_pages: false` corresponds to `--ocr-all-pages`). + +## Boundaries + +Discovery completes before writing. Invalid arguments, unreadable input folders +or paths outside `--root` refuse the call. Traversal accepts at most 1,000 selected +paths, 10,000 visited entries and 64 folder levels. Discovered symbolic links, +Windows reparse points and special files are skipped; selecting a link as a root +is refused. Individual input files are limited to 512 MiB, in addition to engine +limits. Signed documents cannot be optimized, and encrypted PDFs requiring a +password are not supported by this command. + +This is a synchronous, one-shot job. It has no folder watcher, scheduler, resume +database, concurrent file processing or GUI progress dialog. Each file has its +own temporary engine session; completed documents are not retained in memory. diff --git a/docs/development.md b/docs/development.md index 4bf3cc5701..e599effc72 100644 --- a/docs/development.md +++ b/docs/development.md @@ -22,13 +22,35 @@ Cargo stores custom-profile artifacts separately under `target/low-disk`. Switch normal and low-disk profiles can therefore leave both sets of artifacts on disk. To remove only the low-disk artifacts, run `cargo clean --profile low-disk`. -The UI crate and complete desktop/web builds require Rust 1.95 or later, matching the pinned -`craft-ui` dependency. The workspace's core-crate minimum is unchanged. +Building PdfCraft requires Rust 1.95 or later: the UI crate and its `egui` and `craft-ui` +dependencies need it, and the workspace `rust-version` states the same minimum. + +## Interface appearance + +Display theme saves System, Light or Dark separately from the palette currently drawn. New +installations still default to Light. System follows native appearance events on macOS and +Windows. The native window keeps its inherited appearance in every mode; both egui style branches +use the selected application palette, so desktop changes cannot override a manual choice. + +On Linux the native app starts an XDG Settings portal subscription when System is first selected. +It reads `org.freedesktop.appearance` / `color-scheme`, where numeric `1` requests Dark, `2` requests +Light, and `0` means no preference. When that states no preference or is missing, it falls back to +`org.gnome.desktop.interface` / `color-scheme` (`prefer-dark`, `prefer-light`, `default`), which +XFCE's portal answers instead of the freedesktop namespace; KDE answers only the freedesktop one. The subscription is installed before the initial read. Initial +results and later `SettingChanged` signals wake an idle window. There is no periodic desktop +polling, and signals received in a manual mode update the cache without requesting frames. + +System falls back to Light if no appearance is available, including an absent portal, an +unsupported appearance backend, no preference, an unknown value or a failed connection. This +does not rewrite the saved System choice. The watcher cancels when its window closes; it does not +reconnect after a session-bus disconnection. Headless UI contexts use their supplied egui input and +do not contact a desktop session bus. Page and document colours retain their existing behavior. + ## Logs The desktop app writes its `log` records to standard error and to `logs/pdfcraft.log` in its -settings folder, next to `app.ron`: Linux and FreeBSD `~/.local/share/pdfcraft/logs/` (or +data folder: Linux and FreeBSD `~/.local/share/pdfcraft/logs/` (or `$XDG_DATA_HOME/pdfcraft/logs/`), macOS `~/Library/Application Support/PdfCraft/logs/`, Windows `%APPDATA%\PdfCraft\data\logs\`. In portable mode (a `portable.txt` file next to the executable, as the Windows portable zip ships) the settings folder is `PdfCraftData\` beside the executable, and @@ -83,15 +105,45 @@ reach this retry instead of the uncaptured-error panic handler. This does not fi driver's shader compilation, and explicit `PDFCRAFT_RENDERER=wgpu` still disables the retry. `PDFCRAFT_RENDERER` skips it or chooses OpenGL from the start. +## Preferences + +Linux and FreeBSD store `app.ron` in `$XDG_CONFIG_HOME/pdfcraft/`, or +`~/.config/pdfcraft/` when that variable is unset, empty or relative. This follows the +[XDG Base Directory Specification](https://specifications.freedesktop.org/basedir/latest/). +On first launch after upgrading, existing `app.ron` preferences are copied from the old +data folder without replacing an existing configuration file. The old copy is retained; +logs, crash recovery and digital IDs stay in their data locations. If migration fails, +the app logs the failure and continues using the original preferences folder. +The existing PrintCraft-to-PdfCraft migration runs first. macOS, Windows and portable +copies retain their current preferences locations. + ## Environment variables | Variable | Effect | |---|---| | `RUST_LOG` | Log levels for standard error and the log file (see [Logs](#logs)) | | `RUST_BACKTRACE` | `1` adds a backtrace to the report of an internal error | -| `XDG_DATA_HOME` | Linux/FreeBSD: base of the settings folder (`pdfcraft/`), the log folder and crash recovery | +| `XDG_CONFIG_HOME` | Linux/FreeBSD: absolute configuration base for `pdfcraft/app.ron`; defaults to `~/.config` | +| `XDG_DATA_HOME` | Linux/FreeBSD: base of the log folder and crash recovery; previous preferences are migrated from here | | `WGPU_POWER_PREF` | GPU choice; by default PdfCraft draws on the GPU the Wayland compositor draws with (it says so through linux-dmabuf feedback), else the GPU that drives the primary display (Windows) or the built-in panel (Linux), else the low-power (integrated) GPU. The log says which one was chosen | | `WGPU_BACKEND` | Graphics backend; by default Windows uses Direct3D 12, falling back to OpenGL | +| `WGPU_VALIDATION_INDIRECT_CALL` | `1` turns wgpu's check of indirect draw arguments back on. PdfCraft draws nothing indirectly and turns it off, since the compute shader the check needs fails to compile on some drivers (Metal on an Intel Iris Pro), which lost the device at start-up | | `PDFCRAFT_RENDERER` | `gl` starts with OpenGL (glow) and never loads wgpu; `wgpu` reports a wgpu failure instead of retrying with OpenGL; unset, wgpu is retried with OpenGL when it can't start (see [Renderer fallback](#renderer-fallback)) | +| `PDFCRAFT_TITLEBAR` | Linux: `app` draws PdfCraft's own title bar (window buttons in the tab strip, in the order of GNOME's `button-layout`, and resize edges); `system` keeps the frame winit or the desktop draws; unset, PdfCraft draws its own when the Wayland compositor draws no frames (GNOME), and leaves it to the desktop otherwise (KDE, Sway, Hyprland, X11). Where PdfCraft draws it, Preferences ▸ Use system title bar switches back to winit's frame without a restart; `system` also hides that option | +| `PDFCRAFT_BUILD_LABEL` | Build time: a label for a local or patched build (letters, digits, inner dots), shown after the version in `--version`, About and the log (`0.7.1 (titlebar)`). `packaging/linux/package.sh` also puts it in the file names and the .rpm/.deb release (`1.titlebar`), which upgrades over the release of the same version | | `CRAFT_FONTS_DIR` | Build time: a [craft-fonts](https://github.com/storytold/craft-fonts) checkout to embed (Japanese fonts) | | `PDFCRAFT_SYSTEM_FONTS` | `0` stops the desktop app from using an installed font for characters its embedded fonts lack (`cargo xtask screenshots` sets it) | +| `FONTCONFIG_FILE` | On Linux/Unix, the Fontconfig configuration file used to discover installed UI fallback fonts (default: `/etc/fonts/fonts.conf`); relative filenames are resolved under `FONTCONFIG_PATH` | +| `FONTCONFIG_PATH` | Directory for a relative `FONTCONFIG_FILE` (default: `/etc/fonts`; the first entry of a search path is used) | + +On Linux/Unix, the UI's existing fallback filenames are also searched in Fontconfig's configured +directories and their subdirectories. This finds fonts in Nix store paths and user font directories +without invoking `fc-match` or linking libfontconfig. The scan is cached and bounded; missing or +malformed configuration leaves the ordinary `/usr/share/fonts` and `/usr/local/share/fonts` +candidates available. Font size and glyph-coverage checks still apply. This only reads installed +fonts for the interface; it does not embed them into PDFs or packages. + +Relative includes resolve beside the config that contains them. Included directories load only +digit-prefixed `*.conf` files, in lexical order; backups and disabled files are ignored. The scan +limits each config to 1 MiB, visits at most 256 config files/directories, and bounds include depth +to 16 and directory entries to 32,768. Cyclic includes are skipped. diff --git a/docs/editing-existing-text.md b/docs/editing-existing-text.md new file mode 100644 index 0000000000..e8bb03b9a4 --- /dev/null +++ b/docs/editing-existing-text.md @@ -0,0 +1,96 @@ +# Editing existing text and source subsets + +Click a text region in **Edit a PDF → Edit text and images**. One editor contains its +source rows and mixed styles. Embedded TrueType and OpenType/CFF faces are used in memory; +source font programs, colour, size and baseline anchors stay intact. A textless preview +prevents a second copy underneath. The frame shows the live extent as text grows or shrinks. +Rows a text edit doesn't reach keep their original operators, so their kerning, `TJ` word +gaps and glyph widths stay exactly as they were. Only rows whose characters changed, or that +moved because an earlier row wrapped, are rewritten, and formatting a selection rewrites +only the rows it covers. Changing the whole paragraph's formatting, width or position rewrites +every row. + +Add Text also opens existing text when it is clicked; a blank-space click starts a new item. +Both text tools retype an added text item with one click. Draft boxes grow to the page edge +before wrapping, and the saved item uses that width. A single visual source row grows across font/style boundaries; +multi-row paragraphs retain their existing wrap width. Retyping uses a preview with only +that added item's old drawing removed, so deleted draft characters do not show underneath. + +A subset font contains only some of the original face's glyphs. A new character cannot be +reconstructed from a glyph the PDF does not contain. **Load font…** imports an openly licensed, +embeddable standalone `.ttf` or `.otf` face. **Installed fonts…** discovers eligible user-installed +faces on request, off the UI thread; the document-font menu selects resources already on the +page. Select **Keep source fonts** to preserve each run's original face. A matching full face +completes its subsets automatically, including new Chinese characters. When the PDF itself embeds +the complete program of a paragraph's font (not a subset) and its licence allows editable +embedding, a character the font's encoding can't show is drawn with that same program. A +character no available face has is refused with an error that names it. Choosing another face +is explicit. Fonts with restricted embedding, unsupported collections, excluded families or +no open-licence declaration are refused. Interface fallback fonts are never exported. + +The full selected program, glyph mappings, widths and Unicode text are embedded in the PDF. +Saving and reopening therefore needs no locally installed font. Editing the same text again +reuses the font resource the page already has, and every resource made from one program +shares a single copy of it. Original subset bytes are +preserved. Optional matching faces from `CRAFT_FONTS_DIR` can also complete source subsets; +this works only when that build input actually contains the full face. This change does not +add a font binary or change the release font pin. Fonts shared by releases belong in +`storytold/craft-fonts` under the repository's asset rules. + +Rewrapped Chinese and Japanese text, which has no spaces between words, may break between any +two characters. A row never begins with closing punctuation, a closing bracket or quote, a small +kana or the prolonged sound mark, and never ends with an opening bracket or quote or a currency +sign before an amount: the line-start and line-end rules of JIS X 4051, as in Unicode line +breaking (UAX #14). Latin words inside such text are not split. Korean and Thai still break only +at spaces. + +Select text within the editor before changing size, colour, font, underline or spacing to +format that selection. Automation supports up to 1024 disjoint UTF-8 byte ranges with the +same properties; invalid or overlapping ranges fail atomically. Untouched source formatting +survives. OpenType shaping uses the selected/source face's substitution and positioning +rules, preserves marks and ligatures, and applies Unicode bidi ordering for a single face. +Logical text remains available after save/reopen through ToUnicode and ActualText. +Added Arabic text records ActualText per shaped line for copying and searching in typing order. +ActualText already supplies typing order, so the editor preserves it without applying +a second bidi reversal; adjacent visual glyph runs still recover their own reading order. +Only right-to-left ActualText is kept apart this way: left-to-right spans, such as the Latin +ligatures (`fi`, `fl`) Word and Chrome wrap in ActualText mid-word, join their line as before. + +A justified paragraph stretches each row but its last to the paragraph's width by widening its +spaces. Word spacing (`Tw`) widens only single-byte code 32, so where the font shows a space with +another code (a composite font, the generated Type 3 fallback) each space is followed by a `TJ` +adjustment instead. Justifying a paragraph with several formats in it is not supported yet: it is +left-aligned. + +For headless editing, use `text_font_files` → `text_font_import` → `text_fonts` and pass a +resource to `text_edit.font_resource`. `text_edit.ranges` holds selected range formatting. +`text_edit_layout` accepts resource/ranges too and measures a draft without applying it. +All file inputs respect CLI root confinement; importing a font and applying text are undoable. + +On a scanned page, recognised text (Scan & OCR) is an invisible layer over the picture. Edit +text groups it into phrases: words joined by a word space, with a table cell, a tab stop and each +scanned line kept apart. Applying an edit draws the new text visibly in the scan's ink colour (or +the one chosen) and first covers the old words with the paper colour sampled around them. Each +word keeps its position and width, and a longer phrase grows rightwards rather than wrapping. The +cover is one flat colour, and the text uses the layer's Helvetica, not the scanned typeface. +This applies to any invisible text (rendering mode 3), not just a PdfCraft OCR layer: editing +invisible text, such as another OCR tool's layer, makes the rewritten paragraph visible over a +painted cover in the sampled paper colour. Edit text also groups invisible text differently: its lines never +merge into multi-line paragraphs, and words split at gaps of 0.6 em or more. + +Explicit marked paragraph/heading scopes prevent unrelated tagged regions from merging. +Compatible geometry groups untagged content, including runs split across content streams +or equivalent graphics transforms. Geometry cannot infer author intent in every untagged +PDF. Semantic structure-tree preservation, legacy Type 1/Type 3 inline font preview, +font collections/variable-font instances, mixed-font/style contextual runs and complete rotated +layout fidelity still need broader coverage. Do not interpret the passing real-file checks +as a guarantee for every PDF. + +Private acceptance tests are opt-in and keep inputs unmodified. They exercise actual Chinese +keyboard insertion/deletion on all four pages, source-face/baseline checks and identical +in-memory renders before and after saving/reopening. Synthetic tests cover full TrueType +mapping, OpenType/CFF, Arabic marks/ligatures/mixed bidi, repeated edits, disjoint styles, +invalid ranges, font selection and root confinement. CLI acceptance repeats insertion/deletion +and two saves/reopens on four pages; independent PDFium checks extraction and rendering. +No private document, font or screenshot +is included in the repository. diff --git a/docs/editing-images.md b/docs/editing-images.md index fde18518f2..67d3613bf1 100644 --- a/docs/editing-images.md +++ b/docs/editing-images.md @@ -3,9 +3,13 @@ In **Edit a PDF → Edit text and images**, click a figure to select it. Drag the selected figure to move it, drag a corner to resize it proportionally, or press Delete to remove that placement. The context menu also rotates or flips it. Changes support undo and -survive saving/reopening. An image painted inside its own rectangular clip -(`q … re W n … cm Do Q`, as PowerPoint exports it) moves and resizes together with that -clip; a clip shared with other content stays where it is. +survive saving/reopening. For single-figure transforms, a clipping path exclusive to one image moves and resizes +together with its pixels, including nested graphics states, intervening state operators +and paths split across content streams. A clip shared with other content stays where it is. +When the figure already sits alone in a `q … cm … Q` (its own placement, or the one an +earlier edit added), moving or resizing it again changes that `cm`, so repeated edits don't +nest one more graphics state each time. Moving a privately cropped figure again likewise +changes the `cm` before its clip instead of adding another. PDF figures are not always bitmap images. Imported diagrams often use a **Form XObject**, which packages text, vector paths, and nested images into one object. PdfCraft selects @@ -23,3 +27,20 @@ Forms have `pixels: [0, 0]`, since they have no single bitmap resolution. Use th number with `image_edit` (move, rotate, flip_horizontal, flip_vertical, delete). Coordinates are top-left-origin page points. `image_save` and `image_edit`'s replace action return a clear error for Form artwork. + +Selection frames follow the visible portion of artwork after page and path clipping. Clicking +editable text takes precedence over overlapping artwork, including labels inside an image's +visible rectangle. Hidden image placements have no selection frame. Bezier extrema determine +curved-path frames. Hit testing evaluates the actual clipping paths with their nonzero or +even-odd fill rule, so holes and empty corners do not intercept clicks. Intersections of several +curved clips can still have a loose bounding frame. Dragging the visible frame moves or resizes +the full placement by the same change; resizing keeps the opposite corner fixed, including +horizontal-only or vertical-only corner drags, and grabbing a grip slightly off-centre does not +jump. A selected corner keeps control of its drag even over a text label; the drag previews one +frame and applies one undoable edit on release. Exclusive crops and their holes move +with the selected image; shared clips stay fixed without moving neighbouring text or artwork. + +`image_hit` applies the same path and hole checks headlessly. `page_images` retains `rect` as +the full placement rectangle used by `image_edit` and adds `visible_rect` for selection (null +when fully clipped). `object_list` reports visible artwork bounds while keeping the original +source indexes. A selection frame does not create, remove or enlarge the PDF's clipping path. diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index 5871ea9778..d7230c0962 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -1,6 +1,6 @@ # File format parity -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (encrypted output opens in Acrobat, #774; XFDF fixes #807, #809, #819) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS) +> **Last reviewed:** 2026-10-12 · **Last updated:** 2026-10-12 · **Change:** minor (XDP data row corrected; BigTIFF in Create PDF) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS) Every format Acrobat Pro reads or writes, and PdfCraft's support for it. Acrobat's list comes from its bundle's `Info.plist` `CFBundleDocumentTypes` (what it opens) and its public documentation @@ -38,7 +38,8 @@ features total). | FDF (`.fdf`) comments and form data | R W | R W | good | `crates/xfdf`; comment-exchange fixes for popups, polygon vertices and callouts (#807, #809, #819) | | XFDF (`.xfdf`, ISO 19444-1) | R W | R W | good | as above | | XML form data | R W | R W | good | `form.data-xml-csv-txt` | -| XDP (`.xdp`, XFA data package) | R | none | none | Acrobat opens `.xdp` as a document type | +| XDP data (`.xdp`) for XFA forms | R W | R W: Export Data writes the whole datasets as an XDP; Import Data takes an XDP, datasets or data XML | good | `form.xfa-data` (`data_exports_as_datasets_xml_or_an_xdp_and_imports_from_any_of_them`, `xfa_data_files_export_and_import_through_tools`) | +| XDP opened as a document | R | none | none | Acrobat opens `.xdp` as a document type | | CSV / tab-delimited form data | W (merge to spreadsheet) | R W; merge to spreadsheet | good | `form.merge-to-spreadsheet` | | Comment summary (PDF) | W | W (comments only layout) | basic | `comment.summarize` partial | | Measurement CSV | W | W | good | `comment.measure-export` | @@ -62,7 +63,7 @@ features total). | Source | Acrobat | PdfCraft | Fidelity | Tests and evidence | |---|---|---|---|---| -| JPEG, PNG, TIFF (multipage), GIF, BMP, JPEG 2000 | yes | yes (ICC profiles kept; DPI choices) | good | `create.from-images`, `create.from-multipage-tiff` | +| JPEG, PNG, TIFF (multipage, and BigTIFF), GIF, BMP, JPEG 2000 | yes | yes (ICC profiles kept; DPI choices) | good | `create.from-images`, `create.from-multipage-tiff`, `bigtiff_images_become_pages` | | PCX | yes | no | none | in Acrobat's document types | | Plain text | yes | yes | good | `create.from-text` | | Clipboard (image or text) | yes | yes | good | `create.from-clipboard` | @@ -73,19 +74,19 @@ features total). | PostScript / EPS (Distiller) | yes | no | none | `create.distiller` planned (P3) | | Markdown | no | no | none | `create.from-markdown` planned (P2; not an Acrobat feature) | | Scanner | yes | no | none | `ocr.scanner-acquire` planned | -| Several files at once | yes | yes (PDFs, images and text) | good | `create.create-multiple` | +| Several files at once | yes | yes: combine PDFs, images and text into one document, or create a separate PDF for each non-PDF source (GUI and `doc_create_multiple mode=separate`); existing outputs get numbered names | good for supported source types | `create.create-multiple`, `separate_batch_creates_one_pdf_per_source_without_replacing_existing_files` | ## Export PDF to | Target | Acrobat | PdfCraft | Fidelity | Tests and evidence | |---|---|---|---|---| -| Word (`.docx`) | yes, layout-preserving | flowing document: paragraphs, headings, bold/italic, colour, images, tables | **basic** (#773: layout lost, fake borders, duplicated pages) | `create.export-docx` partial | +| Word (`.docx`) | yes, layout-preserving | flowing paragraphs and borderless inferred tables; positioned text/artwork only for pages with rotated text or text overlapping an image/watermark, with per-page sizes | **basic** (#773 synthetic invoice regression covered; exact fonts, vector rules and general layout fidelity remain incomplete) | `create.export-docx` partial | | Word 97–2003 (`.doc`) | yes | no | none | | | Excel (`.xlsx`), XML Spreadsheet 2003 | yes | no | none | `create.export-xlsx` planned | -| PowerPoint (`.pptx`) | yes | no | none | `create.export-pptx` planned | +| PowerPoint (`.pptx`) | yes | slides per page: title from largest text, body text, images, tables (no slide overflow reflow / normAutofit; first page sets presentation dimensions) | basic | `create.export-pptx` partial | | RTF | yes | yes | basic | `create.export-rtf` | | HTML (single page, multiple pages) | yes | single file with headings, paragraphs, images, tables | basic | `create.export-html` partial | -| Plain text | yes | yes (reading order) | good (word-F1 0.98 vs `pdftotext`) | `create.export-text`, `xtask text-oracle` | +| Plain text | yes | yes (reading order) | good (word-F1 0.98 vs `pdftotext`; reading order is scored separately, because F1 cannot see it) | `create.export-text`, `xtask text-oracle` | | Accessible text (from tags) | yes | no | none | `create.export-accessible-text` planned | | PNG, JPEG, TIFF | yes | yes | good | `create.export-png`, `create.export-jpeg-tiff` | | JPEG 2000 | yes | no | none | `create.export-jpeg2000` planned | @@ -109,5 +110,7 @@ features total). | Date | Change | Summary | |---|---|---| +| 2026-10-12 | minor | XDP split into data (read and write for XFA forms, already shipped under `form.xfa-data`) and opening `.xdp` as a document (none). Create PDF reads BigTIFF; malformed GIF headers are no longer detected as images. | +| 2026-10-11 | minor | #773: inferred tables have no invented borders; rotated or overlapping invoice pages use floating shapes and page sections. Full Office layout fidelity remains partial. | | 2026-10-11 | minor | Encrypted PDF row: written files open in Acrobat DC (#774, fixed by #836). FDF row: #807, #809 and #819 fixed | | 2026-10-10 | major | Created from Acrobat 26.002.21931's `CFBundleDocumentTypes`, Acrobat's public Create/Export documentation, and PdfCraft's code and checklist | diff --git a/docs/gaps.md b/docs/gaps.md index 1c4ad963da..2682d090ac 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -1,7 +1,16 @@ # Where PdfCraft falls short of Acrobat Pro -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** patch (gap 2: hayro patch counts after the native-gradient backport) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS) +> **Last reviewed:** 2026-10-15 · **Last updated:** 2026-10-15 · **Change:** minor (gap 12: reading order is scored, not just word F1) · **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-12 · **Last updated:** 2026-10-13 · **Change:** minor (gap 14: Vietnamese catalog shipped; Hindi/Indonesian/Korean and Arabic RTL remain) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS) +> **Last reviewed:** 2026-10-13 · **Last updated:** 2026-10-13 · **Change:** minor (gap 6: #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-12 · **Last updated:** 2026-10-12 · **Change:** minor (gap 6: #1398 stamps read upright on rotated pages; 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 (gap 6: #1317, #1327, #1302, #1315 and #1340; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (#1316: replacing a line keeps a neighbouring line whose glyph box overlaps; gap 3 remains) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** gap 4 closed: the web build prints through the browser's print dialog; exclusive image-crop resize follow-up; minor (Word invoice regression coverage, #773; Office fidelity still partial; gap 2: hayro patch counts after the native-gradient backport) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (gap 13: large-file memory measured, edits keep two copies instead of three, file-backed input blocked on gap 2) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS) > **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (gap 1 closed by #836; fixed issues removed from gaps 3, 6 and 16) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (document-font subset completion and curved/shared image clips in the contributor fork; gap 2: hayro patch counts after the native-gradient backport) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS) Every known shortfall, one entry each, ranked by what it costs users. This is the work list: agents choose from the top unless the owner says otherwise, and prefer these over new P2/P3 checklist @@ -11,7 +20,8 @@ feature lacks). No gap blocks the [alpha gate](roadmap.md#alpha-gate); gap 8 holds the convert-and-share workflow's partial sub-cases and is the first beta item. Gap numbers are kept when a gap closes, so -references elsewhere stay valid (gap 1, encrypted output Acrobat rejected, closed with #836). +references elsewhere stay valid (gap 1, encrypted output Acrobat rejected, closed with #836; gap 4, +no printing on the web, closed by printing through the browser's print dialog). Hours are Opus 5.5 agent wall-clock hours. "Doc" names the parity document the gap belongs to. When a gap closes, delete its entry, note it in the [ROADMAP.md](../ROADMAP.md) progress log and @@ -23,7 +33,6 @@ update the numbers. |---|---|---|---|---| | 2 | Rendering is borrowed and its fidelity unmeasured | feature, spec | 110–190 | [pdf-spec-parity.md](pdf-spec-parity.md) | | 3 | Editing existing text is fragile on real files | feature | 100–180 | [target-app-parity.md](target-app-parity.md) | -| 4 | No printing on the web | feature, platform | 15–30 | [hardware-parity.md](hardware-parity.md) | | 5 | Chinese UI shows missing-glyph boxes in releases | localization | 6–12 | [localization-parity.md](localization-parity.md) | | 6 | Open-issue backlog of wrong results in shipped features | stability | 45–80 | this file | | 7 | OCR reads only unaccented Latin | feature | 40–75 | [file-format-parity.md](file-format-parity.md) | @@ -33,7 +42,7 @@ update the numbers. | 11 | Accessibility: checker only, no tagging tools | feature | 40–70 | [target-app-parity.md](target-app-parity.md) | | 12 | No measured fidelity harness against Acrobat | quality | (in 2) | [target-app-parity.md](target-app-parity.md) | | 13 | Performance budgets and lazy loading of very large files | performance | 25–45 | this file | -| 14 | Arabic not mirrored; no Hindi, Indonesian, Korean, Vietnamese | localization | 30–50 | [localization-parity.md](localization-parity.md) | +| 14 | Arabic not mirrored; no Hindi, Indonesian, Korean | localization | 25–45 | [localization-parity.md](localization-parity.md) | | 15 | Forms: JavaScript object model, actions, submit, XFA depth | feature | 40–75 | [pdf-spec-parity.md](pdf-spec-parity.md) | | 16 | UI precision gaps (multi-select move, rulers, accelerators, windows) | UI/UX | 30–60 | [ui-parity.md](ui-parity.md) | | 17 | Certificate security and other protect gaps | feature, spec | 15–30 | [pdf-spec-parity.md](pdf-spec-parity.md) | @@ -51,8 +60,8 @@ update the numbers. - **Missing:** our own renderer (M2: `model` crate, font engine, DisplayList devices, ADR-0004), or an owner decision to adopt another engine; and any measurement of rendering against Acrobat. -- **Evidence:** pages are drawn by vendored `hayro` 0.7 with 23 local patches to `hayro-interpret`, - 10 to `hayro`, 3 to `hayro-jbig2` and 13 to `hayro-syntax` (`vendor/README.md`); `lopdf` still +- **Evidence:** pages are drawn by vendored `hayro` 0.7 with 26 local patches to `hayro-interpret`, + 15 to `hayro`, 3 to `hayro-jbig2` and 17 to `hayro-syntax` (`vendor/README.md`); `lopdf` still backs document inspection. All 7 rendering P0 features are `partial`. User reports: TikZ pattern fills drawn wrong ([#794](https://github.com/storytold/pdfcraft/issues/794)), blurry text at 1080p/1440p ([#730](https://github.com/storytold/pdfcraft/issues/730)), fixed zoom sizes differ @@ -68,6 +77,27 @@ update the numbers. ### 3. Editing existing text is fragile on real files +- **Fork progress (2026-10-11):** compatible words and subset-font spaces form lines; + source fonts lacking a space glyph remain reusable; inline editing previews remove source + glyphs on a clone. Aligned visual rows now form mixed-style regions (including matrix-scaled headings), + with source-run styles preserved during editing and delete/save/reopen regressions. A private + real-file acceptance check and opt-in UI regression supplement synthetic coverage. + Embedded sfnt faces now render in the editor, including Mac format-6 subsets adapted in memory + through the PDF Unicode map. Source baselines remain stable; draft frames expand/shrink with + visible text, and `text_edit_layout` measures saved draft bounds without applying them. + Page/path-clipped artwork bounds and text-first hit testing prevent large cropped images from + blocking text. A separate private four-page regression opens all 73 detected text regions; + curve/hole hit testing now evaluates clipping paths and fill rules. A clip the image owns + moves with it across nested states and content streams; shared path clips stay where they are. + Full matching document fonts complete absent subset glyphs; explicit installed/file font + selection, TrueType/OpenType-CFF embedding, disjoint style ranges, marked paragraph boundaries + and single-face OpenType shaping/bidi have regression coverage. Actual Chinese keyboard + insertion/deletion and save/reopen renders are checked on all four private pages. + Legacy non-SFNT previews, mixed-font/style contextual runs, structure-tree fidelity and reliable + semantic grouping on every untagged PDF remain open. Replacing one line or paragraph no longer + removes a distinct neighbour whose glyph box overlaps (#1316); a second drawing on the same + baseline is still replaced. Estimates are unchanged. + - **Missing:** robust in-place editing of text PdfCraft didn't write: subset fonts without the needed glyphs, CJK and right-to-left text, rotated text, lists, superscript/subscript, keeping structure tags, find and replace in edit mode; vector and object editing; arrange and align. @@ -83,16 +113,6 @@ update the numbers. how a document looks. - **Estimate:** 100–180 h (the longest feature pole, M7). **Doc:** [target-app-parity.md](target-app-parity.md). -### 4. No printing on the web - -- **Missing:** browser printing. -- **Evidence:** the web build has no spooler: off Windows and unix, `spool::submit` reports that - printing to a printer isn't available and only the print-ready PDF can be saved. - `crates/print/README.md`: "Not yet: the web spooler". -- **Impact:** the web build cannot send a job to a printer; printing is a P0 workflow. Printing on - Windows shipped with [#756](https://github.com/storytold/pdfcraft/issues/756). -- **Estimate:** 15–30 h. **Doc:** [hardware-parity.md](hardware-parity.md). - ### 5. Chinese UI shows missing-glyph boxes in releases - **Missing:** a Simplified Chinese (`Hans`) UI face in release builds and a working fallback. @@ -112,8 +132,18 @@ update the numbers. - **Missing:** fixes for the ≈ 13 of ≈ 25 filed reports of features marked `shipped` that give wrong results still open, plus triage of ≈ 220 other open issues. - **Evidence:** 245 open issues on 2026-10-10. The first batch of `fix(...)` reports was fixed by - 2026-10-10 (#799, #800, #807, #809, #810, #811, #815, #816, #817, #818, #819, #821); the rest of - the backlog is untriaged. + 2026-10-10 (#799, #800, #807, #809, #810, #811, #815, #816, #817, #818, #819, #821). A later + batch keeps a cloudy comment flatten cannot draw (#1317), leaves a refused text-box restyle or + resize unchanged (#1327), keeps arrow defaults off plain lines (#1302), stops a custom text + match on the separator instead of the next glyph (#1315), and states the vertical-text + redaction refusal without claiming an overlap (#1340). Organize Pages opens on the page being + read, selected and scrolled into view (#1910). Stamps placed on a rotated page read upright + (#1398). Another batch keeps a spaced XFA radio value in the datasets (#1319), replaces only the + selected pages when they are not adjacent (#1305), and leaves unrelated page streams in place + when a figure is marked decorative (#1324). The rest of the backlog is untriaged. +- **Fix verified (2026-10-14):** raw-deflate fallback returns a decoding-limit error instead of + accepting an empty partial stream when a misleading zlib header triggers a retry. Synthetic + filter and COS stream regressions cover refusal at the limit and decoding within it. - **Impact:** every one is a user who lost trust. Crash reports outrank feature work (`AGENTS.md` §4). - **Estimate:** 45–80 h (≈ 0.2–0.4 h per issue). **Doc:** this file. @@ -134,8 +164,10 @@ update the numbers. - **Missing:** Word export that keeps layout; Excel and PowerPoint export; creating PDFs from Office, HTML, web pages and PostScript; SVG/XML/EPS/PS/JPEG 2000 export. -- **Evidence:** `create.export-docx` partial; [#773](https://github.com/storytold/pdfcraft/issues/773) - (an invoice converted to Word loses its layout, gains table borders and duplicates pages); +- **Evidence:** `create.export-docx` partial; the synthetic invoice regression for + [#773](https://github.com/storytold/pdfcraft/issues/773) now covers absent inferred borders, + floating watermark text and per-page section sizes. Exact font matching, vector rules and + broader layout fidelity remain incomplete; `create.export-xlsx`, `create.export-pptx`, `create.from-office`, `create.from-html` planned. - **Impact:** "Export PDF" and "Create PDF" are two of Acrobat's most used tools. - **Gate:** lossy or missing sub-cases of the convert-and-share workflow (partial, not blocking). @@ -169,14 +201,19 @@ update the numbers. - **Impact:** government and legal users sign with tokens and need B-LT/B-LTA. - **Estimate:** 40–70 h; tokens need hardware to test. **Doc:** [pdf-spec-parity.md](pdf-spec-parity.md). -### 11. Accessibility: checker only, no tagging tools +### 11. Accessibility: only text-only automatic structure creation -- **Missing:** autotag (document and fields), Tags, Order and Content panels, tag properties, - Reading Order tool, artifact marking, table editor, role map, Make Accessible action, Read Out - Loud; full keyboard operation of PdfCraft itself and a screen-reader audit. -- **Evidence:** all 32 checker rules shipped, but 21 tagging and reading features planned; +- **Missing:** apply auto-tag proposals to marked content and a valid parent tree, tag fields + into structure trees with unusual layouts (only a direct Document root with a direct + ParentTree Nums array is extended), + visual Tags, Order and Content panels, Reading Order tool, table editor, Make Accessible action, + Read Out Loud; full keyboard operation of PdfCraft itself and a screen-reader audit. +- **Evidence:** all 32 checker rules, figure alt text, tag property and sibling order edits, and + a bounded auto-tag preview exist. Apply writes valid structure and parent-tree links for + text-only files; it refuses mixed content and existing tags; `a11y.keyboard-only` (P0) planned, `a11y.app-screen-reader` partial. -- **Impact:** remediation work is impossible; PdfCraft itself is not yet usable without a mouse. +- **Impact:** most untagged files still need another editor to build their structure; PdfCraft itself is + not yet usable without a mouse. - **Estimate:** 40–70 h. **Doc:** [target-app-parity.md](target-app-parity.md). ### 12. No measured fidelity harness against Acrobat @@ -185,10 +222,57 @@ update the numbers. signature validation against Acrobat on synthetic fixtures; an interop suite that opens every kind of output we write (encrypted ones above all, after #774) in pdf.js, MuPDF and Poppler as oracle processes. -- **Evidence:** none exists; only text extraction has an oracle (word-F1 0.98 against `pdftotext`). - Acrobat captures must stay local in `plan/acrobat/` (clean-room, `AGENTS.md` §1.1). -- **Impact:** until it exists, every quality number here is a judgement. -- **Estimate:** counted in gap 2 (≈ 20–30 h of it). **Doc:** [target-app-parity.md](target-app-parity.md). +- **Progress (2026-10-14):** rendering now has an oracle too. `cargo xtask render-oracle` compares + page 1 of every corpus file against Poppler's `pdftoppm` and reports a disagreement rate: the + share of pixels with no near match in the other renderer's 3×3 neighbourhood, which forgives + antialiasing and subpixel placement but not missing or wrong content (`crates/oracle`, module + `pixels`). **First reading, 250 files at 72 dpi, tolerance 8: 229 compared, median 0.89%, mean + 2.16%, p90 5.35%, 25 within 0.1%.** It reports rather than gates — the corpus is not fetched on + CI, and a first reading of a borrowed renderer is a baseline to improve on, not a bar to clear. + The worst results cluster on JBIG2 `bitmap-*` files (`hayro-jbig2` carries 3 local patches) and + on ZapfDingbats, which points the renderer work at something specific for the first time. +- **Read the score as a disagreement, not a verdict (2026-10-14).** The worst file of the 250, + `bug1552113.pdf` at 84%, turned out to be Poppler's defect: it paints the link's `/Border [0 0 + 112]` as a 112-unit band over the two lines of text, and the fixture is named "this text should + be visible". Ours is the readable rendering. Anything at the top of the list needs a person to + look at both images before it is treated as a bug of ours. +- **The JBIG2 cluster was Poppler too, and the suite checks itself (2026-10-14).** The next four + worst files were JBIG2, which looked like a defect in `hayro-jbig2` and its local patches. It is + the reverse. The 96 `bitmap-*.pdf` fixtures are a conformance suite: one picture encoded through + every corner of JBIG2, so a correct decoder renders all 96 identically — a real test needing no + reference image and no oracle. **We render 1 distinct image across all 96; Poppler renders 8**, + drawing a blank page for the seven halftone-skip and symbol fixtures and mis-decoding TPGRON + refinement (its output changes when TPGRON is switched on, which a correct decoder's cannot). + Pinned as `crates/render/tests/jbig2_invariants.rs`. **Poppler is not a usable oracle for JBIG2**, + and roughly a fifth of its fixtures should be read as its bugs rather than ours. +- **Annotations are shown, and that was measured rather than argued.** Poppler synthesises an + appearance for an annotation that has none, where we draw only what the document carries (link + borders without `/AP` are recorded as not covered under `view.render-annotation-appearances`), so + showing them scores us for a known gap. Hiding them is worse, because it then scores Poppler for + every appearance we *do* draw: over the same 250 files, shown gives mean 2.16% / p90 5.35% / 25 + files within 0.1%, hidden gives 2.18% / 5.55% / 22, with `annotation-tx3.pdf` jumping to 45.64%. + The biases nearly cancel; `--hide-annotations` tells the two apart for one file at a time. +- **The text score was blind to reading order (2026-10-15).** Word F1 is a bag of words, so a page + emitted back to front scores a perfect 1.000 — and reading order is most of what text extraction + has left to get wrong (#2387, #2586). `cargo xtask text-oracle` now reports a second score: the + longest common subsequence of the two word sequences over the longer one, both cut to 3,000 + words. The hole was not hypothetical. Measuring #2431, a change that moves text and alters + nothing else, **word F1 called 26 of the 29 files whose output changed identical**, where the + order score separated 18 improvements from 6 regressions and named their shape: forms, where a + checkbox's number was parted from its label (`prefilled_f1040.pdf` 0.672 → 0.481). Read it as a + disagreement, like the pixel score: the same split scored as a win on one file and a loss on + another purely because `pdftotext` makes it in one place and not the other. The corpus-wide + first reading has not been taken yet. +- **Evidence:** text extraction (word-F1 0.98 against `pdftotext`) and now rendering (above); qpdf + checks written output on every CI run since `PDFCRAFT_ORACLES_REQUIRED`. Acrobat captures must + stay local in `plan/acrobat/` (clean-room, `AGENTS.md` §1.1). +- **Still missing:** Acrobat itself as the reference rather than other open renderers; form + calculation and signature validation; the interop suite that opens every kind of output we write + (encrypted ones above all, after #774) in pdf.js and MuPDF; and 15 of those 250 files could not + be compared at all because the two renderers round the page box to different pixel sizes. +- **Impact:** until it is complete, quality numbers outside rendering and text are judgements. +- **Estimate:** counted in gap 2 (≈ 20–30 h of it; roughly a third done). + **Doc:** [target-app-parity.md](target-app-parity.md). ### 13. Performance budgets and lazy loading of very large files @@ -196,17 +280,28 @@ update the numbers. memory budget, with budgets checked on Tier-1 platforms. - **Evidence:** #307 brought a 9,156-page file from > 10 GiB to ≈ 500 MiB inspection, but file-backed input and global memory budgets remain open; blurry fonts at common resolutions - (#730). + (#730). Measured 2026-10-12 on synthetic files (release build, Linux): a 1 GiB file opens at + 1.0 GiB peak in 0.7 s, so viewing holds the whole file once; after edits it held two copies and + peaked at three (3.1 GiB after five edits), because the previous working file stayed alive while + the next was written. Edits now let go of it first: five edits peak at 2.1 GiB (8.5 s, was 16 s). + Saving still peaks at three copies (the saved file is assembled in memory beside the working + one), and display appearances for comments without one add a copy. A 100,000-page file (28 MB) + opens at 403 MiB in 5.5 s and each edit re-inspects it for ≈ 5 s. +- **Blocked part:** reading a GB file from disk on demand (`ByteSource`) needs every reader to + take ranges, but the `hayro` renderer parses one contiguous buffer (`Pdf::new(PdfData)`), so it + waits on gap 2's renderer decision. Until then the budget is one copy of the file to view and two + to edit. - **Estimate:** 25–45 h. **Doc:** this file. -### 14. Arabic not mirrored; no Hindi, Indonesian, Korean, Vietnamese +### 14. Arabic not mirrored; no Hindi, Indonesian, Korean - **Missing:** a right-to-left layout (panels, menus, alignment), bidirectional text in the text - layout itself (wrapping, typed text); catalogs for Hindi, Indonesian, Korean and Vietnamese - (Acrobat ships Korean; it ships Arabic in its Middle East edition; it ships none of the other - three). + layout itself (wrapping, typed text); catalogs for Hindi, Indonesian and Korean (Acrobat ships + Korean; it ships Arabic in its Middle East edition; it ships none of Hindi or Indonesian). + Vietnamese ships as a catalog (`vi`, 100% of the current localizable union) but stays `partial` + like other non-English languages (no translated help; engine/OS details stay English). - **Evidence:** [localization.md](localization.md) §Arabic; `misc.rtl-ui` planned. -- **Estimate:** 30–50 h (Arabic RTL 20–35, four catalogs 10–15 plus review). **Doc:** +- **Estimate:** 25–45 h (Arabic RTL 20–35, three catalogs 8–12 plus review). **Doc:** [localization-parity.md](localization-parity.md). ### 15. Forms: JavaScript object model, actions, submit, XFA depth @@ -221,7 +316,7 @@ update the numbers. ### 16. UI precision gaps -- **Missing:** moving several selected objects across pages (same-page moves shipped, #844), nudging with arrow keys, single-key tool accelerators, +- **Missing:** moving several selected objects across pages (same-page moves shipped, #844), single-key tool accelerators, rulers, grids and guides, loupe and dynamic zoom, multiple windows and split view, comment pop-ups that can be moved, connector lines, customisable quick tools; shortcut display and tool-mode bugs (#746, #721), macOS window controls (#789). @@ -279,14 +374,25 @@ update the numbers. - **Missing:** a menu and tool listing captured from Acrobat (names only, `plan/acrobat/`, never committed) diffed against the checklist, so that features the checklist never listed show up. - Also correct `misc.localization` and `misc.installers`, which are still `planned` though both - ship. + `misc.localization` and `misc.installers` are now `partial` (Chinese, Telugu and Arabic are not + fully usable yet; Linux packages are unsigned). - **Estimate:** 4–8 h. **Doc:** [target-app-parity.md](target-app-parity.md). ## Revision history | Date | Change | Summary | |---|---|---| +| 2026-10-15 | minor | Gap 12: the text oracle scores reading order as well as word F1, which is a bag of words and cannot see order; the first measurement separated 18 improvements from 6 regressions in a change F1 called unchanged on 26 of 29 files. 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-15 | patch | Gap 24: record localization and installers as partial (unusable Chinese, Telugu and Arabic; unsigned Linux packages) | +| 2026-10-13 | minor | Gap 6: Organize Pages selects and reveals the current page on open, including after an earlier grid selection elsewhere (#1910). Estimates unchanged. | +| 2026-10-12 | minor | Gap 6: rubber stamps, explicit image rectangles and PDF-page stamps read upright on rotated pages (#1398). Estimates unchanged. | +| 2026-10-12 | minor | #1316: replacing a line or paragraph keeps a neighbouring line whose glyph box overlaps. Gap 3 remains. Estimates unchanged. | +| 2026-10-12 | minor | Exclusive image crops resize with their pixels across nested states, intervening operators and content streams; shared clips remain fixed. Synthetic engine/tool regressions and private image save/reopen checks; estimates unchanged. | +| 2026-10-12 | minor | Gap 13: measured large-file memory; edits no longer keep the previous working file while writing the next (peak 3 → 2 copies); file-backed input recorded as blocked on gap 2 | +| 2026-10-11 | minor | Closed gap 4: the web build prints through the browser's print dialog (sheets drawn by PdfCraft at 150–300 dpi, one per printed page); number kept | +| 2026-10-11 | minor | Word invoice failure #773 has synthetic regression coverage; broader Office conversion remains gap 8. | +| 2026-10-11 | minor | Contributor fork: mixed-style visual regions, source-font space reuse and a read-only textless background. Embedded sfnt source faces and Mac subset Unicode mappings keep edit baselines stable; live frames follow Apply/Save geometry as text grows or shrinks. Engine/tool/UI regressions and private all-page checks cover the changes; full matching font import, CFF, range styles, shaping and private Chinese typing checks added; legacy programs and general fidelity remain open; estimates unchanged | | 2026-10-11 | patch | Gap 2: 23 hayro-interpret and 10 hayro patches after axial and radial shadings became native gradients (backport of hayro 0.8) | | 2026-10-11 | minor | Closed gap 1 (#774, fixed by #836; numbers kept, its interop suite moved to gap 12); gap 6 lists the fixed #799–#821 batch; gap 3 and 16 updated for #844 (same-page move), #740 (column select) and #759 (1:1 touchpad scrolling) | | 2026-10-10 | minor | Removed the alpha markers: with the cross-app gate rule, gaps 1 and 8 are partial sub-cases, not blockers; stage alpha | diff --git a/docs/hardware-parity.md b/docs/hardware-parity.md index 81a60be8c2..fcd0e8ac0d 100644 --- a/docs/hardware-parity.md +++ b/docs/hardware-parity.md @@ -1,6 +1,6 @@ # Hardware and platform parity -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (Windows printers row and 1:1 touchpad scrolling, #756, #759) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (web printing through the browser's print dialog; earlier: shared craft-print spooler with passing Windows CI; Windows printers row and 1:1 touchpad scrolling, #756, #759) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS) Each hardware feature Acrobat Pro uses, per platform, ours against Acrobat's. Acrobat runs on macOS and Windows (plus a limited web viewer/editor in Document Cloud); PdfCraft runs on macOS, Windows, @@ -17,7 +17,7 @@ printing are counted in their feature areas). **Platforms ≈ 70% ready, 15–30 | Multi-threaded rendering | yes | yes (render pool, tiles, watchdog) | yes | yes | single thread | `view.render-priority` | | HiDPI / Retina | yes | yes | yes, per-monitor DPI v2 (#324, #725) | yes | yes | text blurry at 1080p/1440p (#730) | | Multiple monitors | yes, multiple windows | one window | one window; surfaces fit the GPU limit (#577) | one window | n/a | `view.multiple-windows` planned | -| Printers | all, PostScript, printer properties | CUPS spooler | Windows spooler: lists printers, prints rasterised at the printer's resolution (#756, PR #847) | CUPS spooler | **none** | `crates/print/README.md` | +| Printers | all, PostScript, printer properties | CUPS via craft-print | Windows PDF raster spooler via craft-print (#756, PR #847) | CUPS via craft-print | the browser's print dialog (every printer the system knows): sheets drawn by PdfCraft at 150–300 dpi; copies, two-sided and colour set there | `crates/print/README.md`, `crates/ui-egui/src/web_print.rs` | | Scanners (TWAIN, WIA, ICA) | yes | none | none | none (SANE) | n/a | `ocr.scanner-acquire` planned | | Smart cards and USB tokens | yes (PKCS #11, CryptoTokenKit, CNG) | Keychain identities (software keys) | CNG store keys, with PIN prompts for cards (#742) | none (PKCS #11 planned) | none | `sign.pkcs11` planned | | Pen and touch | Touch mode, pen ink, Windows Ink | trackpad pinch zoom; ink from pointer, no pressure | ink from pointer, no pressure or touch mode | as macOS | pointer | touchpad scrolling 1:1 (#759, PR #868) | @@ -34,12 +34,14 @@ printing are counted in their feature areas). **Platforms ≈ 70% ready, 15–30 | Windows (x64, x86, ARM64) | yes (x64, ARM64) | yes | signed installers | — | | Linux | no | yes | AppImage, deb, rpm, Flatpak, tarball | Chinese faces in Flatpak (#689) | | FreeBSD | no | yes | package | — | -| Web | Acrobat online (cloud) | yes (WASM, offline in the browser) | static site | printing, OCR, crash recovery, PKCS #12 signing, hosted deployment | +| Web | Acrobat online (cloud) | yes (WASM, offline in the browser) | static site | OCR, crash recovery, PKCS #12 signing, hosted deployment | ## Revision history | Date | Change | Summary | |---|---|---| +| 2026-10-11 | minor | Web printers row: printing through the browser's print dialog (gap 4 closed); Web platform row no longer lists printing | | 2026-10-10 | major | Created from the code, `vendor/README.md`, `.github/workflows/release.yml`, the print crate's README and user issues | | 2026-10-10 | minor | Windows printing shipped (#756): the Windows row's gap is closed; the web row keeps "printing" | +| 2026-10-11 | minor | Shared craft-print spooler; Windows preserves PDF sheet size/orientation and uses corrected duplex binding edges. Physical output remains a hardware check. | | 2026-10-11 | minor | The Printers row's Windows cell still said none: now the Windows spooler (#756, PR #847). Touchpad scrolling 1:1 (#759, PR #868) | diff --git a/docs/localization-parity.md b/docs/localization-parity.md index 0ea3daa7ec..995293af3d 100644 --- a/docs/localization-parity.md +++ b/docs/localization-parity.md @@ -1,6 +1,7 @@ # Localization parity -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-10 · **Change:** major (first per-language status, measured from the catalogs) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-13 · **Change:** minor (Vietnamese 100% of current key union; native-speaker community review; twelve-language mean ≈68%) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-14 · **Change:** minor (Italian count after completing its catalog) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS) Per-language status of PdfCraft's interface against Acrobat Pro's. How translations work and how to add a language: [localization.md](localization.md). Summary in [ROADMAP.md](../ROADMAP.md). @@ -8,10 +9,11 @@ add a language: [localization.md](localization.md). Summary in [ROADMAP.md](../R ## How this was measured - **Strings translated** is measured: the catalogs in `crates/ui-egui/src/i18n/.tsv`, counted - as distinct `(context, English)` keys with a non-empty translation. The denominator is the union - of keys across all catalogs, **2,143**. Cross-check: of the 1,151 distinct `tl!("…")` literals in - `crates/ui-egui/src`, every full catalog covers 96.5–99.4% (7 literals are in no catalog yet). - English is built in. + as distinct `(context, English)` keys with a non-empty translation. The **current** denominator is + the union of keys across all catalogs (**2,590** plain keys as of 2026-10-13, plus language-specific + context rows such as Vietnamese `theme`/`Appearance`). The twelve-language percentage table below + still quotes the **historical 2,143-key snapshot** from 2026-10-10 for every language except + Vietnamese (re-measured against the current union). English is built in. - **Not counted:** error details from the engine and the operating system, which are shown in English inside a translated frame; there is no translated user documentation or help yet (Acrobat ships localized help). @@ -38,15 +40,17 @@ add a language: [localization.md](localization.md). Summary in [ROADMAP.md](../R | Japanese | `ja` | 2,123 (99.1%) | dialogs and tooltips; no help | BIZ UDPGothic from craft-fonts; IME patched on macOS, Windows, X11, Wayland; vertical text renders but can't be edited | no | partial | yes | 4–8 | | German | `de` | 2,128 (99.3%) | dialogs and tooltips; no help | Latin | community, in progress (#828: terminology, clipping) | partial | yes | 2–4 | | Korean | `ko` | 0 | none | Hangul IME patched (Microsoft and Apple Korean, `vendor/README.md` winit 3, 6, 9, 10); no Hangul UI face in craft-fonts | no | none | yes | 6–10 | -| Vietnamese | `vi` | 0 | none | Latin with stacked diacritics; bundled Inter covers them (not tested) | no | none | no | 3–5 | +| Vietnamese | `vi` | 2,591 (100% of current union + theme Appearance) | dialogs and tooltips; no help | Latin with stacked diacritics; bundled Inter covers them (fonts test) | community | partial | no | 2–4 | -**Localization dimension ≈ 59%**: the mean over the twelve, counting a catalog's percentage but only -60% for Chinese and Arabic until their script problems are fixed, and 0 for the four missing -languages. **≈ 60–115 h** to bring all twelve to `full`, of which ≈ 35–60 h is engineering (Hans -face, RTL layout, bidi text) and the rest catalogs and fixes; native-speaker review needs humans. +**Localization dimension ≈ 68%**: the mean over the twelve, counting a catalog's percentage but only +60% for Chinese and Arabic until their script problems are fixed, and 0 for Hindi, Indonesian and +Korean (still missing). Vietnamese moved from 0% to 100% in that mean (59% → 68%). **≈ 55–110 h** +to bring all twelve to `full`, of which ≈ 35–60 h is engineering (Hans face, RTL layout, bidi text) +and the rest catalogs and fixes; native-speaker review needs humans. No language is `full` except English: every catalog leaves engine error details in English, and -none has translated help. +none has translated help. Vietnamese's **To `full`** hours are therefore residual help/docs polish +(catalog and script work are already done), not missing UI strings. ## Other shipped languages @@ -59,14 +63,26 @@ none has translated help. | Telugu | `te` | 2,058 (96.0%) | partial (the release-pinned craft-fonts has no Telugu face yet) | no | | Hungarian | `hu` | 2,126 (99.2%) | partial | yes | | Ukrainian | `uk` | 2,142 (100%) | partial | yes | -| Italian | `it` | 2,130 (99.4%) | partial | yes | +| Italian | `it` | 2,528 entries (every key of the other catalogs that the code looks up) | partial | yes | +| Kazakh | `kk` | 2,327 entries | partial | no | +| Polish | `pl` | 2,287 entries | partial | yes | +| Slovenian | `sl` | 2,324 entries | partial | no | +| Greek | `el` | 2,341 entries | partial | no | +| Turkish | `tr` | 2,313 entries | partial | yes | +| Lithuanian | `lt` | 2,585 entries | partial; community native-speaker review ([#1401](https://github.com/storytold/pdfcraft/pull/1401)) | no | -**8 other languages** ship (15 catalogs plus English, 16 interface languages in Preferences). -Acrobat languages PdfCraft lacks: Danish, Dutch, Finnish, Norwegian, Polish, Swedish, Turkish, -Hebrew. +**14 other languages** ship (22 catalogs plus English = **23** interface languages in Preferences). +The entry counts without percentages were measured on 2026-10-12–13; they are not percentages of the older 2,143-key snapshot above. Their presence does not imply a detailed linguistic or runtime review. +Acrobat languages PdfCraft lacks: Danish, Dutch, Finnish, Norwegian, Swedish, Hebrew. Vietnamese is shipped here but not by Acrobat. +**14 other languages** ship (21 catalogs plus English, 22 interface languages in Preferences). +The entry counts without percentages were measured on 2026-10-12 (Italian on 2026-10-14); they are not percentages of the older 2,143-key snapshot above. Their presence does not imply a detailed linguistic or runtime review. +Acrobat languages PdfCraft lacks: Danish, Dutch, Finnish, Norwegian, Swedish, Hebrew. ## Revision history | Date | Change | Summary | |---|---|---| +| 2026-10-13 | minor | Vietnamese (`vi`) catalog: 2,591 entries (100% of current key union + `theme`/`Appearance`); twelve-language mean ≈ 68%; native-speaker community review; registered with `plural_none` | +| 2026-10-14 | minor | Italian: 2,528 entries after adding the 201 keys the other catalogs had and the code looks up | +| 2026-10-12 | minor | Added current counts for Kazakh, Polish, Slovenian, Greek, Turkish and Lithuanian; retained existing language assessments and the original percentage snapshot | | 2026-10-10 | major | Created. Catalog counts measured from `crates/ui-egui/src/i18n/*.tsv` against the 2,143-key union; Acrobat's localizations from its bundle's `.lproj` and Sequences folder names | diff --git a/docs/localization.md b/docs/localization.md index 5a019f6da2..657e2eb247 100644 --- a/docs/localization.md +++ b/docs/localization.md @@ -1,10 +1,10 @@ # Interface language -Choose **Menu > Edit > Preferences…** (Command-comma on macOS, Ctrl-comma elsewhere) **> Interface language** and select **Auto**, **English**, **日本語**, **简体中文**, **繁體中文**, **Русский**, **Български**, **Čeština**, **Português (Brasil)**, **Deutsch**, **Español**, **Français**, **తెలుగు**, **Magyar**, **Українська**, **Italiano** or **العربية**. The change applies immediately and persists between launches. Command ids, document contents and file names are unchanged. +Choose **Menu > Edit > Preferences…** (Command-comma on macOS, Ctrl-comma elsewhere) **> Interface language** and select **Auto**, **English**, **日本語**, **简体中文**, **繁體中文**, **Čeština**, **Português (Brasil)**, **Deutsch**, **Español**, **Français**, **Русский**, **Қазақша**, **Български**, **తెలుగు**, **Magyar**, **Українська**, **Italiano**, **العربية**, **Polski**, **Slovenščina**, **Ελληνικά**, **Türkçe**, **Lietuvių** or **Tiếng Việt**. The change applies immediately and persists between launches. Command ids, document contents and file names are unchanged. Displayed keyboard shortcuts use the same active interface language. For example, German shows `Strg+K` and `Strg+Umschalt+S` in the toolbar, menus, command palette, tooltips and Keyboard shortcuts dialog; macOS keeps its modifier symbols (`⌘`, `⇧`, `⌃`). Key names have their own `key` catalog context, so the Delete key can read `Entf` while the command remains `Löschen`. A missing key-name entry falls back to the English key name. Actual bindings and machine-facing command ids remain stable. -**Auto** (the default) follows the system language: `LC_ALL`, `LC_MESSAGES` or `LANG`, then the preferred-languages list on macOS. Any Portuguese locale (`pt_BR`, `pt_PT`) uses the Brazilian catalog, any French locale (`fr`, `fr_FR`, `fr_CA`, `fr_BE`) the French one, any German locale (`de`, `de_DE`, `de_AT`, `de_CH`) the German one, and Chinese locales for Taiwan, Hong Kong and Macau (`zh_TW`, `zh_HK`, `zh_MO`, `zh-Hant`) the Traditional Chinese one; other Chinese locales (`zh`, `zh_CN`, `zh_SG`, `zh-Hans`) the Simplified Chinese one. Telugu locales (`te`, `te_IN`) use the Telugu catalog, Bulgarian locales (`bg`, `bg_BG`) the Bulgarian one, and Hungarian locales (`hu`, `hu_HU`) the Hungarian one, and Ukrainian locales (`uk`, `uk_UA`) the Ukrainian one, and Italian locales (`it`, `it_IT`, `it_CH`) the Italian one, and Arabic locales (`ar`, `ar_MA`, `ar_EG`, `ar_SA`, …) the Arabic one. A system language without a catalog shows English. On Windows, **Auto** uses the Windows display languages, in preference order, when no supported locale is selected by those environment variables. Neutral `C`/`POSIX` locale values (including `C.UTF-8`) inherited from a launcher do not override the Windows display language; explicitly named supported locales such as `fr_FR.UTF-8` still do. The query asks Windows directly (`GetUserPreferredUILanguages`, through the `sys-locale` crate) without starting a process, and is cached for the process. If detection fails or the display language has no catalog, PdfCraft shows English. +**Auto** (the default) follows the system language: `LC_ALL`, `LC_MESSAGES` or `LANG`, then the preferred-languages list on macOS. Any Portuguese locale (`pt_BR`, `pt_PT`) uses the Brazilian catalog, any French locale (`fr`, `fr_FR`, `fr_CA`, `fr_BE`) the French one, any German locale (`de`, `de_DE`, `de_AT`, `de_CH`) the German one, and Chinese locales for Taiwan, Hong Kong and Macau (`zh_TW`, `zh_HK`, `zh_MO`, `zh-Hant`) the Traditional Chinese one; other Chinese locales (`zh`, `zh_CN`, `zh_SG`, `zh-Hans`) the Simplified Chinese one. Telugu locales (`te`, `te_IN`) use the Telugu catalog, Bulgarian locales (`bg`, `bg_BG`) the Bulgarian one, and Hungarian locales (`hu`, `hu_HU`) the Hungarian one, and Ukrainian locales (`uk`, `uk_UA`) the Ukrainian one, and Italian locales (`it`, `it_IT`, `it_CH`) the Italian one, and Arabic locales (`ar`, `ar_MA`, `ar_EG`, `ar_SA`, …) the Arabic one, and Turkish locales (`tr`, `tr_TR`) the Turkish one, and Slovenian locales (`sl`, `sl_SI`) the Slovenian one, and Vietnamese locales (`vi`, `vi_VN`) the Vietnamese one. A system language without a catalog shows English. On Windows, **Auto** uses the Windows display languages, in preference order, when no supported locale is selected by those environment variables. Neutral `C`/`POSIX` locale values (including `C.UTF-8`) inherited from a launcher do not override the Windows display language; explicitly named supported locales such as `fr_FR.UTF-8` still do. The query asks Windows directly (`GetUserPreferredUILanguages`, through the `sys-locale` crate) without starting a process, and is cached for the process. If detection fails or the display language has no catalog, PdfCraft shows English. The control channel exposes the setting through `ui.set`: @@ -12,12 +12,24 @@ The control channel exposes the setting through `ui.set`: {"method":"ui.set","params":{"key":"language","value":"ja"}} ``` -The value is `auto` or a language code (`en`, `ja`, `zh-hans`, `zh-hant`, `ru`, `bg`, `cs`, `pt-br`, `de`, `es`, `fr`, `te`, `hu`, `uk`, `it`, `ar`), in any case. `ui.state` reports `language` as written in the preferences (`auto`, `en`, `ja`, `zh-hans`, `zh-hant`, `ru`, `bg`, `cs`, `pt-br`, `de`, `es`, `fr`, `te`, `hu`, `uk`, `it` or `ar`). Unknown values return an error without changing the current setting. Preferences saved before this setting existed follow the system language. +The value is `auto` or a language code (`en`, `ja`, `zh-hans`, `zh-hant`, `cs`, `pt-br`, `de`, `es`, `fr`, `ru`, `kk`, `bg`, `te`, `hu`, `uk`, `it`, `ar`, `pl`, `sl`, `el`, `tr`, `lt` or `vi`), in any case. `ui.state` reports `language` as written in the preferences (`auto`, `en`, `ja`, `zh-hans`, `zh-hant`, `cs`, `pt-br`, `de`, `es`, `fr`, `ru`, `kk`, `bg`, `te`, `hu`, `uk`, `it`, `ar`, `pl`, `sl`, `el`, `tr`, `lt` or `vi`). Unknown values return an error without changing the current setting. Preferences saved before this setting existed follow the system language. -Dialogs, panels, menus, notices and history labels go through the catalog (`tl!`). Japanese, Traditional Chinese, Simplified Chinese, Russian, Bulgarian, German, Spanish, French, Telugu, Hungarian, Ukrainian, Italian, Brazilian Portuguese and Arabic translate them (about 2,000–2,140 entries each); Czech covers the menus so far. Untranslated labels use English. Error details that come from the engine or the operating system are shown as they are, inside a translated frame ("操作「…」に失敗しました: …"). The command palette matches the translated label, the English label and the command id. Vertical Japanese PDF rendering is an existing viewer feature; this change does not add vertical text editing. Per-language coverage, script support and what each language still needs are in [localization-parity.md](localization-parity.md). +Dialogs, panels, menus, notices and history labels go through the catalog (`tl!`). Most shipped catalogs (Japanese, Chinese, Russian, Bulgarian, German, Spanish, French, Telugu, Hungarian, Ukrainian, Italian, Brazilian Portuguese, Arabic, Slovenian, Kazakh, Polish, Greek, Turkish, Lithuanian, Vietnamese and others) cover the current localizable surface (about 2,000–2,600 entries each); Czech still covers mainly the menus. Untranslated labels use English. Error details that come from the engine or the operating system are shown as they are, inside a translated frame ("操作「…」に失敗しました: …"). The command palette matches the translated label, the English label and the command id. Vertical Japanese PDF rendering is an existing viewer feature; this change does not add vertical text editing. Document contents, filenames, CLI/MCP/control protocol strings and external help stay outside the catalog. Per-language coverage, script support and what each language still needs are in [localization-parity.md](localization-parity.md). +Dialogs, panels, menus, notices and history labels go through the catalog (`tl!`). Japanese, Traditional Chinese, Simplified Chinese, Russian, Bulgarian, German, Spanish, French, Telugu, Hungarian, Ukrainian, Brazilian Portuguese and Arabic translate them (about 2,000–2,140 entries each), and Italian has 2,528 entries; Czech and Turkish cover the menus so far. Untranslated labels use English. Error details that come from the engine or the operating system are shown as they are, inside a translated frame ("操作「…」に失敗しました: …"). The command palette matches the translated label, the English label and the command id. Vertical Japanese PDF rendering is an existing viewer feature; this change does not add vertical text editing. Per-language coverage, script support and what each language still needs are in [localization-parity.md](localization-parity.md). +Dialogs, panels, menus, notices and history labels go through the catalog (`tl!`). Japanese, Traditional Chinese, Simplified Chinese, Russian, Bulgarian, German, Spanish, French, Telugu, Hungarian, Ukrainian, Brazilian Portuguese, Arabic and Slovenian translate them (about 2,000–2,190 entries each), and Italian has 2,528 entries; Czech covers the menus so far. Untranslated labels use English. Error details that come from the engine or the operating system are shown as they are, inside a translated frame ("操作「…」に失敗しました: …"). The command palette matches the translated label, the English label and the command id. Vertical Japanese PDF rendering is an existing viewer feature; this change does not add vertical text editing. Per-language coverage, script support and what each language still needs are in [localization-parity.md](localization-parity.md). + +Whole sentences that the layers below word — what the accessibility checker found, what the PDF/A verifier reports, what a signature's details say, what is wrong with the pages to print, what an edit is called in the history — are translated too, because those layers hand the sentence over apart from the document's own words. See [Sentences the engine words](#sentences-the-engine-words). The Ukrainian catalog has 2,047 entries, written from the meaning of the English labels using Ukrainian computing vocabulary. Commands use infinitives; fixed-count messages use count-neutral wording. Plural-aware messages use the three [CLDR Ukrainian integer forms](https://www.unicode.org/cldr/charts/48/supplemental/language_plural_rules.html#uk): `1 сторінка`, `2 сторінки`, `5 сторінок`; 11–14 take the third form, while 21 and 22 return to the first and second. Calendar dates use genitive month names (`9 жовтня 2026`), while headings use standalone names (`Жовтень 2026`). Home recommendation cards grow to fit translated text; the toolbar search hint truncates before the shortcut, with the full label available on hover. +The Lithuanian catalog has 2,585 entries and three integer plural forms. Calendar headings use standalone month names; dates use genitive forms. Coverage tests check commands/tools, scanned literal and direct lookups, and source/context keys in the other bundled catalogs. Calendar, inspector and password-dialog UI regressions check dynamic labels and preserve document values. A native Lithuanian-speaking contributor has reviewed the translation. + +The Vietnamese catalog has 2,591 entries and one plural form (`plural_none`). Preferences theme `Appearance` uses context `theme` → `Giao diện`; object/comment/signature appearance stays plain `Diện mạo`. Form fields use `Trường`; redaction apply wording states permanent removal. Calendar months are numeric (`Tháng 1`…`12` / `tháng 1`…`12`). Coverage tests require every current command, UI literal, bundled key and credit label. A native Vietnamese-speaking contributor has reviewed the translation. Status stays `partial`: no translated help, and engine/OS details stay English inside a translated frame. + +Edit text reads Hebrew lines in typing order and writes them back in drawing order (`crates/edit/src/bidi.rs`); letters the page's own font lacks are drawn with the craft-fonts `Hebr` face (Noto Sans Hebrew) as Type 3 glyphs, and a build without one says so. Hebrew points (niqqud) can't be written yet in Edit text. Add text (#128) draws Hebrew with the same face through the shaped path Arabic and Thai use: right to left with numbers and Latin in place, points kept on their letter, and typing order for copy and search. The craft-fonts revision pinned for CI and releases has no `Hebr` face yet, so until one is added releases report that error instead of drawing the missing letters, and refuse Hebrew in Add text. + +Japanese interface text uses BIZ UDPGothic from [craft-fonts](https://github.com/storytold/craft-fonts), an optional build input that every release includes (`CRAFT_FONTS_DIR`; see the README). A build made without it has no Japanese face, so Japanese labels show replacement boxes. Chinese is drawn with the same craft-fonts CJK faces (in Simplified Chinese the `Hans` faces come first, so a line never mixes faces with different baselines), so a character none of them has shows a replacement box (and every Chinese character does in a build without craft-fonts). Russian, Bulgarian, Czech, Brazilian Portuguese, German, Spanish, French, Hungarian, Italian, Turkish and Lithuanian need only the bundled Latin faces (tested in `tests/fonts.rs`). Hungarian uses the same one/other plural rule as English: 1 is singular, and every other count — including 0 — is the other form. After a numeral the noun stays singular (`1 oldal`, `2 oldal`). Telugu is drawn with a craft-fonts `Telu` face (Noto Sans Telugu, kept in the web build too), whose conjuncts egui shapes; a build without one shows Telugu labels as replacement boxes, and the release-pinned craft-fonts input has no Telugu face yet. +Japanese interface text uses BIZ UDPGothic from [craft-fonts](https://github.com/storytold/craft-fonts), an optional build input that every release includes (`CRAFT_FONTS_DIR`; see the README). A build made without it has no Japanese face, so Japanese labels show replacement boxes. Chinese is drawn with the same craft-fonts CJK faces (in Simplified Chinese the `Hans` faces come first, so a line never mixes faces with different baselines), so a character none of them has shows a replacement box (and every Chinese character does in a build without craft-fonts). Russian, Bulgarian, Czech, Brazilian Portuguese, German, Spanish, French, Hungarian, Italian and Slovenian need only the bundled Latin faces (tested in `tests/fonts.rs`). Slovenian has four plural forms: one (1, 101, 201…), two (2, 102…), few (3, 4, 103…) and other (everything else, including 0, 5–20 and 21+), so `@plural` entries list four forms (`1 stran`, `2 strani`, `3 strani`, `5 strani`). Hungarian uses the same one/other plural rule as English: 1 is singular, and every other count — including 0 — is the other form. After a numeral the noun stays singular (`1 oldal`, `2 oldal`). Telugu is drawn with a craft-fonts `Telu` face (Noto Sans Telugu, kept in the web build too), whose conjuncts egui shapes; a build without one shows Telugu labels as replacement boxes, and the release-pinned craft-fonts input has no Telugu face yet. Japanese interface text uses BIZ UDPGothic from [craft-fonts](https://github.com/storytold/craft-fonts), an optional build input that every release includes (`CRAFT_FONTS_DIR`; see the README). A build made without it has no Japanese face, so Japanese labels show replacement boxes. Chinese is drawn with the same craft-fonts CJK faces (in Simplified Chinese the `Hans` faces come first, so a line never mixes faces with different baselines), so a character none of them has shows a replacement box (and every Chinese character does in a build without craft-fonts). Until craft-fonts has an allowed `Hans` face, the desktop app draws a Chinese interface with a Chinese face already installed on the machine (Microsoft YaHei or JhengHei on Windows, PingFang, Hiragino Sans GB or STHeiti on macOS, WenQuanYi or Droid Sans Fallback on Linux; never Noto CJK or Source Han), placed before the Japanese faces so a line keeps one face (#826). The web build has no installed fonts to fall back on. Russian, Bulgarian, Czech, Brazilian Portuguese, German, Spanish, French, Hungarian and Italian need only the bundled Latin faces (tested in `tests/fonts.rs`). Hungarian uses the same one/other plural rule as English: 1 is singular, and every other count — including 0 — is the other form. After a numeral the noun stays singular (`1 oldal`, `2 oldal`). Telugu is drawn with a craft-fonts `Telu` face (Noto Sans Telugu, kept in the web build too), whose conjuncts egui shapes; a build without one shows Telugu labels as replacement boxes, and the release-pinned craft-fonts input has no Telugu face yet. The Brazilian Portuguese catalog has 2,000 entries: the ~1,826-entry gist André Cardoso posted in [#288](https://github.com/storytold/pdfcraft/issues/288), the labels added since, and a review of the existing wording. Portuguese, like French, treats 0 and 1 as singular (`1 página`, `2 páginas`). @@ -53,13 +65,25 @@ PdfCraft uses the same system as PhotoCraft (`crates/ui-egui/src/i18n/`): - Each language has a catalog, `crates/ui-egui/src/i18n/.tsv`, with one entry per line: `contextEnglishtranslation`. An empty context marks a plain string. `@id` keys an entry by command id (for example `file.saveAs`), so one menu item can read differently from another with the same English label. `@plural` holds plural forms. Any other context disambiguates an English word with several meanings (`tl_ctx!` in UI code, for example the signature pad's "Type" button against the "Type" column). The header of `ja.tsv` documents the format and escapes. - Placeholders (`{name}`) must appear in both columns. A trailing `…` must be kept. `i18n::fmt` fills placeholders in one pass, so a value such as a file name containing `{n}` is inserted as written. - Catalogs are validated strictly: unknown escapes, unknown `@` contexts, placeholder or ellipsis mismatches, wrong plural form counts and duplicates are errors. The tests require the bundled catalogs to have none; at run time a bad line is skipped (and logged) and that string shows in English. -- Catalogs are assets: each `.tsv` has an `ATTRIBUTION.toml` entry with `kind = "translation"` and its SHA-256, checked by `cargo xtask assets`. +- Catalogs are assets: each `.tsv` has an `ATTRIBUTION.toml` entry with `kind = "translation"` and its SHA-256, checked by `cargo xtask assets`. After editing a catalog, put the new hash into its entry by hand (`sha256sum crates/ui-egui/src/i18n/.tsv`), because `cargo xtask assets --write` does not touch `ATTRIBUTION.toml`; the hash check is deliberate, so a changed asset gets its provenance re-checked. Without it `cargo xtask assets` and the test `assets::tests::the_real_manifest_passes` fail. - Translations are clean-room: write them from the meaning of the English text, never from another product's string tables. +## Sentences the engine words + +Some sentences are not the interface's own. The accessibility checker, the PDF/A verifier, the signature reader, the page-range parser and the edit history all word their own findings, and matching that wording in the UI with a `tl!` literal would break silently whenever the engine changed it. So those layers hand the wording over instead, apart from the document's own words: + +- Each of them has a `WORDINGS` constant listing every sentence it can say, as `&'static str` with holes in it (`{n}`, `{p}`, `{}`). The sentence is written once, in the layer that means it. +- `said(&words)` says one of them: the layer looks its own wording up through the closure it is given, then fills the holes with the document's values — a page number, a font name, what the person typed — in one pass, so a value containing `{n}` is inserted as written. +- The UI passes `&crate::i18n::words`, which is a catalog lookup, so the sentence reads in the interface language. `in_english` is the same thing in English, and a wording with no entry comes back as it stands, which is the English — a catalog missing one shows an English sentence rather than a gap. +- The lists are `a11y::{WORDINGS, REPORT_WORDS}`, `preflight::WORDINGS`, `sign::pdf::{WORDINGS, kind::ALL}`, `print::WORDINGS` and the engine's `{EDIT_LABELS, BATCH_LABELS}`. `a11y::report_html` takes a language tag and a `words` closure, so a saved report reads the way the panel read, down to its `lang` attribute. +- `spanish_covers_what_the_engine_says` requires an entry for every wording in those lists, and `spanish_keeps_the_holes_in_what_the_engine_says` requires each translation to keep the same holes, so the catalog cannot fall behind the engine without a test saying so. Other languages are free to add the entries when they are translated; until then those sentences show in English as before. + +Error details that come from the operating system, and faults rather than mistakes (a file that cannot be read, a lock that was poisoned), still carry their own text and are shown as they are. + ## Adding a language 1. Copy the header of `ja.tsv` into `xx.tsv` and translate entries. 2. Add one row to `LANGUAGES` in `crates/ui-egui/src/i18n/mod.rs`: code, native name, catalog and plural rule. -3. Add an `ATTRIBUTION.toml` entry for the catalog (`kind = "translation"`), then run `cargo xtask assets --write` and `cargo xtask assets`. +3. Add an `ATTRIBUTION.toml` entry for the catalog (`kind = "translation"`), with the catalog's `sha256sum` as `sha256`, then run `cargo xtask assets --write` (it regenerates `ATTRIBUTION.md` only) and `cargo xtask assets`. The Preferences dropdown, the system-language match and the catalog tests (format, duplicates, placeholders, plural forms, command ids) then pick it up. diff --git a/docs/mcp.md b/docs/mcp.md index 830a6c3120..77a3c9484d 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -1,6 +1,6 @@ # MCP conventions -Start explicitly with `pdfcraft-cli mcp [--root DIR] [--compact]`. The server uses JSON lines on +Start explicitly with `pdfcraft-cli mcp [--root DIR] [--compact] [--automation-read-root DIR] [--automation-write-root DIR]`. The server uses JSON lines on stdin/stdout, opens no port, and stops on EOF. `--root` confines all reads and writes, including commands invoked through the common tools. Logs stay on stderr. @@ -8,6 +8,115 @@ Supply `--root` at most once, followed by a nonempty directory path. Missing ope repeated roots and another long option in place of the directory are refused before any script is read or server is started. Prefix a directory name beginning with `--` with `./`. +## Install and register + +From the repository root, with Rust 1.95+ and a native linker installed +(see [development](development.md) for build and environment notes): + +```sh +cargo install --path apps/pdfcraft-cli --locked +# POSIX shells, for Cargo's default install location: +export PATH="${CARGO_HOME:-$HOME/.cargo}/bin:$PATH" +pdfcraft-cli --help +pdfcraft-cli mcp --help +``` + +The default features include MCP; do not pass `--no-default-features` for this use. +Cargo installs a binary, not a symlink into `target/`, so it survives `cargo clean`. +Re-run the install command after updating the checkout. +By default the executable goes in `$CARGO_HOME/bin` (`~/.cargo/bin` when unset), or +`%USERPROFILE%\.cargo\bin` on Windows. If you set `--root`, `CARGO_INSTALL_ROOT`, or +Cargo's `install.root`, use that installation root's `bin` directory instead. Add it +to the `PATH` inherited by the MCP client, not just an interactive shell, and restart +the client after changing its environment. + +Register the installed command explicitly (replace `/work/project` with an existing folder): + +```sh +claude mcp add pdfcraft -- pdfcraft-cli mcp --root /work/project +``` + +Other MCP clients can use this bare-name configuration: + +```json +{"mcpServers":{"pdfcraft":{"command":"pdfcraft-cli","args":["mcp","--root","/work/project"]}}} +``` + +Linux `.deb` and `.rpm` packages already install `/usr/bin/pdfcraft-cli`; those users +can skip the source install. The [downloads list](../README.md#downloads) also includes +standalone Linux CLI tarballs and a macOS CLI zip; unpack and copy the executable into +a directory on the client's `PATH`. Installing does not start or register a server: +MCP remains opt-in and uses stdio only. + +If a client reports `ENOENT` or "Executable not found in $PATH", verify the install with +`command -v pdfcraft-cli` (PowerShell: `Get-Command pdfcraft-cli`) in the client's +environment. If that environment cannot be changed, set JSON `command` to the absolute +installed binary path, not a literal `~` or `$HOME` expression. Avoid symlinks into +`target/release`: cleaning the build directory breaks them. A source build alone does +not put the CLI on `PATH`. + +## Confining file access + +```sh +pdfcraft-cli mcp --automation-read-root /work/project --automation-write-root /work/project/out +``` + +```json +{"mcpServers": {"pdfcraft": {"command": "pdfcraft-cli", + "args": ["mcp", "--automation-read-root", "/work/project", "--automation-write-root", "/work/project/out"]}}} +``` + +The two flags (VectorCraft, PhotoCraft and the other Craft apps take the same ones, so one client +config works across the suite) keep the agent to the folders you give it: + +- Every file a tool reads must lie inside the **read root**: `doc_open`, `page_insert_file`, + `page_replace`, `doc_combine` and `doc_create_multiple` inputs, `doc_create` images, stamp, + picture, attachment, signature and form-image files, `doc_watermark` and `doc_background` files, + `doc_import_data`, `form_merge_data`, the files `action_run` and `ocr_recognize_files` process, + digital ID, graphic and certificate files for signing and `sign_trust`, `text_font_import`, and + the folders `text_font_files` searches. Without `directories`, `text_font_files` searches the read + root instead of the installed fonts. +- Every file a tool writes must lie inside the **write root**: `doc_save` (also saving in place), + `page_extract`, `doc_combine`, `doc_create_multiple`, `doc_split`, `doc_reduce`, `doc_optimize`, + `doc_print` to a file, the `doc_export_*` tools, `image_save`, `measure_export`, + `comments_summarize`, the accessibility report and review record, `doc_compare_report`, + `form_merge_data`, the output folders of `action_run` and `ocr_recognize_files`, `sign_id_create` + and `sign_document`. +- Read and write access are separate. Give the same folder twice for both. A root left out grants + none of its access: with only `--automation-read-root`, nothing can be written to disk, though + tools that give their result back instead of a path still do. Without either flag nothing changes. +- A path is made absolute against the server's working directory, then its links are followed: a file + (or folder) link that leads out of a root is outside it. For a file that doesn't exist yet, its + nearest existing folder's links are followed and the rest must be plain names, so `..` below a + folder that doesn't exist is refused, as is a link that leads nowhere (writing through it would + create its target). The result is compared with the root by whole folder names: `/work2/x` is not + inside `/work`. On Windows case is ignored, UNC paths are resolved like drive-letter ones, and + device names (`CON`, `NUL`, `COM1`...) are refused. +- A refused path is an ordinary tool error the agent can read and correct, for example + `automation path rejected: outside the write root /work/project/out: /etc/x.pdf` or + `automation filesystem access is not granted: write authority is absent: /work/project/out/x.pdf`. + Tools that handle several files report a refused one in its own entry. +- The roots must be existing folders; a missing one stops the server with an error. Each flag is + given once (`--flag ` or `--flag=`). +- `--root DIR` still works and can be combined with these flags. Both then apply: a path must be + inside `--root` and inside the matching automation root. + +`pdfcraft-cli mcp` always runs the session in its own process (there is no flag to connect it to a +running app), so the roots cover every server it starts. The desktop app is not confined by them. + +What the roots do not cover: tools that take no path still read and change the open documents, and +`text_extract` and the other inspection tools return their content. Keychain and Windows +certificate-store identities for `sign_document` are not files and are not checked. Sending a job +to a printer is not a file write. Every path a tool reads or writes goes through +`Automation::resolve`, and a unit test keeps the tool modules from touching the file system +without it. + +A path is checked, then opened: another program that swaps a link in between can still win that +race, so the roots keep an agent to the files it was given rather than guarding against other +software on the computer. + +## Tools + | Tool | Arguments | |---|---| | `command_list` | `doc?`, `filter?`, `enabled_only?`; returns `{commands: [...]}` with mapped tool schemas | diff --git a/docs/pdf-spec-parity.md b/docs/pdf-spec-parity.md index 4f833aeb72..4e21ef2ec9 100644 --- a/docs/pdf-spec-parity.md +++ b/docs/pdf-spec-parity.md @@ -1,6 +1,6 @@ # PDF specification and standards parity -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (§7.6.4: written encrypted files open in Acrobat, #774) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-14 · **Change:** minor (§12.4.4: page transitions and display times read) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS) How much of ISO 32000-2 (PDF 2.0) and the PDF subset standards PdfCraft implements, set against what Acrobat Pro implements. This is PdfCraft's equivalent of a "geometry" or "codec" checklist: @@ -35,7 +35,7 @@ are borrowed; PDF 2.0 additions and most subset standards are missing. **150–2 | §10 | Rendering: transfer functions, halftones, overprint, smoothness | full | borrowed; no overprint preview | `print.overprint-preview` planned | | §11 | Transparency: blend modes, soft masks, groups, knockout | full | borrowed (knockout shipped) | `view.render-transparency` partial | | §12.1–12.3 | Viewer preferences, page layout, page labels, outlines, destinations | full | **own**: initial view, page labels, bookmarks edit and from structure, named destinations rewired on copy | named-destination editing panel planned | -| §12.4 | Page transitions, thumbnails, articles, presentations | full | none (thumbnails discarded or kept) | `organize.page-transitions`, `view.articles-panel` planned | +| §12.4 | Page transitions, thumbnails, articles, presentations | full | `/Trans` and `/Dur` read and listed by `doc_info`, not played; thumbnails discarded or kept | `organize.page-transitions` partial, `view.articles-panel` planned | | §12.5 | Annotations | 28 types | see table below | | | §12.6 | Actions | 19 types | see table below | | | §12.7 | Interactive forms (AcroForm): fields, appearances, calculation order, JavaScript actions | full | **own**: all field types, regenerated appearances, Acrobat's AF functions and event order, sandboxed JavaScript (boa) | `form.js-host` partial (object-model subset); submit planned | @@ -44,7 +44,7 @@ are borrowed; PDF 2.0 additions and most subset standards are missing. **150–2 | §12.9 | Measurement and geospatial | full | 2D measuring with viewports and calibration; no geospatial | `comment.geospatial` planned | | §12.10 | Document requirements | yes | none | | | §13 | Multimedia: sound, movies, screen, rich media, 3D (U3D, PRC) | yes | preserved untouched; not played or shown | `misc.rich-media-*`, `misc.3d-view` planned (P3) | -| §14.3 | Metadata: info dictionary, XMP | full, XMP editor | info dictionary edited; XMP kept in step for PDF/A; no XMP editor or custom properties | `organize.xmp-editor` planned | +| §14.3 | Metadata: info dictionary, XMP | full, XMP editor | info dictionary edited, with its XMP counterparts (title, author, subject, keywords, creator tool, producer) kept in step; XMP written for PDF/A; no XMP editor or custom properties | `organize.xmp-editor` planned | | §14.6–14.8 | Marked content, logical structure, tagged PDF | full, with editing tools | read (checker, bookmarks from structure, redaction of tags); no tag editing or autotag | gap 11 | | §14.9 | Accessibility support: alt text, ActualText, language | full | alt text figure by figure, language, title | `a11y.alt-text-workflow` | | §14.10 | Web capture | yes | none | | @@ -64,7 +64,7 @@ are borrowed; PDF 2.0 additions and most subset standards are missing. **150–2 | FreeText (text box, typewriter, callout) | create, rich text | create; rich text written but no per-run formatting | `comment.text-box` partial | | Line, Square, Circle, Polygon, PolyLine | create | create, all ten line endings | | | Highlight, Underline, Squiggly, StrikeOut | create | create | | -| Caret (insert/replace text) | create | create; not snapped to text | `comment.insert-text` partial | +| Caret (insert/replace text) | create | create; snapped to the text in the UI (upright horizontal text, unrotated pages) | `comment.insert-text` partial | | Stamp | create, custom, dynamic | create, custom, dynamic, Sign Here | stamp management planned | | Ink | create, erase | create, erase | no pressure | | FileAttachment | create | create | | @@ -81,7 +81,8 @@ are borrowed; PDF 2.0 additions and most subset standards are missing. **150–2 | Action | Acrobat | PdfCraft | |---|---|---| | GoTo | yes | yes (page with Fit; named destinations and zoom partial) | -| GoToR, GoToE | yes | none (preserved; the Optimizer can remove invalid ones) | +| GoToR | yes | partial: local PDF paths open in a PdfCraft tab; `/F` strings and file-spec dictionaries (`/UF`, then `/F`), source-relative paths, and explicit numeric-page destinations are supported. Automation uses the same resolver and `--root` confinement. Named destinations open at the target's default view with a warning; `/NewWindow` is a tab hint. Network/URL file systems are refused. | +| GoToE | yes | none (preserved; the Optimizer can remove invalid ones) | | Launch | yes (restricted) | none | | Thread | yes | none | | URI | yes | yes | @@ -129,5 +130,6 @@ are borrowed; PDF 2.0 additions and most subset standards are missing. **150–2 | Date | Change | Summary | |---|---|---| +| 2026-10-14 | minor | §12.4.4: `/Trans` and `/Dur` read into `DocInfo::transitions` and reported by `doc_info` | | 2026-10-11 | minor | §7.6.4: encrypted files we write open in Acrobat (#774, fixed by #836) | | 2026-10-10 | major | Created from the code, `vendor/README.md` and `parity/acrobat-features.toml`, against ISO 32000-2 and Acrobat Pro 26.002.21931's documented capabilities | diff --git a/docs/releasing.md b/docs/releasing.md index 7f82e29d26..ec2e5853b3 100644 --- a/docs/releasing.md +++ b/docs/releasing.md @@ -119,10 +119,30 @@ neither the MSI nor the portable zip needs the Visual C++ redistributable. this. - `installer-ui.wxs` supplies native welcome, maintenance, progress, files-in-use and outcome dialogs. Full UI confirms success with Finish; failures and cancellations have distinct messages. - The welcome dialog has a "Create a desktop shortcut" checkbox, ticked by default. `/qn` and `/qb` - stay unattended and create the desktop shortcut unless `INSTALLDESKTOPSHORTCUT=0` is passed. -- `test-msi.ps1 ` checks the compiled shortcut and dialog tables. Packaging runs it in a - child process before signing, so the MSI isn't held open when signtool runs. The ARM64 install + The welcome dialog has a "Create a desktop shortcut" checkbox and an "Edit with PdfCraft" + right-click menu checkbox, both ticked by default. `/qn` and `/qb` stay unattended and create + both unless `INSTALLDESKTOPSHORTCUT=0` or `INSTALLPDFCONTEXTMENU=0` is passed; + `INSTALLIMAGECONTEXTMENU=0` (no checkbox) leaves out the image menu. An update (the launcher runs + `msiexec /passive` with no properties) keeps the desktop shortcut only if `PdfCraft.lnk` is still + on the all-users desktop, so deleting it once keeps it away (#1058), and keeps each menu only if + its key path is still in HKLM (#305). Both are AppSearch results read before the old version is + removed. An update from a release that didn't have a menu yet (before 0.7.1 for the PDF menu, + before 0.4.0 for the image menu, found by `OnlyDetect` Upgrade rows) adds it like a first install. + A value given on the command line wins. +- The PDF right-click entry (#682) is a classic verb under + `HKLM\Software\Classes\SystemFileAssociations\.pdf\shell\PdfCraft.Edit`, so it shows whichever app + is the default for `.pdf` and never changes that default. Several selected PDFs (up to 15, the + limit for a `Document` verb) start one `pdfcraft.exe ""` each, and the single-instance + hand-off opens them as tabs. On Windows 11 it is under "Show more options"; a first-level entry + needs an `IExplorerCommand` handler and package identity (a sparse MSIX package), which the MSI + doesn't have. +- The image right-click entry (#190) is the same kind of verb, `PdfCraft.CreatePdf`, for ten image + types, in its own component (`PdfcraftImageContextMenu`) since it became optional. Its rows moved + out of `PdfcraftApp`; that's safe because `MajorUpgrade` removes the old version before installing + the new one (`afterInstallValidate`), which `test-msi.ps1` checks. +- `test-msi.ps1 ` checks the compiled shortcut, context-menu and dialog tables, and + evaluates the first-install and update conditions of the desktop shortcut and both menus. Packaging + runs it in a child process before signing, so the MSI isn't held open when signtool runs. The ARM64 install smoke test checks that both all-users shortcuts point at the installed `pdfcraft.exe` and are removed on uninstall, and that `INSTALLDESKTOPSHORTCUT=0` skips the desktop one. - The portable zip holds both executables, the README, the licences, the OFL licence of each diff --git a/docs/roadmap.md b/docs/roadmap.md index 13f269df50..5d73511e9c 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -1,11 +1,51 @@ # PdfCraft roadmap: milestones and what's next +> **Last reviewed:** 2026-10-18 · **Last updated:** 2026-10-18 · **Change:** minor (M5, PR #1971: bounded nested FDF export excludes source page/page-tree dictionaries; refreshed against upstream 4db6ac4a; estimates unchanged) +> **Last reviewed:** 2026-10-15 · **Last updated:** 2026-10-15 · **Change:** minor (M14/#2383 GPU-limit candidate; 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-15 · **Last updated:** 2026-10-18 · **Change:** minor (M4, #1919: repeated page indices accumulate rotations; M4: indirect page rotations refreshed upstream; M13: CLI check visits each directory once through aliases and cycles; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS) +> **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) +> **Last reviewed:** 2026-10-18 · **Last updated:** 2026-10-18 · **Change:** minor (M8, #1350: named replacement descriptions retired during Sanitize and across shared resources; estimates unchanged) +> **Last reviewed:** 2026-10-17 · **Last updated:** 2026-10-17 · **Change:** minor (M4 #2997: Ctrl-drag (Option on macOS) in the Organize grid copies the pages into the drop gap; `page_duplicate` takes `at`; 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: AcroForm XML text; estimates unchanged) +> **Last reviewed:** 2026-10-15 · **Last updated:** 2026-10-15 · **Change:** minor (M6: preserve XML/XFDF attribute whitespace; refreshed upstream; 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 (M1: raw-deflate fallback preserves decoding-limit errors; refreshed upstream; 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, 2,528 entries; 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-12 · **Last updated:** 2026-10-13 · **Change:** minor (M2/M6: spot-colour images convert once per pixel value; widget placement and the Fields panel scale with the field count; 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 on slow frames and coalesced text selection; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS) +> **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-15 · **Change:** minor (M2: sampled functions index a flat table, one image decode serves every zoom level, patch meshes copy less; 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 (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) +> **Last reviewed:** 2026-10-12 · **Last updated:** 2026-10-12 · **Change:** minor (M13: CLI check visits each directory once through aliases and cycles; 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: Extract and Split preserve shared Type 3 glyph resources; 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 (M2: packed TIFF-predictor RGB rows retain their byte boundaries; 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 (M3 #1364 candidate: consent before absolute local GoToR access; estimates unchanged); earlier: minor (M1 #1339: unchanged incremental saves of a classic cross-reference table write a zero-length subsection; 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 (M5 #1398: stamps read upright on rotated pages; 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 (gap 6: #1317, #1327, #1302, #1315, #1340; 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 (M7 #1316: replacing a line keeps a neighbouring line whose glyph box overlaps; 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 (M5: comment submission shortcuts; 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 (M1 #1339: unchanged incremental saves of a classic cross-reference table write a zero-length subsection; 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 (M3: Windows' Animation effects setting is followed, and Preferences has Animation effects; earlier: exclusive image-crop resize follow-up; M2: short same-line redraws split before placement; 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 (M7: inline Arabic ActualText ligatures join their word; earlier: preserve Arabic ActualText typing order; M3: Windows' Animation effects setting is followed, and Preferences has Animation effects; exclusive image-crop resize follow-up; M2: short same-line redraws split before placement; 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 (M7: local GoToR links and confined automation candidate; 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:** exclusive image-crop resize follow-up; minor (M2: short same-line redraws split before placement; 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:** exclusive image-crop resize follow-up; minor (M2: short same-line redraws split before placement; earlier: M10, printing on the web through the browser's print dialog, gap 4, rank 1 65–120 → 50–90 h) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (M10, #1028: OCR reads pictures on pages that have text, on request; earlier: direct Organize overview and page actions; localized shortcut labels, #496; alpha gate added; convert-and-share re-judged with the cross-app rule → alpha) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS) +> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-11 · **Change:** minor (M13/#971: bound per-page search match allocation; text cache byte budget and extraction warnings remain open) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (#594 paragraph drags validated in the Linux test environment; localized shortcut labels, #496; alpha gate added; convert-and-share re-judged with the cross-app rule → 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 (architectural paper controls from #599; M6: respect existing form-font encodings; retain source fonts on appearance fallback; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS) > **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (direct Organize overview and page actions; localized shortcut labels, #496; alpha gate added; convert-and-share re-judged with the cross-app rule → 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, #817–#821, #809, #819, #816, #844; earlier: localized shortcut labels, #496; alpha gate added; convert-and-share re-judged with the cross-app rule → 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 (M6: respect existing form-font encodings; retain source fonts on appearance fallback; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS) > **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (M6: keep accepted form values when Tab reopens the same field before Save; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS) > **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (M13: reject malformed CLI --root options before dispatch; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS) > **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (Traditional Chinese accessibility semantics and Taiwan form/table terminology; 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:** patch (M1 #1337: exhausted xref hints are repaired; allocation avoids overflow; estimates unchanged) · **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) Forward-looking: the milestones, the current focus and what comes next. The parity numbers behind it are in [target-app-parity.md](target-app-parity.md), the ranked work list in [gaps.md](gaps.md), @@ -26,7 +66,7 @@ exchange with Acrobat users or a missing sub-case is `partial, not blocking` (a | Core workflow | Works end to end? | Evidence | Hours to pass | |---|---|---|---| -| View, search and print | yes | Render, find, select, panels, tiles; 500-page scrolling test; Print with CUPS on macOS/Linux and the Windows spooler (web printing missing, a beta item). Rendering borrowed from `hayro` | 0 | +| View, search and print | yes | Render, find, select, panels, tiles; 500-page scrolling test; Print with CUPS on macOS/Linux, the Windows spooler and, on the web, the browser's print dialog. Rendering borrowed from `hayro` | 0 | | Organize, combine and split pages | yes | `organize.*` P0s shipped with tests; links, fields, layers and bookmarks carried over; page deletion drops dangling destinations and the opening action (#817, #821) | 0 | | Comment and review | yes | Every markup type with appearances, replies, status, XFDF/FDF; comments saved in the PDF reopen in other readers; XFDF keeps polygon vertices and callouts (#809, #819) | 0 | | Fill and sign forms | yes | All AcroForm field types, Acrobat's AF functions, Fill & Sign, PAdES signatures validated with `pdfsig`/OpenSSL | 0 | @@ -70,27 +110,51 @@ total matches the parity estimate. | M | Milestone | Plan size (h) | Done | Remaining (h, 2026-10-10) | Notes | |---|---|---|---|---|---| | M0 | Skeleton: workspace, xtask gates, CI | 15–30 | 98% | 1–2 | GitHub workflow, `deny.toml` (licence audit of every dependency), parity checklist (826 features, `xtask parity`) done. Missing: remaining crate stubs, testkit/oracle crates | -| M1 | COS: filters, crypt, parser, xref, writer | 120–200 | 80% | 20–40 | Done:
- filters and crypt: every standard-security revision R2–R6 (RC4, AES-128/256), SASLprep, permissions, creating encryption;
- cos: parse and repair (including invalid xref object-number ranges), decoded-stream limits including unfiltered data, decrypt on load, re-encrypt on save, incremental and full writing.
Corpus: open/edit/save passes on 958 files, and all 7 password-protected files open.
Full saves now pack objects into compressed object streams. Fuzzing runs nightly (`xtask fuzz`). Missing: ≥ 250 tests, own image codecs | -| M2 | Model, render, text | 200–350 | 15% | 130–230 | hayro bootstrap renderer (vendored patches); inspector predictor scratch is bounded by available input, and JPX sample rescaling handles 1–32 bits safely. Text extraction reaches word-F1 0.98 against pdftotext. Missing: model crate, fonts, DisplayList, renderer independent of hayro. Inspector page-label traversal and output are resource-bounded, with physical-number fallback warnings | -| M3 | Viewer app (native + web) | 80–150 | 90% | 10–20 | Acrobat-style shell, find, select, panels, tiles, web build, UI control channel for agents (opt-in). Missing: 60 fps test on a 500-page document, snapshot tests of every panel
Done since: fit visible, document title in the window; Linux middle-button scrolling; shared three-state theme preferences in the toolbar, View menu and control channel; late browser startup downloads preserve the current document and Save target (#171). | -| M4 | Engine, history, save, organize | 100–180 | 95% | 8–15 | Done:
- command registry (menus, shortcuts and palette all use it);
- undo/redo; incremental, atomic and encrypted saves;
- autosave and crash recovery;
- organize, combine, extract, split and insert-from-file, with identical fonts and images stored once;
- CLI `edit/combine/extract/split`.
Done since: Organize opens directly into the page overview, with checkbox selection and per-page rotate/delete controls; Combine files in its own tab (sortable, resizable columns; folders; early warnings; unlocking protected files), bookmark editing, page labels (Number pages), CLI `run`, Set Page Boxes, Crop tool, Duplicate pages, Ctrl+A / Cmd+A to select all pages in Organize, Replace pages, bookmarks from tagged headings. Missing: page transitions, recovery on the web | -| M3 | Viewer app (native + web) | 80–150 | 90% | 10–20 | Acrobat-style shell, find, select, panels, tiles, web build, UI control channel for agents (opt-in). Missing: 60 fps test on a 500-page document, snapshot tests of every panel
Done since: fit visible, document title in the window; Linux middle-button scrolling; shared three-state theme preferences in the toolbar, View menu and control channel; late browser startup downloads preserve the current document and Save target (#171). Renderer-creation errors return to the existing OpenGL startup fallback; a real-GPU regression covers errors and device reuse. The underlying x86 DX12 shader compiler failure remains unresolved; see `windows-renderer-startup.md`. | +| M1 | COS: filters, crypt, parser, xref, writer | 120–200 | 80% | 20–40 | Done:
- filters and crypt: every standard-security revision R2–R6 (RC4, AES-128/256), SASLprep, permissions, creating encryption;
- cos: parse and repair (including invalid xref object-number ranges), decoded-stream limits including unfiltered data, decrypt on load, re-encrypt on save, incremental and full writing.
Corpus: open/edit/save passes on 958 files, and all 7 password-protected files open.
Full saves now pack objects into compressed object streams. An unchanged incremental save of a classic table writes a zero-length cross-reference subsection instead of a section with no subsections (#1339). A full rewrite keeps unknown trailer entries, including objects reachable only from them (#1338). Fuzzing runs nightly (`xtask fuzz`). Raw-deflate fallback preserves decoding-limit errors, including through tolerant COS stream decoding. Missing: ≥ 250 tests, own image codecs | +| M2 | Model, render, text | 200–350 | 15% | 130–230 | hayro bootstrap renderer (vendored patches); inspector predictor scratch is bounded by available input, and JPX sample rescaling handles 1–32 bits safely. Text extraction reaches word-F1 0.98 against pdftotext. Short same-line redraws split before placement rather than interleaving. Missing: model crate, fonts, DisplayList, renderer independent of hayro. Inspector page-label traversal and output are resource-bounded, with physical-number fallback warnings. Packed TIFF-predictor RGB rows retain later rows in decoding and rendering. Page marks, added content and print output retain small page-unit transform coefficients when serialized. | +| M2 | Model, render, text | 200–350 | 15% | 130–230 | hayro bootstrap renderer (vendored patches); inspector predictor scratch is bounded by available input, and JPX sample rescaling handles 1–32 bits safely. Text extraction reaches word-F1 0.98 against pdftotext. Short same-line redraws split before placement rather than interleaving. Missing: model crate, fonts, DisplayList, renderer independent of hayro. Inspector page-label traversal and output are resource-bounded, with physical-number fallback warnings. Packed TIFF-predictor RGB rows retain later rows in decoding and rendering. The renderer keeps its one-point crop-size floor in physical points after UserUnit scaling, with regression coverage for crop origins, rotations and f32 reciprocal rounding. Existing unit policy and nearly-zero fallback are retained. | +| M2 | Model, render, text | 200–350 | 15% | 130–230 | hayro bootstrap renderer (vendored patches); inspector predictor scratch is bounded by available input, and JPX sample rescaling handles 1–32 bits safely. Text extraction reaches word-F1 0.98 against pdftotext. Short same-line redraws split before placement rather than interleaving. Missing: model crate, fonts, DisplayList, renderer independent of hayro. Inspector page-label traversal and output are resource-bounded, with physical-number fallback warnings | +| M3 | Viewer app (native + web) | 80–150 | 90% | 10–20 | Acrobat-style shell, find, select, panels, tiles, web build, UI control channel for agents (opt-in). Missing: 60 fps test on a 500-page document, snapshot tests of every panel
Done since: fit visible, document title in the window; Linux middle-button scrolling; shared three-state theme preferences in the toolbar, View menu and control channel; numeric Linux portal events and idle wakeups completing #881, with native appearance inheritance retained (#446); late browser startup downloads preserve the current document and Save target (#171). Selected-text Copy in Edit and the canvas context menu is covered by focused regressions and native clipboard checks (#1225); document copy restrictions apply to the floating action too. #1364 candidate: absolute local GoToR links ask before target access; headless callers explicitly opt in. Native-opening candidate: chosen tools survive preparation and password retries; startup intake waits for pending preparation. Page-label navigation displays logical labels, retains the current page on unchanged submission and refreshes focused same-document replacements; clean file-watcher reload preserves the chosen scrolling mode, preventing a Single Page reader from jumping to page 1 during replacement. Synthetic control regressions cover these behaviors. #3149: page-list changes discard render demand based on old geometry; actual raster dimensions are checked against the GPU limit before upload. Synthetic crash and continued-rendering regressions cover the replacement path. Save-prompt shortcuts yield to dialogs above them; stacked link-consent UI regression covers Escape and Return. | +| M3 | Viewer app (native + web) | 80–150 | 90% | 10–20 | Acrobat-style shell, find, select, panels, tiles, web build, UI control channel for agents (opt-in). Missing: 60 fps test on a 500-page document, snapshot tests of every panel
Done since: fit visible, document title in the window; Linux middle-button scrolling; shared three-state theme preferences in the toolbar, View menu and control channel; numeric Linux portal events and idle wakeups completing #881, with native appearance inheritance retained (#446); late browser startup downloads preserve the current document and Save target (#171). Selected-text Copy in Edit and the canvas context menu is covered by focused regressions and native clipboard checks (#1225); document copy restrictions apply to the floating action too. #1364 candidate: absolute local GoToR links ask before target access; headless callers explicitly opt in. Native-opening candidate: chosen tools survive preparation and password retries; startup intake waits for pending preparation. Page-label navigation displays logical labels, retains the current page on unchanged submission and refreshes focused same-document replacements; clean file-watcher reload preserves the chosen scrolling mode, preventing a Single Page reader from jumping to page 1 during replacement. Synthetic control regressions cover these behaviors. Wrapped revision buttons stay above Binding in Document Properties (#2739); a synthetic UI test checks layout and saved revision content. | +| M3 | Viewer app (native + web) | 80–150 | 90% | 10–20 | Acrobat-style shell, find, select, panels, tiles, web build, UI control channel for agents (opt-in). Missing: 60 fps test on a 500-page document, snapshot tests of every panel
Done since: fit visible, document title in the window; Linux middle-button scrolling; shared three-state theme preferences in the toolbar, View menu and control channel; numeric Linux portal events and idle wakeups completing #881, with native appearance inheritance retained (#446); late browser startup downloads preserve the current document and Save target (#171). #1364 candidate: absolute local GoToR links ask before target access; headless callers explicitly opt in. Native-opening candidate: chosen tools survive preparation and password retries; startup intake waits for pending preparation. Later native-open replies stay queued while a modal or picker owns the foreground; regressions cover recipient ownership, completion/cancellation and one-shot wakes. | +| M3 | Viewer app (native + web) | 80–150 | 90% | 10–20 | Acrobat-style shell, find, select, panels, tiles, web build, UI control channel for agents (opt-in). Missing: 60 fps test on a 500-page document, snapshot tests of every panel
Done since: fit visible, document title in the window; Linux middle-button scrolling; shared three-state theme preferences in the toolbar, View menu and control channel; numeric Linux portal events and idle wakeups completing #881, with native appearance inheritance retained (#446); late browser startup downloads preserve the current document and Save target (#171). #1364 candidate: absolute local GoToR links ask before target access; headless callers explicitly opt in. Native-opening candidate: chosen tools survive preparation and password retries; startup intake waits for pending preparation. #1980: exact quit modifiers preserve Ctrl+Alt+Q email input; Ctrl+Q/Cmd+Q asks before discarding unsaved documents. | +| M3 | Viewer app (native + web) | 80–150 | 90% | 10–20 | Acrobat-style shell, find, select, panels, tiles, web build, UI control channel for agents (opt-in). Missing: 60 fps test on a 500-page document, snapshot tests of every panel
Done since: fit visible, document title in the window; Linux middle-button scrolling; shared three-state theme preferences in the toolbar, View menu and control channel; numeric Linux portal events and idle wakeups completing #881, with native appearance inheritance retained (#446); late browser startup downloads preserve the current document and Save target (#171). #1364 candidate: absolute local GoToR links ask before target access; headless callers explicitly opt in. Native-opening candidate: chosen tools survive preparation and password retries; startup intake waits for pending preparation.
Candidate: coordinate control clicks pair press/release across slow frames; coalesced primary presses retain text multi-click selection. Regression cases cover slow-frame button activation, disabled controls and text multi-click selection; estimates unchanged. | +| M3 | Viewer app (native + web) | 80–150 | 90% | 10–20 | Acrobat-style shell, find, select, panels, tiles, web build, UI control channel for agents (opt-in). Missing: 60 fps test on a 500-page document, snapshot tests of every panel
Done since: fit visible, document title in the window; Linux middle-button scrolling; shared three-state theme preferences in the toolbar, View menu and control channel; numeric Linux portal events and idle wakeups completing #881, with native appearance inheritance retained (#446); late browser startup downloads preserve the current document and Save target (#171). #1364 candidate: absolute local GoToR links ask before target access; headless callers explicitly opt in. Native-opening candidate: chosen tools survive preparation and password retries; startup intake waits for pending preparation. #2080 candidate: search-result jumps enter per-tab history; rail and control-channel Back/Forward; no exact-view or restart persistence. | +| M2 | Model, render, text | 200–350 | 15% | 130–230 | hayro bootstrap renderer (vendored patches); inspector predictor scratch is bounded by available input, and JPX sample rescaling handles 1–32 bits safely. Text extraction reaches word-F1 0.98 against pdftotext. Short same-line redraws split before placement rather than interleaving. A custom text match that ends on a reconstructed separator stops before the next glyph (#1315). Missing: model crate, fonts, DisplayList, renderer independent of hayro. Inspector page-label traversal and output are resource-bounded, with physical-number fallback warnings | +| M2 | Model, render, text | 200–350 | 15% | 130–230 | hayro bootstrap renderer (vendored patches); Lab Bradford adaptation uses LMS white-point ratios, covered by independent neutral/chromatic and direct/Indexed render oracles; inspector predictor scratch is bounded by available input, and JPX sample rescaling handles 1–32 bits safely. Text extraction reaches word-F1 0.98 against pdftotext. Short same-line redraws split before placement rather than interleaving. Missing: model crate, fonts, DisplayList, renderer independent of hayro. Inspector page-label traversal and output are resource-bounded, with physical-number fallback warnings. Literal page-label prefix whitespace is preserved; nine public-inspection cases cover prefix spaces, encodings and empty-prefix controls | +| M3 | Viewer app (native + web) | 80–150 | 90% | 10–20 | Acrobat-style shell, find, select, panels, tiles, web build, UI control channel for agents (opt-in). Missing: 60 fps test on a 500-page document, snapshot tests of every panel
Done since: fit visible, document title in the window; Linux middle-button scrolling; shared three-state theme preferences in the toolbar, View menu and control channel; numeric Linux portal events and idle wakeups completing #881, with native appearance inheritance retained (#446); late browser startup downloads preserve the current document and Save target (#171). | +| M4 | Engine, history, save, organize | 100–180 | 95% | 8–15 | Done:
- command registry (menus, shortcuts and palette all use it);
- undo/redo; incremental, atomic and encrypted saves;
- autosave and crash recovery;
- organize, combine, extract, split and insert-from-file, with identical fonts and images stored once;
- CLI `edit/combine/extract/split`.
Done since: Organize opens directly into the page overview, with checkbox selection, an editable page-range selection (#1092), and per-page rotate/delete controls; Combine files in its own tab (sortable, resizable columns; folders; early warnings; unlocking protected files), bookmark editing, page labels (Number pages), CLI `run`, Set Page Boxes, Crop tool, Duplicate pages, Ctrl+A / Cmd+A to select all pages in Organize, Replace pages, bookmarks from tagged headings. Extract and Split preserve shared Type 3 glyph resource graphs, retaining conservative pruning limits. Undo back to the opened or last saved file is clean; Revert opens that saved file after an undo; a later save appends to it so the saved revision stays in the file (#1310, #1311, #1312). Ctrl-drag (Option on macOS) on a grid thumbnail copies the pages into the drop gap, with a "+" badge and the copies selected (#2997). Missing: page transitions, recovery on the web | +| M4 | Engine, history, save, organize | 100–180 | 95% | 8–15 | Done:
- command registry (menus, shortcuts and palette all use it);
- undo/redo; incremental, atomic and encrypted saves;
- autosave and crash recovery;
- organize, combine, extract, split and insert-from-file, with identical fonts and images stored once;
- CLI `edit/combine/extract/split`.
Done since: Organize opens directly into the page overview, with checkbox selection, an editable page-range selection (#1092), and per-page rotate/delete controls; Combine files in its own tab (sortable, resizable columns; folders; early warnings; unlocking protected files), bookmark editing, page labels (Number pages), CLI `run`, Set Page Boxes, Crop tool, Duplicate pages, Ctrl+A / Cmd+A to select all pages in Organize, Replace pages, bookmarks from tagged headings. Extract and Split preserve shared Type 3 glyph resource graphs, retaining conservative pruning limits. Undo back to the opened or last saved file is clean; Revert opens that saved file after an undo; a later save appends to it so the saved revision stays in the file (#1310, #1311, #1312). Rotation queries and edits resolve indirect values on pages and ancestors. Refreshed against upstream `99941daa` (2026-10-15). Repeated page indices accumulate rotations from the latest local value (#1919); saved/reopened regressions retain inherited angles and siblings. Missing: page transitions, recovery on the web | +| M4 | Engine, history, save, organize | 100–180 | 95% | 8–15 | Done:
- command registry (menus, shortcuts and palette all use it);
- undo/redo; incremental, atomic and encrypted saves;
- autosave and crash recovery;
- organize, combine, extract, split and insert-from-file, with identical fonts and images stored once;
- CLI `edit/combine/extract/split`.
Done since: Organize opens directly into the page overview, with checkbox selection, an editable page-range selection (#1092), and per-page rotate/delete controls; Combine files in its own tab (sortable, resizable columns; folders; early warnings; unlocking protected files), bookmark editing, page labels (Number pages), CLI `run`, Set Page Boxes, Crop tool, Duplicate pages, Ctrl+A / Cmd+A to select all pages in Organize, Replace pages, bookmarks from tagged headings. Extract and Split preserve shared Type 3 glyph resource graphs, retaining conservative pruning limits. Undo back to the opened or last saved file is clean; Revert opens that saved file after an undo; a later save appends to it so the saved revision stays in the file (#1310, #1311, #1312). Rotation queries and edits resolve indirect values on pages and ancestors. Missing: page transitions, recovery on the web | +| M4 | Engine, history, save, organize | 100–180 | 95% | 8–15 | Done:
- command registry (menus, shortcuts and palette all use it);
- undo/redo; incremental, atomic and encrypted saves;
- autosave and crash recovery;
- organize, combine, extract, split and insert-from-file, with identical fonts and images stored once;
- CLI `edit/combine/extract/split`.
Done since: Organize opens directly into the page overview, with checkbox selection, an editable page-range selection (#1092), and per-page rotate/delete controls; Combine files in its own tab (sortable, resizable columns; folders; early warnings; unlocking protected files), bookmark editing, page labels (Number pages), CLI `run`, Set Page Boxes, Crop tool, Duplicate pages, Ctrl+A / Cmd+A to select all pages in Organize, Replace pages, bookmarks from tagged headings. Extract and Split preserve shared Type 3 glyph resource graphs, retaining conservative pruning limits. Missing: page transitions, recovery on the web M4: Insert-from-file preserves source bookmarks; integration tests retained with current main. | +| M3 | Viewer app (native + web) | 80–150 | 90% | 10–20 | Acrobat-style shell, find, select, panels, tiles, web build, UI control channel for agents (opt-in). Missing: 60 fps test on a 500-page document, snapshot tests of every panel
Done since: fit visible, document title in the window; Linux middle-button scrolling; shared three-state theme preferences in the toolbar, View menu and control channel; numeric Linux portal events and idle wakeups completing #881, with native appearance inheritance retained (#446); late browser startup downloads preserve the current document and Save target (#171). Renderer-creation errors return to the existing OpenGL startup fallback; a real-GPU regression covers errors and device reuse. The underlying x86 DX12 shader compiler failure remains unresolved; see `windows-renderer-startup.md`. With Windows' Animation effects turned off, the wheel scrolls and zooms without easing and egui's animations take no time (`motion.rs`; macOS, Linux and the browser aren't asked yet); Preferences ▸ Animation effects (Use system setting, On, Off) decides it on every platform. | | M4 | Engine, history, save, organize | 100–180 | 95% | 8–15 | Done:
- command registry (menus, shortcuts and palette all use it);
- undo/redo; incremental, atomic and encrypted saves;
- autosave and crash recovery;
- organize, combine, extract, split and insert-from-file, with identical fonts and images stored once;
- CLI `edit/combine/extract/split`.
Done since: Combine files in its own tab (sortable, resizable columns; folders; early warnings; unlocking protected files), bookmark editing, page labels (Number pages), CLI `run`, Set Page Boxes, Crop tool, Duplicate pages, Ctrl+A / Cmd+A to select all pages in Organize, Replace pages, bookmarks from tagged headings. Missing: page transitions, recovery on the web | -| M5 | Comments (all annotation types, XFDF) | 120–200 | 88% | 12–25 | Done: notes, highlight/underline/strikeout/squiggly, text boxes, ink, lines, arrows, rectangles, ovals, stamps (dynamic, Sign Here, Standard Business), with appearance streams; replies, status, checkmarks, locking, move/resize/restyle/delete; properties dialog; panel filter and sort; hide all; summaries (comments only); XFDF/FDF import and export; flatten; Fill & Sign; agent tools.
Done since: polygons, connected lines, clouds, callouts, inserted text, colour and checkmark filters; image signature pointer previews, selection handles and live image movement/resizing with proportional corners and independent edge handles. Missing: custom stamps, replace-text proposals, summary layouts with the page | -| M6 | Forms + JavaScript | 160–320 | 50% | 40–75 | Done: filling all field types with regenerated appearances; Prepare a form (every field type, multi-select/shared properties, move/resize/delete, Field Properties General/Appearance/Position/Options/Format/Validate/Calculate); Acrobat's AF format/keystroke/validate/calculate functions and simplified field notation run natively in Acrobat's event order; tab order; push-button actions; flatten; agent tools. Form data exchange (FDF, XFDF, XML, CSV, text) done.
Done since: JavaScript engine (boa, sandboxed) running custom keystroke/validate/calculate/format and button scripts, document JavaScripts, console. Missing: Actions tab, wider object model (annotations, layers, dialogs), XFA | -| M7 | Content editing (text, images, header/footer, watermark) | 250–500 | 20% | 100–180 | Done: header & footer, watermarks, backgrounds, Bates; added text and images that stay editable (move, resize, format, rotate, flip, crop, replace); links (Link tool, Link Properties, create from URLs, remove all). Missing: editing existing text and images in place (the longest pole), watermark print/screen visibility and repeat | -| M6 | Forms + JavaScript | 160–320 | 50% | 40–75 | Done: filling all field types with regenerated appearances; Prepare a form (every field type, multi-select/shared properties, move/resize/delete, Field Properties General/Appearance/Position/Options/Format/Validate/Calculate); Acrobat's AF format/keystroke/validate/calculate functions and simplified field notation run natively in Acrobat's event order; tab order; push-button actions; flatten; agent tools. Form data exchange (FDF, XFDF, XML, CSV, text) done.
Done since: JavaScript engine (boa, sandboxed) running custom keystroke/validate/calculate/format and button scripts, document JavaScripts, console. Missing: Actions tab, wider object model (annotations, layers, dialogs), XFA
Done since: Existing simple field-font encodings and Differences are respected when generating appearances; an unencodable value uses a separate Helvetica appearance font without changing the source font. Synthetic reopen regressions cover punctuation, subset fallback and multiline text. Exact font metrics/layout and broader Unicode fallback remain incomplete. | -| M6 | Forms + JavaScript | 160–320 | 50% | 40–75 | Done: filling all field types with regenerated appearances; Prepare a form (every field type, multi-select/shared properties, move/resize/delete, Field Properties General/Appearance/Position/Options/Format/Validate/Calculate); Acrobat's AF format/keystroke/validate/calculate functions and simplified field notation run natively in Acrobat's event order; tab order; push-button actions; flatten; agent tools. Form data exchange (FDF, XFDF, XML, CSV, text) done.
Done since: JavaScript engine (boa, sandboxed) running custom keystroke/validate/calculate/format and button scripts, document JavaScripts, console. Missing: Actions tab, wider object model (annotations, layers, dialogs), XFA
Done since: Tab and Shift-Tab wrapping to the same field or another widget of that field refresh the newly opened editor from the accepted value before Save. Synthetic UI regressions cover both directions, same-frame Save, validation refusal and undo/redo. | +| M5 | Comments (all annotation types, XFDF) | 120–200 | 88% | 12–25 | Done: notes, highlight/underline/strikeout/squiggly, text boxes, ink, lines, arrows, rectangles, ovals, stamps (dynamic, Sign Here, Standard Business), with appearance streams; replies, status, checkmarks, locking, move/resize/restyle/delete; properties dialog; panel filter and sort; hide all; summaries (comments only); XFDF/FDF import and export (partial imports preserve existing reply and review-status links); flatten; Fill & Sign with adjustable text/date size; agent tools.
Done since: stamps placed on a rotated page read upright (#1398); polygons, connected lines, clouds, callouts, inserted text, colour and checkmark filters; image signature pointer previews, selection handles and live image movement/resizing with proportional corners and independent edge handles; Ctrl+Enter submission for highlight comment text and replies, with Shift+Enter newlines (reconciled with upstream main for PR #1457 on 2026-10-14). Missing: custom stamps, replace-text proposals, summary layouts with the page | +| M4 | Engine, history, save, organize | 100–180 | 95% | 8–15 | Done:
- command registry (menus, shortcuts and palette all use it);
- undo/redo; incremental, atomic and encrypted saves;
- autosave and crash recovery;
- organize, combine, extract, split and insert-from-file, with identical fonts and images stored once;
- CLI `edit/combine/extract/split`.
Done since: Combine files in its own tab (sortable, resizable columns; folders; early warnings; unlocking protected files), bookmark editing, page labels (Number pages), CLI `run`, Set Page Boxes, Crop tool, Duplicate pages, Ctrl+A / Cmd+A to select all pages in Organize, Replace pages, bookmarks from tagged headings. Missing: page transitions, recovery on the web M4: Insert-from-file preserves source bookmarks; integration tests retained with current main. | +| M5 | Comments (all annotation types, XFDF) | 120–200 | 88% | 12–25 | Done: notes, highlight/underline/strikeout/squiggly, text boxes, ink, lines, arrows, rectangles, ovals, stamps (dynamic, Sign Here, Standard Business), with appearance streams; replies, status, checkmarks, locking, move/resize/restyle/delete; properties dialog; panel filter and sort; hide all; summaries (comments only); XFDF/FDF import and export (partial imports preserve existing reply and review-status links); flatten; Fill & Sign with adjustable text/date size; agent tools.
Done since: stamps placed on a rotated page read upright (#1398); polygons, connected lines, clouds, callouts, inserted text, colour and checkmark filters; image signature pointer previews, selection handles and live image movement/resizing with proportional corners and independent edge handles. Missing: custom stamps, replace-text proposals, summary layouts with the page | +| M5 | Comments (all annotation types, XFDF) | 120–200 | 88% | 12–25 | Done: notes, highlight/underline/strikeout/squiggly, text boxes, ink, lines, arrows, rectangles, ovals, stamps (dynamic, Sign Here, Standard Business), with appearance streams; replies, status, checkmarks, locking, move/resize/restyle/delete; properties dialog; panel filter and sort; hide all; summaries (comments only); XFDF/FDF import and export (partial imports preserve existing reply and review-status links; nested FDF export uses lazy array owners and annotation positions, with retained entries budgeted before cloning under copied-graph limits and source `/Page` and `/Pages` dictionaries replaced by null at every copy depth); flatten; Fill & Sign with adjustable text/date size; agent tools.
Done since: polygons, connected lines, clouds, callouts, inserted text, colour and checkmark filters; image signature pointer previews, selection handles and live image movement/resizing with proportional corners and independent edge handles. A refused restyle or resize of a text box another program drew leaves that comment unchanged (#1327). Make Current Properties Default on an arrow updates arrows, not plain lines (#1302). Flattening keeps a visible comment it cannot draw (#1317). Missing: custom stamps, replace-text proposals, summary layouts with the page | +| M6 | Forms + JavaScript | 160–320 | 50% | 40–75 | Done: filling all field types with regenerated appearances; Prepare a form (every field type, multi-select/shared properties, move/resize/delete, Field Properties General/Appearance/Position/Options/Format/Validate/Calculate); Acrobat's AF format/keystroke/validate/calculate functions and simplified field notation run natively in Acrobat's event order; tab order; push-button actions; flatten; agent tools. Form data exchange (FDF, XFDF, XML, CSV, text) done; XFDF values retain text across XML comments and processing instructions. Plain AcroForm XML imports retain all direct text around comments and processing instructions, including CDATA and character references. CSV export and spreadsheet merging quote tabs, and import distinguishes quoted tabs from TSV delimiters without changing field identities. XML/XFDF exports preserve tabs and line breaks in names and comment metadata. Refreshed against upstream `99941daa` (2026-10-15).
Done since: JavaScript engine (boa, sandboxed) running custom keystroke/validate/calculate/format and button scripts, document JavaScripts, console. Done since: 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. (#2892). Missing: Actions tab, wider object model (annotations, layers, dialogs), XFA
Done since: Multi-select choice fields write ascending `/I` option indices while preserving `/V` selection order; synthetic forms/engine regressions cover reverse selection and save/reopen. CSV export preserves carriage returns in names and values. | +| M6 | Forms + JavaScript | 160–320 | 50% | 40–75 | Done: filling all field types with regenerated appearances; Prepare a form (every field type, multi-select/shared properties, move/resize/delete, Field Properties General/Appearance/Position/Options/Format/Validate/Calculate); Acrobat's AF format/keystroke/validate/calculate functions and simplified field notation run natively in Acrobat's event order; tab order; push-button actions; flatten; agent tools. Form data exchange (FDF, XFDF, XML, CSV, text) done; XFDF values retain text across XML comments and processing instructions. Plain AcroForm XML imports retain all direct text around comments and processing instructions, including CDATA and character references.
Done since: JavaScript engine (boa, sandboxed) running custom keystroke/validate/calculate/format and button scripts, document JavaScripts, console. Missing: Actions tab, wider object model (annotations, layers, dialogs), XFA
Done since: Multi-select choice fields write ascending `/I` option indices while preserving `/V` selection order; synthetic forms/engine regressions cover reverse selection and save/reopen. CSV export preserves carriage returns in names and values. | +| M6 | Forms + JavaScript | 160–320 | 50% | 40–75 | Done: filling all field types with regenerated appearances; Prepare a form (every field type, multi-select/shared properties, move/resize/delete, Field Properties General/Appearance/Position/Options/Format/Validate/Calculate); Acrobat's AF format/keystroke/validate/calculate functions and simplified field notation run natively in Acrobat's event order; tab order; push-button actions; flatten; agent tools. Form data exchange (FDF, XFDF, XML, CSV, text) done; XFDF values retain text across XML comments and processing instructions. XML/XFDF exports preserve tabs and line breaks in names and comment metadata. Refreshed against upstream `99941daa` (2026-10-15).
Done since: JavaScript engine (boa, sandboxed) running custom keystroke/validate/calculate/format and button scripts, document JavaScripts, console. Missing: Actions tab, wider object model (annotations, layers, dialogs), XFA
Done since: Multi-select choice fields write ascending `/I` option indices while preserving `/V` selection order; synthetic forms/engine regressions cover reverse selection and save/reopen. CSV export preserves carriage returns in names and values. | +| M6 | Forms + JavaScript | 160–320 | 50% | 40–75 | Done: filling all field types with regenerated appearances; Prepare a form (every field type, multi-select/shared properties, move/resize/delete, Field Properties General/Appearance/Position/Options/Format/Validate/Calculate); Acrobat's AF format/keystroke/validate/calculate functions and simplified field notation run natively in Acrobat's event order; tab order; push-button actions; flatten; agent tools. Form data exchange (FDF, XFDF, XML, CSV, text) done; XFDF values retain text across XML comments and processing instructions. XML/XFDF exports preserve tabs and line breaks in names and comment metadata. Plain AcroForm XML imports retain all direct text around comments and processing instructions, including CDATA and character references. CSV export and spreadsheet merging quote tabs, and import distinguishes quoted tabs from TSV delimiters without changing field identities.
Done since: JavaScript engine (boa, sandboxed) running custom keystroke/validate/calculate/format and button scripts, document JavaScripts, console. Missing: Actions tab, wider object model (annotations, layers, dialogs), XFA
Done since: Multi-select choice fields write ascending `/I` option indices while preserving `/V` selection order; synthetic forms/engine regressions cover reverse selection and save/reopen. CSV export preserves carriage returns in names and values. | +| M7 | Content editing (text, images, header/footer, watermark) | 250–500 | 20% | 100–180 | Done: header & footer, watermarks, backgrounds, Bates; added text and images that stay editable (move, resize, format, rotate, flip, crop, replace); links (Link tool, Link Properties, create from URLs, remove all). Done since: Edit text preserves Arabic/Hebrew `/ActualText` typing order, with font-independent regressions; single-glyph RTL ligature spans (including lam-alef) join their surrounding word. Done since (#1116): editable text regions grouped across compatible words and subset fonts, one rich-text editor over a background with the source text removed, embedded sfnt faces reused as editor faces, and visible (clipped) bounds for cropped-image selection and resize. Exclusive crops now resize across nested states and content streams, with shared clips fixed.
Done since: #594 ordinary paragraph drags preserve overlapping source text; UI/engine regressions, native save/reopen, independent-reader checks and full CI passed in the Linux test environment. #1200 candidate: local GoToR links open in desktop tabs and through root-confined automation; appearance-only edits preserve existing actions. Named destinations remain unapplied and `/NewWindow` is a tab hint. Missing: editing existing text and images in place (the longest pole), image/PDF watermarks | +| M7 | Content editing (text, images, header/footer, watermark) | 250–500 | 20% | 100–180 | Done: header & footer, watermarks, backgrounds, Bates; added text and images that stay editable (move, resize, format, rotate, flip, crop, replace); links (Link tool, Link Properties, create from URLs, remove all). Done since: Edit text preserves Arabic/Hebrew `/ActualText` typing order, with font-independent regressions. Done since (#1116): editable text regions grouped across compatible words and subset fonts, one rich-text editor over a background with the source text removed, embedded sfnt faces reused as editor faces, and visible (clipped) bounds for cropped-image selection and resize. Exclusive crops now resize across nested states and content streams, with shared clips fixed.
Done since: #594 ordinary paragraph drags preserve overlapping source text; UI/engine regressions, native save/reopen, independent-reader checks and full CI passed in the Linux test environment. #1200 candidate: local GoToR links open in desktop tabs and through root-confined automation; appearance-only edits preserve existing actions. Named destinations remain unapplied and `/NewWindow` is a tab hint. #1316: replacing a line or paragraph keeps a neighbouring line whose glyph box overlaps when the baselines differ; a second copy on the same baseline is still replaced. Missing: editing existing text and images in place (the longest pole), image/PDF watermarks | +| M7 | Content editing (text, images, header/footer, watermark) | 250–500 | 20% | 100–180 | Done: header & footer, watermarks, backgrounds, Bates; added text and images that stay editable (move, resize, format, rotate, flip, crop, replace); links (Link tool, Link Properties, create from URLs, remove all). #3212: Headers, Bates numbers, watermarks and added text stay visible after unfinished marked content, without revealing hidden content. Tracked editable text distinguishes repeated glyph spacing from word gaps, with synthetic paragraph/object regressions. Missing: editing existing text and images in place (the longest pole), watermark print/screen visibility and repeat | +| M6 | Forms + JavaScript | 160–320 | 50% | 40–75 | Done: filling all field types with regenerated appearances; Prepare a form (every field type, multi-select/shared properties, move/resize/delete, Field Properties General/Appearance/Position/Options/Format/Validate/Calculate); Acrobat's AF format/keystroke/validate/calculate functions and simplified field notation run natively in Acrobat's event order; tab order; push-button actions; flatten; agent tools. Form data exchange (FDF, XFDF, XML, CSV, text) done; XFDF values retain text across XML comments and processing instructions.
Done since: JavaScript engine (boa, sandboxed) running custom keystroke/validate/calculate/format and button scripts, document JavaScripts, console. Missing: Actions tab, wider object model (annotations, layers, dialogs), XFA
Done since: Existing simple field-font encodings and Differences are respected when generating appearances; an unencodable value uses a separate Helvetica appearance font without changing the source font. Synthetic reopen regressions cover punctuation, subset fallback and multiline text. Exact font metrics/layout and broader Unicode fallback remain incomplete. | +| M6 | Forms + JavaScript | 160–320 | 50% | 40–75 | Done: filling all field types with regenerated appearances; Prepare a form (every field type, multi-select/shared properties, move/resize/delete, Field Properties General/Appearance/Position/Options/Format/Validate/Calculate); Acrobat's AF format/keystroke/validate/calculate functions and simplified field notation run natively in Acrobat's event order; tab order; push-button actions; flatten; agent tools. Form data exchange (FDF, XFDF, XML, CSV, text) done; XFDF values retain text across XML comments and processing instructions.
Done since: JavaScript engine (boa, sandboxed) running custom keystroke/validate/calculate/format and button scripts, document JavaScripts, console. Missing: Actions tab, wider object model (annotations, layers, dialogs), XFA
Done since: Tab and Shift-Tab wrapping to the same field or another widget of that field refresh the newly opened editor from the accepted value before Save. Synthetic UI regressions cover both directions, same-frame Save, validation refusal and undo/redo. | | M7 | Content editing (text, images, header/footer, watermark) | 250–500 | 20% | 100–180 | Done: header & footer, watermarks, backgrounds, Bates; added text and images that stay editable (move, resize, format, rotate, flip, crop, replace); links (Link tool, Link Properties, create from URLs, remove all). Missing: editing existing text and images in place (the longest pole), image/PDF watermarks | -| M8 | Security + redaction | 100–180 | 68% | 15–30 | Done: opening protected documents, permissions, Protect Using Password, Remove security; redaction (mark text/areas/pages, Search & Redact with patterns, apply removing glyphs, image pixels, vectors, XObject content, annotations and fields, verification, full rewrite on save); Remove hidden information and Sanitize.
Done since: redaction codes (U.S. FOIA and Privacy Act sets as overlay text, in Redaction properties and `redact_mark`). Missing: certificate security, custom redaction code sets and pattern locales, DCT re-encoding | -| M9 | Signatures (PAdES, validation) | 160–280 | 60% | 40–70 | Done: new `sign` crate (DER, X.509, CMS, PKCS #12 on RustCrypto; aws-lc-rs for RSA private keys); PAdES B-B signing (visible, invisible, existing fields, certification with DocMDP); validation with trust store and changes-after-signing classification; self-signed digital IDs; Signatures panel, message bar, sign dialogs; agent tools. Checked with pdfsig and OpenSSL.
Done since: macOS Keychain and Windows Current User Personal store signing (software-backed CNG keys), certificate viewer, signed documents protected from rewrites. Missing: timestamps (B-T), LTV (DSS, OCSP, CRL), FieldMDP, smart cards, PKCS #11 | -| M10 | OCR, create, export, print | 200–350 | 33% | 120–215 | Done: create from blank/text/PNG/JPEG/TIFF (multi-page)/GIF/BMP; export PNG/JPEG/TIFF and text; Print (Acrobat's sizing, n-up, booklet, poster, comments & forms, preview, the CUPS and Windows spoolers, print-ready PDF).
Done since: embedded/72/custom DPI choices for image imports; export all images; OCR (searchable image for pages, ranges and multiple files); single-sided cut-and-stack imposition with cut marks, through Print and doc_print; Create PDF from multiple files (PDFs, images and text, edited as pages in the grid). Missing: OCR languages beyond Latin, editable-text OCR output, Office export/import, web printing | +| M8 | Security + redaction | 100–180 | 68% | 15–30 | Done: opening protected documents, permissions, Protect Using Password, Remove security; redaction (mark text/areas/pages, Search & Redact with patterns, apply removing glyphs, image pixels, vectors, XObject content, annotations and fields, verification, full rewrite on save); Remove hidden information and Sanitize.
Done since: redaction codes (U.S. FOIA and Privacy Act sets as overlay text, in Redaction properties and `redact_mark`). Redaction cleans indirect and form structure references, preserving surviving form tags. Redaction retires inherited resource dictionaries, so a parent `/Pages` can't keep a removed XObject reachable; unredacted pages are left untouched. Image redaction removes alternate images and unsupported masks only on affected images. Changed marked-content spans lose replacement descriptions; untouched spans keep theirs. Hidden-text cleanup retires unused named descriptions across inherited/shared page and form resources (64 focused tests and scoped Rust 1.96 Clippy/wasm checks pass; publication CI pending). Missing: certificate security, custom redaction code sets and pattern locales, DCT re-encoding | +| M9 | Signatures (PAdES, validation) | 160–280 | 60% | 40–70 | Done: new `sign` crate (DER, X.509, CMS, PKCS #12 on RustCrypto; aws-lc-rs for RSA private keys); PAdES B-B signing (visible, invisible, existing fields, certification with DocMDP); validation with trust store and changes-after-signing classification; self-signed digital IDs; Signatures panel, message bar, sign dialogs; agent tools. Checked with pdfsig and OpenSSL.
Done since: macOS Keychain and Windows Current User Personal store signing (software-backed CNG keys), certificate viewer, signed documents protected from rewrites, page validation overlays separate from original signature artwork (baked Not Verified captions remain; #79 open), snapshot tabs that reuse instead of multiplying. Missing: timestamps (B-T), LTV (DSS, OCSP, CRL), FieldMDP, smart cards, PKCS #11 | +| M10 | OCR, create, export, print | 200–350 | 33% | 120–215 | Done: create from blank/text/PNG/JPEG/TIFF (multi-page)/GIF/BMP; export PNG/JPEG/TIFF and text; Print (Acrobat's sizing, n-up, booklet, poster, comments & forms, preview, the CUPS and Windows spoolers, print-ready PDF).
Done since: embedded/72/custom DPI choices for image imports; export all images; OCR (searchable image for pages, ranges and multiple files); single-sided cut-and-stack imposition with cut marks, through Print and doc_print; architectural/ANSI/large ISO sheet presets, Match page at 100% and PDF-scale preview (#599 follow-up); Create PDF from multiple files (PDFs, images and text, edited as pages in the grid); OCR can also read pictures on pages that have text, without adding that text twice (#1028). Done since: Word invoice export uses borderless inferred tables, floating positioned text/artwork only where text overlaps an image or is rotated, and per-page sizes, covered by synthetic invoice automation regressions (#773); printing on the web through the browser's print dialog, sheets drawn by PdfCraft (gap 4). Lossless image export applies per-component Decode, including Indexed mapping (#1201 partial). Existing 16-bit precision and color-management limits remain. #3120: Office export reads form XObjects 50 levels deep, as the renderer does, and up to 50 000 a page; pages past that are listed in `truncated_pages`. Missing: OCR languages beyond Latin, editable-text OCR output, full Office layout fidelity/import | | M11 | Optimize, preflight, PDF/A/X/UA, print production | 200–350 | 18% | 80–140 | Done: new `optimize` crate: Reduce File Size and the PDF Optimizer (images measured where drawn, bicubic downsampling, JPEG/ZIP recompression only when smaller, discard objects and user data, Flate clean-up, resource merging, object streams). Missing: fonts and transparency panels, space audit, preflight, PDF/A/X/UA | -| M12 | Accessibility, compare, measure, search, XFA | 200–380 | 30% | 90–160 | Done: new `a11y` crate with the Accessibility Checker (all 32 rules, report, Fix/Skip/Explain, options dialog and results panel, agent tools).
Done since: 2D distance, perimeter and area measurements with persistent viewport calibration, snapping, live information and CSV export. Missing: autotag, Tags/Order/Content panels, Reading Order tool, alt-text workflow, compare, geospatial/3D measurement, search index, XFA | -| M13 | Automation (MCP, Action Wizard, CLI) + AI providers | 60–120 | 45% | 25–45 | Done: MCP resources (document info, text, page images); headless tool table (123 tools incl. signing, optimizing, initial view, links, stamps, data exchange, comment review, forms authoring and scripts, redaction, sanitize, print, add content), opt-in MCP server over stdio, CLI `run`/`tools` (closed stdout pipes exit cleanly), UI control channel with drag. Missing: Action Wizard, AI providers
Done since: CLI run and MCP reject missing, empty, repeated and option-valued --root arguments before any tool/script execution or MCP initialization. A single-hyphen directory name remains valid; synthetic CLI regressions preserve a would-be overwritten file. | -| M14 | 1.0 polish: performance, localization, installers | 120–250 | 15% | 80–150 | Done: PhotoCraft's translation system (`i18n/`: TSV catalogs, `tl!`, command-id and plural entries, system-language detection, strict catalog tests); every dialog, panel and notice goes through `tl!`. Catalogs of ≈ 2,000–2,140 entries for Japanese, Simplified and Traditional Chinese, Russian, Bulgarian, German, Spanish, French, Telugu, Hungarian, Ukrainian, Italian, Brazilian Portuguese and Arabic (logical-order catalog put into display order; layout not yet mirrored); Czech covers the menus. Signed and notarized macOS builds, signed Windows installers (x64, x86, ARM64), AppImage, deb, rpm, Flatpak and FreeBSD packages. #307 (bounded large-PDF memory): shared resource indexes and parser metadata, lazy inspection, bounded caches; a 9,156-page file inspects in ≈ 1.8 s at ≈ 500 MiB instead of exceeding 10 GiB. Shortcut display uses localized modifier/key names across menus, toolbar, palette, Help and tooltips (#496). Missing: a right-to-left layout, a Chinese UI face in releases, Hindi/Indonesian/Korean/Vietnamese, file-backed input and global memory budgets, performance budgets, keyboard-only operation | +| M12 | Accessibility, compare, measure, search, XFA | 200–380 | 30% | 90–160 | Done: new `a11y` crate with the Accessibility Checker (all 32 rules, report, Fix/Skip/Explain, options dialog and results panel, agent tools). Locatable figure, annotation and form findings highlight their page bounds.
Done since: 2D distance, perimeter and area measurements with persistent viewport calibration, snapping, live information and CSV export; a bounded, text-only auto-tag proposal tool for one page. Marking a figure decorative rewrites only that stream (#1324). An XFA radio item whose value contains a space is written back unchanged (#1319). Missing: writing proposed tags and parent tree, field autotag, visual Tags/Order/Content panels, Reading Order tool, compare, geospatial/3D measurement, search index, XFA | +| M13 | Automation (MCP, Action Wizard, CLI) + AI providers | 60–120 | 45% | 25–45 | Done: MCP resources (document info, text, page images); headless tool table (123 tools incl. signing, optimizing, initial view, links, stamps, data exchange, comment review, forms authoring and scripts, redaction, sanitize, print, add content), opt-in MCP server over stdio, CLI `run`/`tools` (closed stdout pipes exit cleanly), UI control channel with drag. Missing: Action Wizard, AI providers
Done since: CLI run and MCP reject missing, empty, repeated and option-valued --root arguments before any tool/script execution or MCP initialization. A single-hyphen directory name remains valid; synthetic CLI regressions preserve a would-be overwritten file. `text_find` also caps the per-page match ranges it materializes at the remaining result limit plus one continuation match. CLI robustness scans visit each directory once through overlapping roots and symlink aliases. | +| M12 | Accessibility, compare, measure, search, XFA | 200–380 | 30% | 90–160 | Done: new `a11y` crate with the Accessibility Checker (all 32 rules, report, Fix/Skip/Explain, options dialog and results panel, agent tools). Locatable figure, annotation and form findings highlight their page bounds.
Done since: 2D distance, perimeter and area measurements with persistent viewport calibration, snapping, live information and CSV export; a bounded text-only auto-tag proposal tool; text-only Apply writes marked content, paragraph tags and a parent tree, then opens the Tags panel for review. Missing: mixed-content and field autotag, heading/list/table/figure classification, Order/Content panels, Reading Order tool, compare, geospatial/3D measurement, search index, XFA
Done since: CLI info, text and render use dynamic XFA layout pages, with synthetic count/text/pixel and copy-permission regressions (#1645). | +| M13 | Automation (MCP, Action Wizard, CLI) + AI providers | 60–120 | 45% | 25–45 | Done: MCP resources (document info, text, page images); headless tool table (123 tools incl. signing, optimizing, initial view, links, stamps, data exchange, comment review, forms authoring and scripts, redaction, sanitize, print, add content), opt-in MCP server over stdio, CLI `run`/`tools` (closed stdout pipes exit cleanly), UI control channel with drag. Missing: Action Wizard, AI providers
Done since: CLI run and MCP reject missing, empty, repeated and option-valued --root arguments before any tool/script execution or MCP initialization. A single-hyphen directory name remains valid; synthetic CLI regressions preserve a would-be overwritten file. `text_find` also caps the per-page match ranges it materializes at the remaining result limit plus one continuation match. | +| M14 | 1.0 polish: performance, localization, installers | 120–250 | 15% | 80–150 | Done: PhotoCraft's translation system (`i18n/`: TSV catalogs, `tl!`, command-id and plural entries, system-language detection, strict catalog tests); every dialog, panel and notice goes through `tl!`. Catalogs of ≈ 2,000–2,140 entries for Japanese, Simplified and Traditional Chinese, Russian, Bulgarian, German, Spanish, French, Telugu, Hungarian, Ukrainian, Brazilian Portuguese and Arabic (logical-order catalog put into display order; layout not yet mirrored); Czech covers the menus. Italian has 2,528 entries: every key of the other catalogs that the code looks up. Signed and notarized macOS builds, signed Windows installers (x64, x86, ARM64), AppImage, deb, rpm, Flatpak and FreeBSD packages. #307 (bounded large-PDF memory): shared resource indexes and parser metadata, lazy inspection, bounded caches; a 9,156-page file inspects in ≈ 1.8 s at ≈ 500 MiB instead of exceeding 10 GiB. Edits let go of the previous working file before writing the next (a 1 GiB file peaks at two copies while editing, not three; gap 13). Shortcut display uses localized modifier/key names across menus, toolbar, palette, Help and tooltips (#496). Lithuanian adds a 2,578-entry catalog and date names, reviewed by a native-speaking contributor. Validated candidate (macOS raster probe; Windows runtime unverified): the Windows policy changes only embedded JetBrains Mono; horizontal alpha-edge energy per ink improves 2.78%, 3.87% and 1.59% at 100%, 125% and 150%, and is down 0.79% at 200%. Inter is pixel-identical; layout hashes, glyph coverage and the 8 MiB atlas are unchanged. Timing and RSS ranges overlap, with no improvement claimed. Estimates unchanged. Candidate #2383 clamps GPU requests to supported limits while retaining the adapter texture extent; synthetic V3D/downlevel and Windows GPU-required tests pass, but Raspberry Pi startup is unverified. Estimates unchanged. Missing: a right-to-left layout, a Chinese UI face in releases, Hindi/Indonesian/Korean/Vietnamese, file-backed input and global memory budgets, performance budgets, keyboard-only operation Linux/Unix UI fallback fonts also use Fontconfig directories, including Nix store paths, with bounded discovery and the existing candidate allowlist; relative includes and digit-prefixed config filtering have isolated regressions. | +| M14 | 1.0 polish: performance, localization, installers | 120–250 | 15% | 80–150 | Done: PhotoCraft's translation system (`i18n/`: TSV catalogs, `tl!`, command-id and plural entries, system-language detection, strict catalog tests); every dialog, panel and notice goes through `tl!`. Catalogs of ≈ 2,000–2,140 entries for Japanese, Simplified and Traditional Chinese, Russian, Bulgarian, German, Spanish, French, Telugu, Hungarian, Ukrainian, Brazilian Portuguese and Arabic (logical-order catalog put into display order; layout not yet mirrored); Czech covers the menus. Italian has 2,528 entries: every key of the other catalogs that the code looks up. Signed and notarized macOS builds, signed Windows installers (x64, x86, ARM64), AppImage, deb, rpm, Flatpak and FreeBSD packages. #307 (bounded large-PDF memory): shared resource indexes and parser metadata, lazy inspection, bounded caches; a 9,156-page file inspects in ≈ 1.8 s at ≈ 500 MiB instead of exceeding 10 GiB. Edits let go of the previous working file before writing the next (a 1 GiB file peaks at two copies while editing, not three; gap 13). Shortcut display uses localized modifier/key names across menus, toolbar, palette, Help and tooltips (#496). Lithuanian adds a 2,578-entry catalog and date names, reviewed by a native-speaking contributor. Validated candidate (macOS raster probe; Windows runtime unverified): the Windows policy changes only embedded JetBrains Mono; horizontal alpha-edge energy per ink improves 2.78%, 3.87% and 1.59% at 100%, 125% and 150%, and is down 0.79% at 200%. Inter is pixel-identical; layout hashes, glyph coverage and the 8 MiB atlas are unchanged. Timing and RSS ranges overlap, with no improvement claimed. Estimates unchanged. Missing: a right-to-left layout, a Chinese UI face in releases, Hindi/Indonesian/Korean/Vietnamese, file-backed input and global memory budgets, performance budgets, keyboard-only operation XDG desktops use the configuration base for preferences with non-overwriting migration and original-data preservation (#1395). | | M13 | Automation (MCP, Action Wizard, CLI) + AI providers | 60–120 | 45% | 25–45 | Done: MCP resources (document info, text, page images); headless tool table (123 tools incl. signing, optimizing, initial view, links, stamps, data exchange, comment review, forms authoring and scripts, redaction, sanitize, print, add content), opt-in MCP server over stdio, CLI `run`/`tools` (closed stdout pipes exit cleanly), UI control channel with drag. Missing: Action Wizard, AI providers | -| M14 | 1.0 polish: performance, localization, installers | 120–250 | 15% | 80–150 | Done: PhotoCraft's translation system (`i18n/`: TSV catalogs, `tl!`, command-id and plural entries, system-language detection, strict catalog tests); every dialog, panel and notice goes through `tl!`. Catalogs of ≈ 2,000–2,140 entries for Japanese, Simplified and Traditional Chinese, Russian, Bulgarian, German, Spanish, French, Telugu, Hungarian, Ukrainian, Italian, Brazilian Portuguese and Arabic (logical-order catalog put into display order; layout not yet mirrored); Czech covers the menus. Signed and notarized macOS builds, signed Windows installers (x64, x86, ARM64), AppImage, deb, rpm, Flatpak and FreeBSD packages. #307 (bounded large-PDF memory): shared resource indexes and parser metadata, lazy inspection, bounded caches; a 9,156-page file inspects in ≈ 1.8 s at ≈ 500 MiB instead of exceeding 10 GiB. Shortcut display uses localized modifier/key names across menus, toolbar, palette, Help and tooltips (#496). Traditional Chinese accessibility statuses, rule semantics and Taiwan form/table terms have catalog regressions. Missing: a right-to-left layout, a Chinese UI face in releases, Hindi/Indonesian/Korean/Vietnamese, file-backed input and global memory budgets, performance budgets, keyboard-only operation | +| M14 | 1.0 polish: performance, localization, installers | 120–250 | 15% | 80–150 | Done: PhotoCraft's translation system (`i18n/`: TSV catalogs, `tl!`, command-id and plural entries, system-language detection, strict catalog tests); every dialog, panel and notice goes through `tl!`. Catalogs of ≈ 2,000–2,140 entries for Japanese, Simplified and Traditional Chinese, Russian, Bulgarian, German, Spanish, French, Telugu, Hungarian, Ukrainian, Brazilian Portuguese and Arabic (logical-order catalog put into display order; layout not yet mirrored); Czech covers the menus. Italian has 2,528 entries: every key of the other catalogs that the code looks up. Signed and notarized macOS builds, signed Windows installers (x64, x86, ARM64), AppImage, deb, rpm, Flatpak and FreeBSD packages. #307 (bounded large-PDF memory): shared resource indexes and parser metadata, lazy inspection, bounded caches; a 9,156-page file inspects in ≈ 1.8 s at ≈ 500 MiB instead of exceeding 10 GiB. Edits let go of the previous working file before writing the next (a 1 GiB file peaks at two copies while editing, not three; gap 13). Shortcut display uses localized modifier/key names across menus, toolbar, palette, Help and tooltips (#496). Traditional Chinese accessibility statuses, rule semantics and Taiwan form/table terms have catalog regressions. Long main-menu lists scroll in short windows (#3373). Missing: a right-to-left layout, a Chinese UI face in releases, Hindi/Indonesian/Korean/Vietnamese, file-backed input and global memory budgets, performance budgets, keyboard-only operation | | | **Total** | **2,085–3,840** | **≈ 49% ready** | **≈ 770–1,400** | Area view of the same work: [target-app-parity.md](target-app-parity.md#by-feature-area) | ## Upcoming milestones, ranked @@ -98,7 +162,7 @@ total matches the parity estimate. | Rank | Next milestone | Closes | Estimate (h) | |---|---|---|---| | 0 | Convert and share: Create PDF from Office, Word export keeps layout | part of gap 8 | 32–55 | -| 1 | Backlog and platform: crash and wrong-result reports fixed, web printing, Chinese UI face | gaps 4, 5, 6 | 65–120 | +| 1 | Backlog and platform: crash and wrong-result reports fixed, Chinese UI face (web printing done) | gaps 5, 6 | 50–90 | | 2 | Fidelity harness and renderer decision (M2) | gaps 2, 12 | 110–190 | | 3 | Editing existing content (M7) | gap 3 | 100–180 | | 4 | Pro conversion: OCR languages and clean-up, Office export/import (M10) | gaps 7, 8 | 95–175 | @@ -132,15 +196,77 @@ M2 (renderer), M7 (content editing), M10 (OCR and Office), M11 (Preflight and st ## Revision history +**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-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: Edit text caret height follows its row, with a kittest regression; estimates unchanged | +| 2026-10-19 | minor | M3 #3360: retain each document’s scroll position when switching Read and the docking workspace at fixed zoom. 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-17 | minor | M4 #2997: Ctrl-drag (Option on macOS) in the Organize grid copies the dragged pages into the drop gap (a "+" badge says so; the copies land selected, one undo step); `page_duplicate` takes `at`, and the UI control channel's `ui.drag` modifiers reach the input state. 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-15 | minor | M9 #1895: generic appearance rewriting deferred; separate validation overlays preserve original artwork, exports and printing. 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. | +| 2026-10-13 | minor | M4: rotation queries and edits resolve indirect page-tree values; inherited, negative and large rotations verified through both saves. Estimates unchanged. | +| 2026-10-12 | minor | M6: valid AcroForm XML element names with original PDF field names preserved; saved-PDF collision and Unicode regressions. Estimates unchanged. | +| 2026-10-12 | minor | M6: preserve carriage returns in exported CSV field names and values; saved-PDF round-trip coverage. Estimates unchanged. | +| 2026-10-12 | minor | M7 local candidate: text drafts grow and save their width; single visual rows grow across style boundaries; both text tools retype added items and Add Text edits source text. Added-item textless previews are read-only and covered by UI/tool regressions. Estimates unchanged. | +| 2026-10-12 | minor | M14/#1395: XDG configuration preferences, safe migration and fallback; logs/recovery retain data locations, portable/non-XDG unchanged. Estimates unchanged. | +| 2026-10-12 | minor | M6: XFDF field import and data extraction preserve all text around XML comments and processing instructions; synthetic save/reopen regressions. Estimates unchanged. | +| 2026-10-12 | minor | M2: preserve whitespace-bounded EI bytes in known unfiltered inline images; row padding, masks and aliases tested, with independent saved-PDF pixel checks. Estimates unchanged. | +| 2026-10-12 | minor | M1 #1338: a full rewrite keeps unknown trailer entries and objects reachable only from them. Estimates unchanged. | +| 2026-10-12 | minor | M7 #1443: adding a word to a multi-line paragraph keeps its line breaks. Composite subset fonts whose `/W` leaves out default-width glyphs (Chrome, Skia) no longer measure those letters as missing, and paragraphs drawn turned on the page group into rows along their own baselines. Estimates unchanged. | +| 2026-10-14 | minor | M14: Italian catalog completed (2,528 entries) and dropped from the ≈ 2,000–2,140-entry group; estimates unchanged | +| 2026-10-12 | minor | M4 #1310 #1311 #1312: undo back to the opened file is clean, Revert returns to the last saved file, and the next save keeps that revision. Estimates unchanged. | +| 2026-10-14 | minor | M5: reconciled PR #1457 with upstream main, preserving comment submission shortcuts and upstream roadmap progress. Estimates unchanged. | +| 2026-10-15 | minor | M2: three vendored hayro-interpret patches (31–33): sampled functions index a flat table (`bug1721218` 1.34 → 0.49 s), one JPEG or Flate decode serves every size an image is drawn at (35–40% less render work zooming a scan), patch meshes copy their vertices less (10–16% on mesh-gradient pages); pixel for pixel the same over the pdf.js corpus. Estimates unchanged. | +| 2026-10-13 | minor | M2/M6: images with at most 16 bits of samples a pixel (8-bit Separation/DeviceN, 1-4-bit gray or indexed) convert once per pixel value (hayro-interpret patch 28), so spot-colour form backgrounds draw in milliseconds instead of seconds; widgets without `/P` are placed in one pass; the Fields panel no longer searches the form per row and draws only the rows in view. Estimates unchanged. | +| 2026-10-13 | minor | M3: native-open admission preserves modal/picker ownership; regressions cover queued replies and one-shot picker wakes. Estimates unchanged | +| 2026-10-12 | minor | M13: CLI check directory identity, with overlap and symlink regressions; estimates unchanged | +| 2026-10-12 | minor | M4: shared Type 3 glyph resources survive extraction, with save/reopen and independent raster regressions; estimates unchanged | +| 2026-10-12 | minor | M2: packed TIFF-predictor RGB row boundaries, with decoding and page-render regressions; estimates unchanged | +| 2026-10-12 | minor | M3/#1364 candidate: absolute local GoToR links require desktop consent or headless allow_absolute; existing relative navigation and validation retained. Estimates unchanged. | +| 2026-10-12 | minor | M7: single-glyph RTL ActualText ligatures join their word in Edit text. Regressions cover marks, two-byte codes, replacement and save/reopen; multi-glyph logical spans retain their order. Estimates unchanged. | +| 2026-10-12 | minor | M5 #1398: rubber stamps, explicit image rectangles and PDF-page stamps read upright on rotated pages. Estimates unchanged. | +| 2026-10-12 | minor | M12: text-only auto-tag Apply writes real structure and parent-tree links, with undo and Tags-panel review; mixed content remains unsupported. Estimates unchanged. | +| 2026-10-12 | minor | M7 #1316: replacing a line or paragraph keeps a neighbouring line whose glyph box overlaps; same-baseline copies are still replaced. Estimates unchanged. | +| 2026-10-12 | minor | M1 #1339: unchanged incremental saves of a classic cross-reference table write a zero-length subsection. Estimates unchanged. | +| 2026-10-12 | minor | M12: bounded text-only auto-tag preview through the engine and automation; actual PDF structure writing and visual review remain open. Estimates unchanged. | +| 2026-10-12 | minor | Gap 6: XFA radio values with spaces stay in the datasets (#1319); Replace Pages keeps a gap in a split selection (#1305); decorative figures rewrite only their own content stream (#1324). Estimates unchanged. | +| 2026-10-12 | minor | M7: keep added Arabic copy/search in typing order with per-line ActualText; preserve that order in the editor and keep visual runs separate. Font-independent regressions and tool save/reopen coverage; font fallback and bidi typing remain partial. Estimates unchanged. | +| 2026-10-12 | minor | M3: case-insensitive Find matches normal, final and capital Greek sigma; exact search, word boundaries, glyph ranges and match limits have regression coverage. The tab-menu test uses Finder on macOS. Estimates unchanged. | +| 2026-10-12 | minor | M14: Windows low-DPI JetBrains Mono hinting candidate; raster and layout evidence recorded without a Windows runtime claim. Estimates unchanged. | +| 2026-10-12 | minor | M7/#1200 candidate: bounded local GoToR opening, root-confined automation, and action-preserving appearance edits; named destinations and window behavior remain partial; estimates unchanged. | +| 2026-10-12 | patch | M1 #1337: exhausted xref entries are repaired; allocation safely handles exhausted /Size and high edits; classic-table/stream regressions and opening/allocation controls. Estimates unchanged. | +| 2026-10-12 | minor | Exclusive image crops resize with their pixels across nested states, intervening operators and content streams; shared clips remain fixed. Synthetic engine/tool regressions and private image save/reopen checks; estimates unchanged. | +| 2026-10-11 | minor | M4: editable Organize page-range selection (#1092), synchronized with thumbnails and available through UI control. Estimates unchanged. | +| 2026-10-11 | minor | M6 #1208: expose dropdown and list-box alignment; preserve existing values and cover cancel, undo/redo, save/reopen. Estimates unchanged. | +| 2026-10-11 | minor | M10: Word invoice export regressions (#773); estimates unchanged. | +| 2026-10-12 | minor | M2: short same-line redraws split before placement; estimates unchanged. | +| 2026-10-12 | minor | M5: Ctrl+Enter posts highlight comment text and replies; Shift+Enter adds newlines. Estimates unchanged. | +| 2026-10-11 | minor | M3: Windows' Animation effects setting is followed: wheel scrolling and zooming without easing, no other animations. Preferences ▸ Animation effects (Use system setting, On, Off) overrides it. macOS, Linux and the browser aren't asked yet. Estimates unchanged. | +| 2026-10-11 | minor | M10: printing on the web through the browser's print dialog (gap 4 closed); rank 1 drops web printing, 65–120 → 50–90 h | +| 2026-10-11 | minor | M13/#971: cap materialized text-search matches to the requested limit plus one continuation match. | +| 2026-10-11 | minor | M7: #594 ordinary paragraph drags validated in the Linux test environment with source-loss baseline failures, UI/engine regressions, native save/reopen and all eight CI gates. Estimates unchanged. | | 2026-10-11 | minor | M4: direct Organize overview, checkbox selection and individual page actions. 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-11 | minor | Contributor fork: compatible word/subset-space grouping, source-font space reuse and a read-only textless editor background. Mixed-style regions now covered by engine/tool/UI regressions and private real-file checks; embedded sfnt faces, stable baselines and live draft extents now covered; installed-font selection and unsupported source programs remain open; estimates unchanged | | 2026-10-11 | minor | Synced with closed issues: encrypted output opens in Acrobat (#774, #836), so gap 1 left rank 0 and convert and share (32–55 h, the gap's 8–15 h removed); organize, XFDF and multi-object move fixes (#817, #821, #809, #819, #844); the #816 panic and the #799–#821 batch fixed; rank 1 names web printing (Windows shipped, #756). Stage and readiness numbers not re-measured | | 2026-10-11 | minor | M6: respect existing form-font encodings; retain source fonts on appearance fallback. Estimates unchanged. | +| 2026-10-11 | minor | M5: partial XFDF/FDF imports preserve existing replies and review statuses through save and export. Estimates unchanged. | | 2026-10-11 | minor | M6: keep accepted form values when Tab reopens the same field before Save. Estimates unchanged. | | 2026-10-11 | minor | M13: reject malformed CLI --root options before dispatch. Estimates unchanged. | | 2026-10-11 | minor | M14: Traditional Chinese accessibility statuses, script-rule meaning and Taiwan form/table terminology; added a missing table-row explanation and catalog regressions. Estimates unchanged. | | 2026-10-10 | minor | M14: localized shortcut key names (#496), including runtime switching and native layout checks. Estimates unchanged. | +| 2026-10-10 | minor | M3 records the System appearance completion after #881; estimates unchanged | | 2026-10-10 | minor | Re-judged convert and share with the cross-app gate rule (only a workflow that can't be completed at all blocks): partial, not blocking → stage alpha | | 2026-10-10 | minor | Added the alpha gate (six core workflows; convert and share fails → pre-alpha, 40–70 h to alpha) and rank 0 in the upcoming milestones | +| 2026-10-12 | minor | M8: cleanup removes replacement descriptions only from changed marked-content spans, preserving surviving accessibility text. Estimates unchanged. | +| 2026-10-12 | minor | M8: image redaction removes alternate images and unsupported masks only on affected images. Estimates unchanged. | | 2026-10-10 | major | Created from ROADMAP.md §Milestones, §Critical path, §Risks and §Where we're lacking. Merged the duplicated M1, M2, M3 and M14 rows, re-measured Done and Remaining, added Current focus and the ranked upcoming milestones | diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 2716fc0613..9119063e20 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1,6 +1,6 @@ # PdfCraft parity with Adobe Acrobat Pro -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (closed issues synced, scores not re-judged: #774, #756, #740, #759, #799–#821, #825, #844; earlier: readiness table with hours per audience; full number restated as the standard's additive weighted sum, 47% → 49%; earlier: second look: cross-cutting double count removed, mainstream-practitioner number 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-13 · **Change:** minor (localization ≈59% → ≈68%; weighted readiness 49.25 → 49.7 ≈ 50%) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS) The authoritative assessment of how close PdfCraft is to Acrobat Pro. [ROADMAP.md](../ROADMAP.md) summarizes it; [gaps.md](gaps.md) lists every shortfall one by one; the deep-dive checklists are @@ -21,7 +21,7 @@ Approved Trust List) are out of scope: 23 such features are tracked as `na` in | Feature breadth, unweighted | 56.9% (423 shipped + 74 partial of 808) | measured (`cargo xtask parity` arithmetic) | | Must-have (P0) features | 94.2% (226 + 23 partial of 252) | measured | | Feature breadth, mainstream practitioner | ≈ 70% tier-weighted (63.2% unweighted: 292 + 44 partial of 497; P0 95.3%) | measured ([split below](#mainstream-practitioner-vs-full-acrobat-pro)) | -| **Ready for real work**, full Acrobat Pro | **≈ 49%** (range 45–53%) | estimated (weighted sum over dimensions, [below](#by-dimension)) | +| **Ready for real work**, full Acrobat Pro | **≈ 50%** (range 45–53%) | estimated (weighted sum over dimensions, [below](#by-dimension)) | | **Ready for real work**, mainstream practitioner | **≈ 55%** (range 50–60%) | estimated ([split below](#mainstream-practitioner-vs-full-acrobat-pro)) | | **Ready for real work**, essentials user | **≈ 66%** (range 60–70%) | estimated ([below](#essentials-user)) | | **Stage** | **alpha** (passes the core-workflow gate) | see [Stage](#stage) | @@ -32,7 +32,7 @@ Approved Trust List) are out of scope: 23 such features are tracked as `na` in | Audience | Ready | Opus 5.5 agent-hours to ≈ 95% | Work that dominates | |---|---|---|---| -| Full Acrobat Pro (ready for real work) | ≈ 49% | ≈ 780–1,400 h (≈ 300–550 h wall clock with 3–5 agents; 60–70% parallelizes) | Own renderer and fidelity harness, editing existing content, OCR and Office conversion, Preflight and PDF/A/X/UA, accessibility tagging, signatures for institutions | +| Full Acrobat Pro (ready for real work) | ≈ 50% | ≈ 780–1,400 h (≈ 300–550 h wall clock with 3–5 agents; 60–70% parallelizes) | Own renderer and fidelity harness, editing existing content, OCR and Office conversion, Preflight and PDF/A/X/UA, accessibility tagging, signatures for institutions | | Mainstream practitioner | ≈ 55% | ≈ 500–900 h (≈ 200–400 h wall clock; ≈ 60% parallelizes) | Rendering fidelity, editing existing text (CJK, RTL, subsets), conversion (Word layout, Excel, PowerPoint, Office→PDF), OCR languages, the bug backlog | | Essentials user | ≈ 66% | ≈ 180–330 h (≈ 90–180 h wall clock; ≈ 50% parallelizes) | Rendering fidelity on everyday files, launch and GPU stability, simple text fixes, Word export layout, Chinese UI glyphs | @@ -86,8 +86,8 @@ The **full ready-for-real-work number is an additive weighted sum over the dimen method; weights by what decides whether a professional can switch): features 40%, file formats and conversion 12%, UI/UX 10%, PDF specification 8%, stability 8%, performance 6%, localization 5%, platforms 5%, hardware 4%, ecosystem 1%, AI 1%: -0.40 × 49 + 0.12 × 40 + 0.10 × 55 + 0.08 × 60 + 0.08 × 50 + 0.06 × 40 + 0.05 × 59 + 0.05 × 70 + -0.04 × 35 + 0.01 × 30 + 0.01 × 0 = 49.25 → **≈ 49%**. +0.40 × 49 + 0.12 × 40 + 0.10 × 55 + 0.08 × 60 + 0.08 × 50 + 0.06 × 40 + 0.05 × 68 + 0.05 × 70 + +0.04 × 35 + 0.01 × 30 + 0.01 × 0 = 49.7 → **≈ 50%**. History: the first version of this file subtracted 9 points from the area figure for cross-cutting shortfalls (40%); the second look found that a double count (Windows printing, encrypted-output @@ -180,14 +180,14 @@ features total and are shown for orientation; the others add to it. | UI/UX fidelity | ≈ 55% | 10% | 30–60 | additional | Acrobat-style shell, All tools, Home, command palette; missing single-key accelerators, rulers/guides, multiple windows, popups; user reports #739, #744, #746, #789 (#759 and #844 fixed) | [ui-parity.md](ui-parity.md) | | File formats and conversion | ≈ 40% | 12% | 120–220 | within features | PDF read/write strong, encrypted output included (#774 fixed); export Word is layout-poor (#773); no Excel/PowerPoint export, no Office/HTML/PostScript import | [file-format-parity.md](file-format-parity.md) | | PDF specification and standards | ≈ 60% | 8% | 150–260 | within features | ISO 32000 syntax, encryption, annotations and AcroForm strong; rendering and fonts borrowed from `hayro`; PDF 2.0 extras, linearization, public-key security absent; PDF/A partial, PDF/X/UA none | [pdf-spec-parity.md](pdf-spec-parity.md) | -| Hardware | ≈ 35% | 4% | 25–50 | additional | Page raster is CPU only (Acrobat has GPU rendering); Windows CNG and macOS Keychain keys sign, no PKCS #11; no scanners; no printing on the web (Windows shipped, #756) | [hardware-parity.md](hardware-parity.md) | -| Localization | ≈ 59% over the 12 key languages | 5% | 35–60 | additional | 15 catalogs, 13 at 94–100%; Chinese UI shows tofu in releases (#826, #688); Arabic not mirrored; no Hindi, Indonesian, Korean, Vietnamese | [localization-parity.md](localization-parity.md) | +| Hardware | ≈ 35% | 4% | 25–50 | additional | Page raster is CPU only (Acrobat has GPU rendering); Windows CNG and macOS Keychain keys sign, no PKCS #11; no scanners; web printing goes through the browser's print dialog as pictures, not vectors | [hardware-parity.md](hardware-parity.md) | +| Localization | ≈ 68% over the 12 key languages | 5% | 35–60 | additional | 22 catalogs + English (23 interface languages); Chinese UI shows tofu in releases (#826, #688); Arabic not mirrored; no Hindi, Indonesian, Korean | [localization-parity.md](localization-parity.md) | | Performance | ≈ 40% | 6% | 25–45 | additional | 500-page smooth scrolling and large-file memory work (#307) done; no performance budgets (`misc.performance-budgets`), no lazy loading of GB files; blurry text at 1080p/1440p (#730) | [gaps.md](gaps.md) | | Stability | ≈ 50% | 8% | 45–80 | additional | Never-crash rules and nightly fuzzing; 245 open issues on 2026-10-10, many of them `fix(...)` reports of wrong results in shipped features (the #799–#821 batch is fixed) | [gaps.md](gaps.md) | -| Platforms | ≈ 70% | 5% | 15–30 | additional | macOS (signed, notarized), Windows x64/x86/ARM64 (signed), Linux (AppImage, deb, rpm, Flatpak), FreeBSD, web (WASM). Acrobat has no Linux, FreeBSD or full web editor; we lack Windows and web printing, web OCR and web recovery | [hardware-parity.md](hardware-parity.md) | +| Platforms | ≈ 70% | 5% | 15–30 | additional | macOS (signed, notarized), Windows x64/x86/ARM64 (signed), Linux (AppImage, deb, rpm, Flatpak), FreeBSD, web (WASM). Acrobat has no Linux, FreeBSD or full web editor; we lack web OCR and web recovery | [hardware-parity.md](hardware-parity.md) | | Ecosystem and plug-ins | ≈ 30% | 1% | 10–20 | within features | No plug-in SDK or folder-level JavaScript; Action Wizard partial. Agent automation (CLI, MCP, UI control channel) has no Acrobat equivalent | [gaps.md](gaps.md) | | AI features | ≈ 0% (in scope) | 1% | 20–40 | within features | Acrobat's AI Assistant is cloud-only (out of scope). Local, opt-in provider interface (`misc.ai-provider`) not started | [gaps.md](gaps.md) | -| **Total (weighted sum)** | **≈ 49%** | 100% | **≈ 780–1,400** | | | | +| **Total (weighted sum)** | **≈ 50%** | 100% | **≈ 780–1,400** | | | | ## By feature area @@ -201,13 +201,13 @@ full parity in that area. | C Content editing | 61 | 29 | 7 | 53.3% | 30% | 100–180 | Edit existing paragraphs (font reused or Helvetica substituted, rewrapped), added text and images stay editable, header/footer, watermarks, backgrounds, Bates, links. Missing: robust editing of real-world text (subset fonts, CJK, RTL #766, rotated), lists, vector/object editing, arrange/align, spell check, multi-select move across pages (same-page shipped, #844) | | D Organize pages | 76 | 45 | 3 | 61.2% | 70% | 12–25 | Organize grid, insert/extract/replace/split/combine with links, fields, layers and bookmarks carried over; page labels, boxes, bookmarks from structure. Missing: transitions, attachments editing, portfolios, struct-tree merge, field-name conflicts (destination and annotation fixes #817, #818, #821 shipped) | | E Comments and review | 65 | 46 | 9 | 77.7% | 70% | 12–25 | Every markup type with appearances, replies, status, filters, stamps (custom, dynamic), XFDF/FDF, summaries, flatten, 2D measuring. Missing: rich-text runs, popup windows, summary layouts with pages, per-type hiding (XFDF fidelity fixes #807, #809, #819 shipped) | -| F Forms and JavaScript | 79 | 58 | 8 | 78.5% | 65% | 40–75 | Fill and author every AcroForm field type, Acrobat's AF functions in Acrobat's event order, sandboxed JavaScript (boa), data exchange, Fill & Sign; XFA static/dynamic layout, FormCalc and XFA data (partial). Missing: wider JavaScript object model, document actions, submit, debugger, barcode fields, XFA flattening and full dynamic fidelity | +| F Forms and JavaScript | 79 | 58 | 8 | 78.5% | 65% | 40–75 | Fill and author every AcroForm field type, Acrobat's AF functions in Acrobat's event order, sandboxed JavaScript (boa), data exchange, Fill & Sign; XFA static/dynamic layout, FormCalc and XFA data (partial). CLI info, text and render use the laid-out dynamic pages (#1645). Missing: wider JavaScript object model, document actions, submit, debugger, barcode fields, XFA flattening and full dynamic fidelity | | G Protect, redact, sanitize | 41 | 23 | 2 | 58.5% | 55% | 15–30 | Passwords and permissions, Remove security, redaction that removes glyphs, pixels, vectors, annotations and tags, with verification; word lists, patterns, FOIA/Privacy Act codes; sanitize. Missing: certificate security, pattern locales, custom code sets, partial-word redaction, folder redaction, protected view | | H Digital signatures | 45 | 23 | 10 | 62.2% | 45% | 40–70 | PAdES B-B signing and validation (PKCS #12, macOS Keychain, Windows store with PIN prompts), certification, encrypted documents, certificate usage checks; B-T, LTV, OCSP/CRL with caller-supplied evidence. Missing: PKCS #11 and smart cards outside Windows, timestamp-server preferences, online revocation fetching, FieldMDP and field locking, OS trust store, EU Trusted Lists, compare signed version | | I Scan and OCR | 26 | 5 | 2 | 23.1% | 15% | 40–75 | Searchable image (ocrs, Latin script without accents) on pages, ranges and many files. Missing: every other language (#744 Dutch; accents, CJK, Cyrillic, Arabic), editable-text output, deskew, rotation, despeckle, camera images beyond straightening (found or placed corners): orientation, clean-up, MRC compression, scanners | | J Create and export | 34 | 12 | 3 | 39.7% | 25% | 55–100 | Create from images, multipage TIFF, text, clipboard, blank, many files at once; export PNG/JPEG/TIFF, text, RTF; Word and HTML partial. Missing: Office/HTML/web/PostScript to PDF, Excel and PowerPoint export, Word layout fidelity (#773), SVG/XML/EPS/PS/JPEG 2000 export, accessible text | | K Optimize and standards | 58 | 15 | 10 | 34.5% | 20% | 65–115 | Reduce File Size and the Optimizer (images, discards, clean-up, space audit); PDF/A-2b/3b verify and convert (partial). Missing: Preflight (profiles, fixups, reports), PDF/A-1/4 and a/u levels, PDF/X, PDF/UA, PDF/E, PDF/VT, transparency flattening, font unembedding, monochrome JBIG2/CCITT | -| L Print | 39 | 15 | 1 | 39.7% | 35% | 30–55 | Acrobat's sizing, n-up, booklet, poster, comments & forms, preview, print-ready PDF, CUPS on macOS/Linux and the Windows spooler (#756). Missing: **printing on the web**, print as image, PostScript, all print-production tools (output preview, separations, ink manager, printer marks, colour conversion) | +| L Print | 39 | 15 | 1 | 39.7% | 35% | 30–55 | Acrobat's sizing, n-up, booklet, poster, comments & forms, preview, print-ready PDF, CUPS on macOS/Linux, the Windows spooler (#756) and the browser's print dialog on the web. Missing: print as image, PostScript, all print-production tools (output preview, separations, ink manager, printer marks, colour conversion) | | M Accessibility | 61 | 38 | 1 | 63.1% | 30% | 40–70 | Accessibility Checker (all 32 rules, report, fixes), alternate text, title and language. Missing: autotag, Tags/Order/Content panels, Reading Order tool, artifact marking, table editor, Read Out Loud, full keyboard operation of the app, screen-reader audit | | N Automation, preferences, misc | 75 | 20 | 5 | 30.0% | 35% | 25–45 | CLI, MCP, UI control channel, 141 automation tools, Action Wizard (run, create), compare (text, visual, report). Missing: most Preferences pages, side-by-side compare, Action Wizard management, custom commands, AI providers, rich media/3D, hosted web app | | **All** | **808** | **423** | **74** | **56.9%** | **≈ 49%** | **610–1,100** | Plus 175–325 h of cross-cutting work (dimension table) | @@ -222,7 +222,7 @@ with evidence when next touched. ## Stage -**alpha.** Ready for real work, ≈ 49%, is inside the 40–75% band, and every one of Acrobat Pro's +**alpha.** Ready for real work, ≈ 50%, is inside the 40–75% band, and every one of Acrobat Pro's six everyday workflows can be completed end to end on macOS. Under the gate rule shared by all the Crafting Apps, a workflow blocks only when a typical user can't complete it at all; lossy exchange or a missing sub-case is a beta item. **Converting and sharing** is therefore partial, not @@ -241,7 +241,7 @@ stage set to pre-alpha; see the revision history.) | Edit text and images | partial, not blocking (Latin text with embedded or standard fonts) | | Convert and share (Office ↔ PDF, password protection) | partial, not blocking (Office→PDF and Word layout #773 are beta items; encryption interop #774 fixed) | -**To reach beta** (≈ 75% ready, no blocking gap in PDF interchange), ≈ 26 points and +**To reach beta** (≈ 75% ready, no blocking gap in PDF interchange), ≈ 25 points and **≈ 400–700 agent-hours**: | Beta requirement | Hours | @@ -292,6 +292,9 @@ licensed OCR or AI model. | Date | Change | Summary | |---|---|---| +| 2026-10-12 | minor | Exclusive image crops resize with their pixels across nested states, intervening operators and content streams; shared clips remain fixed. Synthetic engine/tool regressions and private image save/reopen checks; estimates unchanged. | +| 2026-10-11 | minor | Web printing shipped (gap 4): Hardware, Platforms and L Print text updated. Text only: scores, readiness and hours not re-judged | +| 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-11 | minor | Synced with closed issues: encrypted output opens in Acrobat (#774, #836), Windows prints (#756, #847), column select (#740), 1:1 touchpad scrolling (#759), same-page multi-object move (#844), #825 and the #799–#821 fix batch. Text only: scores, readiness and hours not re-judged | | 2026-10-10 | minor | Readiness table: hours to ≈ 95% per audience (full 780–1,400, mainstream 500–900, essentials 180–330). Full number restated as the standard's additive weighted sum over dimensions with written weights: 47% → 49%. Method aligned with the standard, no new evidence | | 2026-10-10 | minor | Added the essentials-user score (≈ 66%): weighted depth of 13 everyday features, discounted for launch, discoverability and opening others' files | diff --git a/docs/ui-parity.md b/docs/ui-parity.md index a7bac6fd01..a78409fade 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -47,12 +47,12 @@ No dump of Acrobat's menu tree has been diffed against the registry yet (gap 24) | Hand tool, middle-button pan, autoscroll | yes | hand tool; Linux autoscroll | partial | | Text selection, column select | yes, Alt-drag columns | reading-order selection; Alt/Option-drag or Edit ▸ Column select (#740) | shipped | | Select and move several objects at once | yes | on one page: Shift/Ctrl/⌘-click and marquee, one undo step (#844); not across pages, no group resize | partial | -| Object handles: corners keep proportion, edges free | yes | yes for added images and signatures | shipped | +| Object handles: corners keep proportion, edges free | yes | yes for added images and signatures; Shift on a corner keeps the proportions of comments, stamps and form fields (#2199); no Alt resize from the centre | shipped | | Arrange, align and distribute page objects | yes | fields only | partial | | Rulers, grids, snap to grid, guides | yes | no | planned | | Cursor coordinates, line weights | yes | no | planned | | Measuring snap to paths, endpoints, midpoints, intersections | yes | yes | shipped | -| Nudge selected objects with arrow keys (Shift for larger steps) | yes | no: arrow keys scroll and turn pages | gap | +| Nudge selected objects with arrow keys (Shift for larger steps) | yes | yes in Edit text: a point, Shift ten points, for selected objects, images and an open paragraph's box (#1520) | shipped | | Single-key tool accelerators (H, V, Z, …) | yes, opt-in | no | planned | | Context menus on pages, thumbnails, comments, fields | yes | thumbnails, comments, fields | partial | | Comment pop-ups as movable windows, connector lines | yes | inline in the panel | planned | diff --git a/docs/word-count.md b/docs/word-count.md new file mode 100644 index 0000000000..4b3d02cba4 --- /dev/null +++ b/docs/word-count.md @@ -0,0 +1,52 @@ +# Word count + +File ▸ Word count counts the current document, including unsaved edits. Select **Current page +only** to count just the displayed page. On desktop, counting runs in the background, one page +at a time. Closing the dialog or changing its options cancels the old count between pages. + +Words use Unicode word boundaries (UAX #29); punctuation alone is not a word. Characters are +Unicode scalar values, not bytes or displayed glyphs. “Characters without spaces” removes all +Unicode whitespace. Characters include the line breaks and spaces reconstructed by text +extraction. The total is the sum of page counts, with no extra separator between pages. + +The three independent exclusions are off by default: + +- **Tagged tables:** Table, TR, TH, TD, THead, TBody and TFoot content. +- **Tagged code blocks:** Code content. +- **Image/OCR text:** Figure content and invisible text, including typical OCR layers. Invisible + text can have other uses too. Visible OCR text without Figure tags is not identifiable as OCR. + +Tags can be explicit marked-content tags or page-local MCIDs classified through the PDF's +structure tree, including parent roles and RoleMap aliases. These are semantic exclusions, not +visual table/code recognition. Untagged or unsupported structure stays in the count; the dialog +reports when structure classification is incomplete. On pages with Form XObjects, structure-tree +MCID exclusions are conservatively disabled because forms have separate MCID namespaces. +Explicit Table/Code/Figure marked content still works there. Cyclic and oversized structure +graphs are bounded and reported as incomplete. + +PDF `/ActualText` replacements are respected. If one replacement spans both included and +excluded glyphs, there is no reliable mapping of its words to the selected content: counting +fails with an explanation instead of silently returning the wrong total. Disable exclusions to +count that page. Unreadable pages likewise fail the operation, without a misleading partial total. + +Images alone contain no extractable words. Run Scan & OCR first when counting scanned pages. +Copy restrictions are respected. Counting never edits, rasterizes or saves the document, and +keeps only one page's extracted text at a time, plus the resulting page statistics. + +## CLI and MCP + +`text_word_count` is the shared read-only automation tool: + +```json +{"tool":"text_word_count","args":{"doc":1,"pages":[1,3],"exclude_tables":true,"exclude_code":true,"exclude_images":false}} +``` + +Omit `pages` to count all pages. Page numbers are 1-based; duplicates count once. The result +contains `total`, per-page `pages` entries with `page` and `counts`, `pages_without_text`, and +`unclassified_structure`. Each count has `words`, `characters`, and `characters_without_spaces`. +`pages_without_text` is measured after exclusions, so it can also include pages whose entire +text was deliberately excluded. + +The engine entry point is `pdfcraft_engine::word_count::count`. Its page indexes are 0-based; +its progress callback can cancel between pages. Frontends must enforce document copy +permissions before supplying an `ExportSource`. diff --git a/packaging/env.sh b/packaging/env.sh index f34f17bd74..1832702ebb 100644 --- a/packaging/env.sh +++ b/packaging/env.sh @@ -4,6 +4,8 @@ # Exports: # ROOT workspace root # VERSION [workspace.package] version from Cargo.toml (override: PDFCRAFT_VERSION) +# PDFCRAFT_BUILD_LABEL optional label for a local or patched build (letters, digits, dots); +# compiled into `--version` and About, and appended to the package release # DIST output directory for release artifacts (default: $ROOT/dist/release) # PDFCRAFT_BUILD_SHA git commit, recorded in the macOS Info.plist (PdfCraftBuildCommit) # PDFCRAFT_BUILD_DATE UTC build date, YYYY-MM-DD @@ -28,6 +30,18 @@ if [ -z "$VERSION" ]; then fi export VERSION +# A labelled build says so in `--version` and About (pdfcraft_engine::build_info) and in its +# package release (packaging/linux/nfpm.yaml), so it can't be mistaken for the release. +if [ -n "${PDFCRAFT_BUILD_LABEL:-}" ]; then + case "$PDFCRAFT_BUILD_LABEL" in + *[!A-Za-z0-9.]* | .* | *.) + echo "error: PDFCRAFT_BUILD_LABEL may hold only letters, digits and inner dots: $PDFCRAFT_BUILD_LABEL" >&2 + exit 1 + ;; + esac + export PDFCRAFT_BUILD_LABEL +fi + DIST="${DIST:-$ROOT/dist/release}" mkdir -p "$DIST" export DIST diff --git a/packaging/linux/nfpm.yaml b/packaging/linux/nfpm.yaml index b20bcb6725..646eb61a36 100644 --- a/packaging/linux/nfpm.yaml +++ b/packaging/linux/nfpm.yaml @@ -1,6 +1,6 @@ # .deb and .rpm packages (nfpm: https://nfpm.goreleaser.com). Run by package.sh, which stages # the file tree, substitutes its path for ${STAGE} (nfpm doesn't expand env vars in `src`) and -# exports VERSION and NFPM_ARCH. +# exports VERSION, RELEASE and NFPM_ARCH. # # nfpm package -f packaging/linux/nfpm.yaml -p deb -t dist/release/ name: pdfcraft @@ -9,7 +9,9 @@ platform: linux version: ${VERSION} # semver: 0.2.0-rc.1 becomes 0.2.0~rc.1 in deb/rpm, so pre-releases sort before the release. version_schema: semver -release: 1 +# 1 for releases; 1.