From 798650e8a09607471dec48e1aa7949a4d37b9aec Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Sat, 10 Oct 2026 14:23:28 -0400 Subject: [PATCH 01/69] Progress docs: target-app parity, gaps, alpha gate, readiness by audience (#873) Progress docs per craftrules standards/progress-docs.md: target-app parity, gaps, roadmap with alpha gate, readiness by audience (full / mainstream / essentials) with Opus 5.5 hours, and feature, format, PDF-spec, hardware, UI and localization parity docs. Co-Authored-By: Claude Opus 5.5 --- CLAUDE.md | 4 +- README.md | 8 +- ROADMAP.md | 337 ++++++++++++++++-------------------- docs/architecture.md | 112 ++++++++++++ docs/file-format-parity.md | 112 ++++++++++++ docs/gaps.md | 305 ++++++++++++++++++++++++++++++++ docs/hardware-parity.md | 43 +++++ docs/localization-parity.md | 72 ++++++++ docs/localization.md | 6 +- docs/pdf-spec-parity.md | 132 ++++++++++++++ docs/roadmap.md | 126 ++++++++++++++ docs/target-app-parity.md | 301 ++++++++++++++++++++++++++++++++ docs/ui-parity.md | 84 +++++++++ 13 files changed, 1446 insertions(+), 196 deletions(-) create mode 100644 docs/architecture.md create mode 100644 docs/file-format-parity.md create mode 100644 docs/gaps.md create mode 100644 docs/hardware-parity.md create mode 100644 docs/localization-parity.md create mode 100644 docs/pdf-spec-parity.md create mode 100644 docs/roadmap.md create mode 100644 docs/target-app-parity.md create mode 100644 docs/ui-parity.md diff --git a/CLAUDE.md b/CLAUDE.md index ccad9e7bd..77d6445b4 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -3,8 +3,8 @@ PdfCraft is a clean-room, open-source, Rust-native PDF application targeting Adobe Acrobat Pro parity. It runs natively on macOS, Windows, Linux and FreeBSD, and on the web via WASM. It is the sibling of `../photocraft` (a Photoshop-class editor) and follows the same conventions. ## Start every session here -1. Read `plan/STATUS.md`: the current milestone, the next unchecked task and any blockers. `ROADMAP.md` holds the milestone estimates and progress; update its table and log at the end of every session. - - Read `ROADMAP.md` §Honest assessment and §Where we're lacking and where we're going before choosing work. They rank the gaps (own renderer, hardening, fidelity against Acrobat, editing existing content, Pro workflows, 1.0 polish); prefer them over new P2/P3 features, and keep both sections true when things change. +1. Read `plan/STATUS.md`: the current milestone, the next unchecked task and any blockers. `ROADMAP.md` is the one-page summary (stage, numbers, progress log) and `docs/roadmap.md` the milestone table; at the end of every session update the milestone row and add a line to the ROADMAP.md progress log. + - Read `docs/gaps.md` (the ranked gaps: Acrobat interop, the open-issue backlog, the renderer and a fidelity harness, editing existing content, Pro workflows, 1.0 polish) and `docs/target-app-parity.md` (the numbers and how they are measured) before choosing work. Prefer the gaps over new P2/P3 features, and keep both documents true when things change (`docs/` progress docs follow craftrules `standards/progress-docs.md`: status line, revision history). - "Shipped" in `parity/` means "exists and tested", not "as good as Acrobat". Don't mark a feature shipped on a generic test, and say in its notes what is still missing. 2. Read that task in `plan/execution-plan.md` §3, the relevant section of `plan/architecture.md`, and the README of the crate you're touching. 3. Follow the **autonomous operation protocol** in `plan/execution-plan.md` §7 (orient → plan → implement + test → verify → record → commit). Don't stop to ask unless §7 lists the decision as the user's. diff --git a/README.md b/README.md index da4256e96..5393fbaa5 100644 --- a/README.md +++ b/README.md @@ -395,16 +395,16 @@ Logs, environment variables and other development notes are in [docs/development 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. -**Where it stands (October 2026), honestly:** about half of Acrobat Pro's offline features are in (88% of the must-haves), but that is roughly a third of the work, because the hardest parts are still ahead. +**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: password-protected files Acrobat can't open, no Office-to-PDF, and Word 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), OCR beyond Latin script, Office import/export, signature timestamps and long-term validation, PDF/A/X/UA preflight, XFA forms and localization. +- **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 on Windows, 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. -**Next, in order:** our own renderer, hardening and a fidelity harness against Acrobat, editing existing content, then the Pro workflows (signatures, OCR, Office, preflight, XFA) and 1.0 polish. +**Next, in order:** encrypted files that Acrobat opens, the reported bugs and Windows printing, 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. -The honest assessment by area, what's lacking and where we're going are in **[ROADMAP.md](ROADMAP.md#honest-assessment-2026-10-05)**, with the full plan, progress and estimates. +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). --- diff --git a/ROADMAP.md b/ROADMAP.md index 9b14cbf28..c0b11558e 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -1,261 +1,222 @@ # PdfCraft roadmap -The milestones, current progress and time estimates to Acrobat Pro feature parity. Keep this up to date: -- **Every session:** update the progress column and add a line to the log. -- **Every milestone:** re-estimate. +**Stage: alpha** · next: beta, ~26% (ready for real work 49% → 75%) and ~400–700 h away -Detailed task lists and acceptance tests are in `plan/execution-plan.md` (local-only). This file is the public summary. +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-10 · **Change:** minor (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) -**What "parity" means here:** the offline feature set of Acrobat Pro, milestones M0–M14. It excludes Adobe's cloud services (Document Cloud storage, Adobe Sign, the Adobe AI Assistant). Those have no clean-room equivalent; PdfCraft's alternatives are local-first, plus opt-in providers (M13). +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 +Assistant) are out of scope; PdfCraft's alternatives are local-first plus opt-in providers. -## Estimate summary +| Document | Holds | +|---|---| +| [docs/target-app-parity.md](docs/target-app-parity.md) | The authoritative assessment: method, weights, every dimension and area, stage, calibration | +| [docs/gaps.md](docs/gaps.md) | Every known shortfall, ranked: the work list | +| [docs/roadmap.md](docs/roadmap.md) | Milestones M0–M14, current focus, upcoming milestones, risks | +| [docs/pdf-spec-parity.md](docs/pdf-spec-parity.md) | ISO 32000-2 coverage, annotations, actions, signatures, PDF/A/X/UA | +| [docs/file-format-parity.md](docs/file-format-parity.md) | Every format Acrobat reads or writes, create/export conversions, OCR | +| [docs/ui-parity.md](docs/ui-parity.md) | Tools, handles, shortcuts, panels, preferences | +| [docs/hardware-parity.md](docs/hardware-parity.md) | GPU, printers, scanners, smart cards, pen, platforms | +| [docs/localization-parity.md](docs/localization-parity.md) | Per-language status | +| [docs/architecture.md](docs/architecture.md) | How PdfCraft is built | +| `parity/acrobat-features.toml` | The machine-checked checklist (`cargo xtask parity`) | -The unit is **wall-clock hours of agent work** (Claude Opus 5.5 coding continuously; human review time not included). +**Keep it current:** every session, update the milestone row in [docs/roadmap.md](docs/roadmap.md) +and add a line to the [progress log](#progress-log); when a gap closes, remove it from +[docs/gaps.md](docs/gaps.md); re-measure fully when a major arc lands, at least monthly. -**Where we are (2026-10-07, measured by `cargo xtask parity` over 806 tracked Acrobat Pro features; 23 more are Adobe-cloud-only and out of scope):** +## Headline numbers -| Tier | Features | Shipped | Partial | Shipped % | Weighted % (partial = ½) | -|---|---|---|---|---|---| -| P0 (must-have for 1.0) | 251 | 222 | 25 | 88.4% | 93.4% | -| P1 | 326 | 174 | 38 | 53.4% | 59.2% | -| P2 | 186 | 14 | 7 | 7.5% | 9.4% | -| P3 | 43 | 1 | 0 | 2.3% | 2.3% | -| **All** | **806** | **411** | **70** | **51.0%** | **55.3%** | - -**Effort-weighted parity: ≈ 30–35%.** Feature counts overstate progress: the remaining features include the hardest ones (our own renderer and font engine, editing existing text and reflow, OCR beyond Latin, XFA, PDF/A/X/UA preflight, Office export, long-term signature validation). Weighting each milestone by its estimated size gives about a third of the total work done. See **[Honest assessment](#honest-assessment-2026-10-05)** for what the numbers don't show. - -**Observed throughput:** about 72k lines of kept, tested Rust (plus 97 agent tools and ≈ 600 tests) in roughly 65–75 agent-hours since 2026-09-30, about 1.0k lines per agent-hour, with corpus, oracle and visual checks. That is faster than the original plan assumed, so the hour estimates below are revised down from the first plan (2,000–4,000 h). - -| Scenario | Remaining hours to full parity (M0–M14) | Calendar time | +| | Value | Kind | |---|---|---| -| One Opus 5.5 agent, continuous | **≈ 600–1,100 h** | ≈ 4–7 weeks | -| 3–5 agents in parallel on separate crates | **≈ 250–450 h wall clock** | ≈ 2–3 weeks | -| With human review, integration and pauses | — | ≈ 2–4 months | +| **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**, 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 | +| Remaining to **full parity** | ≈ 780–1,400 Opus 5.5 agent-hours (≈ 300–550 h wall clock with 3–5 agents) | estimated, calibrated on this repo's history | -**How the estimate is built:** each milestone's remaining fraction (table below) times its size, re-based on the measured rate. The long poles are M2 (own renderer, ≈ 150–250 h), M7 (editing existing text and images, ≈ 150–250 h), M10 OCR and Office export (≈ 100–150 h), M6 JavaScript engine (≈ 60–100 h), M11 PDF/A/X/UA preflight (≈ 80–140 h) and M12 XFA/compare (≈ 80–140 h). The last 5–10% (odd real-world files, pixel-level polish against Acrobat) costs about as much as a mid-sized milestone. +**Why alpha:** all six of Acrobat Pro's everyday workflows can be completed end to end on macOS +(the [alpha gate](docs/roadmap.md#alpha-gate)), and users praise them (7.6k stars, ≈ 217k downloads +of 0.4–0.5, switching reports such as #179). Two are only partial: editing (no CJK/RTL) and convert +and share (password-protected files we write don't open in Acrobat or Reader, #774; no in-app +Office→PDF; Word export loses layout, #773). Those, unmeasured quality against Acrobat, the borrowed +`hayro` renderer, no printing on Windows (#756) and 245 open issues keep it far from beta. -## Honest assessment (2026-10-05) +### Readiness by audience -Read this before choosing work. The feature table above counts what exists; this section says how solid it is. +| 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 | +| 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, Windows printing, the bug backlog | +| Essentials user | ≈ 66% | ≈ 180–330 h (≈ 90–180 h wall clock; ≈ 50% parallelizes) | Rendering fidelity on everyday files, Windows printing, launch and GPU stability, encrypted output Acrobat opens (#774), simple text fixes, Word export layout, Chinese UI glyphs | -| Dimension | State | In one line | +Hours are calibrated as in [Effort and calibration](docs/target-app-parity.md#effort-and-calibration) (≈ 1k kept lines per agent-hour for new features, 3–10× slower for hardening and fidelity). The audiences are nested subsets, so essentials ≤ mainstream ≤ full. + +## By dimension + +| Dimension | Ready | Weight | Remaining (h) | Doc | +|---|---|---|---|---| +| Features (14 areas) | ≈ 49% depth · 67% breadth | 40% | 610–1,100 | [target-app-parity.md](docs/target-app-parity.md#by-feature-area) | +| UI/UX fidelity | ≈ 55% | 10% | 30–60 | [ui-parity.md](docs/ui-parity.md) | +| 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) | +| 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** | | + +## Features + +| Area | Breadth (measured) | Ready (estimated) | Remaining (h) | +|---|---|---|---| +| A Core model and fidelity | 76.8% | 70% | 25–45 | +| B View and navigation | 62.5% | 55% | 110–190 | +| C Content editing | 53.3% | 30% | 100–180 | +| D Organize pages | 61.2% | 70% | 12–25 | +| E Comments and review | 77.7% | 70% | 12–25 | +| F Forms and JavaScript | 78.5% | 65% | 40–75 | +| G Protect, redact, sanitize | 58.5% | 55% | 15–30 | +| H Digital signatures | 62.2% | 45% | 40–70 | +| I Scan and OCR | 23.1% | 15% | 40–75 | +| J Create and export | 39.7% | 25% | 55–100 | +| K Optimize and standards | 34.5% | 20% | 65–115 | +| L Print | 39.7% | 35% | 30–55 | +| M Accessibility | 63.1% | 30% | 40–70 | +| N Automation, preferences, misc | 30.0% | 35% | 25–45 | + +What's strong and missing in each: [target-app-parity.md](docs/target-app-parity.md#by-feature-area). + +## Languages + +Measured from `crates/ui-egui/src/i18n/*.tsv` (2,143 keys). Detail and script notes: +[localization-parity.md](docs/localization-parity.md). + +| Language | Code | Strings | Status | +|---|---|---|---| +| English | `en` | 100% | full | +| Simplified Chinese | `zh-hans` | 97.4% | partial (no Hans UI face in releases, #826) | +| Spanish | `es` | 95.5% | partial | +| Hindi | `hi` | 0% | none | +| Arabic | `ar` | 100% | partial (layout not mirrored) | +| French | `fr` | 99.3% | partial | +| Portuguese (Brazil) | `pt-br` | 99.4% | partial | +| Indonesian | `id` | 0% | none | +| Japanese | `ja` | 99.1% | partial | +| German | `de` | 99.3% | partial | +| Korean | `ko` | 0% | none | +| Vietnamese | `vi` | 0% | none | + +Also shipped: Traditional Chinese, Russian, Bulgarian, Telugu, Hungarian, Ukrainian, Italian +(94–100%) and Czech (menus only): **8 more languages**. + +## Upcoming + +Ranked; detail and the full milestone table in [docs/roadmap.md](docs/roadmap.md). + +| Rank | Milestone | Estimate (h) | |---|---|---| -| Feature count | 51.0% shipped (P0 88%, P1 53%, P2 8%, P3 2%) | A typical viewer, annotator, form-filler or page organizer is mostly covered | -| Effort | ≈ 30–35% | The remaining work is the hardest: M2 15%, M7 17%, M11 18%, M12 23%, M14 0% | -| Foundations | Weakest | Rendering is still the bootstrap `hayro`; the local #307 candidate reads inspector data lazily through COS with `lopdf` compatibility logic; vendored patches are still carried | -| Robustness | Early beta | Each 15-minute fuzz run found new out-of-memory crashes or hangs until 2026-10-05 | -| Acrobat fidelity | Unmeasured | No systematic side-by-side comparison with Acrobat, visual or behavioural | -| Product readiness | 0.2.1 | No code signing, performance budgets or keyboard-only operation yet; localization has its catalog system (English, partial Japanese) but few strings go through it | +| 0 | Convert and share: encrypted output opens in Acrobat (#774), Create PDF from Office, Word export that keeps layout (#773) | 40–70 | +| 1 | Backlog and platform: crash and wrong-result reports, Windows printing (#756), Chinese UI face (#826) | 65–120 | +| 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 | +| 5 | Preflight and PDF/A/X/UA (M11) | 65–115 | +| 6 | Signatures for institutions: PKCS #11, timestamp servers, online LTV (M9) | 40–70 | +| 7 | Accessibility tagging (M12) | 40–70 | +| 8 | 1.0 polish: performance, right-to-left, missing languages, keyboard-only (M14) | 55–95 | -**By area** (shipped share of each area's features): +## Progress log -| Area | Shipped | What's strong / what's missing | -|---|---|---| -| A Core | 75% | Parser, repair, encryption, incremental saves. Missing: lazy loading (`ByteSource`), own image codecs, PDF 2.0 extras, Arlington validation | -| F Forms | 71% | Filling, authoring, AF scripts, sandboxed JavaScript, data exchange. Missing: XFA, wider JavaScript object model | -| E Comments | 71% | Markup types with appearances, XFDF/FDF, summaries, calibrated 2D measuring (single-unit decimal scales). Missing: replace-text proposals, summary layouts, compound/fractional measurement formats | -| M Accessibility | 62% | Checker (all 32 rules). Missing: autotag, Tags/Order/Content panels, Reading Order tool, keyboard-only operation | -| B View | 55% | Shell, find, panels, tiles, web build. **Rendering is borrowed (`hayro`)**, so the 7 rendering P0s are only partial | -| D Organize | 55% | Pages, combine, split, bookmarks, labels. Missing: replace pages, transitions | -| G Protect | 54% | Passwords, permissions, redaction (including word lists), sanitize. Redaction codes (built-in FOIA and Privacy Act sets). Missing: certificate security, custom redaction code sets | -| H Sign | 48% | PAdES B-B signing and validation; macOS Keychain and Windows CNG certificate store signing. Missing: timestamps (B-T), LTV (DSS/OCSP/CRL), FieldMDP, smart cards, PKCS #11 | -| C Edit | 46% | Added text and images stay editable; header/footer/watermark. Missing: robust editing of existing text and images (fonts, subsets, reflow) | -| L Print | 36% | Acrobat-style sizing, n-up, booklet, CUPS. Missing: Windows and web printing, production options | -| J Create | 32% | From images, text, clipboard; Word/HTML/RTF export. Missing: Office import, Excel/PowerPoint export | -| N Misc | 27% | CLI, MCP, UI control channel, Action Wizard. Missing: AI providers, performance budgets | -| K Optimize | 26% | Reduce File Size, Optimizer. Missing: preflight, PDF/X/UA, transparency/fonts panels | -| I OCR | 19% | Searchable image for Latin script. Missing: other scripts and accents, editable-text output, deskew | - -**What "shipped" means, and doesn't.** A feature is shipped when it exists and at least one specific test covers it. 159 of 398 shipped features rest on exactly one test, and only about 5 cite an external oracle (`pdftotext`, `pdfsig`, OpenSSL, a corpus). Shipped does not mean "as good as Acrobat". - -**Robustness, measured.** `main`'s CI was red for 30+ runs until 2026-10-04 (a clippy lint, Windows line endings, flaky perf tests). Once the nightly fuzz job could finish, three 15-minute runs found 7 out-of-memory crashes (one aborted on every platform) and 11 hangs, 7 of them in our own `cos` parser rather than `hayro`. All are fixed or in review, but expect more: keep the nightly fuzz job green and turn every finding into a synthetic test. - -## Where we're lacking and where we're going - -The gaps that matter most, in priority order. Agents: prefer these over adding P2/P3 features, and check `parity/acrobat-features.toml` notes for the specifics of each. - -1. **Our own renderer (M2).** Replace `hayro` with the `model` crate, our font engine and the DisplayList devices (ADR-0004). It turns the 7 rendering P0 partials into shipped features and removes most vendored patches. The largest single lever. -2. **Hardening.** Keep the nightly fuzz job green; fix every crash and hang with a synthetic regression test; finish lazy loading so large files aren't read whole; set performance budgets (`misc.performance-budgets`). -3. **Measure fidelity against Acrobat.** Build a side-by-side harness (render, text, form behaviour) over synthetic fixtures (`plan/acrobat/`, clean-room rules apply). Until then, parity numbers count features, not quality. -4. **Editing existing content (M7).** Fonts, subsets, reflow and CJK. The most visible gap for Pro users. -5. **Pro workflows.** Signatures with timestamps and LTV (M9), OCR beyond Latin (M10), Office import/export (M10), preflight and PDF/A/X/UA (M11), XFA (M12). -6. **1.0 polish (M14).** Code-signed installers, localization (the catalog system is in place and French, German, Spanish, Japanese, Russian and both Chinese catalogs cover the UI; Czech and Brazilian Portuguese still cover menus only), keyboard-only operation, a full screen-reader audit. -6. **1.0 polish (M14).** Code-signed installers, localization (the catalog system is in place and French, Spanish, Japanese, Russian, Ukrainian and both Chinese catalogs cover the UI; Czech and Brazilian Portuguese still cover menus only), keyboard-only operation, a full screen-reader audit. -6. **1.0 polish (M14).** Code-signed installers, localization (the catalog system is in place and French, Spanish, Italian, Japanese, Russian and both Chinese catalogs cover the UI; Czech and Brazilian Portuguese still cover menus only), keyboard-only operation, a full screen-reader audit. - -Decided against for now (owner, 2026-10-05): self-installing updates and an update check at start. Help ▸ Check for updates is manual only. - -## Milestones - -Hours are for a single agent (low–high). "Done" is the estimated fraction of that milestone's *acceptance criteria* that are met. It is not a count of lines of code. - -| M | Milestone | Est. hours | Done | Remaining (h) | Notes | -|---|---|---|---|---|---| -| M0 | Skeleton: workspace, xtask gates, CI | 15–30 | 92% | 2–4 | GitHub workflow, `deny.toml` (licence audit of every dependency), parity checklist (826 features, `xtask parity`) done. Missing: remaining crate stubs, testkit/oracle crates | -| M2 | Model, render, text | 200–350 | 15% | 170–300 | 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 | -| M1 | COS: filters, crypt, parser, xref, writer | 120–200 | 78% | 25–45 | 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 | -| M1 | COS: filters, crypt, parser, xref, writer | 120–200 | 78% | 25–45 | 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), 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% | 170–300 | hayro bootstrap renderer (vendored patches). 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 | 88% | 10–19 | 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. | -| M3 | Viewer app (native + web) | 80–150 | 88% | 10–19 | 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% | 9–17 | 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% | 15–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 | 45% | 95–185 | 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 | 17% | 210–420 | 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 | 66% | 35–65 | 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 | 55% | 70–125 | 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% | 130–235 | 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, CUPS spooler, 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, Windows/web printing | -| M11 | Optimize, preflight, PDF/A/X/UA, print production | 200–350 | 18% | 165–290 | 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 | 23% | 155–295 | 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% | 33–66 | 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 | 5% | 115–240 | 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!`; Japanese, Traditional Chinese, Simplified Chinese, Russian, Bulgarian, German, Spanish, French, Hungarian and Ukrainian catalogs have ≈1,800–2,100 entries each; Simplified Chinese, French, German, Hungarian and Ukrainian coverage is checked against the UI source; Czech and Brazilian Portuguese for menus. French locales (`fr`, `fr_FR`, `fr_CA`) follow the French catalog; Ukrainian locales (`uk`, `uk_UA`) follow the Ukrainian one; its certificate and accessibility wording has a dictionary-checked follow-up. #307 (bounded large-PDF memory): share resource indexes and native parser metadata, inspect structure lazily, bound scan/full-save caches and raster storage, and reuse signature discovery safely. Three Linux trials retain full-save workflow RSS at 1.06 GiB (previously 3.69 GiB); rebased engine navigation uses 821 MiB (previously 997 MiB). With the RSS guard removed, the unmodified baseline was kernel OOM-killed in a 15 GiB container; rebased optimized inspection completed at 497.7 MiB in 1.78 s. Strict Clippy, 1,336 workspace tests and 30 WASM crate checks pass. The larger-host follow-up is closed. File-backed input, global memory budgets and Windows validation remain pending. Missing: more catalogs filled in, installers, performance budgets | -| M14 | 1.0 polish: performance, localization, installers | 120–250 | 5% | 115–240 | 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!`; Japanese, Traditional Chinese, Simplified Chinese, Russian, Spanish, French and Italian catalogs have ≈1,800 entries each; Simplified Chinese and French coverage is checked against the UI source; Czech and Brazilian Portuguese for menus. French locales (`fr`, `fr_FR`, `fr_CA`) follow the French catalog; Italian locales (`it`, `it_IT`) the Italian one. Arabic (`ar`, every `ar-*` locale) has a full catalog whose labels are reordered for display; the layout itself is still left to right. Missing: a right-to-left layout for Arabic, more catalogs filled in, installers, performance budgets | -| | **Total (original plan sizing)** | **2,085–3,840** | **≈ 35%** | **≈ 1,350–2,500 at the planned rate; ≈ 600–1,100 at the measured rate** | | - -**Overall progress: about 30–35% of the effort (51.0% of features shipped).** The viewer and the core are far ahead of the editing features, because the viewer was built first so progress could be seen; rendering itself is still borrowed from `hayro`. See [Honest assessment](#honest-assessment-2026-10-05). - -## Critical path - -M0 → M1 → M2 → M3 → M4 must happen in order. After M4, M5–M12 can run in parallel across crates. The long poles are M7 (content editing), M9 (signatures) and M12 (XFA). - -## Risks most likely to push estimates up - -- Fidelity of text editing: fonts, subsets, reflow. -- XFA dynamic layout. -- Real-world signature chains and revocation checks. -- The rendering long tail: Type3 fonts, broken fonts, shadings. -- Correctness of PDF/A and PDF/UA conversion. -- CPU rendering performance on the web. -- Hostile input: every fuzz run so far has found new crashes or hangs. -- Unmeasured fidelity: without an Acrobat comparison harness, quality gaps surface as user reports. - -## Log +Newest first: the date, what moved, and the new overall estimate. +- **2026-10-10 (progress docs, hours per audience):** Each readiness number gets its own hours to ≈ 95%: full ≈ 49% / 780–1,400 h, mainstream ≈ 55% / 500–900 h, essentials ≈ 66% / 180–330 h. The full number is now the standard's additive weighted sum over the dimensions (written weights), 47% → 49%: method aligned with the standard, no new evidence. Beta ≈ 26 points, ≈ 400–700 h. +- **2026-10-10 (progress docs, gate consistency check):** Re-judged the convert-and-share row with the rule applied across all Crafting Apps (a workflow blocks only when it can't be completed at all; lossy exchange and missing sub-cases are beta items, as for FilmCraft's .prproj or VectorCraft's .ai). Unencrypted sharing works, encryption interop (#774) and Office→PDF are sub-cases, Word export is lossy rather than absent: partial, not blocking. **Stage pre-alpha → alpha**; numbers unchanged (47%, mainstream 55%; beta ≈ 400–700 h). Added an essentials-user score: ≈ 66% for someone who only opens, reads, comments, fills, signs, prints and rearranges pages. +- **2026-10-10 (progress docs, second look and alpha gate):** At the owner's request, after practitioners praised PdfCraft, re-examined the numbers. User sentiment: 7,595 stars, 177k + 39k downloads of 0.4.0/0.5.0, 23 user issues with explicit praise and switching reports (#179, #232, #273), issue themes concentrated in core paths (launch/GPU, rendering, editing, CJK, signing, printing). The checklist is not niche-dominated (497 mainstream vs 311 specialist rows; P0 95% / 91%). The first pass's 9-point cross-cutting deduction double-counted Windows printing, interop and glyphs: **ready for real work 40% → 47%**, and **≈ 55% for the mainstream practitioner** (new). The new core-workflow gate (craftrules progress-docs) fails on convert and share (#774, no Office import, #773): **stage alpha → pre-alpha**, ≈ 40–70 h from alpha; beta still ≈ 400–700 h. +- **2026-10-10 (progress docs, full re-measure):** Re-measured against Acrobat Pro 26.002.21931 (installed bundle inspected: document types, localizations, plug-in and framework names). Checklist: 423 shipped + 74 partial of 808 (56.9%; P0 94.2%), ≈ 67% tier-weighted. Ready for real work ≈ 40%; stage **alpha**; ≈ 400–700 h to beta, ≈ 780–1,400 h to full parity (up from 600–1,100 h: the first public releases surfaced Acrobat interop of encrypted output, Windows printing, Chinese UI glyphs, Word-export layout and a 245-issue backlog). This file restructured to the craftrules progress-docs standard; the honest assessment moved to `docs/target-app-parity.md`, the gap list to `docs/gaps.md`, the milestones (duplicated rows merged) to `docs/roadmap.md`; new `docs/architecture.md`, `docs/localization-parity.md`, `docs/file-format-parity.md`, `docs/pdf-spec-parity.md`, `docs/ui-parity.md`, `docs/hardware-parity.md`. - **2026-10-10 (M8, redact word lists):** Find text and redact gains "Multiple words or phrases": a list with one word or phrase per line, typed or imported from a `.txt` file, whose every match is marked in one undo step. Lists are trimmed, deduplicated and bounded (1,000 entries of 256 characters, files up to 1 MB). `redact_mark` takes `words` and reports how many marks each made, so an agent sees which found nothing. Tests cover the list parsing, the tool end to end (mark, undo, apply, invalid lists) and the dialog with an imported list; the new labels are translated in all 13 UI catalogs. `protect.redact-word-lists` is shipped; whole-word and case-sensitive matching and exporting the list are still missing. Estimates unchanged (about 30–35%). - **2026-10-10 (M4, bookmarks from structure):** Bookmark options ▸ New bookmarks from structure (command `bookmark.from_structure`, tool `bookmark_from_structure`) turns a tagged document's headings into bookmarks: H1–H6 through the role map, a plain H by its section nesting, nested by level under a new first "Untitled" bookmark as in Acrobat, each linked to its structure element (/SE). Titles come from /ActualText, the marked text on the page (decoded through the font's ToUnicode or encoding), /Alt or /T. The walk is bounded (depth, visits, heading and title caps, cycle-safe), an untagged document gets a clear error, and the edit is one undo step. Tests cover role maps, nesting, ToUnicode text, a self-referencing heading, the tool, undo and the panel. Still missing: Acrobat's element-type choice and destinations at the heading's position. The M4 row no longer lists Replace pages as missing (it shipped earlier). Overall estimate unchanged (about 30–35%). - **2026-10-10 (M9, signing encrypted documents):** Digital signatures, certification and document timestamps work on password-protected PDFs instead of failing with "not supported: signing encrypted documents". `pdfcraft-cos` no longer encrypts or decrypts a signature dictionary's `/Contents` (§7.6.2; by `/Type /Sig|/DocTimeStamp` or `/Contents` + `/ByteRange`), so the placeholder is patched after hashing as in a plain file and existing signatures in encrypted files read their real CMS (which also fixes their DSS `/VRI` keys and keeps them intact in a full rewrite). Opened with the user password, the signer applies the document's permissions (fill-forms for existing fields and timestamps, annotate + modify for new fields, modify for certifying) and names the permissions password when it refuses. Synthetic tests cover all four algorithms, counter-signing, tampering, permissions and the automation tools. Overall estimate unchanged (about 30–35%). - - **2026-10-10 (M9, signer certificate usage):** A trusted signer's certificate must also allow signing documents (RFC 5280 §4.2.1.3, §4.2.1.12, §4.2). A key usage without digitalSignature or nonRepudiation, an extended key usage with no document-signing purpose (any, emailProtection, codeSigning, RFC 9336 documentSigning, Adobe Authentic Documents, Microsoft document signing), or a critical extension PdfCraft doesn't process makes the verdict Unknown instead of Valid, and the details say which. Certificate policies are accepted as any policy. In-process certificates test each refusal and the accepted cases; a signed PDF from a trusted TLS-server certificate was Valid before and is Unknown now. Still missing: name constraints, and unknown critical extensions on issuer certificates. Overall estimate unchanged (about 30–35%). - **2026-10-10 (M14, side panel width):** The side panel keeps the width it is dragged to. The Bookmarks search row was 2 px wider than the panel, and a resizable egui panel keeps the width its content used, so every repaint (any pointer move) widened it up to 520 px. The Comments reply row did the same in languages with a longer "Post" (Russian, Spanish, …); Post now sits under a full-width reply field. Tests hold both panels' widths steady across 30 repaints. Overall estimate unchanged (about 30–35%). - **2026-10-10 (M8):** Redaction codes. Redaction properties offers "Custom text" or "Redaction code" under "Use overlay text": pick the U.S. FOIA (5 U.S.C. 552(b)) or U.S. Privacy Act (552a) set and tick codes, and new marks carry them as overlay text in the set's order ("(b)(6), (b)(7)(C)"). `redact_mark` takes `code_set` and `codes`, rejecting unknown sets and codes with the allowed list. Tests cover the code sets, the tool end to end (mark, apply, text) and the dialog; the new label is translated in all 13 UI catalogs. `protect.redaction-codes` is partial: Edit Codes, custom sets and their import/export are still missing. Estimates unchanged. - **2026-10-10 (M14, platform shortcut labels):** View ▸ Zoom and Page navigation, the rail, find-bar, toolbar and bookmark tooltips, and Help ▸ Keyboard shortcuts write shortcuts for the platform (`Ctrl+1`, `Ctrl+Shift++` off macOS) instead of hard-coded ⌘. Labels follow egui's detected OS, so the web build in a Linux or Windows browser shows Ctrl too. Fit visible is listed once in View. Translated tooltips take a `{key}` placeholder. Overall estimate unchanged (about 30–35%). - **2026-10-10 (M14):** Arabic interface language: a 2,137-entry catalog (`ar.tsv`, six CLDR plural forms, Arabic month and weekday names for dates), selected by `ar` and every `ar-*` locale. egui joins Arabic letters but places words left to right, so the catalog is put into display order when it loads (`bidi::display_rtl`: placeholders, keyboard shortcuts, edge spaces and caption colons are kept in place), and the command search matches a multi-word Arabic query word by word. Arabic glyphs come from the craft-fonts `Arab` face (kept in the web build); the CI and release pin of craft-fonts moves to the revision that has it, and `crates/fonts/build.rs` now refuses to embed the Noto Sans CJK SC that revision also carries (AGENTS.md §1.1). Not done: the layout is not mirrored, a wrapped Arabic paragraph breaks at the wrong end, and text typed into fields is not reordered; these need bidi support in the text layout itself. - **2026-10-10 (M6, #95):** Prepare a form edits shared General, Appearance and applicable Options properties in one atomic undo step. Explicit opt-in controls keep mixed values unchanged; partial appearance patches preserve custom font resources, each widget's style, indirect dictionary contents and unknown keys. Ctrl/Command+A selects the current page; rectangle selection and the Fields panel support multi-selection, with panel double-click opening properties. CLI/MCP `form_set_props` accepts a bounded, unique `fields` list. Regression tests cover save/reopen, undo/redo, locked-field rollback, stale dialogs, invalid arguments and selection. Names, positions, choices/defaults and script tabs remain individual GUI edits. Integrated upstream 0.5.0 and completed Portuguese/Italian catalogs without losing translator credit; shared-property labels now cover all 14 bundled translations. Kept the new Italian interface and Fill & Sign date-language registries aligned, including accented date output; the About-dialog regression waits for the larger contributor list to settle before clicking. Milestone and overall effort estimates unchanged (about 30–35%). - - **2026-10-09 (M5, line-ending labels):** Comment properties names each line ending in words ("Reverse open arrow", "Butt", …) and those labels are translated. The PDF `/LE` token is unchanged. Overall estimate unchanged (about 30–35%). - **2026-10-09 (M3, #171):** A browser startup `?file=` download opens in a background tab once the user has started working, so it cannot switch the active document or the next Save. Deliberate opens still activate their tab; Home, Combine files, workspace choices and another file's password prompt are preserved. Initial-view geometry is applied to the background document, and its script notices wait until its tab is selected. Synthetic shell tests cover the race, saving the local edit, tab switching, encrypted arrivals, errors and converted text. Milestone and overall effort estimates unchanged (about 30–35%). - - **2026-10-09 (never-crash, #378):** On Windows the window is drawn on the GPU that drives the primary display, as on Linux: `EnumDisplayDevices` (through the safe `winsafe` crate) says which adapter shows each desktop display, and `pick_adapter` matches it to the wgpu adapters by PCI ids. Before, the low-power preference chose a Ryzen integrated GPU with no monitor on a desktop whose monitors hang off an NVIDIA card, and presenting across adapters took the AMD display driver down for two minutes. The log now names the GPU chosen. Synthetic tests for the Windows device-id parser and the primary-display rule; a live test on the running machine. Overall unchanged. - **2026-10-09 (page preview resolution):** The page view draws whole pages at twice the screen density when that bitmap still fits, then filters it down, so type and fine lines keep more detail. Print preview, page thumbnails, alternate-text figures and the page behind a moving signature are rendered at the size they are shown instead of being stretched up from a small image. Milestone and effort estimates unchanged. - **2026-10-09 (M5, line endings):** Lines, connected lines and callouts draw all ten standard line-ending styles. `comment_add` accepts `endings`, and Comment properties can change them in one undo step. An unknown name is left undrawn, and a non-finite direction is rejected. Cloudy borders remain polygon-only, so `comment.line-endings` stays partial. Overall estimate unchanged (about 30–35%). - **2026-10-09 (M6, field rotation):** Regenerated form appearances honour `/MK /R` (0, 90, 180, 270). Filling or restyling a rotated field keeps the text, check mark and button caption turned, and Field Properties ▸ Position and `form_set_props` `rotation` set it, swapping width and height around the field's center when the axis changes. `form.appearance-regeneration` stays partial (Helvetica metrics are still approximate). Overall estimate unchanged (about 30–35%). - **2026-10-09 (M2, JPX sample normalization):** JPEG 2000 rescaling uses bounded integer arithmetic for supported 1–32-bit samples, including the 32-bit boundary that previously shifted past the integer width. Zero-sized rows are refused before scaling. Small contributor-original codestreams exercise decoding and rendering; endpoint and standard-depth regressions cover the same helper. Overall estimate unchanged (about 30–35%). - - **2026-10-09 (M14, #577):** The desktop GPU setup enables the selected adapter's supported 2D texture extent instead of egui-wgpu's fixed 8192-pixel extent. This removes the artificial surface ceiling behind a mixed-DPI Windows startup crash when opening Outlook PDF attachments. Synthetic regressions cover the observed 8600 × 3423 surface, downlevel adapters and preservation of other device limits. Hardware resolution limits still apply; milestone and overall effort estimates are unchanged. - **2026-10-09 (M14, #577 follow-up):** A window larger than the GPU's texture limit no longer closes PdfCraft. Vendored egui-wgpu 0.36.2 fits the window's surface within `max_texture_dimension_2d` and draws egui at the matching scale, on every path that sizes a surface (start-up restore, resize, maximise, DPI change, a recreated surface); upstream is emilk/egui#8361. Root cause of the Outlook-only trigger: winit creates the window on the launching app's monitor and converts the restored size at that monitor's scale. Unit tests cover the 8600 × 3423 case and edge sizes; a Windows Sandbox (WARP) run of a window one pixel over the limit panicked before and kept drawing after. Estimates unchanged. - **2026-10-09 (M14, #577 follow-up):** A window larger than the GPU's texture limit no longer closes PdfCraft. Vendored egui-wgpu 0.36.2 fits the window's surface within `max_texture_dimension_2d` and draws egui at the matching scale, on every path that sizes a surface (start-up restore, resize, maximise, DPI change, a recreated surface); upstream is emilk/egui#8361. Root cause of the Outlook-only trigger: winit creates the window on the launching app's monitor and converts the restored size at that monitor's scale. Unit tests cover the 8600 × 3423 case and edge sizes; a Windows Sandbox (WARP) run of a window one pixel over the limit panicked before and kept drawing after. Estimates unchanged. - **2026-10-09 (M14, #519):** A GPU that can't configure the window's surface at launch no longer panics, so PdfCraft's OpenGL retry runs. Vendored egui-wgpu configures a new window's surface inside wgpu error scopes (`winit::catch_errors`) and returns a failure as an error, so eframe returns `Error::Wgpu` and the existing OpenGL retry (#530) runs (a device lost during that configure still panics, on the first frame). Before, wgpu's default handler panicked: on a 2015 Intel GPU with no D3D12 driver wgpu picked the GL adapter over WARP and timed out in `Surface::configure` (`GpuWaitTimeout`). A real-device regression test (a wgpu validation error inside `catch_errors`) panicked with the scopes removed; in Windows Sandbox a forced configure failure panicked unpatched and retried with OpenGL patched (Sandbox has no OpenGL 2.0, so the OpenGL window itself was not shown there). Defaulting Windows to DX12 only (WARP instead of wgpu's GL backend) is left to the maintainers. Overall estimate unchanged (about 30–35%). - - **2026-10-09 (M5, Fill & Sign flatten on save):** Preferences and `doc_save` can bake Fill & Sign text, marks and signatures into the page when saving. The option defaults off. Other comments and form fields stay, a second flatten does not reuse an XObject name already on the page, and a document that cannot be changed is left unwritten. Overall estimate unchanged (about 30–35%). - **2026-10-09 (M5.7, #434 partial):** Natural-size image stamps placed at a click or through `stamp_custom` remain upright on rotated pages, with a displayed-space-sized rectangle and a counterrotated standard appearance matrix. Move, resize, restyle and undo/redo preserve that orientation. Native and external Poppler save/reopen rendering cover all four quarter-turns; UI tests verify the displayed bounds. Explicit rectangle placement, image signatures, PDF-page stamps and image-file orientation metadata remain outside this repair. #434 stays open; milestone table and effort estimates remain unchanged. - **2026-10-09 (M1, raw-stream decoding limits):** COS checks unfiltered decoded data against the caller and per-stream limits before copying it, including empty filter arrays and streams whose Crypt filter was applied on load. Strict decoding uses the same raw-data guard. Tiny synthetic regressions cover exceeded, exact and zero-byte limits while retaining the filtered decoder behavior. Milestone and overall effort estimates unchanged (about 30–35%). - - **2026-10-09 (M2, bounded page labels):** Inspector number trees have shared work, depth, and cycle limits; prefixes are bounded before decoding and complete custom labels have per-label and aggregate UTF-8 byte caps. A refused tree leaves physical page numbers intact and records one warning, with custom labels applied only after every range succeeds. Pure COS Roman and repeated-letter formatters are shared with Organize; label queries and edits return errors for unsupported output before changing the stored tree, while ordinary labels and prefix-only ranges remain supported. Synthetic tests cover safe just-over-limit cases, large first numbers, aliases, cycles, wide/deep trees, UTF-16/UTF-8 boundaries, aggregate output, and unchanged refused edits. Milestone and overall effort estimates unchanged (about 30–35%). - - **2026-10-09 (M4, page grid zoom):** The Organize page grid zooms from 50% to 300%: toolbar buttons with a percentage that resets, trackpad pinch or Ctrl/⌘ + wheel about the page under the pointer, and ⌘+ / ⌘− / ⌘0. Pages drawn larger than their thumbnails are rendered at the size drawn while in view, so text stays sharp in documents of any length. Not yet: a slider, remembering the size, a size control for the Pages panel. Overall estimate unchanged (about 30–35%). - -- **2026-10-08 (M14, #307, upstream integration):** Rebased the local candidate onto main `a6bc63a`, preserving render-request deduplication, pixel-aligned rasters, signature editing and organizer insertion controls. Regressions cover inactive signature-preview cleanup, insertion gaps under virtualization, and canceled/reintroduced render requests. All 1,336 workspace tests pass (6 ignored), 193 parser tests pass (2 missing-fixture tests filtered), strict workspace/all-target Clippy passes without allowances, and 30 WASM crate checks pass. All 1,010 navigation frames, 12 workflow artifacts and eight 100-page external PDF checks match; 1×/2× desktop protocols pass again. Inspection is 497.7 MiB / 1.78 s, full-save 1.06 GiB / 29.64 s, and fresh desktop navigation/close/reopen 877 MiB. The desktop result is higher than the pre-rebase 832 MiB, while remaining 21.9% below the earlier bounded-writer stage. Windows, cold-start latency and global memory budgets remain open; no milestone percentage increase. - - **2026-10-09 (M7, grouped artwork):** Edit text/images selects page-drawn Form XObjects as whole figures, including nested raster/vector/text content. Their BBox and optional Matrix determine the selection; move/resize/rotate/flip/delete rewrite only the selected page placement, preserving shared Forms and other placements. Engine, automation and UI core regressions cover geometry, rendered movement, save/reopen, undo and selection/drag/Delete. Bitmap replacement/extraction remain raster-only; inline images and individual elements inside a Form are not editable through this tool. Overall estimate unchanged (about 30–35%). - -- **2026-10-08 (M5.7, signature resize proportions):** Image signature corner handles preserve the embedded image's original aspect ratio and anchor the opposite corner; edge midpoint handles stretch width or height independently. The live preview and committed edit share the constrained geometry, including page/view rotation and reopened PDFs whose rectangles were stretched previously. Synthetic UI tests cover all corners, edge resizing, undo and redo. Milestone and effort estimates unchanged. -- **2026-10-08 (M5.7, live signature adjustment):** Embedded image signatures resize and move with every pointer frame. Movement hides the frame and handles, which return on release; Escape cancels without committing. A cached background preserves the page beneath the image, and only the completed gesture enters undo history. The read-only engine preview and `comment_image_preview` tool share bounded image decoding and render layers. Synthetic pixel tests cover live geometry, alpha, coloured page content, rotation, cancellation and encrypted save/reopen. Milestone and effort estimates unchanged. -- **2026-10-08 (M10/M4, create from multiple files):** Create a PDF ▸ Multiple files is live. The picked PDFs, images and `.txt` files open as one unsaved document in the Organize page grid, where pages are removed and reordered and Save pages writes what is shown. The grid gained a "+" at every gap that inserts files right there, and on desktop files dropped on the grid go to the gap under the pointer; Insert from file now takes several files in order, converts images and text, and selects what it inserted. File-type detection moved from the shell into `pdfcraft-create` (`source_kind`), with one engine entry point (`Session::convert_to_pdf`) shared by Open, Insert, Create and the new `doc_create_multiple` tool (which can also write one PDF per file). Not yet: Office/HTML/Markdown inputs, PDF Portfolios, progress for big batches, dropping onto the grid in the web build. Overall estimate unchanged (about 30–35%). - -- **2026-10-08 (M5.7, signature placement):** Image signatures and initials preview at the pointer at their actual page size, including zoom and document/view rotation. A successful placement switches to Select with the new annotation selected; stamp resize handles use the existing engine edit, automation tool and undo history while preserving the appearance. Synthetic rendered UI and automation tests verify positioning, one placement per selection, resizing, undo/redo and save/reopen. Milestone and overall effort estimates unchanged. -- **2026-10-08 (M5.7, image signatures):** Fill & Sign imports local PNG/JPEG signatures and initials through the Image tab, with previews, local persistence and undoable placement. The shared engine validates bounded images and preserves transparency and orientation; `fill_sign_add` accepts an image path. Synthetic UI and automation tests cover cancellation, invalid input, settings, undo/redo and save/reopen rendering. Overall effort estimate unchanged (about 30–35%). -- **2026-10-08 (M14, #307, confirmed baseline OOM):** Removed the benchmark RSS guard and ran the frozen original and optimized CLI binaries in separate containers with a verified 15 GiB kernel memory limit, no swap and four CPUs. The unmodified baseline was kernel OOM-killed after 20.06 s with 14.97 GiB observed RSS; Docker recorded `OOMKilled=true`, exit 137 and an `oom` event. The optimized build completed the same 9,156-page inspection at 497.5 MiB in 1.75 s with unchanged metadata and no warnings. The original RSS remains a lower bound and elapsed time is time to failure. The kernel limit still constrains this experiment; no remote run was needed. No product changes or milestone percentage increase. - -- **2026-10-08 (M14, #307, original-baseline cutoff):** Repeated original unmodified CLI inspection with a 14 GiB RSS guard, up from 10 GiB, on the unchanged 9,156-page PDF. It reached 14,682,080 KiB and was stopped after 48.21 s before completion; no cgroup OOM kill occurred. The optimized build completed the same inspection at 495.7 MiB in 1.70 s. The original measurement is a lower bound, not a completed peak or timing. This guarded run failed before completion without an OS OOM kill. The user closed the larger-host follow-up; the prepared benchmark is retained as evidence. The later local guard-free experiment is recorded above. No product changes or milestone percentage increase. - -- **2026-10-08 (M14, #307, shared parsing and raster budgets):** Native render workers share a lazily initialized parser after cold-load/repair concurrency and watchdog recovery fixes, while retaining private render caches. Pages, organizer and Print request current visible thumbnails; retained active-view textures are limited to 24/128/128 MiB for thumbnails/pages/tiles and completed raster storage to 32 MiB/32 results, allowing one oversized result alone. Signature discovery uses a bounded source/edit-aware cache; the writer borrows unchanged objects and reuses xref rows. Engine navigation drops from 997 to 807 MiB with all 1,010 frame hashes unchanged. Three-trial full/incremental-save workflow times improve to 29.92/16.35 s at unchanged 1.06/1.08 GiB peaks; full serialization remains slower than before bounded saving, and the single mixed workflow takes 67.99 s versus 65.52 s. All 1,158 workspace tests pass (6 ignored in the regular run), 193 vendor tests pass (2 missing-fixture tests filtered), eight outputs pass 100-page external checks, and 29 WASM crate checks pass. Existing strict workspace Clippy warnings remain; UI lint validation explicitly allows the existing collapsible-match category. Fresh desktop navigation/close/reopen falls from 1,123 to 832 MiB; 1x/2x GUI runs pass visible-demand and texture-budget checks on original 10,000-page fixtures and the real file. Output-capacity and LRU experiments did not justify production changes. These are local Linux results and specific storage budgets, not global RAM limits or Windows validation. No parity or milestone percentage increase. - -- **2026-10-08 (M14, #307, bounded saving):** Full rewriting now uses private temporary caches and packs objects in batches limited by count and serialized bytes, preserving unknown objects, reference identity and encryption behavior without populating undo snapshots. Native render workers initialize only on a live request, avoiding unused parser copies during refresh/rebase. Three same-file Linux trials reduce rotate/full-save/close/reopen peak RSS from 3.69 to 1.06 GiB (35.53 to 32.51 s); incremental workflow RSS falls from 1.56 to 1.08 GiB. All 1,114 workspace tests pass (4 ignored); eight saved outputs pass external text/raster checks on 100 sampled pages each, and 1,010 navigation frames match. First use of each worker now pays parser initialization; steady-state viewing remains about 1 GiB. Single oversized objects, whole source/output buffers, and Windows behavior remain outside a global budget claim. No parity or milestone percentage increase. - -- **2026-10-08 (M14, #307, research/comparison):** Added a local external-reference suite outside the repository, using the same 9,156-page PDF with MuPDF, PDFium, Poppler and qpdf. Three Linux trials put the candidate's single-renderer 102-frame peak at 392 MiB, versus 348/347 MiB for MuPDF/PDFium with whole-file bytes (164/163 MiB with file input). The rotate/full-save/close/reopen workflow peaks at 3.69 GiB during serialization; incremental saving peaks at 1.56 GiB during rebasing. Different UI features, input/cache policies and writer compression settings prevent treating these as app rankings. Next targets are bounded object-stream packing, parsed-object lifetimes, snapshot overlap and file-backed input. No further runtime optimization or milestone percentage increase in this research step. - -- **2026-10-08 (M14, #307, local candidate):** Validated the 192.9 MB, 9,156-page VMware document in Linux. The original CLI exceeded a 10 GiB measurement cutoff before finishing inspection; the candidate inspects in about 1.8 s at 496 MiB and a 1,000-page navigation/close/reopen probe peaks at 990 MiB. It shares validated large resource indexes within each parser, reads panel structure on demand through COS with the previous inspector as a compatibility fallback, and scans standalone timestamps with temporary caches instead of retaining every object in the editor. Inspection output and 104 common sampled page renders match the handoff candidate. This is a different PDF revision from the original Windows issue; Windows GUI private memory and responsiveness remain unverified. No parity or milestone percentage increase. - -- **2026-10-08 (M9, #179):** Windows Current User Personal certificate store identities join file-based digital IDs, signing through CNG without exporting keys. RSA-2048 and ECDSA P-256 round trips use temporary certificates with cleanup guards; enumeration, missing identities, automation errors and the password-free UI have regression coverage. Smart cards and PKCS #11 remain planned; overall effort estimate unchanged. - -Newest first. One line per session: the date, what moved, and the new overall percentage. - - **2026-10-09 (M9, review of #144):** Chain building checks what an issuer may do. `x509::build_chain` linked certificates by name and signature only, so an ordinary subscriber certificate embedded in a PDF, or one in the trusted list, could "issue" a certificate for any identity and chain to a trusted root. An issuer now needs basicConstraints CA:TRUE (an old v1 self-signed root without constraints still anchors), keyCertSign when it has a key usage, a pathLenConstraint that allows the CAs below it, and validity at the signing time (or the trusted timestamp/token time) when known; the signature's details say why an issuer was refused. Negative tests issue certificates in-process and cover each refusal, plus a signed PDF whose signer chains through an end-entity certificate (Unknown, not Valid). Still missing: key usage and extended key usage of the signer, name constraints and unknown critical extensions. Overall estimate unchanged (about 30–35%). - - **2026-10-09 (M9):** A document timestamp stored as a signature field (Documenso writes `Timestamp_1` that way, its `/V` a `/Type /DocTimeStamp` dictionary) was read as a CMS signature and reported "altered or corrupted since the signature was applied" on an untouched file. A field value with `/SubFilter /ETSI.RFC3161` is validated as a timestamp now, and standalone timestamps are found by `/DocTimeStamp` (ISO 32000-2 §12.8.5) as well as the `/Type /Sig` older writers used; `timestamp_document` writes `/DocTimeStamp`. Regression tests build the same structure with a test TSA. Overall estimate unchanged (about 30–35%). - - **2026-10-09 (M9, signatures as DocuSign and Documenso write them, #144):** A real DocuSign + Telekom qualified-signature PDF showed "DER signature wrong" although Acrobat validates it: both CMS blobs use BER indefinite lengths. The CMS is read with the BER reader that #453 added (`Tlv::parse_ber`: indefinite lengths, segmented OCTET STRINGs, depth-bounded) while certificates and signed attributes are still verified over their own exact bytes, and the details say the signature was BER; tests build the nesting bombs a crafted /Contents could hold. Behind that, later revisions that add DSS data (indirect `/Certs`, `/VRI`) or rewrite the catalog's XMP were classified as disallowed changes; they are permitted now, narrowly (review by echelon on #144): only objects reached through `/DSS` `/Certs|CRLs|OCSPs|VRI` with the shape of their role, and new or only grown since signing, count as the store, and only the catalog's `/Metadata` is the document's XMP. A `/DSS` entry naming a page's contents, or a `/Type /DSS` or `/Type /Metadata` label on an existing dictionary or stream, makes nothing permitted. Six of the new tests fail on the previous code, among them a Form XObject labelled `/Type /Metadata` before signing that could be rewritten under a Valid signature. Negative tests at every DocMDP level. Cross-checked with pdfsig (both signatures valid). Still missing: AATL/EUTL trust lists (a separate change: they need CA-constraint checks in chain building first). Overall estimate unchanged (about 30–35%). - - **2026-10-09 (M9, signature algorithms, #386):** Validation reads what real signers write. Digests SHA-224, SHA-512/224, SHA-512/256, SHA3-224/256/384/512 and RIPEMD-160 (beside SHA-1/2); RSA PKCS#1 with or without the DigestInfo's NULL (checked by building the one expected encoding and comparing it byte for byte, in `rsa_pad`, for every digest; a bare digest without any DigestInfo is refused, it doesn't say which hash it uses); RSA-PSS with the hash, MGF1 hash and salt length the signature declares; ECDSA as DER, non-minimal DER or raw r‖s on P-256, P-384, P-521 and brainpoolP256r1/P384r1/P512r1 (the last hand-written on `crypto-bigint`, no RustCrypto crate exists); Ed25519 (RFC 8419); the legacy `adbe.x509.rsa_sha1` format. Algorithms PdfCraft can't check give Unknown instead of Invalid. Review of #386 (echelon): an RSA signature value must be below the modulus (RFC 8017 §5.2.2; `s` and `s + n` both verified before), and SHA-1 and RIPEMD-160 signatures still validate but the signature's details now say the algorithm is weak (wording pinned by tests). Found on the way, each with a regression test that failed first: a brainpoolP512r1 key with a coordinate not reduced modulo p panicked inside the bigint arithmetic (the certificate comes from the PDF), now refused, and the point at infinity is an invalid signature rather than an error. Tolerated encodings are listed in the signature's details. Vectors made with OpenSSL; cross-checked with pdfsig. Still missing: Ed448/DSA, signing with the new digests. Overall estimate unchanged (about 30–35%). - - **2026-10-09 (M14, German interface follow-up):** Follow the merged German catalog with shorter search/action labels, corrected callout/accessibility wording and a distinct certificate Subject context. Localized tool names retain their capitalization and comment composer titles go through the catalog. Search hints reserve shortcut space and Home cards bound text with full hover text; layout, context and German Preferences/search control regression tests cover these changes. Overall effort estimate remains about 30–35%. - - **2026-10-09 (M2, PDF-function hardening):** Vendored function construction has a shared 64-level/10,000-node limit and path cycle detection; calculator parsing and evaluation have shared depth/work budgets. Calculator integer division, logical shifts, rotations and indices avoid input-driven panics, and fixed-size stack errors propagate. Synthetic tests exercise exact/over limits, shared children and cyclic references, with invalid shading calculations preserving later page content. Limits return the existing optional failure and log their reason when logging is enabled. Milestone/overall effort estimates remain unchanged (about 30–35%). - - **2026-10-09 (community, destinations):** Bookmarks and links go to their destination's position, not just its page (ISO 32000-2 §12.3.2.2): `/XYZ` puts (left, top) at the window's top-left and applies a non-zero zoom; `/Fit`, `/FitH` and `/FitV` switch to fit page, width and height with `top` or `left` at the window's edge; `/FitR` zooms to fit the rectangle, centred. Named destinations resolve the same way; crop-box origin, `/Rotate` and view rotation are accounted for; malformed or non-finite operands fall back to the top of the page. Not yet: the `/FitB*` content box (read as the page). Overall estimate unchanged (about 30–35%). - - **2026-10-09 (M2, image resampling bounds):** Image resampling checks source buffers, finite transforms, target RGBA sizes, intermediate scratch dimensions and estimated filter work before planning or allocating; wide valid sources may shrink before backend side limits. Oversized or failed resampling retains the original pixels and placement when the source fits the backend; invalid sources, unrepresentable fallbacks and failed staging/output reserves are skipped with a logged warning. Small synthetic metadata regressions and an anisotropically transformed image cover the guards and fallback. Overall estimate unchanged (about 30–35%). - - **2026-10-09 (community, M14, Hungarian):** Magyar (`hu`) is a complete interface catalog (2,036 entries) covering every command, All tools entry, dialog, panel and notice, selected automatically for `hu` and `hu_HU`, and from Preferences. Informal "te"; commands are nouns (Mentés, Megnyitás). After a numeral the noun stays singular, so both plural forms match. Coverage tests check commands, All tools labels and every `tl!` literal; history labels keep captured names; the bundled Latin faces cover Hungarian diacritics including ő and ű. Overall estimate unchanged (about 30–35%). - **2026-10-09 (M14, Windows Auto):** Neutral launcher locales (`C`, `C.UTF-8`, `POSIX`) no longer select English ahead of the Windows preferred display-language list. Explicit supported locale overrides retain their priority. Regression tests use existing catalogs and cover neutral/mixed overrides, unsupported first display tags and multiple preferred Windows languages. Catalogs and other platforms are unchanged; overall effort estimate remains about 30–35%. - - **2026-10-09 (community, M14, German):** Deutsch (`de`) is a complete interface catalog (2,035 entries) covering every command, All tools entry, dialog, panel and notice, selected automatically for `de`, `de_DE`, `de_AT`, `de_CH` and other German locales, and from Preferences. Informal "du", infinitive for commands. Coverage tests check commands, All tools labels and every `tl!` literal; history labels keep captured names; the bundled Latin faces cover umlauts, ß and German quotes. Overall estimate unchanged (about 30–35%). - **2026-10-09 (community, M7, Arabic added text):** Add text writes Arabic into the PDF instead of `?`. The text is shaped with the craft-fonts `Arab` face through harfrust (already in the tree via egui): joining forms, ligatures and mark positions. Each line is laid out in display order by the Unicode bidirectional algorithm, so Arabic mixed with Latin words, European or Arabic-Indic digits and brackets reads correctly; justified right-to-left paragraphs end at the right edge. Each cluster (a letter with its dots or marks) becomes one glyph of a Type 3 font built from the face's outlines, with a ToUnicode entry, so `text_extract`, copy and search get every letter once and no font file is embedded. Wrapping measures shaped words. Each item has its own fonts under names free on the page (a full save renumbers objects but not resource names); updating or deleting the item removes them. Without the face, adding Arabic text returns a clear error and changes nothing, while an existing item holding Arabic still moves and retypes, drawn as `?` as before. Tests cover shaping, display order, ToUnicode, font names and clean-up on one page, and odd input (empty lines, direction controls, lone marks, 18,000 characters). Not yet: bold/italic Arabic, Hebrew, right-to-left typing in the box (misc.rtl-ui), editing existing Arabic text (edit.rtl-cjk-editing). The craft-fonts revision pinned for releases predates its Arabic face, so release builds report the missing font until the pin moves. Overall estimate unchanged (about 30–35%). - **2026-10-09 (M2, predictor hardening):** Empty PNG predictor frames return without row buffers. Packed TIFF Predictor 2 rows reject overflowing dimensions and size per-component scratch to the samples present in the input, aligned with upstream lopdf#589. Synthetic regressions cover malformed dimensions, partial rows, 1/2/4-bit decoding and padding. Overall estimate unchanged (about 30–35%). - **2026-10-09 (community, document inspection):** Annotation dates preserve non-date Unicode text instead of panicking while opening; failed auxiliary structure inspection keeps renderer page geometry and reports a warning. Synthetic UTF16BE annotation, UTF8-boundary and independent panic regressions cover the fix. Overall estimate unchanged (about 30–35%). - - **2026-10-09 (community, M14, #503):** Ukrainian (Українська, `uk`) adds 2,047 original catalog entries covering commands, tools, dialogs, panels, notices, history and contributor controls. Auto resolves `uk`/`uk_UA`; the manual preference persists. Integer plurals distinguish one/few/many, including 11–14 and large counts; fixed-count templates use count-neutral wording. Calendar dates use genitive month names; Home cards fit wrapped text and the search hint keeps clear of its shortcut. Coverage checks scan UI literals, and font checks use bundled Inter and JetBrains Mono, including ґ/є/і/ї. Control tests check translated Preferences, command search, error framing and language switching with unsaved documents. Overall estimate unchanged (about 30–35%). - - **2026-10-09 (community, M5, #158):** Fill & Sign date format. Preferences ▸ Date format sets the pattern Fill & Sign dates use: 12 presets or any pattern in Acrobat date letters (with `\` escapes), checked and previewed as typed, remembered, and settable through `fill_sign_date_format`, `fill_sign_add` `format`/`language` and `ui set date-format`/`date-language`. Month and weekday names come in the interface languages (Czech in the genitive after a day), following the interface language unless one is picked. Fill & Sign text is still Helvetica/WinAnsi, so a date with other letters (Czech ř, Japanese, Chinese) is refused with a warning rather than saved as "?". This goes past Acrobat, whose Fill & Sign has no format setting. Overall estimate unchanged (about 30–35%). - - **2026-10-09 (M14, #503 follow-up):** Reviewed the complete Ukrainian catalog with VESUM 6.8.6 and manually checked the labels added when #509 landed. Certificate Subject now names the certificate holder separately from the document topic; certificate types and fingerprints, equal field sizes, keyboard/assistive-technology guidance and table sections use clearer contextual wording. Reviewed technical compounds and abbreviations remain unchanged. Overall effort estimate remains about 30–35%. - - **2026-10-09 (community, Home pinned folders):** Home gets a Folders section: File ▸ Pin folder to Home… (or "Pin a folder…" on Home) pins up to 8 folders, and each lists its newest PDFs (name, how long ago, size; 8 at first, up to 100 with Show more), one click to open, Unpin to remove. Folders are read on a worker thread and listed again every few seconds while Home shows, so new scans appear without reopening anything; a folder that can't be read (moved, offline synced drive) stays pinned and says so. Each folder is described by where it is ("iCloud Drive", "OneDrive · Contoso", "Documents › Work") rather than its full path, so two folders called "Scans" can be told apart. Cloud drives work through their local synced folder; nothing is read through a provider's API. Settings keep only absolute paths, without duplicates. Command label translated in every catalog; Home strings in French, Spanish, Japanese and both Chinese catalogs. Not yet: subfolders, search inside a pinned folder, the web build. Overall estimate unchanged (about 30–35%). - - **2026-10-09 (community, M14, Telugu):** Telugu (తెలుగు, `te`) as a complete catalog of 1,984 entries covering every command, All tools entry, dialog, panel and notice; `te` and `te_IN` locales follow it. Telugu text is drawn with a craft-fonts `Telu` face (Noto Sans Telugu, storytold/craft-fonts#10), kept in the web build too, after the CJK and Arabic faces; egui shapes its conjuncts. Commands use the plain imperative and sentences the polite form. The craft-fonts revision pinned for releases has no Telugu face yet, so release builds show Telugu as replacement boxes until the pin moves. Overall estimate unchanged (about 30–35%). - - **2026-10-09 (M14, Simplified Chinese About):** The About tabs, contributor controls and statistics, and model table now have Simplified Chinese translations, along with multiline link-privacy and cut-and-stack guidance. Coverage checks include direct `i18n::t` calls, multiline literals and dynamic credit labels; UI control checks exercise the translated About tabs and tables. Chinese font delivery remains incomplete; overall ≈ 30–35% (unchanged). - - **2026-10-09 (M14, #305):** Windows MSI rejects per-user installation overrides while allowing removal of legacy per-user installs and keeps its progress message visible. README documents unattended deployment without a desktop shortcut. Compiled x64 MSI checks pass; ARM64 install regressions added for CI. Overall estimate unchanged (about 30–35%). - +- **2026-10-09 (M13, MCP #451):** common command, batch, inspection and bounded preview tools share the existing confined automation path. MCP tools carry complete hints and reject unknown top-level keys as invalid parameters. +- **2026-10-08 (M14, #307, upstream integration):** Rebased the local candidate onto main `a6bc63a`, preserving render-request deduplication, pixel-aligned rasters, signature editing and organizer insertion controls. Regressions cover inactive signature-preview cleanup, insertion gaps under virtualization, and canceled/reintroduced render requests. All 1,336 workspace tests pass (6 ignored), 193 parser tests pass (2 missing-fixture tests filtered), strict workspace/all-target Clippy passes without allowances, and 30 WASM crate checks pass. All 1,010 navigation frames, 12 workflow artifacts and eight 100-page external PDF checks match; 1×/2× desktop protocols pass again. Inspection is 497.7 MiB / 1.78 s, full-save 1.06 GiB / 29.64 s, and fresh desktop navigation/close/reopen 877 MiB. The desktop result is higher than the pre-rebase 832 MiB, while remaining 21.9% below the earlier bounded-writer stage. Windows, cold-start latency and global memory budgets remain open; no milestone percentage increase. +- **2026-10-08 (M5.7, signature resize proportions):** Image signature corner handles preserve the embedded image's original aspect ratio and anchor the opposite corner; edge midpoint handles stretch width or height independently. The live preview and committed edit share the constrained geometry, including page/view rotation and reopened PDFs whose rectangles were stretched previously. Synthetic UI tests cover all corners, edge resizing, undo and redo. Milestone and effort estimates unchanged. +- **2026-10-08 (M5.7, live signature adjustment):** Embedded image signatures resize and move with every pointer frame. Movement hides the frame and handles, which return on release; Escape cancels without committing. A cached background preserves the page beneath the image, and only the completed gesture enters undo history. The read-only engine preview and `comment_image_preview` tool share bounded image decoding and render layers. Synthetic pixel tests cover live geometry, alpha, coloured page content, rotation, cancellation and encrypted save/reopen. Milestone and effort estimates unchanged. +- **2026-10-08 (M10/M4, create from multiple files):** Create a PDF ▸ Multiple files is live. The picked PDFs, images and `.txt` files open as one unsaved document in the Organize page grid, where pages are removed and reordered and Save pages writes what is shown. The grid gained a "+" at every gap that inserts files right there, and on desktop files dropped on the grid go to the gap under the pointer; Insert from file now takes several files in order, converts images and text, and selects what it inserted. File-type detection moved from the shell into `pdfcraft-create` (`source_kind`), with one engine entry point (`Session::convert_to_pdf`) shared by Open, Insert, Create and the new `doc_create_multiple` tool (which can also write one PDF per file). Not yet: Office/HTML/Markdown inputs, PDF Portfolios, progress for big batches, dropping onto the grid in the web build. Overall estimate unchanged (about 30–35%). +- **2026-10-08 (M5.7, signature placement):** Image signatures and initials preview at the pointer at their actual page size, including zoom and document/view rotation. A successful placement switches to Select with the new annotation selected; stamp resize handles use the existing engine edit, automation tool and undo history while preserving the appearance. Synthetic rendered UI and automation tests verify positioning, one placement per selection, resizing, undo/redo and save/reopen. Milestone and overall effort estimates unchanged. +- **2026-10-08 (M5.7, image signatures):** Fill & Sign imports local PNG/JPEG signatures and initials through the Image tab, with previews, local persistence and undoable placement. The shared engine validates bounded images and preserves transparency and orientation; `fill_sign_add` accepts an image path. Synthetic UI and automation tests cover cancellation, invalid input, settings, undo/redo and save/reopen rendering. Overall effort estimate unchanged (about 30–35%). +- **2026-10-08 (M14, #307, confirmed baseline OOM):** Removed the benchmark RSS guard and ran the frozen original and optimized CLI binaries in separate containers with a verified 15 GiB kernel memory limit, no swap and four CPUs. The unmodified baseline was kernel OOM-killed after 20.06 s with 14.97 GiB observed RSS; Docker recorded `OOMKilled=true`, exit 137 and an `oom` event. The optimized build completed the same 9,156-page inspection at 497.5 MiB in 1.75 s with unchanged metadata and no warnings. The original RSS remains a lower bound and elapsed time is time to failure. The kernel limit still constrains this experiment; no remote run was needed. No product changes or milestone percentage increase. +- **2026-10-08 (M14, #307, original-baseline cutoff):** Repeated original unmodified CLI inspection with a 14 GiB RSS guard, up from 10 GiB, on the unchanged 9,156-page PDF. It reached 14,682,080 KiB and was stopped after 48.21 s before completion; no cgroup OOM kill occurred. The optimized build completed the same inspection at 495.7 MiB in 1.70 s. The original measurement is a lower bound, not a completed peak or timing. This guarded run failed before completion without an OS OOM kill. The user closed the larger-host follow-up; the prepared benchmark is retained as evidence. The later local guard-free experiment is recorded above. No product changes or milestone percentage increase. +- **2026-10-08 (M14, #307, shared parsing and raster budgets):** Native render workers share a lazily initialized parser after cold-load/repair concurrency and watchdog recovery fixes, while retaining private render caches. Pages, organizer and Print request current visible thumbnails; retained active-view textures are limited to 24/128/128 MiB for thumbnails/pages/tiles and completed raster storage to 32 MiB/32 results, allowing one oversized result alone. Signature discovery uses a bounded source/edit-aware cache; the writer borrows unchanged objects and reuses xref rows. Engine navigation drops from 997 to 807 MiB with all 1,010 frame hashes unchanged. Three-trial full/incremental-save workflow times improve to 29.92/16.35 s at unchanged 1.06/1.08 GiB peaks; full serialization remains slower than before bounded saving, and the single mixed workflow takes 67.99 s versus 65.52 s. All 1,158 workspace tests pass (6 ignored in the regular run), 193 vendor tests pass (2 missing-fixture tests filtered), eight outputs pass 100-page external checks, and 29 WASM crate checks pass. Existing strict workspace Clippy warnings remain; UI lint validation explicitly allows the existing collapsible-match category. Fresh desktop navigation/close/reopen falls from 1,123 to 832 MiB; 1x/2x GUI runs pass visible-demand and texture-budget checks on original 10,000-page fixtures and the real file. Output-capacity and LRU experiments did not justify production changes. These are local Linux results and specific storage budgets, not global RAM limits or Windows validation. No parity or milestone percentage increase. +- **2026-10-08 (M14, #307, bounded saving):** Full rewriting now uses private temporary caches and packs objects in batches limited by count and serialized bytes, preserving unknown objects, reference identity and encryption behavior without populating undo snapshots. Native render workers initialize only on a live request, avoiding unused parser copies during refresh/rebase. Three same-file Linux trials reduce rotate/full-save/close/reopen peak RSS from 3.69 to 1.06 GiB (35.53 to 32.51 s); incremental workflow RSS falls from 1.56 to 1.08 GiB. All 1,114 workspace tests pass (4 ignored); eight saved outputs pass external text/raster checks on 100 sampled pages each, and 1,010 navigation frames match. First use of each worker now pays parser initialization; steady-state viewing remains about 1 GiB. Single oversized objects, whole source/output buffers, and Windows behavior remain outside a global budget claim. No parity or milestone percentage increase. +- **2026-10-08 (M14, #307, research/comparison):** Added a local external-reference suite outside the repository, using the same 9,156-page PDF with MuPDF, PDFium, Poppler and qpdf. Three Linux trials put the candidate's single-renderer 102-frame peak at 392 MiB, versus 348/347 MiB for MuPDF/PDFium with whole-file bytes (164/163 MiB with file input). The rotate/full-save/close/reopen workflow peaks at 3.69 GiB during serialization; incremental saving peaks at 1.56 GiB during rebasing. Different UI features, input/cache policies and writer compression settings prevent treating these as app rankings. Next targets are bounded object-stream packing, parsed-object lifetimes, snapshot overlap and file-backed input. No further runtime optimization or milestone percentage increase in this research step. +- **2026-10-08 (M14, #307, local candidate):** Validated the 192.9 MB, 9,156-page VMware document in Linux. The original CLI exceeded a 10 GiB measurement cutoff before finishing inspection; the candidate inspects in about 1.8 s at 496 MiB and a 1,000-page navigation/close/reopen probe peaks at 990 MiB. It shares validated large resource indexes within each parser, reads panel structure on demand through COS with the previous inspector as a compatibility fallback, and scans standalone timestamps with temporary caches instead of retaining every object in the editor. Inspection output and 104 common sampled page renders match the handoff candidate. This is a different PDF revision from the original Windows issue; Windows GUI private memory and responsiveness remain unverified. No parity or milestone percentage increase. +- **2026-10-08 (M9, #179):** Windows Current User Personal certificate store identities join file-based digital IDs, signing through CNG without exporting keys. RSA-2048 and ECDSA P-256 round trips use temporary certificates with cleanup guards; enumeration, missing identities, automation errors and the password-free UI have regression coverage. Smart cards and PKCS #11 remain planned; overall effort estimate unchanged. - **2026-10-08 (M4, Combine files):** Combine files is now its own tab instead of a dialog: a table of the files (name, pages, size, modified, warnings) whose columns sort, resize and move, with the layout kept in the settings. Files, folders (optionally with subfolders) and open documents can be added, or dropped on the tab; rows select like a file manager (Ctrl/Shift), move as a block by toolbar, Alt+arrows or drag, and every change undoes with ⌘Z. Problems show before Combine: password-protected files, security settings that withhold page assembly, bad page ranges (checked as typed), duplicates; protected files unlock with their open or permissions password, also headlessly (`doc_combine` `passwords`). The result is never encrypted, and the list says so. `organize.combine-sort` shipped; `organize.combine-options` partial (open files). Still missing: protecting the result in the same step, file-size and PDF/A options. Overall estimate unchanged (about 30–35%). - - **2026-10-08 (M14, French):** Français (`fr`) is a complete interface catalog (about 1,880 entries), selected automatically for `fr`, `fr_FR`, `fr_CA` and other French locales, and from Preferences. Coverage tests check commands, All tools labels and every `tl!` literal; history labels keep captured names. Latin UI faces cover the catalog. Localization remains partial for Czech and Brazilian Portuguese; overall ≈ 30–35% (unchanged). - - **2026-10-08 (M14, right-to-left file names):** A tab with an Arabic or Hebrew file name showed replacement boxes. The interface now takes an `Arab` face from craft-fonts when the build input has one, and on desktop falls back to one font installed on the machine for characters no embedded face has (`PDFCRAFT_SYSTEM_FONTS=0` turns that off; README screenshots do). File names and paths in the tab strip, the recent-files list and Properties are put in display order, since egui joins the letters but does not reorder runs. Still missing: multi-line text (bookmarks, comments, search results), text input and a mirrored layout, so `misc.rtl-ui` stays planned; the craft-fonts revision pinned for releases predates its Arabic face, so release builds rely on the installed font until the pin moves. Overall estimate unchanged (about 30–35%). - - **2026-10-08 (community, M3, #214):** In single-page view, when the whole page fits, the wheel turns pages: one per wheel notch, one per trackpad swipe including its momentum. The rail gets a Page display button between rotate and zoom with Acrobat's view choices (Fit to width scrolling, Fit one full page, Actual size, Zoom to page level, Fit visible, Read mode, Full screen) and the page layouts. Preferences gets Default page display and Default zoom, which every new tab uses. In continuous view, Fit page and Fit height fit the largest page, so mixed page sizes no longer jump while scrolling. Overall estimate unchanged (about 30–35%). - - **2026-10-08 (M14, Windows Chinese Auto):** Auto now reads the Windows display language after locale environment overrides, selecting Simplified or Traditional Chinese through the existing locale mapping. Added Chinese region/script and Windows query regressions. Chinese font delivery remains incomplete. Localization remains partial; overall ≈ 30–35% (unchanged). - - **2026-10-08 (M3, themes):** Toolbar and Display theme now share System, Light and Dark preferences, one command path and persistent settings; the duplicate View toggle is removed. Effective colours track the OS only in System mode, with egui popup styles kept in sync. Overall estimate unchanged (about 30–35%). - - **2026-10-08 (M14, Simplified Chinese):** Added 208 translations, bringing the catalog from 1,625 to 1,833 entries. PhotoCraft-style coverage checks protect registered commands, All tools labels and UI `tl!` literals; generated history and diagnostic regressions preserve user values. Chinese font delivery remains incomplete: the release-pinned craft-fonts input has no Hans face and the web build only embeds the Japanese UI face (see #112 and #140). Localization remains partial; overall ≈ 30–35% (unchanged). - +- **2026-10-08 (community, #135):** CLI output exits cleanly when a pipe reader closes early. Other stdout errors return a failure instead of panicking, and command/file errors keep their diagnostics. Synthetic subprocess regressions cover closed pipes and a reader stopping after one line. Parity counts and effort estimate unchanged (≈ 30–35%). +- **2026-10-08 (community):** Cut and stack in Print > Multiple: single-sided n-up sheets can be cut into cell piles and restacked in selected page order, with aligned gutter marks and blanks kept in their cells. Available through doc_print; duplex is refused. Grid-size overflow returns an error. No change to the parity count or the overall estimate (about 30–35%). - **2026-10-07 (community, #208):** Measure: distance, perimeter and area annotations (standard LineDimension/PolyLineDimension/PolygonDimension with rectilinear /Measure), persistent viewport scales with two-point calibration, vector snapping (endpoints, midpoints, intersections, paths) through nested forms, live readings, CSV export, and eight `measure_*` agent tools. Hardened on landing: snap extraction caps segments and snap targets page-wide and skips undecodable streams and over-deep `q` instead of failing; unsupported imported measurements (compound ft-in, fractional, invalid geometry) are listed as unsupported rather than failing the list; non-ASCII units ("m²") are allowed and rendered in WinAnsi; a closing duplicate polygon vertex counts as closed. Distance and scale are partial (single-unit decimal formats only; Acrobat interop untested). 51.0% shipped; ≈ 30–35% effort. - **2026-10-07 (community, #190):** Create PDF from Images offers embedded resolution, 72 DPI, or custom DPI (1–1200) without resampling. The engine and doc_create share the choice; Windows image context menus open the chooser through --create-images. Overall estimate unchanged (about 30–35%). - -- **2026-10-08 (community, #135):** CLI output exits cleanly when a pipe reader closes early. Other stdout errors return a failure instead of panicking, and command/file errors keep their diagnostics. Synthetic subprocess regressions cover closed pipes and a reader stopping after one line. Parity counts and effort estimate unchanged (≈ 30–35%). - -- **2026-10-08 (community):** Cut and stack in Print > Multiple: single-sided n-up sheets can be cut into cell piles and restacked in selected page order, with aligned gutter marks and blanks kept in their cells. Available through doc_print; duplex is refused. Grid-size overflow returns an error. No change to the parity count or the overall estimate (about 30–35%). - **2026-10-07 (community, #161):** The "Save changes?" prompt keeps a long file name inside the modal: the title wraps to the dialog width, so the centered panel no longer stretches past the viewport, clips its message and pushes Save/Cancel off-screen (Windows report). The password prompt's file-name line wraps the same way, and a regression test pins the title and all three buttons inside a 1365×719 screen. Parity counts unchanged; ≈ 30–35%. - **2026-10-07 (community, #143):** Windows MSI gets a full-UI welcome dialog with a "Create a desktop shortcut" checkbox (on by default; `INSTALLDESKTOPSHORTCUT=0` for unattended installs), progress, files in use, repair/remove, and a "Setup completed successfully" screen with Finish (plus distinct failed/cancelled screens). Start Menu and desktop shortcuts are now plain links to `pdfcraft.exe` instead of advertised MSI shortcuts. Packaging checks the compiled MSI's shortcut, checkbox and dialog tables; the ARM64 smoke test checks both links' targets, their removal on uninstall, and the opt-out. Installer polish only; feature counts unchanged. ≈ 30–35%. - **2026-10-07 (localization):** The interface translates through the same system as PhotoCraft: one TSV catalog per language (`crates/ui-egui/src/i18n/ja.tsv`), the `tl!` macro, entries keyed by command id or with plural forms, and catalog tests (format, duplicates, placeholders, command ids). Catalogs are validated strictly (unknown escapes, reserved contexts, placeholders, ellipses, plural forms), a bad line is skipped and logged at run time, `fmt` substitutes in one pass, the drawing language is per thread, and each `.tsv` is an attributed asset (`kind = "translation"`, checked by `cargo xtask assets`); validation, attribution and the language-switch control test folded in from #175. The 49 existing Japanese strings moved over unchanged, plus `Auto`. The `language` preference now defaults to `auto` (follow the system language; English when it has no catalog); `en`/`ja` still persist, and unknown values are rejected. Not covered: dialogs and panels, Windows system-language detection. ≈ 30–35% (unchanged). - **2026-10-07 (session 16):** PrintCraft is renamed PdfCraft: repo `storytold/pdfcraft`, crates and binaries `pdfcraft*`, bundle id `ai.storyteller.pdfcraft`. On first launch the app moves the old PrintCraft settings and crash-recovery folders to the new name, so upgrades keep recent files, preferences and unsaved work. No feature change. ≈ 30–35%. - **2026-10-07 (community, #163):** Fill & Sign shows saved signature/initials previews with add, change and remove controls in the quick picker and left panel. The editor loads the saved typed text or drawn strokes; Apply replaces future placements and Cancel preserves the saved value. Existing annotations stay unchanged. Long typed names shrink to the placement width and include glyph overhangs in preview/PDF bounds. Regression coverage includes persistence, removal/recreation, long names and the opt-in UI control channel. Parity counts and effort estimate unchanged (50.2% shipped, ≈ 30–35% effort). - - **2026-10-07 (fork, default-workspace-mode):** Preferences ▸ Documents and view remembers the workspace used for newly opened PDFs. All Tools remains the default; explicit CLI/control mode overrides last for the session and PDF Initial View metadata stays independent. Overall estimate unchanged (≈ 30–35%). - - **2026-10-07 (community, M14, #112):** Simplified Chinese (简体中文, `zh-hans`) as a catalog of about 1,600 entries; `zh`, `zh_CN` and `zh-Hans` locales follow it. In Simplified Chinese the craft-fonts `Hans` faces come before the Japanese ones, so Simplified-only characters don't sit on a different baseline. - **2026-10-07 (community, M14, #140):** Traditional Chinese (繁體中文, `zh-hant`, Taiwan vocabulary) as a catalog of about 1,800 entries; `zh_TW`, `zh_HK`, `zh_MO` and `zh-Hant` locales follow it. Drawn with the craft-fonts CJK faces (missing characters show replacement boxes). - **2026-10-07 (community, M14, #141):** Every dialog, panel, notice, shortcut list and native-picker title goes through the catalog (`tl!`, `i18n::fmt`), and Japanese grows from 49 to about 1,800 entries (`ja.tsv`); worker threads (export, OCR, actions) write their summaries in the UI language; history labels keep captured file and field names; the command palette searches translated and English labels and ids. Engine and OS error text is no longer matched against English templates: it is shown as is, inside a translated frame. Localization remains partial for other languages. @@ -264,7 +225,6 @@ Newest first. One line per session: the date, what moved, and the new overall pe - **2026-10-06 (session 14):** Issues: move and resize existing text boxes in Edit text (#59, closes #40); Windows release builds open no console window and the Start Menu shortcut has its icon (#58, #57; rolled out to every Craft app); native Windows ARM64 installers, installed and run on an ARM64 runner in CI (#66, #71; the intermittent ARM64 test crash was a WARP shader-JIT race between parallel GPU-rendering tests, not our code); XFA forms are detected and explained instead of failing silently (#65, #60 stays open for real XFA). Also: squiggly underline tool, comment opacity, Preferences ▸ Identity, drag-and-drop/hover tests; community table export to Word/HTML/RTF (#62, with linear-time detection and a column cap). #63 (timestamps/LTV with outbound network) held for review fixes and an owner decision on network access. ≈ 30–35% (unchanged). - **2026-10-06 (community, #68):** Interface language: Czech (Čeština, `ui.set language=cs`) joins English and Japanese. It translates every registered File/Edit/Pages/View/Help command and every label routed through the translation table, with tests that fail when a menu label lacks Czech; the interface fonts are tested to cover Czech diacritics. Dialogs and panels remain English (misc.prefs-language: partial). - **2026-10-06:** M1: invalid object-number ranges in xref tables and streams take the existing reconstruction path, with the reason recorded, instead of overflowing or aliasing another object. Synthetic open/repair regression added. Overall estimate unchanged (≈ 30–35%). - - **2026-10-05 (session 13, later):** FreeBSD: CI builds and tests the workspace in a FreeBSD 14.3 VM (#45) and the release ships a FreeBSD x86_64 tarball, checked in CI (#42); README lists FreeBSD. Fuzzing: the nightly job's 11 hangs fixed (#41: our parser's exponential retries, inline images, Type 3 fan-out), plus a JBIG2 hang (#43, vendored hayro-jbig2) and a Type 1 font stack overflow (#46, skrifa 0.47); a full run now finds 0 crashes. Landed community PRs #36 (text editing, Japanese replacements), #47 (English/Japanese interface) and #48 (XFDF colour crash). Windows launch crash with Intel's Vulkan driver fixed by using Direct3D 12 (#49, issue #37); one copy of the Japanese font instead of two (#51, −8.3 MB); full screen and About get real tests (#50). P0 88%, P1 52%. ≈ 30–35%. - **2026-10-05 (community, #47):** Interface language: Preferences ▸ Interface language switches English/Japanese and persists (`ui.set language=ja|en`, reported by `ui.state`). Core menu labels are translated; dialogs, panels and more languages remain open (misc.prefs-language: partial). - **2026-10-05 (session 13):** Landed community PRs #3–#7 and the never-crash rollout (#11–#25); CI green on every job for the first time in 30+ runs (clippy 1.99, Windows line endings, perf-test noise); nightly fuzz job made to finish (child memory cap) and its findings fixed: 7 out-of-memory crashes (#27, #29, #30) and 11 hangs (in review); issue #8 (integrated GPU, keyboard save prompt); Help ▸ Check for updates (manual only). Added the honest assessment and gap list above. P0 88%, P1 52%; 804 features 49.5% shipped (53.3% weighted). ≈ 30–35%. @@ -272,7 +232,6 @@ Newest first. One line per session: the date, what moved, and the new overall pe - **2026-10-03 (session 12):** Edit text and images in place (lines, paragraphs, formatting, existing images), Scan & OCR (new `ocr` crate on ocrs with CC-BY-SA models fetched by `cargo xtask models`: searchable image, page ranges, multiple files), Acrobat JavaScript (new `js` crate on boa: custom field scripts in Acrobat's event order, button scripts, document JavaScripts, console, Enable JavaScript preference, sandbox limits). 110+ tools; P0 88%, P1 48%; 803 features 47.9% shipped (51.1% weighted). ≈ 37%. - **2026-10-02 (session 11, later):** Sign with macOS Keychain identities, Add alternate text, Advanced Search panel, space audit and link clean-up in the Optimizer, hybrid-reference files, signed documents protected from rewrites, redaction cleans the tags, custom stamps, image fields, Combine files with page choice. 97 tools; P0 82%, P1 45%. ≈ 30%. - **2026-10-02 (session 11):** Accessibility Checker (new `a11y` crate: all 32 rules, report, fixes, options dialog and results panel), MCP resources for page images and text, revisions (list, open an earlier one), export all images, fit visible, form Preview and locked fields, document title in the window; plus attachment comments, eraser, date picker, duplicate/align/distribute fields. 91 tools; P0 80%, P1 43%. ≈ 29%. - - **2026-10-02 (session 10, last):** Manual tab order, backgrounds/watermarks from files (PDF pages as forms, images), Initial View and reading options (honoured on open), bookmarks expand/collapse, make-default comment properties, required-field borders, cut/copy/paste pages, marquee zoom, snapshot. 85 tools; P0 80%, P1 ≈ 27%. - **2026-10-02 (session 10, latest):** PDF Optimizer and real Reduce File Size (new `optimize` crate: effective-resolution image downsampling, recompression, discards), Field Properties ▸ Options for every field type, Redaction Properties overlay styling, drag-to-reorder pages, repair notice and log; signature revalidation made incremental (comment edit on a 120 MB signed file 84 → 6 ms). 84 tools; P0 parity ≈ 80%. ≈ 27%. - **2026-10-02 (session 10, later):** Digital signatures (new `sign` crate: PKCS #12, X.509, CMS; PAdES B-B signing incl. certification; validation with trust store and DocMDP-aware change detection; Signatures panel, message bar, sign and digital ID dialogs; signed documents keep incremental saves), polygon/cloud/connected-lines/callout/caret comments, Create PDF from clipboard, comment filters by colour and checkmark. Verified against pdfsig and OpenSSL. 83 tools; P0 parity 78%, P1 22.5%. ≈ 26%. @@ -293,14 +252,12 @@ Newest first. One line per session: the date, what moved, and the new overall pe - Fixed: the Hand tool did not pan. - 50 agent tools. - Overall ≈ 16%. - - **2026-10-01 (session 7):** - Bookmark editing and page numbering (Number pages), with undo, agent tools and UI. - A render watchdog: pathological pages are skipped after 20 s instead of spinning forever. - The agent control channel no longer reports success when the window is hidden. - Community links: Discord button in the title bar, plus Home, About, Help menu, CLI and README. - Overall ≈ 10%. - - **2026-10-01 (session 6):** - UI control channel: agents can inspect the widget tree, click, type, press keys, run commands and take screenshots of the running app. Off unless the app is started with `--control`; loopback only, token-authenticated. - Combine and insert store identical fonts, images and other resources once. 40 copies of the showcase: 130 MB → 3.9 MB, 3× faster, pixel-identical. @@ -308,14 +265,12 @@ Newest first. One line per session: the date, what moved, and the new overall pe - Fuzzing (`cargo xtask fuzz`, nightly in CI): about 150,000 mutated files tried. Seven crash and hang bugs found and fixed, each with a regression test: two in our code, five in the temporary renderer. - Parity checklist: 826 Acrobat Pro features tracked in `parity/acrobat-features.toml`; `cargo xtask parity` checks every claim against code and tests. 11.8% shipped overall, 31.6% of P0. - Overall ≈ 9.5%. - - **2026-09-30 (session 5, after a machine crash; no work lost):** - Agent control: new `pdfcraft-automation` crate with 20 JSON-Schema tools, an opt-in MCP server (`pdfcraft-cli mcp`, stdio only, can be compiled out), and `pdfcraft-cli run`/`tools`. - Text on rotated pages now reads along its lines; the pdf.js oracle is unchanged at median 0.980. - Finding text in 520 pages: 17.9 s → 3.7 s (parallel), about 20 ms when repeated (cached). - CI: dependency licence audit (`deny.toml`, `xtask deny`) and a GitHub workflow for macOS, Windows, Linux and wasm. - Overall ≈ 8%. - - **2026-09-30 (session 4):** - Combine, extract and split, with links, named destinations, fields, layers, attachments and nested bookmarks all carried over. Combined pages render pixel-identical to their sources. - Fixed a precision bug in how reals were written. @@ -336,4 +291,14 @@ Newest first. One line per session: the date, what moved, and the new overall pe - Overall ≈ 3–4%. - **2026-09-30 (session 1):** planning complete; viewer vertical slice. -- M13: common command, batch, inspection and bounded preview tools share the existing confined automation path. MCP tools carry complete hints and reject unknown top-level keys as invalid parameters. +## Revision history + +| Date | Change | Summary | +|---|---|---| +| 2026-10-10 | minor | Readiness table with hours per audience; full number restated as the additive weighted sum over dimensions (47% → 49%; method aligned with the standard, no new evidence) | +| 2026-10-10 | minor | Essentials-user score added (≈ 66%) | +| 2026-10-10 | minor | Gate consistency check: convert and share re-judged partial, not blocking; stage pre-alpha → alpha | +| 2026-10-10 | minor | Second look: ready 40% → 47% (cross-cutting double count removed), mainstream-practitioner numbers and user sentiment added; core-workflow alpha gate applied, stage alpha → pre-alpha | +| 2026-10-10 | major | Restructured to the progress-docs standard (stage, two numbers, dimensions, features, languages, upcoming, log); full re-measure; assessment, gaps and milestones moved to `docs/`; log sorted newest first and a stray merged entry dated | +| 2026-10-07 | minor | 806 tracked features, 51.0% shipped, effort-weighted ≈ 30–35%, 600–1,100 h | +| 2026-10-05 | major | Honest assessment and "Where we're lacking" added | diff --git a/docs/architecture.md b/docs/architecture.md new file mode 100644 index 000000000..eb56cdb3e --- /dev/null +++ b/docs/architecture.md @@ -0,0 +1,112 @@ +# 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) + +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 +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 +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. + +| Binary | What it is | +|---|---| +| `apps/pdfcraft` | The desktop app (macOS, Windows, Linux, FreeBSD): eframe/egui on wgpu, single instance, Apple events, update check on request | +| `apps/pdfcraft-cli` | `inspect`, `edit`, `combine`, `extract`, `split`, `run` (any automation tool or a JSON script), `tools`, `ui` (drive the running app), and the opt-in `mcp` server | +| `apps/pdfcraft-web` | The same egui shell compiled to WebAssembly | + +## Layers + +`cargo xtask layers` enforces the layering (`xtask/src/layers.rs`). A crate may depend only on +crates in lower layers; nothing below L7 may depend on egui, winit, eframe or rfd; L0–L1 crates are +standalone and publishable. + +| Layer | Crates today | Role | +|---|---|---| +| L0 foundation | `geom`, `filters`, `crypt` | Geometry; stream filters (Flate, LZW, predictors, ASCII85, ASCIIHex, RunLength); standard security handler R2–R6 (RC4, AES-128/256, SASLprep) | +| L1 objects | `cos` | Lazy, tolerant parser with xref repair; copy-on-write object graph; incremental and full writers with object streams; decryption on load and re-encryption on save | +| L2 core model | `content`, `model`, `fonts` | Content-stream operators with source spans and a serializer; typed page tree with inherited attributes and display geometry (`/UserUnit`, `/Rotate`); font metrics and encodings for generated appearances | +| L3 core services | `render`, `annot`, `forms`, `js`, `xfa` | Rendering and inspection (bootstrap: vendored `hayro`, `lopdf`); annotation builders and appearance streams; AcroForm model, filling, appearances; sandboxed JavaScript (boa) and FormCalc; XFA template layout and data | +| L4 features | `edit`, `organize`, `redact`, `xfdf`, `sign`, `ocr`, `create`, `export`, `optimize`, `preflight`, `a11y`, `compare`, `measure`, `print` | One crate per Acrobat tool family | +| L6 façade | `engine` | Session, open documents, edit history, the command registry and tool catalogue. Frontends never reach past it | +| L7 platform and frontends | `platform`, `ui-egui`, `automation` | OS services; the egui shell (desktop and web); headless JSON-Schema tools, CLI and MCP | + +Planned crates that the layer table already reserves (`arlington`, `color`, `ops`, `text`, `ml`, +`security`, `prepress`, `search`, `media`, `ai`, `viewport`, `tools`, `ui-common`) don't exist yet. + +## Data model and editing + +The PDF object graph *is* the model (`crates/engine/README.md` §The editing model): + +1. Each open document holds a `pdfcraft_cos::Document`: the parsed objects plus a copy-on-write + overlay. Unknown data is preserved because nothing is rebuilt that wasn't understood. +2. An edit runs on a clone; on success the previous state goes onto the undo stack. Clones share + unchanged data, so snapshots are cheap. +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. +5. Autosave and crash recovery keep encrypted documents encrypted. Signed documents are protected + from rewrites. + +## Rendering and text + +- Pages are rasterized on the CPU by the vendored `hayro` family (`hayro`, `hayro-interpret`, + `hayro-syntax`, `hayro-jbig2`), with local patches for appearance states, layers, resource + limits, cancellation, tiles and `/UserUnit` (`vendor/README.md`). A render pool renders visible + pages first, then neighbours and thumbnails; tiles keep deep zoom sharp; a watchdog skips + pathological pages. +- The raster is uploaded to the GPU as a texture; egui draws the interface through wgpu (OpenGL + fallback when a device can't be configured). +- Text extraction (reading order, columns, vertical CJK, rotated text) feeds find, select, copy, + redaction, export and compare; word-F1 0.98 against `pdftotext` on the corpus. +- The replacement (M2, ADR-0004) is the `model` crate, our own font engine and DisplayList devices; + only the first pieces of `model` exist. See [gaps.md](gaps.md) gap 2. + +## File I/O + +- PDF in: any revision chain, xref tables or streams, hybrid files, damaged files (repaired and + logged), all standard-security revisions. Image codecs (DCT, JPX, JBIG2, CCITT) decode through + the bootstrap renderer. +- PDF out: incremental or full saves, object streams, re-encryption, resource de-duplication when + combining. +- Other formats: images and text in (`create`); PNG/JPEG/TIFF, text, RTF, Word and HTML out + (`export`, `render`); FDF/XFDF/XML/CSV/TXT form and comment data (`xfdf`); PKCS #12, X.509, + PEM/DER certificates (`sign`). Full matrix: [file-format-parity.md](file-format-parity.md). + +## Agent control + +Every user-facing feature is reachable headlessly (`AGENTS.md` §3): + +- the **command registry** (`crates/engine/src/commands.rs`, 184 commands) generates menus, + shortcuts and the command palette; +- the **automation tool table** (`crates/automation`, 141 tools) is shared by `pdfcraft-cli run` + and the opt-in **MCP server** (stdio only; `pdfcraft-cli mcp`), confined to `--root`; +- the opt-in **UI control channel** (`pdfcraft --control FILE`; loopback, per-launch token) lets an + agent inspect, click, type, run commands and take screenshots of the real interface. + +## Safety + +- `unsafe_code = "forbid"` workspace-wide; crates deny `unwrap`/`expect`/`panic!`; engine entry + points keep a last-resort `catch_unwind` (`AGENTS.md` §4). +- Every input-derived size is bounded (decompression, images, recursion, content per page). +- `cargo xtask fuzz` runs nightly; every finding becomes a synthetic regression test. + +## Build and quality gates + +`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`), +`xtask demo-pdf`, `xtask screenshots`, `xtask models` (OCR models), `xtask version`. Releases are +built by `.github/workflows/release.yml` ([releasing.md](releasing.md)). + +## Revision history + +| Date | Change | Summary | +|---|---|---| +| 2026-10-10 | major | Created from the code on main (crate list, `xtask/src/layers.rs`, crate READMEs, `vendor/README.md`) | diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md new file mode 100644 index 000000000..4fc21f59d --- /dev/null +++ b/docs/file-format-parity.md @@ -0,0 +1,112 @@ +# File format parity + +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-10 · **Change:** major (first format matrix; Acrobat's formats from its bundle) · **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 +(Create PDF and Export PDF). PdfCraft's support comes from the code and +`parity/acrobat-features.toml`. Summary in [target-app-parity.md](target-app-parity.md); gaps +in [gaps.md](gaps.md). + +Legend: **R** read/import, **W** write/export. Fidelity: *high* (tested against an external oracle +or round-trips exactly), *good* (tested, known limits), *basic* (works, visibly lossy), *none*. + +**File formats and conversion ≈ 40% ready** (estimated): PDF itself is strong except encrypted +output interoperability; the conversion formats users reach for most (Word, Excel, PowerPoint, +Office to PDF, OCR beyond English) are weak or missing. **120–220 h** to parity (within the +features total). + +## PDF + +| Format | Acrobat | PdfCraft | Fidelity | Tests and evidence | +|---|---|---|---|---| +| PDF 1.0–1.7, ISO 32000-1 | R W | R W | high for reading and unencrypted writing | 958-file pdf.js corpus open/edit/save; `qpdf --check` on saved outputs; `xtask check` baselines | +| PDF 2.0 (ISO 32000-2) | R W | R W (2.0-only features ignored but preserved) | good | No UTF-8 strings, `/AF`, DPart, namespaces ([pdf-spec-parity.md](pdf-spec-parity.md)) | +| Encrypted PDF (standard security R2–R6) | R W | R W | **reading high; writing fails in Acrobat and Reader** | `crates/crypt`, `crates/cos` tests; all 7 corpus encrypted files open; **#774** | +| Certificate-encrypted PDF (public-key handler) | R W | none (clear message) | none | `core.unsupported-handlers` | +| Linearized PDF (Fast Web View) | R W | R (as normal PDF); no W | none for W | `core.linearization` planned | +| Damaged PDF | R (repair) | R (repair, logged) | high | `core.repair-broken-files`, nightly fuzzing | +| PDF/A-1, 2, 3, 4 | R W, verify | R; verify/convert 2b and 3b (partial) | basic | `optimize.pdfa-*`; veraPDF not yet used as oracle | +| PDF/X, PDF/UA, PDF/E, PDF/VT | R W, verify | R only | none | [pdf-spec-parity.md](pdf-spec-parity.md) | +| PDF Portfolio (collection) | R W | R as a PDF with attachments; no portfolio view | none | `organize.portfolio-*` planned | +| XFA forms (in PDF) | R W (fill) | R, lay out dynamic forms, fill, save datasets (partial) | basic | `form.xfa-*` partial; `crates/xfa` tests | + +## Data and interchange formats + +| Format | Acrobat | PdfCraft | Fidelity | Tests and evidence | +|---|---|---|---|---| +| FDF (`.fdf`) comments and form data | R W | R W | good | `crates/xfdf`; bugs #807, #809, #819 in comment exchange | +| 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 | +| 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` | +| Compare report (PDF) | W | W | good | `misc.compare-report` | +| Action Wizard sequences (`.sequ`) | R W | own JSON actions; no `.sequ` | none for `.sequ` | `misc.action-wizard-*` | +| Security settings (`.acrobatsecuritysettings`), `.apf` profiles, `.secstore` | R W | none | none | out of scope unless asked | +| Search index (`.pdx`) | R W | none | none | `view.search-index` planned | +| JDF / MJD job definitions | R | none | none | `print.jdf` planned (P3) | +| Preflight profiles (`.kfp`) and reports | R W | none | none | gap 9 | +| PDF Optimizer settings (`.optimize`) | R W | none (dialog settings not saved as presets) | none | `optimize.optimizer-presets` planned | + +## Certificates and keys + +| Format | Acrobat | PdfCraft | Fidelity | Tests and evidence | +|---|---|---|---|---| +| PKCS #12 digital IDs (`.p12`, `.pfx`) | R W | R W (self-signed IDs) | high | OpenSSL-made fixtures (`crates/sign/tests/data/README.md`) | +| Certificates (`.cer`, `.der`, `.pem`, `.p7c`, `.p7b`) | R W | R (`.cer/.pem/.der/.p12` trust import); W public certificate | good; `.p7c/.p7b` bundles not read | `sign.trust-import` partial, `sign.export-certificate` | +| CMS / PKCS #7 signatures, RFC 3161 tokens, OCSP, CRL | R W | R W | high | `pdfsig` and OpenSSL oracles | + +## Create PDF from + +| 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` | +| 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` | +| Screenshot | yes | no | none | `create.from-screenshot` planned | +| Word, Excel, PowerPoint (and Visio, Publisher on Windows) | yes | no | none | `create.from-office` planned (LibreOffice sidecar) | +| HTML file, web page URL | yes | no | none | `create.from-html`, `create.from-web-page` planned | +| RTF | yes | no | none | | +| 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` | + +## 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 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 | +| 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` | +| 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 | +| All embedded images | yes | yes | good | `create.export-all-images` | +| XML 1.0 | yes | no | none | `create.export-xml` planned | +| SVG | no (dropped by Adobe) | no | none | `create.export-svg` planned | +| PostScript, EPS | yes | no | none | `create.export-ps`, `create.export-eps` planned | + +## OCR (scans to searchable PDF) + +| Capability | Acrobat | PdfCraft | Fidelity | +|---|---|---|---| +| Searchable image (invisible text layer) | yes | yes (Tr 3 text fitted to word boxes) | good for English | +| Editable text and images output | yes | no | none | +| Languages | dozens (Latin with accents, Cyrillic, Greek, CJK, Arabic, Hebrew, Indic) | Latin without accents only (ocrs models) | **basic** (#744) | +| Deskew, rotation, despeckle, background and halo removal, descreen | yes | no | none | +| Camera images (edges, perspective, whiteboard, card) | yes | no | none | +| MRC (adaptive) compression, PDF/A output of scans | yes | no | none | + +## Revision history + +| Date | Change | Summary | +|---|---|---| +| 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 new file mode 100644 index 000000000..d1e90e348 --- /dev/null +++ b/docs/gaps.md @@ -0,0 +1,305 @@ +# Where PdfCraft falls short of Acrobat Pro + +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-10 · **Change:** minor (alpha markers removed after the gate was re-judged: no gap blocks alpha) · **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 +features. The numbers behind it are in [target-app-parity.md](target-app-parity.md); the per-feature +detail is in `parity/acrobat-features.toml` (`cargo xtask parity --partial` lists what each partial +feature lacks). + +No gap blocks the [alpha gate](roadmap.md#alpha-gate); gaps 1 and 8 hold the convert-and-share +workflow's partial sub-cases and are the first beta items. + +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 +update the numbers. + +## Summary + +| # | Gap | Kind | Hours | Doc | +|---|---|---|---|---| +| 1 | Encrypted files we write don't open in Acrobat | file format | 8–15 | [file-format-parity.md](file-format-parity.md) | +| 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 Windows or 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) | +| 8 | Office export weak, Office import absent | file format | 55–100 | [file-format-parity.md](file-format-parity.md) | +| 9 | No Preflight; PDF/A partial; PDF/X and PDF/UA absent | standards | 65–115 | [pdf-spec-parity.md](pdf-spec-parity.md) | +| 10 | Signatures: no PKCS #11, timestamp servers or online revocation | feature, hardware | 40–70 | [pdf-spec-parity.md](pdf-spec-parity.md) | +| 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) | +| 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) | +| 18 | Print production tools absent | feature | 25–45 | [target-app-parity.md](target-app-parity.md) | +| 19 | Preferences mostly missing | UI/UX | 15–25 | [ui-parity.md](ui-parity.md) | +| 20 | Scanners, GPU page rendering, pen pressure, Read Out Loud | hardware | 25–50 | [hardware-parity.md](hardware-parity.md) | +| 21 | Compare: no side-by-side view or filters | feature | 10–20 | [target-app-parity.md](target-app-parity.md) | +| 22 | PDF 2.0 extras, linearization, Arlington validation | spec | 20–35 | [pdf-spec-parity.md](pdf-spec-parity.md) | +| 23 | Local AI provider (summarize, ask, alt text, redaction suggestions) | AI | 20–40 | [target-app-parity.md](target-app-parity.md) | +| 24 | Checklist not diffed against Acrobat's own menus | measurement | 4–8 | [target-app-parity.md](target-app-parity.md) | + +## The gaps + +### 1. Encrypted files we write don't open in Acrobat + +- **Missing:** interoperable encryption output. A document protected with a password in PdfCraft + opens in PdfCraft, SumatraPDF, PDF24 and LibreOffice, but Adobe Reader, Acrobat and PDF-XChange + report it as damaged. +- **Evidence:** [#774](https://github.com/storytold/pdfcraft/issues/774) (2026-10-10, confirmed by a + second user). Our encryption tests round-trip through our own reader and `qpdf --check`, which + is more lenient than Acrobat. +- **Impact:** blocking. Passwords are the most common reason to touch security; a file a recipient + can't open is data loss in practice. Partial sub-case of the convert-and-share gate row (unencrypted sharing works), so not an alpha blocker; the first beta item. +- **Fix:** find the divergence (likely `/Encrypt` dictionary or R6 `/Perms`/`/OE`/`/UE` details, or + a string/stream encrypted that must not be), then add an interop suite that opens every kind of + output we write in other readers (pdf.js, MuPDF and Poppler as oracle processes; Acrobat by hand + on the owner's Mac). +- **Estimate:** 8–15 h. **Doc:** [file-format-parity.md](file-format-parity.md). + +### 2. Rendering is borrowed and its fidelity unmeasured + +- **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 22 local patches to `hayro-interpret`, + 9 to `hayro`, 3 to `hayro-jbig2` and 13 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 + from Acrobat ([#739](https://github.com/storytold/pdfcraft/issues/739)). An outside author + proposes his MIT/Apache renderer as the bridge + ([#841](https://github.com/storytold/pdfcraft/issues/841)). +- **Impact:** every page a user sees. Wrong glyphs, shadings or blend modes erode trust faster than + any missing feature. +- **Fix:** first a fidelity harness (gap 12), then the renderer: own devices (150–250 h in the old + plan) or an adopted engine plus our own font and text layers. +- **Estimate:** 110–190 h (includes the harness). **Owner decision needed** on #841 vs M2. + **Doc:** [pdf-spec-parity.md](pdf-spec-parity.md). + +### 3. Editing existing text is fragile on real files + +- **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. +- **Evidence:** `edit.text-edit` is partial (font reused or Helvetica substituted); + `edit.rtl-cjk-editing`, `edit.rotated-text-editing`, `edit.keep-tags-on-edit`, + `edit.vector-edit`, `edit.edit-object-tool` are planned. Users: + [#766](https://github.com/storytold/pdfcraft/issues/766) (editing Arabic text), + [#844](https://github.com/storytold/pdfcraft/issues/844) (can't move several elements), + [#787](https://github.com/storytold/pdfcraft/issues/787) (move images between pages), + [#791](https://github.com/storytold/pdfcraft/issues/791). +- **Impact:** "Edit PDF" is one of Acrobat Pro's top reasons to buy. Substituting Helvetica changes + 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 Windows or the web + +- **Missing:** a Windows spooler (and printer properties), browser printing. +- **Evidence:** [#756](https://github.com/storytold/pdfcraft/issues/756): on Windows 11 the Print + dialog only saves a PDF. `crates/print/README.md`: "Not yet: Windows and web spoolers". +- **Impact:** Windows is most of Acrobat's market. Printing is a P0 workflow. +- **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. +- **Evidence:** [#826](https://github.com/storytold/pdfcraft/issues/826), + [#688](https://github.com/storytold/pdfcraft/issues/688), + [#728](https://github.com/storytold/pdfcraft/issues/728), + [#758](https://github.com/storytold/pdfcraft/issues/758), + [#689](https://github.com/storytold/pdfcraft/issues/689) (Flatpak). `AGENTS.md` §1.1 bars Noto + CJK (Source Han rebranded), so craft-fonts needs another openly licensed Hans face. +- **Impact:** the second-largest language group sees a broken interface although the catalog is 97% + translated. +- **Estimate:** 6–12 h (font sourcing in craft-fonts plus the fallback chain). **Doc:** + [localization-parity.md](localization-parity.md). + +### 6. Open-issue backlog of wrong results in shipped features + +- **Missing:** fixes for ≈ 25 filed reports of features marked `shipped` that give wrong results, + plus triage of ≈ 220 other open issues. +- **Evidence:** 245 open issues on 2026-10-10, e.g. XFDF exchange loses polygon vertices and turns + callouts into text boxes (#809, #819), page deletion leaves null destinations (#817, #821), + automatic URL linking panics on Unicode text (#816), autosave skips new unsaved PDFs (#815), + saving ignores typing in an open paragraph (#811), automation reports success after replacing an + earlier result (#799, #800), Shift-Cmd-G goes forward (#810). +- **Impact:** every one is a user who lost trust. #816 is a crash, which outranks feature work + (`AGENTS.md` §4). +- **Estimate:** 45–80 h (≈ 0.2–0.4 h per issue). **Doc:** this file. + +### 7. OCR reads only unaccented Latin + +- **Missing:** every other script and accented Latin; editable-text output; deskew, rotation, + despeckle, background and camera clean-up; MRC compression; pluggable engines; OCR in the + browser; scanner input. +- **Evidence:** `ocr.ocr-languages` partial ("the models read only the Latin alphabet without + accents"); 19 of 26 OCR features planned; [#744](https://github.com/storytold/pdfcraft/issues/744) + (Dutch). Acrobat ships on-device recognition for dozens of languages. +- **Impact:** scanned documents are a core Pro workflow outside English. +- **Estimate:** 40–75 h, partly blocked on openly licensed models. **Doc:** + [file-format-parity.md](file-format-parity.md). + +### 8. Office export weak, Office import absent + +- **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); + `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). + First beta items: Create PDF from Office (LibreOffice sidecar, 10–20 h) and a Word export that + keeps layout (20–35 h). +- **Estimate:** 55–100 h. **Doc:** [file-format-parity.md](file-format-parity.md). + +### 9. No Preflight; PDF/A partial; PDF/X and PDF/UA absent + +- **Missing:** Preflight (profiles, single checks and fixups, results tree, reports, droplets); + PDF/A-1, PDF/A-4 and conformance levels a/u; PDF/X verify and convert; PDF/UA-1/2 verify; PDF/E, + PDF/VT; output-intent choice; transparency flattening. +- **Evidence:** 12 Preflight features and 9 standards features planned; `optimize.pdfa-validate`, + `optimize.pdfa-convert` partial (2b/3b structural rules; CMYK documents get no output intent). + Acrobat ships hundreds of built-in Preflight profiles. +- **Impact:** blocks print shops, archives and accessibility compliance work. +- **Estimate:** 65–115 h. **Doc:** [pdf-spec-parity.md](pdf-spec-parity.md). + +### 10. Signatures: no PKCS #11, timestamp servers or online revocation + +- **Missing:** PKCS #11 tokens and smart cards on macOS and Linux; timestamp-server preferences; + fetching OCSP/CRL online for LTV; FieldMDP and locking fields on signing; the OS trust store; EU + Trusted Lists; signature graphics and saved appearances; compare signed version; signing in the + browser. +- **Evidence:** `sign.pkcs11`, `sign.timestamp-servers`, `sign.field-mdp`, `sign.os-trust`, + `sign.eutl` planned; `sign.pades-bt`, `sign.ltv`, `sign.ocsp-crl` partial (caller-supplied + evidence). Users ask for Acrobat-like validation + ([#824](https://github.com/storytold/pdfcraft/issues/824), + [#775](https://github.com/storytold/pdfcraft/issues/775), + [#718](https://github.com/storytold/pdfcraft/issues/718)). +- **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 + +- **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; + `a11y.keyboard-only` (P0) planned, `a11y.app-screen-reader` partial. +- **Impact:** remediation work is impossible; 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 + +- **Missing:** side-by-side comparison of rendering, text extraction, form calculation and + signature validation against Acrobat on synthetic fixtures. +- **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). + +### 13. Performance budgets and lazy loading of very large files + +- **Missing:** `misc.performance-budgets` (P0) and `core.lazy-loading` (P0): open GB files within a + 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). +- **Estimate:** 25–45 h. **Doc:** this file. + +### 14. Arabic not mirrored; no Hindi, Indonesian, Korean, Vietnamese + +- **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). +- **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:** + [localization-parity.md](localization-parity.md). + +### 15. Forms: JavaScript object model, actions, submit, XFA depth + +- **Missing:** the wider Acrobat JavaScript object model (annotations, layers, dialogs), document + actions, the Actions tab triggers that run JavaScript, form submission, the debugger, barcode + fields, rich-text values; XFA flattening and complete dynamic-form fidelity. +- **Evidence:** `form.js-host` partial, `form.document-actions`, `form.submit-form`, + `edit.action-javascript`, `form.js-debugger`, `form.xfa-flatten` planned; XFA layout, FormCalc + and data partial. +- **Estimate:** 40–75 h. **Doc:** [pdf-spec-parity.md](pdf-spec-parity.md). + +### 16. UI precision gaps + +- **Missing:** moving several selected objects together (#844), nudging with arrow keys, 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, column text selection + (#740); shortcut display and touchpad scrolling bugs (#746, #759, #721), macOS window controls + (#789). +- **Estimate:** 30–60 h. **Doc:** [ui-parity.md](ui-parity.md). + +### 17. Certificate security and other protect gaps + +- **Missing:** encrypting for certificate recipients (and opening such files), security policies, + attachment-only encryption, redaction pattern locales and partial-word redaction, folder + redaction, protected view, URL and attachment trust. +- **Estimate:** 15–30 h. **Doc:** [pdf-spec-parity.md](pdf-spec-parity.md). + +### 18. Print production tools absent + +- **Missing:** Output Preview, separations, total area coverage, overprint preview, ink manager, + colour conversion, printer marks, fix hairlines, flattener preview, colour management settings; + print as image; PostScript output. +- **Evidence:** 18 print-production features planned (L area). +- **Estimate:** 25–45 h (part of the L area's 30–55). **Doc:** [target-app-parity.md](target-app-parity.md). + +### 19. Preferences mostly missing + +- **Missing:** most of Acrobat's twenty or so preference pages (General, Documents, Commenting, Forms, Identity, + Signatures, Security, Measuring, Units and guides, Convert, …). Today: Interface language, + Documents and view, Identity, Fill & Sign, JavaScript. +- **Estimate:** 15–25 h. **Doc:** [ui-parity.md](ui-parity.md). + +### 20. Scanners, GPU page rendering, pen pressure, Read Out Loud + +- **Missing:** TWAIN/WIA/ICA/SANE scanning; GPU-accelerated page rendering (pages are rasterized on + the CPU and uploaded); pressure-sensitive ink; platform text-to-speech. +- **Estimate:** 25–50 h; scanners need hardware. **Doc:** [hardware-parity.md](hardware-parity.md). + +### 21. Compare: no side-by-side view or filters + +- **Missing:** synchronised side-by-side view, filters by change type, page ranges, scanned + documents. +- **Estimate:** 10–20 h. **Doc:** [target-app-parity.md](target-app-parity.md). + +### 22. PDF 2.0 extras, linearization, Arlington validation + +- **Missing:** UTF-8 text strings, associated files (`/AF`), document parts, structure namespaces; + linearized save (Fast Web View); deterministic save; structural validation against the Arlington + model. +- **Estimate:** 20–35 h. **Doc:** [pdf-spec-parity.md](pdf-spec-parity.md). + +### 23. Local AI provider + +- **Missing:** the opt-in provider interface (local model or the user's own endpoint) and the + features on it: summarize, ask with page citations, alternate-text and redaction suggestions. + Acrobat's AI Assistant is a cloud service and out of scope. +- **Estimate:** 20–40 h. **Doc:** [target-app-parity.md](target-app-parity.md). + +### 24. Checklist not diffed against Acrobat's own menus + +- **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. +- **Estimate:** 4–8 h. **Doc:** [target-app-parity.md](target-app-parity.md). + +## Revision history + +| Date | Change | Summary | +|---|---|---| +| 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 | +| 2026-10-10 | minor | Marked the alpha blockers (gap 1 and the Office part of gap 8) after the core-workflow gate put the stage at pre-alpha | +| 2026-10-10 | major | Created. Ranked 24 gaps from the 2026-10-10 re-measure, the open GitHub issues and the former ROADMAP.md §Where we're lacking and where we're going (renderer, hardening, fidelity, editing, Pro workflows, 1.0 polish), which this file replaces | diff --git a/docs/hardware-parity.md b/docs/hardware-parity.md new file mode 100644 index 000000000..1e68f0bf8 --- /dev/null +++ b/docs/hardware-parity.md @@ -0,0 +1,43 @@ +# Hardware and platform parity + +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-10 · **Change:** major (first hardware and platform matrix) · **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, +Linux, FreeBSD and the web. Gaps ranked in [gaps.md](gaps.md). + +**Hardware ≈ 35% ready, 25–50 h** (additional to the features total; scanners, smart cards and +printing are counted in their feature areas). **Platforms ≈ 70% ready, 15–30 h.** + +## Hardware + +| Feature | Acrobat (macOS / Windows) | PdfCraft macOS | PdfCraft Windows | PdfCraft Linux / FreeBSD | PdfCraft web | Evidence | +|---|---|---|---|---|---|---| +| GPU page rendering | yes (Page Display ▸ GPU-accelerated rendering) | no: CPU raster, GPU for UI compositing (wgpu, Metal) | no (wgpu DX12, GL fallback) | no (wgpu Vulkan/GL) | no (WebGL/WebGPU UI) | `crates/render`; gap 20 | +| 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 | **none** (#756) | CUPS spooler | **none** | `crates/print/README.md` | +| 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 not 1:1 (#759) | +| Keyboard input methods (IME) | yes | yes; Japanese and Korean IME fixes patched into winit | yes (Microsoft Korean cursor fix) | X11 and Wayland IME fixes | browser | `vendor/README.md` winit patches 2–10 | +| Screen readers | VoiceOver, NVDA, JAWS | AccessKit labels (partial) | AccessKit (partial) | AccessKit (partial) | partial | `a11y.app-screen-reader` partial | +| Text to speech (Read Out Loud) | yes | none | none | none | none | `a11y.read-aloud` planned | +| Apple Silicon / ARM64 | native | native | ARM64 build (#61) | ARM64 builds | n/a | `release.yml` | + +## Platforms + +| Platform | Acrobat | PdfCraft | Packaging | Gaps | +|---|---|---|---|---| +| macOS | yes | yes | signed and notarized DMG | — | +| Windows (x64, x86, ARM64) | yes (x64, ARM64) | yes | signed installers | printing (#756) | +| 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 | + +## Revision history + +| Date | Change | Summary | +|---|---|---| +| 2026-10-10 | major | Created from the code, `vendor/README.md`, `.github/workflows/release.yml`, the print crate's README and user issues | diff --git a/docs/localization-parity.md b/docs/localization-parity.md new file mode 100644 index 000000000..0ea3daa7e --- /dev/null +++ b/docs/localization-parity.md @@ -0,0 +1,72 @@ +# 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) + +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). + +## 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. +- **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). +- **Script support** comes from [localization.md](localization.md), the vendored winit IME patches + (`vendor/README.md`) and user reports. +- **Native review**: "community" means a native speaker contributed or corrected the catalog in a + pull request or issue; "no" means no record of review. No catalog has had a formal review. +- **Acrobat** ships 24 interface localizations on macOS (`.lproj`: cs, da, de, en, es, fi, fr, hu, + it, ja, ko, nl, no, pl, pt, ru, sv, tr, uk, zh_CN, zh_TW, plus en_AE, en_IL, fr_MA) and Middle + East / North Africa editions (MEA Arabic, MEH Hebrew, NAF French; `Resources/Sequences/`). + +## The twelve key languages + +| Language | Code | UI strings translated | Dialogs / tooltips / help | Script support | Native review | Status | Acrobat ships it | To `full` (h) | +|---|---|---|---|---|---|---|---|---| +| English | `en` | built in (100%) | all; README and docs | Latin | yes | **full** | yes | 0 | +| Simplified Chinese | `zh-hans` | 2,088 (97.4%) | dialogs and tooltips through `tl!`; no help | CJK text and IME work, but **release builds have no Hans face**: many characters show as boxes (#826, #688, #728, #758; Flatpak #689) | no | partial | yes | 6–12 | +| Spanish | `es` | 2,046 (95.5%) | dialogs and tooltips; no help | Latin | no | partial | yes | 2–4 | +| Hindi | `hi` | 0 | none | Devanagari not tested (egui shapes Telugu conjuncts, so shaping is likely workable); no Devanagari face in craft-fonts | no | none | no | 10–18 | +| Arabic | `ar` | 2,143 (100%) | dialogs and tooltips; no help | Letters joined and lines reordered at load, but **layout not mirrored**, wrapped lines break at the wrong end, typed text runs left to right (`localization.md` §Arabic) | no | partial | yes (Middle East edition) | 25–45 | +| French | `fr` | 2,128 (99.3%) | dialogs and tooltips; no help | Latin | no | partial | yes | 1–2 | +| Portuguese (Brazil) | `pt-br` | 2,130 (99.4%) | dialogs and tooltips; no help | Latin | community (#288) | partial | yes | 1–2 | +| Indonesian | `id` | 0 | none | Latin | no | none | no | 3–5 | +| 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 | + +**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. + +No language is `full` except English: every catalog leaves engine error details in English, and +none has translated help. + +## Other shipped languages + +| Language | Code | UI strings translated | Status | Acrobat ships it | +|---|---|---|---|---| +| Traditional Chinese | `zh-hant` | 2,016 (94.1%) | partial (same Hans/Hant face caveat) | yes | +| Russian | `ru` | 2,043 (95.3%) | partial | yes | +| Bulgarian | `bg` | 2,094 (97.7%) | partial | no | +| Czech | `cs` | 135 (6.3%) | menus only | yes | +| 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 | + +**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. + +## Revision history + +| Date | Change | Summary | +|---|---|---| +| 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 de74c4e8a..cdf9163b8 100644 --- a/docs/localization.md +++ b/docs/localization.md @@ -12,15 +12,13 @@ The control channel exposes the setting through `ui.set`: 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. -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 and Arabic translate them (about 1,800 entries each); Czech and Brazilian Portuguese 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. -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 and Brazilian Portuguese translate them (about 1,800 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. +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). 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. 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 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`). -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 and Hungarian 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`). ## Arabic and right-to-left text diff --git a/docs/pdf-spec-parity.md b/docs/pdf-spec-parity.md new file mode 100644 index 000000000..6c74039f5 --- /dev/null +++ b/docs/pdf-spec-parity.md @@ -0,0 +1,132 @@ +# PDF specification and standards parity + +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-10 · **Change:** major (first spec-coverage checklist) · **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: +the parts of the format users never see named but always feel. Feature-level detail is in +`parity/acrobat-features.toml`; the gaps are ranked in [gaps.md](gaps.md). + +Legend: **own** (our code), **borrowed** (the vendored `hayro` bootstrap or `lopdf`), **partial**, +**none**. "Evidence" names tests or checklist ids. + +**PDF specification and standards ≈ 60% ready** (estimated): syntax, encryption, annotations, +AcroForm, redaction and signatures are our own and well tested; graphics, fonts and image codecs +are borrowed; PDF 2.0 additions and most subset standards are missing. **150–260 h** to parity +(within the features total; most of it is the renderer and font engine, and Preflight). + +## ISO 32000-2 by clause + +| Clause | Topic | Acrobat | PdfCraft | Evidence / what's missing | +|---|---|---|---|---| +| §7.2–7.5 | Syntax, objects, file structure, xref tables and streams, object streams, hybrid files, incremental updates | full | **own**, tolerant, repairs damaged files | `crates/cos`; corpus 958 files; fuzzed nightly | +| §7.4 | Stream filters: Flate, LZW, ASCII85, ASCIIHex, RunLength, predictors | full | **own** | `crates/filters` | +| §7.4 | Image filters: DCT, JPX, JBIG2, CCITTFax | full | **borrowed** (decode only) | `core.image-filters` partial; own codecs M1.1/M1.2; no JBIG2/CCITT encoding (Optimizer) | +| §7.5.8 | Linearization | R W | R only | `core.linearization` planned | +| §7.6.4 | Standard security handler R2–R6 (RC4 40/128, AES-128, AES-256), crypt filters, `/EncryptMetadata`, SASLprep | full | **own**, read and write | `crates/crypt`; **written files rejected by Acrobat (#774)** | +| §7.6.5 | Public-key security handler | full | none | `core.open-public-key`, `protect.certificate-encryption` planned | +| §7.6.2 | Unencrypted signature `/Contents` | yes | yes | signing encrypted documents (#753) | +| §7.9.2 | Text strings incl. PDF 2.0 UTF-8 | full | PDFDocEncoding and UTF-16; UTF-8 none | `core.pdf20-utf8-strings` planned | +| §7.11 | File specifications, embedded files | full | read and save attachments; no add/delete/describe | `organize.attachments-*` planned | +| §7.12 | Extensions dictionary | yes | preserved | | +| §8 | Graphics: paths, colour spaces, patterns, shadings, images, XObjects | full | **borrowed** renderer; own content parser and serializer (`crates/content`) for editing and redaction | rendering P0s partial | +| §8.6 | ICC-based colour, output intents, colour management | full | ICC preserved; sRGB output intent for PDF/A; no colour management settings or soft proofing | `print.color-management` planned | +| §9 | Text and fonts: Type 1, TrueType, OpenType/CFF, Type 3, CID-keyed, CMaps, ToUnicode | full | **borrowed** rendering; own text extraction (word-F1 0.98); standard-14 metrics for generated appearances; embedded fonts reused for editing, else Helvetica | `view.render-type3`, `view.render-cid-fonts` partial; no own font engine; no font embedding/subsetting for arbitrary new text beyond craft-fonts Japanese | +| §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.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 | +| §12.7.8 | XFA (deprecated in PDF 2.0; Acrobat still fills XFA) | static and dynamic | **own**, partial: template layout, FormCalc, XFA JavaScript model, datasets | `form.xfa-*` partial; no flattening | +| §12.8 | Digital signatures | see table below | | | +| §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.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 | | +| §14.11 | Prepress: page boxes, output intents, trapping, OPI | full | page boxes and crop; sRGB output intent | `print.*` production planned | +| §14.12 | Document parts (DPart) | yes | none | `core.pdf20-dpart` planned | +| §14.13 | Associated files (`/AF`) | yes | none | `core.pdf20-associated-files` planned | +| — | Structural validation (Arlington PDF model) | yes (Preflight syntax checks) | none | `core.arlington-validation` planned | +| — | Optional content (layers) | full | **own** toggle and default state; set-OCG-state actions; no layer editing | `view.layers-panel` | +| — | Redaction (`/Redact` annotations, applying) | full | **own**: removes glyphs, image pixels, vectors, XObject content, annotations, fields and tags; verifies no residue | `protect.redact-*`, `protect.redaction-verification` | + +## Annotations (§12.5.6) + +| Type | Acrobat | PdfCraft | Notes | +|---|---|---|---| +| Text (note), Popup | create, edit | create, edit; pop-ups not movable windows | `comment.popups` planned | +| Link | create, edit | create, edit, from URLs | `edit.link-*` | +| 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 | +| Stamp | create, custom, dynamic | create, custom, dynamic, Sign Here | stamp management planned | +| Ink | create, erase | create, erase | no pressure | +| FileAttachment | create | create | | +| Sound | create, play | preserved | P3 | +| Redact | create, apply | create, apply | own | +| Widget | create, edit | create, edit | AcroForm | +| Watermark | create | page-content watermarks (as Acrobat writes them); `/Watermark` annotations preserved | | +| Screen, Movie, RichMedia, 3D | create, play | preserved | P3 | +| PrinterMark, TrapNet | create (print production) | preserved | | +| Projection | create (PDF 2.0) | preserved | | + +## Actions (§12.6.4) + +| 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) | +| Launch | yes (restricted) | none | +| Thread | yes | none | +| URI | yes | yes | +| Sound, Movie, Rendition, GoTo3DView, RichMediaExecute | yes | none (P3) | +| Hide | yes | yes | +| Named | yes | safe list (print, page navigation) | +| SubmitForm | yes | explained, not sent | +| ResetForm | yes | yes | +| ImportData | yes | via Import form data, not as an action | +| JavaScript | yes | yes in fields and buttons (sandboxed); document actions none | +| SetOCGState | yes | yes | +| Trans | yes | none | + +## Digital signatures (§12.8, ETSI EN 319 142 PAdES) + +| Capability | Acrobat | PdfCraft | +|---|---|---| +| Sub-filters `adbe.pkcs7.detached`, `ETSI.CAdES.detached`; read `adbe.pkcs7.sha1`, `adbe.x509.rsa_sha1` | yes | yes | +| PAdES B-B | yes | yes (PKCS #12, macOS Keychain, Windows store with PIN) | +| PAdES B-T (signature timestamp), document timestamps | yes, with configured TSA | partial: works when a TSA is given; no timestamp-server preferences | +| PAdES B-LT, B-LTA (DSS/VRI, OCSP/CRL) | yes, fetched online | partial: evidence supplied by the caller; no online fetching | +| DocMDP certification | yes | yes | +| FieldMDP (lock fields) | yes | none | +| UR3 usage rights | preserved, Reader-extending is Adobe-only | none (P3) | +| Seed values | yes | none | +| Validation: chain, key usage, EKU, critical extensions, changes after signing, signed version | yes | yes; name constraints and issuer critical extensions missing | +| Trust: own store, OS store, AATL, EUTL | yes | own store only; AATL out of scope; OS and EUTL planned | +| Smart cards and tokens (PKCS #11, CryptoTokenKit) | yes | Windows CNG store (with PIN prompt) only | + +## Subset standards + +| Standard | Acrobat | PdfCraft | Hours | +|---|---|---|---| +| PDF/A-1a/b (ISO 19005-1) | verify, convert | none | | +| PDF/A-2a/b/u, PDF/A-3a/b/u | verify, convert | **partial**: 2b and 3b structural rules and conversion (sRGB intent; not CMYK) | | +| PDF/A-4, 4e, 4f | verify, convert | none | | +| PDF/X-1a, X-3, X-4 (ISO 15930) | verify, convert | none | | +| PDF/UA-1, PDF/UA-2 (ISO 14289) | verify (Preflight), checker | Accessibility Checker (32 rules, Acrobat's own), no PDF/UA verification | | +| PDF/E, PDF/VT | verify, convert | none | | +| Preflight (profiles, fixups, reports) | hundreds of profiles | none | | +| Factur-X / ZUGFeRD (e-invoice in PDF/A-3) | Preflight checks | none | | +| **All subset standards** | | | **65–115** (gap 9) | + +## Revision history + +| Date | Change | Summary | +|---|---|---| +| 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/roadmap.md b/docs/roadmap.md new file mode 100644 index 000000000..e74bfe1b4 --- /dev/null +++ b/docs/roadmap.md @@ -0,0 +1,126 @@ +# PdfCraft roadmap: milestones and what's next + +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-10 · **Change:** minor (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) + +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), +and the one-page summary in [ROADMAP.md](../ROADMAP.md). Detailed task lists and acceptance tests +live in `plan/execution-plan.md` (local-only, gitignored). + +Keep it current: update the milestone row and the [ROADMAP.md](../ROADMAP.md) progress log at the +end of every session; re-estimate when a milestone lands. + +## Alpha gate + +The stage standard's gate: Acrobat Pro's everyday core workflows must each work end to end on the +main platform (macOS), with the work saved and reopened. Any `no`, or a `partial` that blocks the +workflow, keeps PdfCraft pre-alpha; those rows are the alpha checklist. Assessed 2026-10-10 with the +rule applied across the Crafting Apps: a row blocks only when a typical user cannot complete the +workflow at all with the app on its main platform for typical inputs; degraded fidelity, lossy +exchange with Acrobat users or a missing sub-case is `partial, not blocking` (a beta item). + +| 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 and Linux (Windows printing missing, #756, a beta item). Rendering borrowed from `hayro` | 0 | +| Organize, combine and split pages | yes | `organize.*` P0s shipped with tests; links, fields, layers and bookmarks carried over; open fixes #817, #821 are edge cases | 0 | +| Comment and review | yes | Every markup type with appearances, replies, status, XFDF/FDF; comments saved in the PDF reopen in other readers. XFDF fidelity bugs #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 | +| Edit text and images | partial, not blocking | Editing a paragraph of Latin text works (embedded font reused, else Helvetica substituted, rewrapped); added text and images stay editable. Fails for CJK/RTL (#766), can't move several objects (#844); a beta item (gap 3) | 0 (beta: 100–180) | +| Convert and share (Office ↔ PDF, password protection) | partial, not blocking | Unencrypted PDFs we save open in Acrobat and other readers (958-file corpus round trip, `qpdf --check`); export to Word, RTF, HTML, text and images works, lossily (#773: an invoice's layout lost in Word); Create PDF works from images, text, the clipboard and many files at once. Missing sub-cases: password-protected output that Acrobat and Reader accept (#774; the file opens in PdfCraft, SumatraPDF, PDF24 and LibreOffice), an in-app Create PDF from Office (Office on macOS saves PDF itself), Excel/PowerPoint export. All beta items (gaps 1, 8) | 0 (beta: 40–70 for #774, Office→PDF and a layout-keeping Word export) | + +**Result: alpha.** Every core workflow can be completed on macOS; two are lossy or missing sub-cases +(editing CJK/RTL text; encrypted output for Acrobat users, Office→PDF, Word layout), which are the +first beta items. Ready for real work, ≈ 49%, is inside the alpha band. (A first application of the +gate the same day judged convert and share as blocking and put PdfCraft at pre-alpha; re-judged with +the shared rule, encryption and Office→PDF are sub-cases and Word export is lossy, not absent.) + +## Current focus + +In order. Each item names its entry in [gaps.md](gaps.md). + +1. **Convert and share:** encrypted output that Acrobat opens (gap 1, #774, small and the most + damaging), then Create PDF from Office and a Word export that keeps layout (gap 8, #773). +2. **Fix the crash and wrong-result reports** filed against shipped features (gap 6), starting with + the panic in #816. +3. **Printing on Windows** (gap 4, #756) and **a Chinese UI face in releases** (gap 5, #826). +4. **A fidelity harness against Acrobat, then the renderer decision** (gaps 12 and 2). Owner + decision needed: our own M2 devices, or #841's proposal as the bridge. +5. **Editing existing content** (gap 3, M7): fonts, subsets, reflow, CJK and RTL. +6. **Pro workflows:** OCR languages (gap 7), Office export/import (gap 8), Preflight and PDF/A/X/UA + (gap 9), signatures with tokens, timestamp servers and online LTV (gap 10), accessibility + tagging (gap 11). +7. **1.0 polish (M14):** performance budgets and lazy loading (gap 13), right-to-left layout and the + missing languages (gap 14), keyboard-only operation, a screen-reader audit. + +Decided against for now (owner, 2026-10-05): self-installing updates and an update check at start. +Help ▸ Check for updates is manual only. + +## Milestones + +"Plan size" is the original single-agent sizing. "Done" is the estimated share of that milestone's +*acceptance criteria* that are met, not lines of code. "Remaining" is re-measured on 2026-10-10 at +the measured rate (see [target-app-parity.md](target-app-parity.md#effort-and-calibration)); the +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: 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), 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, CUPS spooler, 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, Windows/web printing | +| 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 | +| 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. 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 + +| Rank | Next milestone | Closes | Estimate (h) | +|---|---|---|---| +| 0 | Convert and share: encrypted output opens in Acrobat, Create PDF from Office, Word export keeps layout | gap 1, part of gap 8 | 40–70 | +| 1 | Backlog and platform: crash and wrong-result reports fixed, Windows printing, Chinese UI face | gaps 4, 5, 6 | 65–120 | +| 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 | +| 5 | Standards: Preflight, PDF/A all parts, PDF/UA, PDF/X (M11) | gap 9 | 65–115 | +| 6 | Signatures for institutions: PKCS #11, timestamp servers, online LTV (M9) | gap 10 | 40–70 | +| 7 | Accessibility tagging (M12) | gap 11 | 40–70 | +| 8 | 1.0 polish: performance, RTL, languages, keyboard-only (M14) | gaps 13, 14 | 55–95 | + +Beta needs ranks 1–7 in substance (≈ 400–700 h; see [target-app-parity.md](target-app-parity.md#stage)). + +## Critical path + +M0 → M1 → M2 → M3 → M4 had to happen in order, and did, except that M2's own renderer was deferred +behind the `hayro` bootstrap. After M4, M5–M13 run in parallel across crates. The long poles are +M2 (renderer), M7 (content editing), M10 (OCR and Office), M11 (Preflight and standards) and M12 +(tagging and XFA). + +## Risks most likely to push estimates up + +- Fidelity of text editing: fonts, subsets, reflow, CJK and RTL. +- XFA dynamic layout on real government and bank forms. +- Real-world signature chains, revocation and tokens. +- The rendering long tail: Type 3 fonts, broken fonts, shadings, blend modes. +- Correctness of PDF/A, PDF/X and PDF/UA conversion. +- Interoperability: files other readers (above all Acrobat) reject, as in #774. +- OCR and AI quality limited by what openly licensed models exist. +- CPU rendering performance on the web. +- Hostile input: every fuzz run so far has found new crashes or hangs. +- Unmeasured fidelity: without an Acrobat comparison harness, quality gaps surface as user reports. + +## Revision history + +| Date | Change | Summary | +|---|---|---| +| 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-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 new file mode 100644 index 000000000..dc7443adf --- /dev/null +++ b/docs/target-app-parity.md @@ -0,0 +1,301 @@ +# PdfCraft parity with Adobe Acrobat Pro + +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-10 · **Change:** minor (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) + +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 +[pdf-spec-parity.md](pdf-spec-parity.md), [file-format-parity.md](file-format-parity.md), +[ui-parity.md](ui-parity.md), [hardware-parity.md](hardware-parity.md) and +[localization-parity.md](localization-parity.md). + +**Scope.** Parity means the *offline* feature set of Acrobat Pro. Adobe's cloud services (Document +Cloud storage, Acrobat Sign requests, shared review, the AI Assistant, Adobe Express, the Adobe +Approved Trust List) are out of scope: 23 such features are tracked as `na` in +`parity/acrobat-features.toml` and excluded from every percentage. + +## Headline + +| | Value | Kind | +|---|---|---| +| **Feature breadth**, tier-weighted | **≈ 67%** | measured (checklist; weights below) | +| 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**, 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) | +| Remaining to beta | ≈ 400–700 Opus 5.5 agent-hours | estimated | +| Remaining to full parity | ≈ 780–1,400 Opus 5.5 agent-hours | estimated | + +### 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 | +| 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, Windows printing, the bug backlog | +| Essentials user | ≈ 66% | ≈ 180–330 h (≈ 90–180 h wall clock; ≈ 50% parallelizes) | Rendering fidelity on everyday files, Windows printing, launch and GPU stability, encrypted output Acrobat opens (#774), simple text fixes, Word export layout, Chinese UI glyphs | + +Hours are calibrated as in [Effort and calibration](#effort-and-calibration) (≈ 1k kept lines per agent-hour for new features, 3–10× slower for hardening and fidelity). The audiences are nested subsets, so essentials ≤ mainstream ≤ full. + +The gap between the numbers is the story of this release: most of what an Acrobat user reaches +for *exists*, but much of it is shallower than Acrobat's, rendering is still borrowed, quality has +never been measured side by side with Acrobat, and the first public releases (0.4.0, 0.5.0) drew +245 open issues in two days, including one that makes our encrypted files unreadable in Acrobat +([#774](https://github.com/storytold/pdfcraft/issues/774)). + +## How this was measured + +- **Target identified from the installed app.** `/Applications/Adobe Acrobat DC/Adobe Acrobat.app`, + `CFBundleShortVersionString` 26.002.21931. Inspected without launching it and without opening + anything inside `Contents/Resources/JavaScripts/` (clean-room rules, `AGENTS.md` §2): the + `Info.plist` `CFBundleDocumentTypes` (the formats it opens: see + [file-format-parity.md](file-format-parity.md)), the `.lproj` folder names (24 localizations, + plus the MEA/MEH/NAF Middle East and North Africa editions named in `Resources/Sequences/`), the + names in `Contents/Plugins/` (Accessibility, AcroForm, Checkers, Comments, DigSig, EScript, + MakeAccessible, PPKLite, ReadOutLoud, Reflow, SaveAsRTF, Search, Spelling, WebLink, …), + `Contents/Frameworks/` (AdobeJP2K, Adobe3D, AdobeCoolType, AdobeLinguistic, the OCR-capable + `OnDeviceIntelligence_SDK`, …) and the `PDF Optimizer` and `Sequences` (Action Wizard) folders. + Names and listings only. +- **Feature breadth from the checklist.** `parity/acrobat-features.toml` tracks 831 Acrobat Pro + features (808 in scope). `cargo xtask parity` (in `xtask ci`) rejects any `shipped` claim whose + command, automation tool or cited test does not exist. The numbers here were computed from the + file on `origin/main` at 5d590604 (2026-10-10) with the same arithmetic as the tool (partial + counts half). The checklist itself was written from Acrobat's public documentation, not diffed + against a menu dump, so it can miss features; that risk is one of the gaps. +- **Ready for real work by judgement, from evidence:** the code (152k lines of Rust in `crates/`, + `apps/` and `xtask`, 1,765 tests, 184 registered commands, 141 automation tools); the vendored + renderer's patch list (`vendor/README.md`); user reports on GitHub (245 open, 107 closed issues on + 2026-10-10); release packaging (`.github/workflows/release.yml`); and knowledge of Acrobat Pro's + feature set from Adobe's public documentation. +- **Not measured, and the largest uncertainty:** no side-by-side comparison of rendering, text + extraction or form behaviour against Acrobat exists yet. Every "ready" figure below is an + estimate. + +### Weights + +Feature breadth weights the checklist's tiers by how much Acrobat users rely on them: **P0 50%, +P1 30%, P2 15%, P3 5%** → 0.50 × 94.2 + 0.30 × 61.0 + 0.15 × 12.0 + 0.05 × 2.3 ≈ **67%**. + +Ready for real work weights the areas by how often an Acrobat Pro user reaches for them (judgement: +Adobe publishes no usage data): view 16%, comment 12%, edit 12%, organize 10%, forms 10%, +create/export 9%, sign 7%, protect 5%, print 5%, OCR 4%, core 3%, optimize/standards 3%, +accessibility 2%, automation/preferences 2%. That gives the Features dimension, ≈ 49%. + +The **full ready-for-real-work number is an additive weighted sum over the dimensions** (standard +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%**. + +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 +interop and Chinese glyphs were already scored in their areas) and used a 2-point deduction (47%); +this version replaces the deduction with the standard's weighted sum over dimensions (49%). Method +aligned with the standard, no new evidence. Multiplicative discounts are used only for the +mainstream and essentials numbers. + +## Mainstream practitioner vs full Acrobat Pro + +The checklist covers all of Acrobat Pro, including surfaces a typical practitioner rarely opens. +Splitting it (measured from `parity/acrobat-features.toml`): + +| Slice | Features | Shipped | Partial | Unweighted | Tier-weighted | P0 | +|---|---|---|---|---|---|---| +| Mainstream: view, comment (without measuring), edit, organize, forms (without XFA and JavaScript tooling), protect and redact, OCR, create and export, compress (Reduce File Size, Optimizer), print (without print production) | 497 | 292 | 44 | 63.2% | ≈ 70% | 95.3% (193) | +| Specialist: core internals, digital-signature PKI, accessibility remediation, automation and preferences, Preflight and PDF/A/X/UA, print production, measuring, XFA and JavaScript tooling | 311 | 131 | 30 | 46.9% | ≈ 63% | 90.7% (59) | +| All | 808 | 423 | 74 | 56.9% | ≈ 67% | 94.2% (252) | + +So the checklist is **not** dominated by niche surface (38% of rows), and the P0 tier, which is +mostly mainstream, is at 94–95% in both slices. What holds the mainstream number down is P1 depth +(69%) and the P2 long tail (13%: rulers, loupe, portfolios, spell check, Bates across files). + +**Ready for real work, mainstream practitioner ≈ 55%**, weighting the mainstream areas by use and +scoring only their mainstream features: view 20% × 60, comment 15% × 70, edit 15% × 35, +organize 12% × 75, fill and sign 12% × 70, convert 10% × 30, protect and redact 6% × 55, +OCR 4% × 20, compress 3% × 60, print 3% × 50 = 55.6%. Read-only, review, form-filling and page +workflows are close to Acrobat; editing existing content, conversion and OCR are not. + +### Essentials user + +Someone who uses Acrobat for the everyday basics and never opens the rest. Scored on those features +only, by depth, then discounted for what such a user actually runs into. Advanced options, Pro +workflows, exchange edge cases and everything outside the mainstream slice are excluded. + +| Core feature | Weight | Depth | Evidence | +|---|---|---|---| +| Open the PDFs people send and read them (render, zoom, scroll, page through) | 20% | 80% | Repair of damaged files, 958-file corpus; rendering borrowed but broadly right (#794 is a TikZ pattern) | +| Find, select and copy text | 8% | 85% | Find, options, results list; word-F1 0.98 | +| Print | 8% | 55% | Good on macOS and Linux; nothing on Windows, where most such users are (#756) | +| Highlight, sticky notes, text comments | 12% | 85% | All markup types, replies, properties | +| Fill in forms | 10% | 85% | Every AcroForm field type, Acrobat's formatting and calculations | +| Fill & Sign: typed, drawn or image signature | 10% | 85% | Fill & Sign tools; flatten on save | +| Rotate, delete, reorder and extract pages | 8% | 85% | Organize grid, drag reorder, thumbnails | +| Combine files | 6% | 85% | Combine tab with bookmarks per file | +| Fix a typo in existing text | 5% | 40% | Latin paragraphs only; font may be substituted | +| Export to Word or images | 5% | 45% | Images good; Word loses layout (#773) | +| Reduce file size | 3% | 75% | Reduce File Size, Optimizer | +| Protect with a password | 3% | 30% | Works in PdfCraft; Acrobat and Reader reject the file (#774) | +| Undo, save, autosave | 2% | 90% | Undo after save, atomic saves, crash recovery | + +Weighted depth: **≈ 75%**. Discounts an essentials user hits: launch and stability (54 issues about +installing, launching, GPU and windows; OpenGL fallback) −5 points; discoverability and clarity +(an Acrobat-like shell with Home and All tools, but tool-mode friction such as #825 and #721) −2; +opening what people send (Chinese UI glyph boxes #826, CJK documents #758) −2. +**Essentials user ≈ 66%** (60–70%). + +### User sentiment (second look, 2026-10-10) + +Practitioners' praise is real and consistent with these numbers: + +- **Adoption:** 7,595 GitHub stars, 2,661 forks; release downloads 177,301 (v0.4.0) and 39,447 + (v0.5.0, its first day). +- **Praise:** 23 of 359 issues written by users carry explicit praise ("amazing bit of software" + #693, "ridiculously fast and awesome PDF editor" #320, "brilliant" #542, "fantastic and very useful" + #369, "Gigantic stuff… I love it" #449). Switching reports: #179 switched from Adobe Reader on + Windows ("covers nearly everything I used Reader for"); #232 replacing macOS Preview; #273 + "open source alternative to Acrobat"; #195 would "replace Acrobat completely" once hardware-token + signing exists. +- **What users hit** (issue titles, excluding 35 agent-filed `fix(...)` reports): installation, + launch and GPU/window problems 54, rendering and display 46, editing text and images 41, CJK and + other fonts and glyphs 41, signing and certificates 35, printing 23, forms 18, conversion and + export 16, OCR 5. Roughly two thirds are bugs (≈ 213 vs ≈ 111 feature requests), concentrated in + core paths rather than niche features. + +The praise comes overwhelmingly from Reader-class work (view, annotate, fill, sign, organize), where +the mainstream number is highest; the requests and bugs cluster on editing, CJK, printing, +conversion and signing tokens, which is where the gaps are. That supports raising the full number +from 40% to 47% (the double count; restated as 49% by the weighted-sum method) and reporting the mainstream 55% beside it; it does not support +calling PdfCraft a replacement for Acrobat Pro yet. + +## By dimension + +Hours are Opus 5.5 agent wall-clock hours, low–high. Rows marked *within features* are part of the +features total and are shown for orientation; the others add to it. + +| Dimension | Ready | Weight | Remaining (h) | Counted | Evidence | Doc | +|---|---|---|---|---|---|---| +| Features (14 areas) | ≈ 49% depth; 67% breadth | 40% | 610–1,100 | base | Area table below | this file | +| 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, #759, #789, #844 | [ui-parity.md](ui-parity.md) | +| File formats and conversion | ≈ 40% | 12% | 120–220 | within features | PDF read/write strong but encrypted output fails in Acrobat (#774); 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; Windows has no printing (#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) | +| 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, many of them `fix(...)` reports of wrong results in shipped features (#799–#821) | [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) | +| 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** | | | | + +## By feature area + +Breadth is measured from the checklist (partial = ½). Ready is estimated. Hours are remaining to +full parity in that area. + +| Area | Features | Shipped | Partial | Breadth | Ready | Remaining (h) | What's strong / what's missing | +|---|---|---|---|---|---|---|---| +| A Core model and fidelity | 56 | 42 | 2 | 76.8% | 70% | 25–45 | Parser, repair, R2–R6 encryption, incremental and atomic saves, undo, autosave, crash recovery, revisions. Missing: encrypted output that Acrobat opens (#774), lazy loading of GB files, linearization, PDF 2.0 extras, Arlington validation, public-key security, own image codecs | +| B View and navigation | 92 | 52 | 11 | 62.5% | 55% | 110–190 | Shell, layouts, zoom, find, panels, tiles, web build, 500-page scrolling. **Pages are drawn by the vendored `hayro`** (22 + 9 local patches): all 7 rendering P0s are partial. Missing: own renderer or a decided replacement (#841), multiple windows, split view, rulers/guides, loupe, reflow, search across files | +| 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 (#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; open fixes #817, #818, #821 | +| 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 bugs #807, #809, #819 | +| 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 | +| 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, 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. Missing: **printing on Windows (#756) and the web**, 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) | + +Shipped features rest on thin evidence: 141 of 423 cite exactly one test, and only a handful cite +an external oracle (`pdftotext`, `pdfsig`, OpenSSL, `qpdf --check`, a corpus). + +The checklist lags reality in two places: `misc.localization` is still `planned` although 15 +catalogs ship, and `misc.installers` is `planned` although signed macOS, Windows and Linux packages +ship (`.github/workflows/release.yml`). Neither moves the numbers by more than 0.2 points; fix them +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 +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 +blocking: unencrypted PDFs we save open everywhere and export to Word, RTF, HTML, text and images +works, but password-protected output is rejected by Acrobat and Reader (#774), there is no in-app +Create PDF from Office, and Word export loses layout (#773). The gate table, with evidence, is in +[roadmap.md](roadmap.md#alpha-gate). (Earlier the same day this row was judged blocking and the +stage set to pre-alpha; see the revision history.) + +| Core workflow | Works end to end? | +|---|---| +| View, search and print | yes | +| Organize, combine and split pages | yes | +| Comment and review | yes | +| Fill and sign forms | yes | +| Edit text and images | partial, not blocking (Latin text with embedded or standard fonts) | +| Convert and share (Office ↔ PDF, password protection) | partial, not blocking (encryption interop #774, Office→PDF and Word layout #773 are beta items) | + +**To reach beta** (≈ 75% ready, no blocking gap in PDF interchange), ≈ 26 points and +**≈ 400–700 agent-hours**: + +| Beta requirement | Hours | +|---|---| +| Encrypted output opens in Acrobat and Reader (#774), with an interop suite that opens our outputs in other readers | 8–15 | +| Rendering: a fidelity harness against Acrobat captures (local, clean-room) and fixing what it finds; settle the renderer (own M2 devices, or #841's proposal); Chinese UI faces in releases | 80–140 | +| Editing existing text robust on real files: subset fonts, CJK, RTL, rotated text | 70–120 | +| Printing on Windows and the web | 15–30 | +| OCR in the major languages (accents, Cyrillic, CJK), deskew and rotation | 35–60 | +| Export: Word that keeps layout; Excel and PowerPoint | 45–80 | +| Signatures: timestamp servers, online revocation and LTV, PKCS #11, OS trust | 30–55 | +| PDF/A (all parts and levels), PDF/UA validation, a first Preflight | 45–80 | +| Accessibility tagging: autotag, Tags panel, Reading Order | 35–60 | +| Open-issue backlog, stability and performance budgets | 40–70 | +| **Total** | **≈ 400–700** | + +## Effort and calibration + +**Calibrated against this repository's own history.** + +- The repo is 11 days old (first commit 2026-09-30 05:17), with 591 commits and 371 merged pull + requests (all since 2026-10-04). Rust in `crates/`, `apps/` and `xtask/` grew from 37.7k lines + (2026-10-02) to 82.6k (10-04), 99.2k (10-08) and 152.7k (10-10). +- The previous ROADMAP measured about **1.0k kept, tested lines per agent-hour** (72k lines in + roughly 65–75 agent-hours). New features land at about that rate: medium P1 features such as + redaction word lists (#757, 314 lines), bookmarks from structure (#754, 588 lines) and signing + encrypted documents (#753, 308 lines) each took about 1–3 agent-hours including tests, with 2–4 h + from PR to merge, mostly CI. Large arcs take longer: XFA dynamic layout, its JavaScript and + FormCalc (#121, #230, #301; ≈ 13k lines) about 15–25 agent-hours; the Arabic interface + (#692, 2.7k lines) about 3–5. +- Hardening and fidelity work is 3–10× slower per line than new features: the 2026-10-07 → 10-10 + block landed ≈ 330 commits but moved the checklist by only 12 shipped features (411 → 423), because most of + it was never-crash work, Windows fixes, localization and XFA. + +The previous estimate (2026-10-07) was **600–1,100 h** to full parity. Since then XFA, signature +timestamps/LTV and signing encrypted documents progressed (≈ −60 h), but the first public releases +surfaced work the estimate did not hold: 245 open issues, missing Windows printing, Acrobat +interoperability of encrypted output, Chinese UI glyphs and Word-export layout (≈ +200–300 h). +Hence **≈ 780–1,400 h** now. Roughly 60–70% parallelizes across crates (3–5 agents: ≈ 300–550 h +wall clock). + +**Needs a human:** the renderer decision (own M2 devices vs an outside renderer, #841); Acrobat +oracle captures on the owner's Mac (synthetic fixtures only); native-speaker review of every +catalog; smart cards and PKCS #11 tokens, scanners and Windows printers to test against; any +licensed OCR or AI model. + +## Revision history + +| Date | Change | Summary | +|---|---|---| +| 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 | +| 2026-10-10 | minor | Consistency check across apps: re-judged the convert-and-share gate row with the shared rule (blocking only when the workflow can't be completed at all; lossy exchange and missing sub-cases are beta items). Encrypted output (#774) and Office→PDF are sub-cases, Word export is lossy not absent → partial, not blocking; stage pre-alpha → **alpha**. Numbers unchanged | +| 2026-10-10 | minor | Second look requested by the owner after practitioners praised PdfCraft: user sentiment gathered (stars, downloads, 23 praising issues, switching reports, issue themes); checklist split into mainstream (497) and specialist (311) features; the 9-point cross-cutting deduction removed as a double count (Windows printing, interop and glyphs were already scored in their areas). Ready for real work 40% → 47%; mainstream practitioner ≈ 55% added. Core-workflow alpha gate applied: convert and share fails → stage alpha → **pre-alpha**, ≈ 40–70 h from alpha | +| 2026-10-10 | major | Created from ROADMAP.md §Estimate summary and §Honest assessment (2026-10-05/07). Full re-measure against Acrobat Pro 26.002.21931: checklist recomputed (56.9% unweighted, ≈ 67% tier-weighted), ready for real work ≈ 40%, stage alpha, hours re-calibrated (780–1,400 h to parity, 400–700 h to beta), dimension and area tables | +| 2026-10-07 | minor | (in ROADMAP.md) 806 tracked features, 51.0% shipped, effort-weighted ≈ 30–35%, 600–1,100 h | +| 2026-10-05 | major | (in ROADMAP.md) First honest assessment by dimension and area | diff --git a/docs/ui-parity.md b/docs/ui-parity.md new file mode 100644 index 000000000..403f490d9 --- /dev/null +++ b/docs/ui-parity.md @@ -0,0 +1,84 @@ +# UI and interaction parity + +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-10 · **Change:** major (first UI/UX checklist) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS) + +How PdfCraft's tools, panels, shortcuts and feel compare with Acrobat Pro's current interface (the +2023+ "new Acrobat": All tools pane on the left, quick-action toolbar, Home view). Behaviour is +observed clean-room (`plan/acrobat/02-ui-ux.md`, local-only; `AGENTS.md` §2); nothing from Adobe's +interface is copied. Gaps ranked in [gaps.md](gaps.md). + +**UI/UX fidelity ≈ 55% ready** (estimated), **30–60 h** additional to the features total. + +## Measured + +- **184 registered commands** (`crates/engine/src/commands.rs`), every one in the menus, the + command palette and the automation `command_run` tool; menus are generated from the registry + (`view.menus-from-registry`). +- **141 automation tools** (`crates/automation/src/tools.rs`), so every UI action can be checked + headlessly. +- **View area of the checklist: 62.5% breadth** (52 shipped, 11 partial of 92). + +No dump of Acrobat's menu tree has been diffed against the registry yet (gap 24). + +## Shell and navigation + +| Element | Acrobat | PdfCraft | Status | +|---|---|---|---| +| Home view (recent files, recommended tools) | yes | yes | shipped | +| All tools pane, tool sets | yes | yes, with milestone badges | shipped | +| Quick-action toolbar, customisable | yes | fixed | partial (`view.customize-quick-tools` planned) | +| Command search | "Search tools" | command palette (⌘K), matches translated and English labels | shipped | +| Document tabs | yes | yes, with unsaved marker | shipped | +| Multiple windows, drag a tab out | yes | no | planned | +| Split window, spreadsheet split | yes | no | planned | +| Read mode, full screen | yes | yes | shipped; presentation options planned | +| Light, dark, system theme | yes | yes | shipped | +| Side panels: thumbnails, bookmarks, attachments, layers, comments, fields, signatures | yes | yes; panels keep their width (#704) | shipped | +| Destinations, articles, model tree, Tags, Order, Content panels | yes | no | planned | +| Message bars (restricted, signed, PDF/A, forms) | yes | restricted and signed; no PDF/A view mode | partial | + +## Tools, handles and precision + +| Behaviour | Acrobat | PdfCraft | Status / evidence | +|---|---|---|---| +| Zoom: wheel, pinch, keys, marquee, fit page/width/height/visible | yes | yes | shipped | +| Fixed zoom percentages match Acrobat's physical size | yes | differ (#739, documented in #736) | gap | +| Dynamic zoom, loupe, pan-and-zoom window | yes | no | planned | +| Hand tool, middle-button pan, autoscroll | yes | hand tool; Linux autoscroll | partial | +| Text selection, column select | yes, Alt-drag columns | reading-order selection; no column mode (#740) | partial | +| Select and move several objects at once | yes | no (#844) | gap | +| Object handles: corners keep proportion, edges free | yes | yes for added images and signatures | 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 | +| 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 | +| Disabled commands explain why | no | yes | ahead | + +## Shortcuts and modifiers + +| Item | Acrobat | PdfCraft | Status | +|---|---|---|---| +| Platform shortcuts for registered commands | yes | yes; ⌘ on macOS, Ctrl elsewhere (#695) | shipped; find bar still shows ⌘ on Windows (#746) | +| Keyboard shortcuts reference | yes | yes | shipped | +| Full keyboard operation of the app | yes | no | planned (`a11y.keyboard-only`, P0) | +| Shift-⌘-G finds previous | yes | goes forward (#810) | bug | + +## Dialogs and preferences + +| Item | Acrobat | PdfCraft | Status | +|---|---|---|---| +| Print dialog with preview | yes | yes | shipped | +| Document Properties (description, security, fonts, initial view, custom, advanced) | yes | all but Custom and XMP | partial | +| Field Properties (General, Appearance, Position, Options, Actions, Format, Validate, Calculate, Signed) | yes | all but Signed | partial | +| Comment properties and properties bar | yes | yes | shipped | +| Preferences | ≈ 20 pages | Interface language, Documents and view, Identity, Fill & Sign, JavaScript | partial (gap 19) | + +## Revision history + +| Date | Change | Summary | +|---|---|---| +| 2026-10-10 | major | Created from the command registry, automation tools, the View area of the checklist and open UI issues | From 8ae9f6be8b678fc716a61f6db4784537cf7ca832 Mon Sep 17 00:00:00 2001 From: MarekSpodBiedry <73122968+MarekSpodBiedry@users.noreply.github.com> Date: Sat, 10 Oct 2026 21:05:48 +0200 Subject: [PATCH 02/69] Measure: snap to the implicit closing edge of filled paths (#803) (#862) --- crates/measure/src/snap.rs | 3 ++- crates/measure/src/tests.rs | 10 ++++++++++ 2 files changed, 12 insertions(+), 1 deletion(-) diff --git a/crates/measure/src/snap.rs b/crates/measure/src/snap.rs index f9eeb92d4..46455dcf4 100644 --- a/crates/measure/src/snap.rs +++ b/crates/measure/src/snap.rs @@ -321,7 +321,8 @@ impl Walker<'_> { } } "S" | "s" | "f" | "F" | "f*" | "B" | "B*" | "b" | "b*" => { - if matches!(String::from_utf8_lossy(&op.op).as_ref(), "s" | "b" | "b*") + // Fills close open subpaths implicitly (ISO 32000-2 8.5.3.3); only a bare stroke leaves the path open. + if String::from_utf8_lossy(&op.op).as_ref() != "S" && let (Some(a), Some(b)) = (current, start) { path.edge(a, b); diff --git a/crates/measure/src/tests.rs b/crates/measure/src/tests.rs index 8cd701d05..1348a3ec3 100644 --- a/crates/measure/src/tests.rs +++ b/crates/measure/src/tests.rs @@ -120,6 +120,16 @@ fn rejects_invalid_geometry_and_unsupported_scales() { assert!(set_scale(&mut d, 0, [0.0; 4], "bad", &Scale::default()).is_err()); } #[test] +fn filled_open_triangle_snaps_to_closing_edge() { + for (op, closed) in [("f", true), ("F", true), ("f*", true), ("B", true), ("B*", true), ("h f", true), ("S", false)] { + let d = fixture(&format!("40 50 m 160 50 l 100 170 l {op}"), "", &[]); + let paths = snap::geometry(&d, 0).unwrap(); + assert_eq!(paths.segments.len(), if closed { 3 } else { 2 }, "{op}"); + let hit = paths.snap([70.0, 110.0], 5.0, snap::SnapOptions::default()).unwrap(); + assert_eq!(hit.is_some(), closed, "{op}"); + } +} +#[test] fn snap_paths_endpoints_midpoints_and_intersections() { let d = fixture("10 20 m 110 20 l S 60 0 m 60 80 l S 10 100 100 40 re S", "", &[]); let paths = snap::geometry(&d, 0).unwrap(); From 527128d0075c62d9578bcab29062767dc1ab1c50 Mon Sep 17 00:00:00 2001 From: muraa-p <85794781+muraa-p@users.noreply.github.com> Date: Sun, 11 Oct 2026 03:06:02 +0800 Subject: [PATCH 03/69] fix(organize): reject rotation angles that are not multiples of 90 (#801) (#833) Page rotation only turns in quarter steps, but the degree value was read with an integer division by 90 that threw the remainder away. Asking for 45 degrees saved an unrotated page, 91 and 135 saved a 90 degree turn, and the command still reported success. Reject the value before touching the document instead, so every caller gets an actionable error and the pages are left alone. The automation tool already rejected it; the single edit command did not. Fixes #801. Co-authored-by: Big Pickle --- crates/organize/src/lib.rs | 3 +++ crates/organize/src/tests.rs | 12 ++++++++++++ 2 files changed, 15 insertions(+) diff --git a/crates/organize/src/lib.rs b/crates/organize/src/lib.rs index 5419eb773..1459d67c6 100644 --- a/crates/organize/src/lib.rs +++ b/crates/organize/src/lib.rs @@ -149,6 +149,9 @@ fn check(indices: &[usize], n: usize) -> Result<(), OrganizeError> { /// Rotate pages by a multiple of 90° (positive = clockwise), adjusting `/Rotate` (§7.7.3.3). pub fn rotate_pages(doc: &mut Document, indices: &[usize], degrees: i64) -> Result<(), OrganizeError> { + if degrees % 90 != 0 { + return Err(OrganizeError::Invalid("rotation must be a multiple of 90 degrees".into())); + } let all = walk(doc)?; check(indices, all.len())?; let delta = (degrees / 90) * 90; diff --git a/crates/organize/src/tests.rs b/crates/organize/src/tests.rs index 152b961ac..5dddf0296 100644 --- a/crates/organize/src/tests.rs +++ b/crates/organize/src/tests.rs @@ -16,6 +16,18 @@ fn page_rotation_resolves_inheritance_overrides_and_missing_pages() { assert_eq!(page_rotation(&doc, 3), Err(OrganizeError::NoSuchPage(3))); } +#[test] +fn rotate_rejects_angles_that_are_not_multiples_of_90() { + // The page tree only turns in quarter steps, so anything else used to be silently truncated + // by the integer division. It has to be an error, and the document must stay as it was. + let mut doc = Document::open(Arc::new(fixture())).unwrap(); + assert_eq!(rotate_pages(&mut doc, &[0], 45), Err(OrganizeError::Invalid("rotation must be a multiple of 90 degrees".into()))); + assert_eq!(rotate_pages(&mut doc, &[0], 91), Err(OrganizeError::Invalid("rotation must be a multiple of 90 degrees".into()))); + assert_eq!(page_rotation(&doc, 0).unwrap(), 90); + rotate_pages(&mut doc, &[0], -90).unwrap(); + assert_eq!(page_rotation(&doc, 0).unwrap(), 0); +} + /// A 3-page document with a nested page tree. MediaBox and Rotate are inherited from the root, /// Resources from an intermediate node; each page's content says which page it is. fn fixture() -> Vec { From f3f4019fad4fb087bf7cf50b421fda7ae351c3e3 Mon Sep 17 00:00:00 2001 From: MarekSpodBiedry <73122968+MarekSpodBiedry@users.noreply.github.com> Date: Sat, 10 Oct 2026 21:06:15 +0200 Subject: [PATCH 04/69] Autosave: snapshot a new unsaved document before its first edit (#815) (#850) --- crates/engine/src/lib.rs | 2 ++ crates/engine/src/tests.rs | 13 +++++++++++++ 2 files changed, 15 insertions(+) diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 335528745..7b4efab47 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -2890,6 +2890,8 @@ impl Session { let id = self.open(name, None, bytes, None)?; if let Some(d) = self.docs.iter_mut().find(|d| d.id == id) { d.dirty = true; + // Unsaved work with no edit yet still needs its first recovery snapshot. + d.generation += 1; } Ok(id) } diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index e1c57481b..55af82606 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -577,6 +577,19 @@ fn autosave_snapshots_only_changed_documents() { assert!(s.autosave_snapshots().is_empty(), "saved documents need no recovery"); } +#[test] +fn autosave_snapshots_a_new_unsaved_document_once() { + let (mut s, clean) = session_with(1); + let id = s.open_new("Untitled.pdf", Arc::new(fixture(1))).unwrap(); + let snaps = s.autosave_snapshots(); + assert_eq!(snaps.len(), 1, "the new document is unsaved work"); + assert_eq!(snaps[0].doc, id); + assert!(snaps.iter().all(|x| x.doc != clean), "the clean document stays out"); + assert!(s.autosave_snapshots().is_empty(), "nothing new since the last snapshot"); + s.apply(id, Edit::RotatePages { pages: vec![0], degrees: 90 }).unwrap(); + assert_eq!(s.autosave_snapshots().len(), 1, "a later edit is snapshotted again"); +} + #[test] fn recovered_documents_reopen_unsaved_at_their_original_path() { let (mut s, id) = session_with(2); From f9ed8b264ab0743c2381ab80140ffb46790dcc51 Mon Sep 17 00:00:00 2001 From: mlkonrad Date: Sat, 10 Oct 2026 22:06:29 +0300 Subject: [PATCH 05/69] OCR: Recognize text in multiple files refuses paths outside the root (#795) ocr_recognize_files turned a path outside the automation root into a per-file error and returned success, so no_tool_escapes_the_root reported 9 leaks once the OCR models were installed. Without them, loading the models failed first and hid it, and CI never installs them. Every path is now checked before any work starts, and one outside the root refuses the whole call, like the other tools. A missing file inside the root stays a per-file error. Nothing was ever read from outside the root. ocr_recognize_files_refuses_paths_outside_the_root checks this without the models. Co-authored-by: Claude Opus 5.5 --- crates/automation/src/a11y.rs | 8 +++++++- crates/automation/tests/automation.rs | 12 ++++++++++++ 2 files changed, 19 insertions(+), 1 deletion(-) diff --git a/crates/automation/src/a11y.rs b/crates/automation/src/a11y.rs index 2a853083c..b58b2d587 100644 --- a/crates/automation/src/a11y.rs +++ b/crates/automation/src/a11y.rs @@ -156,10 +156,16 @@ impl Automation { pub(crate) fn ocr_recognize_files(&mut self, a: &Args) -> Result { let settings = self.ocr_settings(a)?; let folder = self.resolve(a.str("folder")?, true)?; + // A path that leaves the root refuses the whole call before any work starts, like the + // other tools (the write check passes missing files, which stay per-file errors below). + let paths = a.strs("paths")?; + for p in &paths { + self.resolve(p, true)?; + } std::fs::create_dir_all(&folder).map_err(|e| failed(e.to_string()))?; let ocr = pdfcraft_engine::ocr::engine().map_err(failed)?; let mut out = Vec::new(); - for p in a.strs("paths")? { + for p in paths { let name = std::path::Path::new(p).file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_else(|| "document.pdf".into()); let result = self.resolve(p, false).map_err(|e| e.to_string()).and_then(|src| std::fs::read(&src).map_err(|e| e.to_string())).and_then(|bytes| { diff --git a/crates/automation/tests/automation.rs b/crates/automation/tests/automation.rs index e83e2125c..f31ffc2a2 100644 --- a/crates/automation/tests/automation.rs +++ b/crates/automation/tests/automation.rs @@ -2829,6 +2829,18 @@ fn ocr_tools_make_a_scan_searchable() { assert!(matches!(a.call("ocr_recognize", &json!({ "doc": scan, "language": "xx" })), Err(ToolError::InvalidArgs(_)))); } +#[test] +fn ocr_recognize_files_refuses_paths_outside_the_root() { + let dir = workdir("ocr-files-root"); + let mut a = auto(&dir); + // Refused before anything else, with or without the OCR models installed. + for path in ["../outside.pdf", "sub/../../outside.pdf", "/outside.pdf"] { + let r = a.call("ocr_recognize_files", &json!({ "paths": ["a.pdf", path], "folder": "out" })); + assert!(matches!(&r, Err(e) if e.to_string().contains("outside the allowed directory")), "{path}: {r:?}"); + } + assert!(!dir.join("out").exists(), "nothing was started"); +} + #[test] fn ocr_recognize_files_writes_searchable_copies() { let dir = workdir("ocr-files"); From e0625cd5cfc53f7f69583619942034fd6fd92204 Mon Sep 17 00:00:00 2001 From: muraa-p <85794781+muraa-p@users.noreply.github.com> Date: Sun, 11 Oct 2026 03:06:43 +0800 Subject: [PATCH 06/69] fix(text): match phrases across repeated whitespace (#814) (#831) The phrase search collapsed runs of whitespace in the query but not in the page text it was matching against, so a document drawn with two space glyphs in a row like the issue example never matched the phrase. Single words still matched, which is why it looked like only some files were affected. Collapse whitespace while the text is flattened, the same way the query is normalised. The regression test fails on the old code because the phrase find returns an empty range, and it also covers tabs and single word queries. Fixes #814. Co-authored-by: Big Pickle --- crates/render/src/text.rs | 28 +++++++++++++++++++++++++++- 1 file changed, 27 insertions(+), 1 deletion(-) diff --git a/crates/render/src/text.rs b/crates/render/src/text.rs index 5db3fa47c..34c3d94d6 100644 --- a/crates/render/src/text.rs +++ b/crates/render/src/text.rs @@ -88,7 +88,12 @@ impl PageText { chars.push((' ', i)); } for c in fold(&g.text).chars() { - chars.push((if c.is_whitespace() { ' ' } else { c }, i)); + let c = if c.is_whitespace() { ' ' } else { c }; + // The query collapses a run of whitespace to one space, so collapse the page too. + if c == ' ' && chars.last().is_some_and(|k| k.0 == ' ') { + continue; + } + chars.push((c, i)); } } let word = |k: Option<&(char, usize)>| k.is_some_and(|c| c.0.is_alphanumeric()); @@ -932,6 +937,27 @@ mod tests { assert_eq!(t.nearest(52.0, 14.0), Some(5)); } + #[test] + fn phrase_search_ignores_repeated_spaces() { + // "quick brown" drawn with two space glyphs in a row. Text extraction keeps both spaces, + // but the phrase search has always collapsed runs of whitespace in the query, so the page + // text has to be collapsed the same way or the phrase is never found (#814). + let mut v = Vec::new(); + word(&mut v, "quick", 10.0, 10.0, 6.0); + v.push(g(" ", 46.0, 10.0, 52.0)); + v.push(g(" ", 52.0, 10.0, 58.0)); + word(&mut v, "brown", 58.0, 10.0, 6.0); + let n = v.len(); + let t = PageText { glyphs: v, line_of: vec![0; n], space_before: vec![false; n] }; + assert_eq!(t.plain_text(), "quick brown"); + assert_eq!(t.find("quick brown"), vec![0..12]); + assert_eq!(t.find("quick brown"), vec![0..12], "the query's own run of spaces is collapsed too"); + assert_eq!(t.find("quick\tbrown"), vec![0..12]); + assert_eq!(t.find("quick"), vec![0..5]); + assert_eq!(t.find("brown"), vec![7..12]); + assert!(t.find("quick absent").is_empty()); + } + fn word(v: &mut Vec, s: &str, x: f32, y: f32, advance: f32) { for (i, c) in s.chars().enumerate() { let x0 = x + i as f32 * advance; From 4f7aa22214667f216cbc951664365fbb0122b656 Mon Sep 17 00:00:00 2001 From: muraa-p <85794781+muraa-p@users.noreply.github.com> Date: Sun, 11 Oct 2026 03:06:56 +0800 Subject: [PATCH 07/69] fix(edit): keep a comment whose appearance is missing when flattening (#805) (#838) Viewers draw notes, FreeText, ink and stamps from their dictionary when the file stores no /AP /N. flatten only drew annotations that already had an appearance and then removed the rest, so a supported note disappeared and the saved page came back blank. Build the appearance a viewer would show for every selected, visible, non-widget annotation that has none, then draw it as usual. A subtype this build cannot render still fails to build and is handled exactly as before. The new synthetic test draws the note icon into the page and asserts it is baked in; without the fix flatten reports 0 drawn and the icon is lost. Co-authored-by: Big Pickle --- crates/edit/src/flatten.rs | 17 +++++++++++++++++ crates/edit/src/tests.rs | 20 ++++++++++++++++++++ 2 files changed, 37 insertions(+) diff --git a/crates/edit/src/flatten.rs b/crates/edit/src/flatten.rs index dd05dbb7e..134696665 100644 --- a/crates/edit/src/flatten.rs +++ b/crates/edit/src/flatten.rs @@ -118,6 +118,23 @@ fn flatten_which(doc: &mut Document, pages: &[usize], which: Which) -> Result = HashSet::new(); let mut gone_inline: HashSet = HashSet::new(); + // A viewer draws many comments from their dictionary even when the file stores no + // appearance (notes, FreeText, ink, stamps…). Build the appearance it would show, so + // flattening keeps a comment that still has ink on screen instead of deleting it. + for entry in &list { + let Some(r) = entry.as_ref() else { continue }; + let d = doc.resolve(entry).as_dict().cloned().unwrap_or_default(); + let subtype = d.name(b"Subtype").unwrap_or_default(); + if subtype == b"Widget" || !selected(doc, &d, which) { + continue; + } + let flags = d.get(b"F").and_then(|f| doc.resolve(f).as_int()).unwrap_or(0); + if flags & (HIDDEN | NO_VIEW) != 0 || appearance(doc, &d).is_some() { + continue; + } + // A subtype this build cannot draw only fails here; it is still removed below. + let _ = pdfcraft_annot::set_appearance(doc, r); + } for (k, entry) in list.iter().enumerate() { let obj = doc.resolve(entry); let Some(d) = obj.as_dict() else { continue }; diff --git a/crates/edit/src/tests.rs b/crates/edit/src/tests.rs index 54aecc759..03baeac09 100644 --- a/crates/edit/src/tests.rs +++ b/crates/edit/src/tests.rs @@ -175,6 +175,26 @@ fn flattening_draws_appearances_into_the_page_and_removes_the_comments() { assert!(marks_present(&doc).is_empty(), "flattened content is not a removable mark"); } +/// #805: a supported note with no stored appearance is still drawn by viewers. Flattening must +/// generate that appearance and bake it into the page, not delete a comment that shows an icon. +#[test] +fn flattening_keeps_a_note_whose_appearance_is_missing() { + let page = stream("", " "); + let mut doc = build(&[ + "<< /Type /Catalog /Pages 2 0 R >>", + "<< /Type /Pages /Kids [3 0 R] /Count 1 >>", + "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 200 200] /Resources << >> /Contents 4 0 R /Annots [5 0 R] >>", + &page, + "<< /Type /Annot /Subtype /Text /Rect [10 10 30 30] /Contents (Synthetic note) /Name /Comment /Open false /F 0 >>", + ]); + assert_eq!(flatten(&mut doc, &[0], true, false).unwrap(), 1, "the note icon is drawn"); + let doc = reopen(&doc); + let p = &pdfcraft_model::pages(&doc)[0]; + assert!(!p.dict.contains(b"Annots"), "the note is gone once its icon is baked in"); + let flat = streams(&doc, 0).pop().unwrap(); + assert!(flat.contains("q 1 0 0 1 10 10 cm /PCFl0 Do Q"), "{flat}"); +} + /// Display space is in points, so on a `/UserUnit 2` page an item's box maps to half as many /// user-space units. Items record the `/UserUnit` they were written for; one without it was /// written when display space was in user-space units, and reads back scaled to points. From 9c4414c50e787aa3e2c57f9f4699bae0b0048f35 Mon Sep 17 00:00:00 2001 From: muraa-p <85794781+muraa-p@users.noreply.github.com> Date: Sun, 11 Oct 2026 03:07:10 +0800 Subject: [PATCH 08/69] fix(comments): keep a reply to a reply in the comment listing (#806) (#839) comment_list collected only the replies whose /IRT pointed at the root comment, so a reply to a reply was listed nowhere at all. Roots and the root count were already right; the nested reply was simply dropped from its thread. Follow the /IRT chain to any depth, with a bound, so every reply under a comment is listed in document order. A reply to a reply now stays discoverable through the tool that promises every comment and its replies. The new test builds a root note, a direct reply and a reply to that reply, then checks both texts are listed and the two notes stay roots. It listed only the direct reply before the fix. Co-authored-by: Big Pickle --- crates/automation/src/comments.rs | 17 +++++++++++++++-- crates/automation/tests/automation.rs | 23 +++++++++++++++++++++++ 2 files changed, 38 insertions(+), 2 deletions(-) diff --git a/crates/automation/src/comments.rs b/crates/automation/src/comments.rs index b7f83c2f1..c6e722d2c 100644 --- a/crates/automation/src/comments.rs +++ b/crates/automation/src/comments.rs @@ -191,8 +191,21 @@ impl Automation { .filter(|c| c.in_reply_to.is_none() && only.is_none_or(|p| c.page == p)) .map(|c| { let mut v = view(c); - let thread: Vec<&Annotation> = match &c.name { - Some(nm) => all.iter().filter(|r| r.in_reply_to.as_deref() == Some(nm.as_str())).collect(), + // Every reply under this comment, at any depth, in document order. A reply to a + // reply keeps its `/IRT` on the direct parent, so following one level would drop it. + let descends_from = |r: &Annotation, root: &str| -> bool { + let mut parent = r.in_reply_to.as_deref(); + for _ in 0..64 { + let Some(nm) = parent else { return false }; + if nm == root { + return true; + } + parent = all.iter().find(|x| x.name.as_deref() == Some(nm)).and_then(|x| x.in_reply_to.as_deref()); + } + false + }; + let thread: Vec<&Annotation> = match c.name.as_deref() { + Some(nm) => all.iter().filter(|r| r.in_reply_to.is_some() && descends_from(r, nm)).collect(), None => Vec::new(), }; let status = thread.iter().rev().filter(|r| !r.is_mark()).find_map(|r| r.state.clone()); diff --git a/crates/automation/tests/automation.rs b/crates/automation/tests/automation.rs index f31ffc2a2..6d0e8262f 100644 --- a/crates/automation/tests/automation.rs +++ b/crates/automation/tests/automation.rs @@ -1023,6 +1023,29 @@ fn comments_through_tools() { assert_eq!(ok(&mut b, "comment_list", json!({ "doc": re }))["count"], 5); } +/// #806: a reply to another reply stays in the listing, at any depth. +#[test] +fn nested_replies_stay_in_the_listing() { + let dir = workdir("nested-replies"); + let mut a = auto(&dir); + let doc = ok(&mut a, "doc_open", json!({ "path": "a.pdf" }))["doc"].as_u64().unwrap(); + let root = ok(&mut a, "comment_add", json!({ "doc": doc, "page": 1, "type": "note", "at": [40, 40], "contents": "ROOT_NOTE", "author": "A" })); + let root_id = root["comment"]["id"].as_str().unwrap().to_string(); + ok(&mut a, "comment_reply", json!({ "doc": doc, "id": root_id, "text": "DIRECT_REPLY", "author": "B" })); + let listed = ok(&mut a, "comment_list", json!({ "doc": doc })); + let root_comment = listed["comments"].as_array().unwrap().iter().find(|c| c["contents"] == "ROOT_NOTE").unwrap().clone(); + let direct_id = + root_comment["replies"].as_array().unwrap().iter().find(|r| r["contents"] == "DIRECT_REPLY").unwrap()["id"].as_str().unwrap().to_string(); + ok(&mut a, "comment_reply", json!({ "doc": doc, "id": direct_id, "text": "NESTED_REPLY", "author": "C" })); + ok(&mut a, "comment_add", json!({ "doc": doc, "page": 1, "type": "note", "at": [120, 120], "contents": "CONTROL_NOTE", "author": "D" })); + + let list = ok(&mut a, "comment_list", json!({ "doc": doc })); + assert_eq!(list["count"], 2, "the two notes stay roots: {list}"); + let root = list["comments"].as_array().unwrap().iter().find(|c| c["contents"] == "ROOT_NOTE").unwrap().clone(); + let replies: Vec<&str> = root["replies"].as_array().unwrap().iter().filter_map(|r| r["contents"].as_str()).collect(); + assert!(replies.contains(&"DIRECT_REPLY") && replies.contains(&"NESTED_REPLY"), "the nested reply is listed: {replies:?}"); +} + #[test] fn protecting_through_tools() { let dir = workdir("protect"); From 010b677829712956448c179a18f34033a6b3aaf9 Mon Sep 17 00:00:00 2001 From: Tristan von Kries Date: Sat, 10 Oct 2026 21:07:38 +0200 Subject: [PATCH 09/69] Issue #774: keep the catalog out of object streams in encrypted files (Acrobat) (#836) Acrobat reports an encrypted PDF as "damaged and could not be repaired", before asking for the password, when the document catalog sits in an object stream. Every other reader tried (qpdf, pypdf, poppler, PDFKit, PdfCraft) accepts such files, and qpdf keeps the catalog standalone too. Full saves of encrypted documents now write the catalog as its own object; unencrypted output is unchanged. Verified with Acrobat DC on macOS: the same file fails with the catalog packed and opens with it standalone, in AES-256 and RC4-128. Claude-Session: https://claude.ai/code/session_01REz7wTfQgnopyvN9eQJJVu Co-authored-by: Tristan von Kries Co-authored-by: Claude Fable 5.1 --- crates/cos/src/writer.rs | 7 ++++++- crates/cos/tests/writer.rs | 29 +++++++++++++++++++++++++++++ 2 files changed, 35 insertions(+), 1 deletion(-) diff --git a/crates/cos/src/writer.rs b/crates/cos/src/writer.rs index c8e2e0040..26dcfc46f 100644 --- a/crates/cos/src/writer.rs +++ b/crates/cos/src/writer.rs @@ -284,13 +284,18 @@ pub fn write_full(doc: &Document, opts: &SaveOptions) -> Result, CosErro collect_refs(&reader.get(r), &mut |x| pending.push(x)); } } + // Acrobat refuses an encrypted file whose catalog sits in an object stream ("the file is + // damaged and could not be repaired", before it even asks for the password), while other + // readers accept it. qpdf keeps the catalog out of object streams too. Verified with + // Acrobat DC on macOS (#774). + let root_outside = if reader.document.output_security().0.is_some() { trailer_in.reference(b"Root") } else { None }; let mut rows: BTreeMap = BTreeMap::new(); // Keep only references, not renamed copies of the entire object graph. Standalone // objects still precede object streams, preserving the established output order. let mut packed: Vec<(u32, ObjRef)> = Vec::new(); for (i, (r, is_stream)) in order.iter().enumerate() { let num = output_number(i)?; - let in_stream = opts.object_streams && !is_stream && !encryption_objects.contains(r); + let in_stream = opts.object_streams && !is_stream && !encryption_objects.contains(r) && root_outside != Some(*r); if in_stream { packed.push((num, *r)); } else { diff --git a/crates/cos/tests/writer.rs b/crates/cos/tests/writer.rs index 4be398bf2..448cf3577 100644 --- a/crates/cos/tests/writer.rs +++ b/crates/cos/tests/writer.rs @@ -152,6 +152,35 @@ fn qpdf_accepts_object_stream_output() { assert!(check.status.success() && report.contains("No syntax or stream encoding errors"), "{report}"); } +#[test] +fn the_catalog_of_an_encrypted_document_stays_outside_object_streams() { + // Acrobat reports an encrypted file as damaged when its catalog is in an object stream, + // before asking for the password (#774). Unencrypted output still packs the catalog. + fn root_is_packed(bytes: &[u8], password: Option<&str>) -> bool { + let doc = Document::open_with_password(Arc::new(bytes.to_vec()), password).unwrap(); + let root = doc.trailer().reference(b"Root").unwrap(); + let direct = format!("\n{} 0 obj", root.num); + !bytes.windows(direct.len()).any(|w| w == direct.as_bytes()) + } + let plain = Document::open(Arc::new(fixture())).unwrap(); + assert!(root_is_packed(&write_full(&plain, &SaveOptions::default()).unwrap(), None), "unencrypted: catalog in an object stream"); + let mut enc = plain.clone(); + enc.set_encryption(&pdfcraft_cos::NewEncryption { + algorithm: pdfcraft_cos::Algorithm::Aes256, + user_password: "user", + owner_password: "owner", + permissions: -4, + encrypt_metadata: true, + seed: [7; 32], + }) + .unwrap(); + let packed = write_full(&enc, &SaveOptions::default()).unwrap(); + assert!(!root_is_packed(&packed, Some("user")), "encrypted: catalog must be a standalone object"); + let back = Document::open_with_password(Arc::new(packed.clone()), Some("user")).expect("reopens"); + assert!(back.repair_log().is_empty(), "{:?}", back.repair_log()); + assert_eq!(title(&back), b"Caf\xe9 (draft)"); +} + #[test] fn objects_referenced_by_the_encrypt_dictionary_stay_outside_object_streams() { // Encrypt a document, then move its crypt filters into indirect objects, as some producers From f8334d0cd3b64cd95aadd7e45d73113e6c38de1a Mon Sep 17 00:00:00 2001 From: muraa-p <85794781+muraa-p@users.noreply.github.com> Date: Sun, 11 Oct 2026 03:07:51 +0800 Subject: [PATCH 10/69] fix(edit): refuse a header start number that would wrap the next page (#804) (#834) Page numbering adds the start number to the position of the page in the run. At the top of the u32 range that addition wrapped, so a release build saved zero for the second page and still reported success, and a debug build hit a caught overflow error instead. Check the whole run before drawing anything. When the last page number would pass the u32 limit the operation returns an actionable error and the document keeps no header, rather than a half applied run. Fixes #804. Co-authored-by: Big Pickle --- crates/edit/src/lib.rs | 17 ++++++++++++++++- crates/edit/src/tests.rs | 19 +++++++++++++++++++ 2 files changed, 35 insertions(+), 1 deletion(-) diff --git a/crates/edit/src/lib.rs b/crates/edit/src/lib.rs index 532b32487..7c7816d5a 100644 --- a/crates/edit/src/lib.rs +++ b/crates/edit/src/lib.rs @@ -414,6 +414,20 @@ pub fn add_header_footer(doc: &mut Document, pages: &[usize], hf: &HeaderFooter, if !(hf.font_size.is_finite() && hf.font_size > 0.0 && hf.font_size <= 200.0 && hf.margins.iter().all(|m| m.is_finite() && *m >= 0.0)) { return Err(EditError::Invalid("invalid font size or margins".into())); } + // Page numbers count up from start_number, so the last selected page needs the most room. + // Refuse the whole run before drawing anything: headers applied to some pages and then a + // failed page number would leave the document half changed. + let last_number = u64::from(hf.start_number) + .checked_add(pages.len().saturating_sub(1) as u64) + .ok_or_else(|| EditError::Invalid("the header page numbers are too large".into()))?; + if last_number > u64::from(u32::MAX) { + return Err(EditError::Invalid(format!( + "the header start number {} leaves no room for {} pages (page numbers stop at {})", + hf.start_number, + pages.len(), + u32::MAX + ))); + } if replace { remove_marks(doc, pages, MarkKind::HeaderFooter)?; } @@ -423,7 +437,8 @@ pub fn add_header_footer(doc: &mut Document, pages: &[usize], hf: &HeaderFooter, for (k, &i) in pages.iter().enumerate() { let page = &all[i]; let (w, h) = page.display_size(doc); - let number = hf.start_number + k as u32; + let number = + u32::try_from(u64::from(hf.start_number) + k as u64).map_err(|_| EditError::Invalid("the header page numbers are too large".into()))?; let bates = bates0 + k as u64; let size = hf.font_size; let [top, bottom, left, right] = hf.margins; diff --git a/crates/edit/src/tests.rs b/crates/edit/src/tests.rs index 03baeac09..10750dcc3 100644 --- a/crates/edit/src/tests.rs +++ b/crates/edit/src/tests.rs @@ -96,6 +96,25 @@ fn header_and_footer_are_drawn_in_display_space_and_wrap_the_original_content() assert_eq!(marks_present(&doc), [MarkKind::HeaderFooter]); } +#[test] +fn header_start_number_that_would_wrap_is_refused_without_drawing() { + // u32::MAX is a valid start_number on its own, but there is no room for a second page + // number after it. The old code wrapped the second page to zero; now the whole run is + // refused and the document keeps no header at all. + let mut doc = fixture(); + let hf = HeaderFooter { + text: [String::new(), String::new(), "<<1>>".into(), String::new(), String::new(), String::new()], + start_number: u32::MAX, + ..HeaderFooter::default() + }; + let err = add_header_footer(&mut doc, &[0, 1], &hf, false, &cx()).unwrap_err(); + assert!(err.to_string().contains("leaves no room"), "{err}"); + assert!(marks_present(&doc).is_empty(), "nothing is drawn when the run is refused"); + // A single page still fits the start number itself. + add_header_footer(&mut doc, &[0], &hf, false, &cx()).unwrap(); + assert_eq!(marks_present(&doc).len(), 1); +} + #[test] fn replace_and_remove_restore_the_original_content() { let mut doc = fixture(); From c7fcee4ed94414c81f68c7da8b6abd93f4a63b7d Mon Sep 17 00:00:00 2001 From: mlkonrad Date: Sat, 10 Oct 2026 22:08:04 +0300 Subject: [PATCH 11/69] =?UTF-8?q?OCR:=20a=20scanned=20euro=20sign=20reads?= =?UTF-8?q?=20as=20=E2=82=AC,=20not=20E=20(#796)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The ocrs crate's default alphabet has an E where its recognition model was trained with € (ocrs-models DEFAULT_ALPHABET), so a scanned "€250" was recognised as "E250". pdfcraft-ocr now gives ocrs the alphabet the model was trained with. the_alphabet_is_the_trained_one checks the alphabet without the models; recognize_text_reads_the_euro_sign reads a scan end to end when they are installed. Co-authored-by: Claude Opus 5.5 --- crates/engine/src/tests.rs | 18 ++++++++++++++++++ crates/ocr/src/lib.rs | 15 +++++++++++++++ 2 files changed, 33 insertions(+) diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 55af82606..5460a2112 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -1697,6 +1697,24 @@ fn backgrounds_and_watermarks_from_files() { /// Scan & OCR ▸ Recognize text on a page that is only a picture of text (needs the models: /// `cargo xtask models`; skipped without them). +#[test] +fn recognize_text_reads_the_euro_sign() { + if !ocr::available() { + eprintln!("skipped: OCR models not installed"); + return; + } + let mut s = Session::new().with_clock(|| 1_700_000_000); + let text = s.create_from_text("t", "Total amount due: €250 by Friday").unwrap(); + let id = s.open("text.pdf", None, text, None).unwrap(); + let png = export::Exporter::new(s.get(id).unwrap()).png(0, 150.0).unwrap(); + let scan = s.create_from_images(&[("scan.png".into(), png)]).unwrap(); + let id = s.open("scan.pdf", None, scan, None).unwrap(); + s.recognize_text(id, &[], ocr::OcrSettings::default()).unwrap(); + let text = page_texts(&s, id)[0].clone(); + // The model was trained with € where the ocrs crate's alphabet has a second E. + assert!(text.contains("€250"), "{text}"); +} + #[test] fn recognize_text_makes_a_scanned_page_searchable() { if !ocr::available() { diff --git a/crates/ocr/src/lib.rs b/crates/ocr/src/lib.rs index ae829d965..a34386c15 100644 --- a/crates/ocr/src/lib.rs +++ b/crates/ocr/src/lib.rs @@ -18,6 +18,11 @@ pub use pdfcraft_fonts::helvetica_width; pub const DETECTION_MODEL: &str = "text-detection.rten"; pub const RECOGNITION_MODEL: &str = "text-recognition.rten"; +/// The recognition model's alphabet, as it was trained (ocrs-models' `DEFAULT_ALPHABET`): its +/// output classes follow this order. The ocrs crate's own copy has an `E` where the training had +/// `€`, which made a scanned "€250" read as "E250". +pub const ALPHABET: &str = " 0123456789!\"#$%&'()*+,-./:;<=>?@[\\]^_`{|}~€ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz"; + /// The languages the models read (ISO 639-1); all use the Latin alphabet without accents. pub const LANGUAGES: &[(&str, &str)] = &[("en", "English")]; @@ -109,6 +114,7 @@ impl Ocr { let params = ocrs::OcrEngineParams { detection_model: Some(load(&models.detection)?), recognition_model: Some(load(&models.recognition)?), + alphabet: Some(ALPHABET.into()), ..Default::default() }; let engine = ocrs::OcrEngine::new(params).map_err(|e| OcrError::Load("ocr engine".into(), e.to_string()))?; @@ -278,6 +284,15 @@ mod tests { } /// The message tells end users what to do; release builds have no cargo. + #[test] + fn the_alphabet_is_the_trained_one() { + let chars: Vec = ALPHABET.chars().collect(); + assert_eq!(chars.len(), 96); + // The euro sign follows the ASCII punctuation (class 44; 0 is the CTC blank), not a second E. + assert_eq!(chars.iter().position(|&c| c == '€'), Some(43)); + assert_eq!(chars.iter().filter(|&&c| c == 'E').count(), 1); + } + #[test] fn missing_models_message_is_for_end_users() { let message = OcrError::NoModels.to_string(); From 5ab928d5296e17aa5ee3d2c48e04651d73b9e7f0 Mon Sep 17 00:00:00 2001 From: muraa-p <85794781+muraa-p@users.noreply.github.com> Date: Sun, 11 Oct 2026 03:08:18 +0800 Subject: [PATCH 12/69] fix(comments): include replies to replies in the comment summary (#820) (#840) The comment summary gathered a comment's direct replies only, matching /IRT against the comment itself. A reply to a reply points at the direct reply, so its text never reached the generated summary even though the stored discussion kept it. Replies are now walked to any depth, with a bound, and indented one level per reply. The existing output for direct replies is unchanged. The new test builds a rectangle, a reply and a reply to that reply, then checks the summary carries both texts with the nested one indented under its parent. It omitted the nested reply before the fix. Co-authored-by: Big Pickle --- crates/engine/src/lib.rs | 29 +++++++++++++++++++++-------- crates/engine/src/tests.rs | 13 +++++++++++++ 2 files changed, 34 insertions(+), 8 deletions(-) diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 7b4efab47..1e2979d2f 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -3190,9 +3190,25 @@ pub fn comment_kind(a: &pdfcraft_render::Annotation) -> &str { pub fn comment_summary(name: &str, all: &[pdfcraft_render::Annotation], sort: SummarySort) -> String { use std::fmt::Write; let top: Vec<&pdfcraft_render::Annotation> = all.iter().filter(|a| a.in_reply_to.is_none() && a.state.is_none()).collect(); - let replies_of = |a: &pdfcraft_render::Annotation| -> Vec<&pdfcraft_render::Annotation> { - all.iter().filter(|r| r.state.is_none() && r.in_reply_to.is_some() && r.in_reply_to == a.name && a.name.is_some()).collect() - }; + // Write a comment's replies, indenting each level. A reply to a reply keeps its `/IRT` on the + // direct parent, so following one level would leave the deeper replies out of the summary. + fn write_replies(out: &mut String, all: &[pdfcraft_render::Annotation], parent: &str, depth: usize) { + use std::fmt::Write; + if depth > 64 { + return; + } + for r in all.iter().filter(|r| r.state.is_none() && r.in_reply_to.as_deref() == Some(parent)) { + let indent = " ".repeat(depth + 1); + let _ = + writeln!(out, "{indent}Author: {} Subject: Reply Date: {}", r.author.as_deref().unwrap_or(""), r.modified.as_deref().unwrap_or("")); + for line in r.contents.as_deref().unwrap_or("").lines() { + let _ = writeln!(out, "{indent}{line}"); + } + if let Some(nm) = r.name.as_deref() { + write_replies(out, all, nm, depth + 1); + } + } + } let mut numbered: Vec<(usize, &pdfcraft_render::Annotation)> = Vec::new(); let mut last = usize::MAX; let mut n = 0; @@ -3229,11 +3245,8 @@ pub fn comment_summary(name: &str, all: &[pdfcraft_render::Annotation], sort: Su if let Some(c) = a.contents.as_deref().filter(|c| !c.is_empty()) { let _ = writeln!(out, "{c}"); } - for r in replies_of(a) { - let _ = writeln!(out, " Author: {} Subject: Reply Date: {}", r.author.as_deref().unwrap_or(""), r.modified.as_deref().unwrap_or("")); - for line in r.contents.as_deref().unwrap_or("").lines() { - let _ = writeln!(out, " {line}"); - } + if let Some(nm) = a.name.as_deref() { + write_replies(&mut out, all, nm, 0); } out.push('\n'); } diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 5460a2112..8687cc586 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -1553,6 +1553,19 @@ fn comments_lock_take_checkmarks_hide_and_summarize() { assert_eq!(comment_summary("x", &[], SummarySort::Page), "Summary of Comments on x\n\nThis document has no comments.\n"); } +/// #820: the generated summary includes replies to replies, indented one level deeper. +#[test] +fn comment_summary_includes_replies_to_replies() { + let (mut s, id) = session_with(1); + s.apply(id, rect_comment(0, [40.0, 40.0, 90.0, 90.0])).unwrap(); + s.apply(id, Edit::ReplyToAnnotation { page: 0, index: 0, text: "FIRST_REPLY".into(), author: "A".into() }).unwrap(); + s.apply(id, Edit::ReplyToAnnotation { page: 0, index: 1, text: "NESTED_REPLY".into(), author: "B".into() }).unwrap(); + let text = comment_summary("doc.pdf", &s.get(id).unwrap().info.annotations, SummarySort::Page); + assert!(text.contains("FIRST_REPLY"), "{text}"); + assert!(text.contains("NESTED_REPLY"), "replies to replies are summarized: {text}"); + assert!(text.contains(" NESTED_REPLY"), "the nested reply is indented under its parent: {text}"); +} + #[test] fn signing_saving_trusting_and_commenting_afterwards() { let p12 = std::fs::read(concat!(env!("CARGO_MANIFEST_DIR"), "/../sign/tests/data/ec-p256.p12")).unwrap(); From 7608632db1923b3899a68e935a95654aa1c0f00d Mon Sep 17 00:00:00 2001 From: MarekSpodBiedry <73122968+MarekSpodBiedry@users.noreply.github.com> Date: Sat, 10 Oct 2026 21:08:32 +0200 Subject: [PATCH 13/69] fix(xfdf): drop the replaced note's popup when re-importing a comment (#807) (#860) --- crates/xfdf/README.md | 7 ++++--- crates/xfdf/src/lib.rs | 10 +++++++++- crates/xfdf/src/tests.rs | 21 +++++++++++++++++++++ 3 files changed, 34 insertions(+), 4 deletions(-) diff --git a/crates/xfdf/README.md b/crates/xfdf/README.md index f30155b63..fc79172fd 100644 --- a/crates/xfdf/README.md +++ b/crates/xfdf/README.md @@ -17,6 +17,7 @@ let report = import(&mut doc, &bytes)?; // XFDF, FDF, XML, CSV o - **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). -Import replaces comments with the same `/NM`, 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 with `roxmltree`. +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 +with `roxmltree`. diff --git a/crates/xfdf/src/lib.rs b/crates/xfdf/src/lib.rs index 190080611..d839a0525 100644 --- a/crates/xfdf/src/lib.rs +++ b/crates/xfdf/src/lib.rs @@ -635,13 +635,21 @@ fn place(doc: &mut Document, page_ref: ObjRef, d: Dict) -> Result = Vec::new(); 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 { + if same { + removed.extend(a.as_ref()); + } else { kept.push(a); } } + // 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)); // Our own appearance where we can draw one; others keep none (viewers draw a default). let _ = pdfcraft_annot::set_appearance(doc, r); diff --git a/crates/xfdf/src/tests.rs b/crates/xfdf/src/tests.rs index ca1debe49..371884ded 100644 --- a/crates/xfdf/src/tests.rs +++ b/crates/xfdf/src/tests.rs @@ -331,3 +331,24 @@ fn unicode_annotation_colours_are_ignored_without_panicking() { assert_eq!(report.comments, 1); } } + +/// Importing the same note again must not leave the earlier pop-up on the page. +#[test] +fn reimporting_a_note_replaces_its_popup() { + let xfdf = concat!( + r#"body"#, + r#""#, + ); + let mut doc = blank(); + for _ in 0..3 { + import(&mut doc, xfdf.as_bytes()).unwrap(); + } + let page = pdfcraft_model::pages(&doc)[0].obj; + let annots = doc.get(page).as_dict().and_then(|p| p.get(b"Annots").cloned()).and_then(|a| doc.resolve(&a).as_array().cloned()).unwrap(); + let refs: Vec = annots.iter().filter_map(|a| a.as_ref()).collect(); + let is_popup = |r: &ObjRef| doc.get(*r).as_dict().and_then(|d| d.name(b"Subtype")) == Some(b"Popup".as_slice()); + let popups: Vec = refs.iter().copied().filter(is_popup).collect(); + assert_eq!(popups.len(), 1, "one current pop-up, not one per import"); + let parent = doc.get(popups[0]).as_dict().and_then(|d| d.reference(b"Parent")).unwrap(); + assert!(refs.contains(&parent), "the pop-up belongs to a note that is still on the page"); +} From 724f13cced3d84441660217d2cfb81cb5798e7cd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Krzysztof=20Cie=C5=9Blik?= <132496025+Hathor875@users.noreply.github.com> Date: Sat, 10 Oct 2026 21:08:55 +0200 Subject: [PATCH 14/69] fix(view): check Shift-Command-G before Command-G for find navigation (#810) (#835) * view: check Shift-Command-G before Command-G for find navigation (#810) * Tests: the find shortcut test waits for find by a deadline (cherry picked from commit eaf3180bd8ebb353b3d8c5ca7b9ef7548ed55c37) --------- Co-authored-by: Brandon Thomas --- crates/ui-egui/src/canvas.rs | 7 +++--- crates/ui-egui/tests/viewer.rs | 39 ++++++++++++++++++++++++++++++++++ 2 files changed, 42 insertions(+), 4 deletions(-) diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 2dc7c5765..6a3193e80 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -1612,7 +1612,9 @@ pub fn shortcuts(view: &mut DocView, ctx: &egui::Context) { view.fit = Fit::Width; view.goto = Some((view.current, 0.0)); } - if pressed(cmd(Key::G)) { + if pressed(KeyboardShortcut::new(Modifiers::COMMAND | Modifiers::SHIFT, Key::G)) { + view.find_step(false); + } else if pressed(cmd(Key::G)) { view.find_step(true); } if pressed(cmd(Key::OpenBracket)) { @@ -1621,9 +1623,6 @@ pub fn shortcuts(view: &mut DocView, ctx: &egui::Context) { if pressed(cmd(Key::CloseBracket)) { view.view_history(true); } - if pressed(KeyboardShortcut::new(Modifiers::COMMAND | Modifiers::SHIFT, Key::G)) { - view.find_step(false); - } // ⌘C arrives as a Copy event on most platforms. let copy = ctx.input(|i| i.events.iter().any(|e| matches!(e, egui::Event::Copy))); if copy && let Some(text) = view.selected_text() { diff --git a/crates/ui-egui/tests/viewer.rs b/crates/ui-egui/tests/viewer.rs index 46c04cc0f..e6465d2cb 100644 --- a/crates/ui-egui/tests/viewer.rs +++ b/crates/ui-egui/tests/viewer.rs @@ -130,6 +130,45 @@ fn view_history_select_all_and_find_options() { assert_eq!(h.state().views[0].find.as_ref().unwrap().matches.len(), 5); } +#[test] +fn find_shortcuts_advance_and_retreat_matches() { + let mut h = harness(); + h.state_mut().active = Some(0); + h.run_steps(2); + h.state_mut().views[0].open_find(); + h.state_mut().views[0].find.as_mut().unwrap().query = "page".into(); + h.state_mut().views[0].rerun_find(); + // Find runs in the background: wait by a deadline, not a frame count, which a loaded machine + // can use up before the last page is searched. + let deadline = std::time::Instant::now() + std::time::Duration::from_secs(20); + while std::time::Instant::now() < deadline { + h.run_steps(2); + std::thread::sleep(std::time::Duration::from_millis(5)); + if h.state().views[0].find.as_ref().unwrap().matches.len() >= 15 { + break; + } + } + let n = h.state().views[0].find.as_ref().unwrap().matches.len(); + assert_eq!(n, 15); + // Surrender text edit focus so canvas shortcut handler receives key events. + h.state_mut().views[0].find.as_mut().unwrap().focus = false; + h.ctx.memory_mut(|m| m.surrender_focus(egui::Id::new("find-input"))); + h.run_steps(2); + let start = h.state().views[0].find.as_ref().unwrap().current.unwrap(); + // ⌘G advances to next match. + h.key_press_modifiers(egui::Modifiers::COMMAND, egui::Key::G); + h.run_steps(2); + assert_eq!(h.state().views[0].find.as_ref().unwrap().current, Some((start + 1) % n)); + // ⇧⌘G retreats to previous match. + h.key_press_modifiers(egui::Modifiers::COMMAND | egui::Modifiers::SHIFT, egui::Key::G); + h.run_steps(2); + assert_eq!(h.state().views[0].find.as_ref().unwrap().current, Some(start)); + // ⇧⌘G again wraps backwards. + h.key_press_modifiers(egui::Modifiers::COMMAND | egui::Modifiers::SHIFT, egui::Key::G); + h.run_steps(2); + assert_eq!(h.state().views[0].find.as_ref().unwrap().current, Some((start + n - 1) % n)); +} + #[test] fn layouts_fit_height_labels_and_system_theme() { use pdfcraft_ui_egui::canvas::{Fit, PageLayout}; From f54aa1db1eb9ab16145ab4dd40e0403b2a48eb5e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Krzysztof=20Cie=C5=9Blik?= <132496025+Hathor875@users.noreply.github.com> Date: Sat, 10 Oct 2026 21:11:19 +0200 Subject: [PATCH 15/69] fix(editing): recognize paragraph drafts as unsaved work (#811) (#843) * view: check Shift-Command-G before Command-G for find navigation (#810) * fix: recognize paragraph drafts as unsaved work (Issue #811) --------- Co-authored-by: Brandon Thomas --- crates/ui-egui/src/edit_text_ui.rs | 5 +++++ crates/ui-egui/src/editing.rs | 1 + 2 files changed, 6 insertions(+) diff --git a/crates/ui-egui/src/edit_text_ui.rs b/crates/ui-egui/src/edit_text_ui.rs index f5d530713..946a3080c 100644 --- a/crates/ui-egui/src/edit_text_ui.rs +++ b/crates/ui-egui/src/edit_text_ui.rs @@ -87,6 +87,11 @@ impl LineEditor { (self.max_width > self.rect.width()).then_some(self.max_width) } + /// Whether the edited text differs from the original (unsaved changes). + pub fn has_unsaved_text(&self) -> bool { + self.text != self.original + } + /// The formatting the panel changed. pub fn style(&self) -> pdfcraft_engine::BlockStyle { let (l, o) = (&self.look, &self.look0); diff --git a/crates/ui-egui/src/editing.rs b/crates/ui-egui/src/editing.rs index f1b3166e7..08aab2873 100644 --- a/crates/ui-egui/src/editing.rs +++ b/crates/ui-egui/src/editing.rs @@ -349,6 +349,7 @@ impl PdfCraftApp { || v.content.blocked().is_some() || v.forms.focus.as_ref().is_some_and(|f| crate::forms_ui::draft_edit(f, &doc.form).is_some()) || v.fill_text.as_ref().is_some_and(|t| !t.text.trim().is_empty()) + || v.line_editor.as_ref().map_or_else(|| false, |ed| ed.has_unsaved_text()) } /// Save the active document. Returns `true` if it was written. From fc08d56e973ca8b05ec95d26f6fd8ba4985df78c Mon Sep 17 00:00:00 2001 From: Nigel Ihza Firdausy <106657905+Ni-zav@users.noreply.github.com> Date: Sun, 11 Oct 2026 02:29:47 +0700 Subject: [PATCH 16/69] fix(automation): retain batch results with identical filenames (#877) * fix(automation): retain batch results with identical filenames * Format --------- Co-authored-by: Brandon Thomas --- crates/automation/src/a11y.rs | 12 ++++++++++- crates/automation/tests/automation.rs | 30 +++++++++++++++++++++++++++ 2 files changed, 41 insertions(+), 1 deletion(-) diff --git a/crates/automation/src/a11y.rs b/crates/automation/src/a11y.rs index b58b2d587..81f79d14e 100644 --- a/crates/automation/src/a11y.rs +++ b/crates/automation/src/a11y.rs @@ -3,7 +3,7 @@ use pdfcraft_engine::a11y::{Category, Options, Report, Rule, Status}; use serde_json::{Value, json}; -use crate::{Args, Automation, Result, ToolError, child, failed, write_atomic}; +use crate::{Args, Automation, Result, ToolError, child, failed, unused, write_atomic}; fn status_id(s: Status) -> &'static str { match s { @@ -347,6 +347,16 @@ impl Automation { out.push(match result { Ok(r) => { let target = child(&folder, &name); + let target = if target.exists() { + let stem = + std::path::Path::new(&name).file_stem().map(|s| s.to_string_lossy().into_owned()).unwrap_or_else(|| "document".into()); + unused(&folder, &stem) + } else { + target + }; + if target.exists() { + return Err(failed(format!("{}: no unused output name available", target.display()))); + } write_atomic(&target, &r.bytes)?; json!({ "path": p, "output": target.to_string_lossy(), "log": r.log }) } diff --git a/crates/automation/tests/automation.rs b/crates/automation/tests/automation.rs index 6d0e8262f..c50966847 100644 --- a/crates/automation/tests/automation.rs +++ b/crates/automation/tests/automation.rs @@ -3026,6 +3026,36 @@ fn actions_through_tools() { assert!(a.call("action_run", &json!({ "steps": [{ "step": "fly" }], "paths": ["a.pdf"], "folder": "x" })).is_err()); } +#[test] +fn action_run_keeps_outputs_with_duplicate_input_basenames() { + let dir = workdir("action-duplicate-basenames"); + std::fs::create_dir_all(dir.join("left")).unwrap(); + std::fs::create_dir_all(dir.join("right")).unwrap(); + std::fs::write(dir.join("left/report.pdf"), fixture(1)).unwrap(); + std::fs::write(dir.join("right/report.pdf"), fixture(2)).unwrap(); + let mut a = auto(&dir); + let args = json!({ + "steps": [{ "step": "set_title", "arg": "Processed" }], + "paths": ["left/report.pdf", "right/report.pdf"], + "folder": "results" + }); + let result = ok(&mut a, "action_run", args.clone()); + let files = result["files"].as_array().unwrap(); + assert_eq!(files.len(), 2); + let first_path = files[0]["output"].as_str().unwrap(); + let second_path = files[1]["output"].as_str().unwrap(); + assert!(first_path.ends_with("report.pdf"), "{first_path}"); + assert!(second_path.ends_with("report (2).pdf"), "{second_path}"); + let first_bytes = std::fs::read(first_path).unwrap(); + assert_eq!(ok(&mut a, "doc_open", json!({ "path": first_path }))["pages"], 1); + assert_eq!(ok(&mut a, "doc_open", json!({ "path": second_path }))["pages"], 2); + + let repeated = ok(&mut a, "action_run", args); + assert!(repeated["files"][0]["output"].as_str().unwrap().ends_with("report (3).pdf")); + assert!(repeated["files"][1]["output"].as_str().unwrap().ends_with("report (4).pdf")); + assert_eq!(std::fs::read(first_path).unwrap(), first_bytes, "an earlier output must not be replaced"); +} + #[test] fn pdfa_through_tools() { let dir = workdir("pdfa"); From 40a57f0d1903aefb18fabaa6e97f283184cc44d4 Mon Sep 17 00:00:00 2001 From: Tevinhead Date: Sun, 11 Oct 2026 02:31:06 +0700 Subject: [PATCH 17/69] M13: preserve desktop Action Wizard batch results (#886) * M13: preserve desktop Action Wizard batch results Co-Authored-By: Codex * ROADMAP: date the desktop action outputs entry today --------- Co-authored-by: Tevinhead <173904641+Tevinhead@users.noreply.github.com> Co-authored-by: Codex Co-authored-by: Brandon Thomas --- ROADMAP.md | 83 ++++++++++++++++++++++++++++++++ crates/ui-egui/src/actions_ui.rs | 32 +++++++++++- crates/ui-egui/tests/actions.rs | 45 +++++++++++++++++ parity/acrobat-features.toml | 4 +- 4 files changed, 161 insertions(+), 3 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index c0b11558e..fc09e2886 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -133,8 +133,91 @@ Ranked; detail and the full milestone table in [docs/roadmap.md](docs/roadmap.md ## Progress log +| Area | Shipped | What's strong / what's missing | +|---|---|---| +| A Core | 75% | Parser, repair, encryption, incremental saves. Missing: lazy loading (`ByteSource`), own image codecs, PDF 2.0 extras, Arlington validation | +| F Forms | 71% | Filling, authoring, AF scripts, sandboxed JavaScript, data exchange. Missing: XFA, wider JavaScript object model | +| E Comments | 71% | Markup types with appearances, XFDF/FDF, summaries, calibrated 2D measuring (single-unit decimal scales). Missing: replace-text proposals, summary layouts, compound/fractional measurement formats | +| M Accessibility | 62% | Checker (all 32 rules). Missing: autotag, Tags/Order/Content panels, Reading Order tool, keyboard-only operation | +| B View | 55% | Shell, find, panels, tiles, web build. **Rendering is borrowed (`hayro`)**, so the 7 rendering P0s are only partial | +| D Organize | 55% | Pages, combine, split, bookmarks, labels. Missing: replace pages, transitions | +| G Protect | 54% | Passwords, permissions, redaction (including word lists), sanitize. Redaction codes (built-in FOIA and Privacy Act sets). Missing: certificate security, custom redaction code sets | +| H Sign | 48% | PAdES B-B signing and validation; macOS Keychain and Windows CNG certificate store signing. Missing: timestamps (B-T), LTV (DSS/OCSP/CRL), FieldMDP, smart cards, PKCS #11 | +| C Edit | 46% | Added text and images stay editable; header/footer/watermark. Missing: robust editing of existing text and images (fonts, subsets, reflow) | +| L Print | 36% | Acrobat-style sizing, n-up, booklet, CUPS. Missing: Windows and web printing, production options | +| J Create | 32% | From images, text, clipboard; Word/HTML/RTF export. Missing: Office import, Excel/PowerPoint export | +| N Misc | 27% | CLI, MCP, UI control channel, Action Wizard. Missing: AI providers, performance budgets | +| K Optimize | 26% | Reduce File Size, Optimizer. Missing: preflight, PDF/X/UA, transparency/fonts panels | +| I OCR | 19% | Searchable image for Latin script. Missing: other scripts and accents, editable-text output, deskew | + +**What "shipped" means, and doesn't.** A feature is shipped when it exists and at least one specific test covers it. 159 of 398 shipped features rest on exactly one test, and only about 5 cite an external oracle (`pdftotext`, `pdfsig`, OpenSSL, a corpus). Shipped does not mean "as good as Acrobat". + +**Robustness, measured.** `main`'s CI was red for 30+ runs until 2026-10-04 (a clippy lint, Windows line endings, flaky perf tests). Once the nightly fuzz job could finish, three 15-minute runs found 7 out-of-memory crashes (one aborted on every platform) and 11 hangs, 7 of them in our own `cos` parser rather than `hayro`. All are fixed or in review, but expect more: keep the nightly fuzz job green and turn every finding into a synthetic test. + +## Where we're lacking and where we're going + +The gaps that matter most, in priority order. Agents: prefer these over adding P2/P3 features, and check `parity/acrobat-features.toml` notes for the specifics of each. + +1. **Our own renderer (M2).** Replace `hayro` with the `model` crate, our font engine and the DisplayList devices (ADR-0004). It turns the 7 rendering P0 partials into shipped features and removes most vendored patches. The largest single lever. +2. **Hardening.** Keep the nightly fuzz job green; fix every crash and hang with a synthetic regression test; finish lazy loading so large files aren't read whole; set performance budgets (`misc.performance-budgets`). +3. **Measure fidelity against Acrobat.** Build a side-by-side harness (render, text, form behaviour) over synthetic fixtures (`plan/acrobat/`, clean-room rules apply). Until then, parity numbers count features, not quality. +4. **Editing existing content (M7).** Fonts, subsets, reflow and CJK. The most visible gap for Pro users. +5. **Pro workflows.** Signatures with timestamps and LTV (M9), OCR beyond Latin (M10), Office import/export (M10), preflight and PDF/A/X/UA (M11), XFA (M12). +6. **1.0 polish (M14).** Code-signed installers, localization (the catalog system is in place and French, German, Spanish, Japanese, Russian and both Chinese catalogs cover the UI; Czech and Brazilian Portuguese still cover menus only), keyboard-only operation, a full screen-reader audit. +6. **1.0 polish (M14).** Code-signed installers, localization (the catalog system is in place and French, Spanish, Japanese, Russian, Ukrainian and both Chinese catalogs cover the UI; Czech and Brazilian Portuguese still cover menus only), keyboard-only operation, a full screen-reader audit. +6. **1.0 polish (M14).** Code-signed installers, localization (the catalog system is in place and French, Spanish, Italian, Japanese, Russian and both Chinese catalogs cover the UI; Czech and Brazilian Portuguese still cover menus only), keyboard-only operation, a full screen-reader audit. + +Decided against for now (owner, 2026-10-05): self-installing updates and an update check at start. Help ▸ Check for updates is manual only. + +## Milestones + +Hours are for a single agent (low–high). "Done" is the estimated fraction of that milestone's *acceptance criteria* that are met. It is not a count of lines of code. + +| M | Milestone | Est. hours | Done | Remaining (h) | Notes | +|---|---|---|---|---|---| +| M0 | Skeleton: workspace, xtask gates, CI | 15–30 | 92% | 2–4 | GitHub workflow, `deny.toml` (licence audit of every dependency), parity checklist (826 features, `xtask parity`) done. Missing: remaining crate stubs, testkit/oracle crates | +| M2 | Model, render, text | 200–350 | 15% | 170–300 | 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 | +| M1 | COS: filters, crypt, parser, xref, writer | 120–200 | 78% | 25–45 | 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 | +| M1 | COS: filters, crypt, parser, xref, writer | 120–200 | 78% | 25–45 | 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), 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% | 170–300 | hayro bootstrap renderer (vendored patches). 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 | 88% | 10–19 | 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. | +| M3 | Viewer app (native + web) | 80–150 | 88% | 10–19 | 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% | 9–17 | 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% | 15–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 | 45% | 95–185 | 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 | 17% | 210–420 | 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 | 66% | 35–65 | 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 | 55% | 70–125 | 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% | 130–235 | 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, CUPS spooler, 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, Windows/web printing | +| M11 | Optimize, preflight, PDF/A/X/UA, print production | 200–350 | 18% | 165–290 | 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 | 23% | 155–295 | 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% | 33–66 | 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. Desktop Action Wizard results receive unused filenames when names repeat or earlier outputs exist. Missing: Action Wizard, AI providers | +| M14 | 1.0 polish: performance, localization, installers | 120–250 | 5% | 115–240 | 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!`; Japanese, Traditional Chinese, Simplified Chinese, Russian, Bulgarian, German, Spanish, French, Hungarian and Ukrainian catalogs have ≈1,800–2,100 entries each; Simplified Chinese, French, German, Hungarian and Ukrainian coverage is checked against the UI source; Czech and Brazilian Portuguese for menus. French locales (`fr`, `fr_FR`, `fr_CA`) follow the French catalog; Ukrainian locales (`uk`, `uk_UA`) follow the Ukrainian one; its certificate and accessibility wording has a dictionary-checked follow-up. #307 (bounded large-PDF memory): share resource indexes and native parser metadata, inspect structure lazily, bound scan/full-save caches and raster storage, and reuse signature discovery safely. Three Linux trials retain full-save workflow RSS at 1.06 GiB (previously 3.69 GiB); rebased engine navigation uses 821 MiB (previously 997 MiB). With the RSS guard removed, the unmodified baseline was kernel OOM-killed in a 15 GiB container; rebased optimized inspection completed at 497.7 MiB in 1.78 s. Strict Clippy, 1,336 workspace tests and 30 WASM crate checks pass. The larger-host follow-up is closed. File-backed input, global memory budgets and Windows validation remain pending. Missing: more catalogs filled in, installers, performance budgets | +| M14 | 1.0 polish: performance, localization, installers | 120–250 | 5% | 115–240 | 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!`; Japanese, Traditional Chinese, Simplified Chinese, Russian, Spanish, French and Italian catalogs have ≈1,800 entries each; Simplified Chinese and French coverage is checked against the UI source; Czech and Brazilian Portuguese for menus. French locales (`fr`, `fr_FR`, `fr_CA`) follow the French catalog; Italian locales (`it`, `it_IT`) the Italian one. Arabic (`ar`, every `ar-*` locale) has a full catalog whose labels are reordered for display; the layout itself is still left to right. Missing: a right-to-left layout for Arabic, more catalogs filled in, installers, performance budgets | +| | **Total (original plan sizing)** | **2,085–3,840** | **≈ 35%** | **≈ 1,350–2,500 at the planned rate; ≈ 600–1,100 at the measured rate** | | + +**Overall progress: about 30–35% of the effort (51.0% of features shipped).** The viewer and the core are far ahead of the editing features, because the viewer was built first so progress could be seen; rendering itself is still borrowed from `hayro`. See [Honest assessment](#honest-assessment-2026-10-05). + +## Critical path + +M0 → M1 → M2 → M3 → M4 must happen in order. After M4, M5–M12 can run in parallel across crates. The long poles are M7 (content editing), M9 (signatures) and M12 (XFA). + +## Risks most likely to push estimates up + +- Fidelity of text editing: fonts, subsets, reflow. +- XFA dynamic layout. +- Real-world signature chains and revocation checks. +- The rendering long tail: Type3 fonts, broken fonts, shadings. +- Correctness of PDF/A and PDF/UA conversion. +- CPU rendering performance on the web. +- Hostile input: every fuzz run so far has found new crashes or hangs. +- Unmeasured fidelity: without an Acrobat comparison harness, quality gaps surface as user reports. + +## Log Newest first: the date, what moved, and the new overall estimate. +- **2026-10-10 (M13, desktop action outputs):** Desktop Action Wizard keeps every result when input basenames repeat, or when the destination already has a result from an earlier run. It preserves the original filename and extension for a free name and adds numbered suffixes for occupied names; the bounded search returns an error rather than reusing an occupied path. A real-shell regression drives Start twice and reopens the three-page and one-page results, checking that the original inputs and earlier output remain intact. This covers the desktop writer separately from the CLI/MCP action_run repair in #877. Milestone and effort estimates unchanged. + - **2026-10-10 (progress docs, hours per audience):** Each readiness number gets its own hours to ≈ 95%: full ≈ 49% / 780–1,400 h, mainstream ≈ 55% / 500–900 h, essentials ≈ 66% / 180–330 h. The full number is now the standard's additive weighted sum over the dimensions (written weights), 47% → 49%: method aligned with the standard, no new evidence. Beta ≈ 26 points, ≈ 400–700 h. - **2026-10-10 (progress docs, gate consistency check):** Re-judged the convert-and-share row with the rule applied across all Crafting Apps (a workflow blocks only when it can't be completed at all; lossy exchange and missing sub-cases are beta items, as for FilmCraft's .prproj or VectorCraft's .ai). Unencrypted sharing works, encryption interop (#774) and Office→PDF are sub-cases, Word export is lossy rather than absent: partial, not blocking. **Stage pre-alpha → alpha**; numbers unchanged (47%, mainstream 55%; beta ≈ 400–700 h). Added an essentials-user score: ≈ 66% for someone who only opens, reads, comments, fills, signs, prints and rearranges pages. - **2026-10-10 (progress docs, second look and alpha gate):** At the owner's request, after practitioners praised PdfCraft, re-examined the numbers. User sentiment: 7,595 stars, 177k + 39k downloads of 0.4.0/0.5.0, 23 user issues with explicit praise and switching reports (#179, #232, #273), issue themes concentrated in core paths (launch/GPU, rendering, editing, CJK, signing, printing). The checklist is not niche-dominated (497 mainstream vs 311 specialist rows; P0 95% / 91%). The first pass's 9-point cross-cutting deduction double-counted Windows printing, interop and glyphs: **ready for real work 40% → 47%**, and **≈ 55% for the mainstream practitioner** (new). The new core-workflow gate (craftrules progress-docs) fails on convert and share (#774, no Office import, #773): **stage alpha → pre-alpha**, ≈ 40–70 h from alpha; beta still ≈ 400–700 h. diff --git a/crates/ui-egui/src/actions_ui.rs b/crates/ui-egui/src/actions_ui.rs index d3e0bfeac..b31426373 100644 --- a/crates/ui-egui/src/actions_ui.rs +++ b/crates/ui-egui/src/actions_ui.rs @@ -28,6 +28,36 @@ pub struct RunProgress { pub message: Option, } +/// Keep each desktop result, including when input basenames repeat or an earlier run already +/// used the name. The search is bounded and never falls back to an occupied destination. +#[cfg(not(target_arch = "wasm32"))] +fn write_result(dir: &std::path::Path, name: &str, bytes: &[u8]) -> std::io::Result { + use std::io::{Error, ErrorKind}; + let source = std::path::Path::new(name); + let filename = source.file_name().ok_or_else(|| Error::from(ErrorKind::InvalidInput))?; + let stem = source.file_stem().unwrap_or(filename).to_string_lossy(); + for number in 1..=10_000 { + let path = if number == 1 { + dir.join(filename) + } else { + let suffix = match source.extension() { + Some(ext) => format!("{stem} ({number}).{}", ext.to_string_lossy()), + None => format!("{stem} ({number})"), + }; + dir.join(suffix) + }; + match std::fs::symlink_metadata(&path) { + Ok(_) => continue, + Err(e) if e.kind() == ErrorKind::NotFound => { + crate::editing::write_atomically(&path.to_string_lossy(), bytes)?; + return Ok(path); + } + Err(e) => return Err(e), + } + } + Err(Error::from(ErrorKind::AlreadyExists)) +} + impl PdfCraftApp { /// Built-in actions, then the user's. pub fn all_actions(&self) -> Vec { @@ -75,7 +105,7 @@ impl PdfCraftApp { } let r = pdfcraft_engine::actions::run_on(&action, &name, Arc::new(bytes), |_, _| {}).and_then(|r| { #[cfg(not(target_arch = "wasm32"))] - crate::editing::write_atomically(&dir.join(&name).to_string_lossy(), &r.bytes).map_err(|e| e.to_string())?; + write_result(&dir, &name, &r.bytes).map_err(|e| e.to_string())?; #[cfg(target_arch = "wasm32")] crate::editing::download(&name, &r.bytes)?; Ok(()) diff --git a/crates/ui-egui/tests/actions.rs b/crates/ui-egui/tests/actions.rs index dd4da8d3d..8e5f37cf0 100644 --- a/crates/ui-egui/tests/actions.rs +++ b/crates/ui-egui/tests/actions.rs @@ -57,3 +57,48 @@ fn run_and_create_actions() { h.run_steps(3); assert_eq!(h.state().toast.clone().unwrap().0, "Stamp drafts: 1 file done"); } + +#[test] +fn desktop_actions_preserve_duplicate_names_and_results_from_earlier_runs() { + let dir = std::env::temp_dir().join(format!("pdfcraft-actions-collisions-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(dir.join("first")).unwrap(); + std::fs::create_dir_all(dir.join("second")).unwrap(); + std::fs::create_dir_all(dir.join("out")).unwrap(); + let three_pages = Session::new().create_blank(200.0, 300.0, 3).unwrap(); + let one_page = Session::new().create_blank(200.0, 300.0, 1).unwrap(); + std::fs::write(dir.join("first/report.PDF"), &*three_pages).unwrap(); + std::fs::write(dir.join("second/report.PDF"), &*one_page).unwrap(); + // A previous output must survive too, not just the first file in this batch. + std::fs::write(dir.join("out/report.PDF"), &*one_page).unwrap(); + let d = dir.clone(); + let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).build_eframe(move |_cc| { + let mut app = PdfCraftApp::new(); + app.set_option("language", "en").unwrap(); + app.run_inline = true; + app.export_dir_override = Some(d.join("out").to_string_lossy().into_owned()); + app.action_files_override = + Some(vec![d.join("first/report.PDF").to_string_lossy().into_owned(), d.join("second/report.PDF").to_string_lossy().into_owned()]); + app + }); + h.run_steps(2); + for (first, second) in [(2, 3), (4, 5)] { + // Start closes the wizard: reopen it just as a user does for the next run. + h.state_mut().execute("actions.distribution"); + h.run_steps(2); + h.get_by_label("Start").click(); + h.run_steps(3); + assert_eq!(h.state().toast.clone().unwrap().0, "Prepare for Distribution: 2 files done"); + for (number, pages) in [(first, 3), (second, 1)] { + let path = dir.join(format!("out/report ({number}).PDF")); + let mut reopened = Session::new(); + let id = reopened.open(path.to_string_lossy().into_owned(), None, std::sync::Arc::new(std::fs::read(&path).unwrap()), None).unwrap(); + assert_eq!(reopened.get(id).unwrap().info.pages.len(), pages); + } + } + assert_eq!(std::fs::read_dir(dir.join("out")).unwrap().count(), 5); + assert_eq!(std::fs::read(dir.join("out/report.PDF")).unwrap(), *one_page); + assert_eq!(std::fs::read(dir.join("first/report.PDF")).unwrap(), *three_pages); + assert_eq!(std::fs::read(dir.join("second/report.PDF")).unwrap(), *one_page); + std::fs::remove_dir_all(dir).unwrap(); +} diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index 09f1d8582..e55bff060 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -9122,8 +9122,8 @@ milestone = "M13" status = "shipped" commands = ["actions.wizard"] tools = ["action_list", "action_run"] -evidence = ["crates/engine/src/tests.rs::actions_run_their_steps_on_files", "crates/automation/tests/automation.rs::actions_through_tools", "crates/ui-egui/tests/actions.rs::run_and_create_actions"] -notes = "Pick an action and files; results go to a chosen folder, in the background with progress." +evidence = ["crates/ui-egui/tests/actions.rs::desktop_actions_preserve_duplicate_names_and_results_from_earlier_runs", "crates/engine/src/tests.rs::actions_run_their_steps_on_files", "crates/automation/tests/automation.rs::actions_through_tools", "crates/ui-egui/tests/actions.rs::run_and_create_actions"] +notes = "Desktop output filenames receive numbered suffixes when occupied, preserving every batch result and previous outputs. Pick an action and files; results go to a chosen folder, in the background with progress." [[feature]] id = "misc.action-wizard-create" From c7b9db8faaa4279e8bf5e9c59f13506ecf86b40d Mon Sep 17 00:00:00 2001 From: MarekSpodBiedry <73122968+MarekSpodBiedry@users.noreply.github.com> Date: Sat, 10 Oct 2026 21:31:20 +0200 Subject: [PATCH 18/69] Fit to width fits the shown page in single-page view (#808) (#859) --- crates/ui-egui/src/canvas.rs | 43 +++++++++++++++++++++++++++++++----- 1 file changed, 37 insertions(+), 6 deletions(-) diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 6a3193e80..737cad34f 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -1357,7 +1357,7 @@ impl DocView { let avail_w = (self.viewport_w - 2.0 * SIDE).max(100.0); let per_row = if self.layout == PageLayout::TwoUp { 2.0 } else { 1.0 }; match self.fit { - Fit::Width => self.zoom = (avail_w - GAP * (per_row - 1.0)) / (max_w * PT * per_row), + Fit::Width => self.zoom = (avail_w - GAP * (per_row - 1.0)) / (w * PT * per_row), Fit::Page => { let zw = (avail_w - GAP * (per_row - 1.0)) / (w * PT * per_row); let zh = (self.viewport_h - 2.0 * MARGIN) / (h * PT); @@ -1369,6 +1369,16 @@ impl DocView { self.zoom = self.zoom.clamp(0.08, 64.0); } + /// Width of the scroll content. Single-page view sizes it to the page it shows, so a + /// wider page elsewhere in the document can't push the shown one off-centre. + fn content_width(&self, info: &DocInfo, avail_w: f32) -> f32 { + let single = (self.layout == PageLayout::Single).then(|| self.current.min(info.pages.len().saturating_sub(1))); + let shown = info.pages.iter().enumerate().filter(|&(i, _)| single.is_none_or(|c| c == i)); + let max_w = shown.map(|(_, p)| self.display_size(p).0).fold(0.0, f32::max); + let per_row = if self.layout == PageLayout::TwoUp { 2.0 } else { 1.0 }; + (max_w * self.zoom * PT * per_row + 2.0 * SIDE).max(avail_w) + } + /// Page rects in content coordinates (origin at the scroll content's top-left). fn layout(&self, info: &DocInfo, content_w: f32) -> Vec { let s = self.zoom * PT; @@ -1755,11 +1765,7 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) { Vec2::ZERO }; - let max_w = info.pages.iter().map(|p| view.display_size(p).0).fold(0.0, f32::max) - * view.zoom - * PT - * if view.layout == PageLayout::TwoUp { 2.0 } else { 1.0 }; - let content_w = (max_w + 2.0 * SIDE).max(avail.width()); + let content_w = view.content_width(info, avail.width()); let rects = view.layout(info, content_w); let middle_gesture = view.auto_scroll.blocks_input(); // Single page: when the whole page fits, the wheel would do nothing, so it turns pages @@ -4079,6 +4085,31 @@ trailer << /Root 1 0 R >> } } + #[test] + fn fit_width_fits_the_shown_page_in_single_page_view_only() { + let page = + |width: f32, height: f32| pdfcraft_render::PageInfo { width, height, label: String::new(), crop: [0.0, 0.0, width, height], rotation: 0 }; + let info = DocInfo { pages: vec![page(300.0, 400.0), page(600.0, 400.0)], ..Default::default() }; + let mut v = DocView::new(DocId(1), &info, ViewDefaults::default()); + (v.fit, v.viewport_w, v.viewport_h) = (Fit::Width, 1000.0, 800.0); + v.layout = PageLayout::Single; + v.fit_zoom(&info); + let narrow = v.zoom; + // The shown page fills the width between the side gutters, not centred for the wide page. + let shown = v.layout(&info, v.content_width(&info, 1000.0))[0]; + assert!((shown.left() - SIDE).abs() < 1e-2 && (shown.width() - 860.0).abs() < 1e-2, "{shown:?}"); + v.current = 1; + v.fit_zoom(&info); + assert!((narrow / v.zoom - 2.0).abs() < 1e-3, "the narrow page gets twice the zoom of the wide one"); + // Scrolling views keep one zoom for the whole document. + v.layout = PageLayout::Continuous; + v.fit_zoom(&info); + let wide = v.zoom; + v.current = 0; + v.fit_zoom(&info); + assert_eq!(v.zoom, wide, "continuous fit width holds still across page sizes"); + } + #[test] fn layouts_match_their_commands_and_names() { for l in PageLayout::ORDER { From eb75e5a9d910220b35a383137abc08ded48cf52f Mon Sep 17 00:00:00 2001 From: muraa-p <85794781+muraa-p@users.noreply.github.com> Date: Sun, 11 Oct 2026 03:31:35 +0800 Subject: [PATCH 19/69] fix(links): find URLs when lowercasing changes the text length (#816) (#830) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit find_urls case-folded a copy of the page text and then sliced the original characters with offsets taken from that copy. Case folding can change a string's length — U+0130 (İ) lowercases to "i" plus a combining dot — so the offsets drifted past the end of the source and `chars[i..j]` panicked. The CLI exited 101 before it could create the link; MCP reported an internal tool error but kept serving. Match the ASCII prefixes case-insensitively against the original characters instead, so every offset stays in the source's own index space, and use get() so no lookup can index past the end. A new test reproduces the reported panic on the old code (range start index 41 out of range for slice of length 39) and passes with the fix. pdfcraft-annot's tests, clippy and rustfmt are green. Fixes #816. Co-authored-by: Big Pickle --- crates/annot/src/links.rs | 26 ++++++++++++++------------ crates/annot/src/tests.rs | 25 +++++++++++++++++++++++++ 2 files changed, 39 insertions(+), 12 deletions(-) diff --git a/crates/annot/src/links.rs b/crates/annot/src/links.rs index 3d8d6d4d1..f5ba6fb1c 100644 --- a/crates/annot/src/links.rs +++ b/crates/annot/src/links.rs @@ -299,27 +299,29 @@ pub fn remove_all(doc: &mut Document, pages: Option<&[usize]>) -> Result Vec<(std::ops::Range, String)> { - let text: String = chars.iter().collect(); - let lower = text.to_lowercase(); - let lc: Vec = lower.chars().collect(); - let mut out = Vec::new(); let url_char = |c: char| !c.is_whitespace() && !"<>\"'()[]{}".contains(c); + // Case-insensitive prefix test that never steps past the end. Comparing against the original + // characters keeps every offset in `chars`'s own index space: case-folding a copy can change + // its length (U+0130 becomes "i" plus a combining dot), so a folded copy must never supply + // offsets into the source (that mismatch panicked; #816). + let starts_with = + |start: usize, pat: &str| pat.chars().enumerate().all(|(k, p)| chars.get(start + k).is_some_and(|c| c.eq_ignore_ascii_case(&p))); + let mut out = Vec::new(); let mut i = 0; - while i < lc.len() { - let at = |s: &str| lc[i..].iter().take(s.chars().count()).copied().eq(s.chars()); - let boundary = i == 0 || !lc[i - 1].is_alphanumeric(); - if boundary && (at("http://") || at("https://") || at("www.")) { + while i < chars.len() { + let boundary = i == 0 || chars.get(i - 1).is_some_and(|c| !c.is_alphanumeric()); + if boundary && (starts_with(i, "http://") || starts_with(i, "https://") || starts_with(i, "www.")) { let mut j = i; - while j < chars.len() && url_char(chars[j]) { + while chars.get(j).is_some_and(|c| url_char(*c)) { j += 1; } // Trailing punctuation belongs to the sentence. - while j > i && ".,;:!?".contains(chars[j - 1]) { + while j > i && chars.get(j - 1).is_some_and(|c| ".,;:!?".contains(*c)) { j -= 1; } - let s: String = chars[i..j].iter().collect(); + let s: String = chars.get(i..j).map(|c| c.iter().collect()).unwrap_or_default(); if s.len() > 6 && s.contains('.') { - let uri = if s.to_lowercase().starts_with("www.") { format!("http://{s}") } else { s }; + let uri = if s.get(..4).is_some_and(|p| p.eq_ignore_ascii_case("www.")) { format!("http://{s}") } else { s }; out.push((i..j, uri)); i = j; continue; diff --git a/crates/annot/src/tests.rs b/crates/annot/src/tests.rs index e7091c512..2e42d1f5c 100644 --- a/crates/annot/src/tests.rs +++ b/crates/annot/src/tests.rs @@ -537,6 +537,31 @@ fn urls_are_found_in_text() { assert_eq!(found, ["https://example.org/a?b=1", "http://www.rust-lang.org"]); } +#[test] +fn urls_are_found_after_length_changing_lowercase() { + // U+0130 (İ) lowercases to "i" plus a combining dot above, so a case-folded copy is longer + // than the source. The old matcher took the address offset from that copy and sliced the + // source with it, panicking at `chars[i..j]` (#816). + let mut text = "\u{130}".repeat(20); + text.push_str(" http://example.com"); + let chars: Vec = text.chars().collect(); + let found = crate::links::find_urls(&chars); + assert_eq!(found.len(), 1, "{found:?}"); + let (range, uri) = found.into_iter().next().unwrap(); + assert_eq!(uri, "http://example.com"); + assert_eq!(range, 21..39, "range is in source-character indices"); +} + +#[test] +fn url_scheme_and_www_match_case_insensitively() { + let t: Vec = "HTTP://Example.COM".chars().collect(); + let found: Vec = crate::links::find_urls(&t).into_iter().map(|(_, u)| u).collect(); + assert_eq!(found, ["HTTP://Example.COM"]); + let t: Vec = "\u{130} www.Example.org".chars().collect(); + let found: Vec = crate::links::find_urls(&t).into_iter().map(|(_, u)| u).collect(); + assert_eq!(found, ["http://www.Example.org"]); +} + #[test] fn polygons_clouds_connected_lines_callouts_and_carets_are_drawn() { let mut doc = fixture(); From 6fd053b91844c141a2a24947cb12ed10482d2aec Mon Sep 17 00:00:00 2001 From: MarekSpodBiedry <73122968+MarekSpodBiedry@users.noreply.github.com> Date: Sat, 10 Oct 2026 21:31:51 +0200 Subject: [PATCH 20/69] organize: deleting the opening page drops the catalog OpenAction (#821) (#855) --- crates/organize/src/lib.rs | 14 ++++++++++++- crates/organize/src/tests.rs | 39 ++++++++++++++++++++++++++++++++++++ 2 files changed, 52 insertions(+), 1 deletion(-) diff --git a/crates/organize/src/lib.rs b/crates/organize/src/lib.rs index 1459d67c6..4d6cacb17 100644 --- a/crates/organize/src/lib.rs +++ b/crates/organize/src/lib.rs @@ -195,7 +195,7 @@ pub fn delete_pages(doc: &mut Document, indices: &[usize]) -> Result<(), Organiz } /// Bookmarks, and links on the `keep` pages, that go to one of the `gone` pages lose that -/// destination (/Dest, or a GoTo /A), rather than pointing at nothing. +/// destination (/Dest, or a GoTo /A), rather than pointing at nothing; so does the catalog's /OpenAction. fn drop_destinations_to(doc: &mut Document, gone: &[ObjRef], keep: &[(ObjRef, Dict)]) -> Result<(), OrganizeError> { let mut holders = outline::items(doc); for (p, _) in keep { @@ -222,6 +222,18 @@ fn drop_destinations_to(doc: &mut Document, gone: &[ObjRef], keep: &[(ObjRef, Di dead.push((h, b"A")); } } + // The catalog's /OpenAction is either a destination array or a GoTo action; one that opens a + // deleted page is dropped, as a bookmark's would be. + if let Some(root) = doc.root() { + let open = doc.get(root).as_dict().and_then(|c| c.get(b"OpenAction").map(|o| doc.resolve(o))); + let opens_gone = match open.as_deref() { + Some(Object::Dict(a)) => a.name(b"S") == Some(b"GoTo") && to_gone(a.get(b"D")), + other => to_gone(other), + }; + if opens_gone { + dead.push((root, b"OpenAction")); + } + } for (h, key) in dead { doc.update_dict(h, |d| { d.remove(key); diff --git a/crates/organize/src/tests.rs b/crates/organize/src/tests.rs index 5dddf0296..a6d4d978a 100644 --- a/crates/organize/src/tests.rs +++ b/crates/organize/src/tests.rs @@ -170,6 +170,45 @@ fn deleted_pages_are_not_kept_by_what_points_at_them() { assert_eq!(form.as_dict().and_then(|f| f.get(b"Fields")).and_then(Object::as_array).map(Vec::len), Some(0), "the field went with its page"); } +#[test] +fn deleting_the_opening_page_drops_the_open_action() { + let open_action = |doc: &Document| doc.get(doc.root().unwrap()).as_dict().unwrap().get(b"OpenAction").cloned(); + let set_open = |doc: &mut Document, value: Object| { + let root = doc.root().unwrap(); + doc.update_dict(root, |c| c.set(b"OpenAction".to_vec(), value)).unwrap(); + }; + let page = |doc: &Document, i: usize| pages(doc).unwrap()[i].obj; + for as_action in [false, true] { + let target = |doc: &Document, i: usize| { + let dest = Object::Array(vec![Object::Ref(page(doc, i)), Object::Name(b"Fit".to_vec())]); + if as_action { + let mut a = Dict::new(); + a.set(b"S".to_vec(), Object::Name(b"GoTo".to_vec())); + a.set(b"D".to_vec(), dest); + Object::Dict(a) + } else { + dest + } + }; + // The opening page goes: no OpenAction is left to point at nothing. + let mut doc = doc_a(); + let open = target(&doc, 0); + set_open(&mut doc, open); + delete_pages(&mut doc, &[0]).unwrap(); + let out = full_roundtrip(&doc); + assert_eq!(labels(&out), ["A2", "A3"]); + assert_eq!(open_action(&out), None); + // Another page goes: the opening page keeps its OpenAction. + let mut doc = doc_a(); + let open = target(&doc, 0); + set_open(&mut doc, open); + delete_pages(&mut doc, &[2]).unwrap(); + let out = full_roundtrip(&doc); + assert_eq!(labels(&out), ["A1", "A2"]); + assert!(open_action(&out).is_some()); + } +} + #[test] fn cannot_delete_every_page_or_missing_pages() { let mut doc = open(fixture()); From 259002da974f020a9949fe54765399429da7b42e Mon Sep 17 00:00:00 2001 From: snipeTR <31975916+snipeTR@users.noreply.github.com> Date: Sat, 10 Oct 2026 22:32:07 +0300 Subject: [PATCH 21/69] fix(text): Turkish dotted and dotless i match in case-insensitive search (#892) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Case-insensitive search folded the query and the page with str::to_lowercase, which follows no locale: U+0130 (İ) becomes "i" plus a combining dot above (two characters) and U+0131 (ı) has no case pair. So "istanbul" never found "İSTANBUL", and "ırmak"/"kış" never found "IRMAK"/"KIŞ" (or the reverse). The same PageText::find serves Find, Search & Redact (phrases and word lists) and the automation text search, so Turkish words could also be left unredacted. Fold I, i, İ and ı to "i" and every other character with its own lowercase, one character for one character. Case-sensitive search is unchanged. --- crates/render/src/text.rs | 59 ++++++++++++++++++++++++++++++++++++++- 1 file changed, 58 insertions(+), 1 deletion(-) diff --git a/crates/render/src/text.rs b/crates/render/src/text.rs index 34c3d94d6..8a6c38fd1 100644 --- a/crates/render/src/text.rs +++ b/crates/render/src/text.rs @@ -30,6 +30,24 @@ pub struct TextGlyph { pub direction: [f32; 2], } +/// `s` folded for case-insensitive search, one character for one character. +/// +/// The dotted and dotless i of Turkish and Azerbaijani (`İ` U+0130, `ı` U+0131) and the Latin +/// `I`/`i` all fold to `i`. `str::to_lowercase` follows no locale: it turns `İ` into `i` plus a +/// combining dot (two characters) and leaves `ı` alone, so neither `İSTANBUL` nor `IRMAK` could +/// be found by typing `istanbul` or `ırmak`. Search can't know the document's language, so the +/// four are treated as one letter; this only adds matches where an `ı` or `İ` is involved. +fn fold_case(s: &str) -> String { + let mut out = String::with_capacity(s.len()); + for c in s.chars() { + match c { + 'I' | 'i' | '\u{130}' | '\u{131}' => out.push('i'), + _ => out.extend(c.to_lowercase()), + } + } + out +} + /// The text of one page, in content order, with line structure. #[derive(Clone, Debug, Default, PartialEq)] pub struct PageText { @@ -76,7 +94,7 @@ impl PageText { /// Search with Acrobat's find options: case-sensitive, whole words only. pub fn find_opts(&self, needle: &str, case_sensitive: bool, whole_words: bool) -> Vec> { - let fold = |s: &str| if case_sensitive { s.to_string() } else { s.to_lowercase() }; + let fold = |s: &str| if case_sensitive { s.to_string() } else { fold_case(s) }; let needle: Vec = fold(needle).split_whitespace().collect::>().join(" ").chars().collect(); if needle.is_empty() { return Vec::new(); @@ -911,6 +929,45 @@ mod tests { assert_eq!(to_upright([290.0, 0.0, 300.0, 10.0], 1, 300.0, 200.0), [0.0, 0.0, 10.0, 10.0]); } + #[test] + fn turkish_dotted_and_dotless_i_match_in_any_case() { + // "İSTANBUL IRMAK KIŞ" on one line: each word typed in lower case must find it, and the + // reverse. `to_lowercase` alone made `İ` two characters and left `ı` unmatched. + let mut v = Vec::new(); + word(&mut v, "İSTANBUL", 10.0, 10.0, 7.0); + word(&mut v, "IRMAK", 80.0, 10.0, 7.0); + word(&mut v, "KIŞ", 130.0, 10.0, 7.0); + let upper = layout(v); + assert_eq!(upper.plain_text(), "İSTANBUL IRMAK KIŞ"); + assert_eq!(upper.find("istanbul"), vec![0..8]); + assert_eq!(upper.find("İstanbul"), vec![0..8]); + assert_eq!(upper.find("ırmak"), vec![8..13]); + assert_eq!(upper.find("kış"), vec![13..16]); + assert_eq!(upper.find("istanbul ırmak kış"), vec![0..16], "a phrase across the words"); + assert_eq!(upper.find_opts("ırmak", false, true), vec![8..13], "whole words"); + assert!(upper.find_opts("istanbul", true, false).is_empty(), "case-sensitive search stays exact"); + assert_eq!(upper.find_opts("İSTANBUL", true, false), vec![0..8]); + + let mut v = Vec::new(); + word(&mut v, "istanbul", 10.0, 10.0, 7.0); + word(&mut v, "ırmak", 80.0, 10.0, 7.0); + word(&mut v, "kış", 130.0, 10.0, 7.0); + let lower = layout(v); + assert_eq!(lower.find("İSTANBUL"), vec![0..8]); + assert_eq!(lower.find("IRMAK"), vec![8..13]); + assert_eq!(lower.find("KIŞ"), vec![13..16]); + } + + #[test] + fn case_folding_keeps_one_character_per_character() { + for s in ["İSTANBUL", "ırmak", "Hello WORLD", "ÇAĞLAR", "Straße", "ΣΟΦΙΑ", "日本語"] { + assert_eq!(fold_case(s).chars().count(), s.chars().count(), "{s}"); + } + assert_eq!(fold_case("Hello WORLD"), "hello world"); + assert_eq!(fold_case("ÇAĞLAR Şişli"), "çağlar şişli"); + assert_eq!(fold_case("İIıi"), "iiii"); + } + fn g(t: &str, x0: f32, y0: f32, x1: f32) -> TextGlyph { let rect = [x0, y0, x1, y0 + 10.0]; TextGlyph { text: t.into(), rect, quad: [[x0, y0], [x1, y0], [x1, rect[3]], [x0, rect[3]]], direction: [1.0, 0.0] } From 4b74f655de75af304e7ebb3df6bcda3180c8a543 Mon Sep 17 00:00:00 2001 From: DWAA1660 Date: Sat, 10 Oct 2026 13:35:25 -0600 Subject: [PATCH 22/69] XFDF keeps polygon and polyline vertices (#809) (#848) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Export wrote polygon and polyline comments without their points and import had nowhere to read them from, so an XFDF round trip left the comments with no /Vertices and no appearance. Export now writes the points as XFDF x,y;x,y;…, and import reads them back (whole x,y pairs only) before the appearance is drawn. Co-authored-by: DWAA1660 Co-authored-by: Claude Opus 5.5 Co-authored-by: Brandon Thomas --- crates/xfdf/src/lib.rs | 14 ++++++++++ crates/xfdf/src/tests.rs | 56 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 70 insertions(+) diff --git a/crates/xfdf/src/lib.rs b/crates/xfdf/src/lib.rs index d839a0525..f1ee4b156 100644 --- a/crates/xfdf/src/lib.rs +++ b/crates/xfdf/src/lib.rs @@ -264,6 +264,12 @@ fn xfdf_annots(doc: &Document, out: &mut String) { } out.push_str(""); } + // Polygon and polyline points (XFDF ``: `x,y;x,y;…`). + let vertices = nums_of(doc, d.get(b"Vertices")); + let pts: Vec = vertices.as_chunks::<2>().0.iter().map(|p| format!("{},{}", n(p[0]), n(p[1]))).collect(); + if !pts.is_empty() { + let _ = write!(out, "{}", pts.join(";")); + } if let Some(pd) = d.get(b"Popup").map(|p| doc.resolve(p)).and_then(|p| p.as_dict().cloned()) { let r = nums_of(doc, pd.get(b"Rect")); if r.len() == 4 { @@ -617,6 +623,14 @@ fn annot_from_xml(node: roxmltree::Node, subtype: &str, page_ref: ObjRef) -> Opt .collect(); d.set(b"InkList".to_vec(), Object::Array(list)); } + "vertices" => { + // Whole x,y pairs only; a stray trailing number is dropped. + let mut v = csv(child.text().unwrap_or("")); + v.truncate(v.len() - v.len() % 2); + if !v.is_empty() { + d.set(b"Vertices".to_vec(), arr(&v)); + } + } _ => {} } } diff --git a/crates/xfdf/src/tests.rs b/crates/xfdf/src/tests.rs index 371884ded..022b36c66 100644 --- a/crates/xfdf/src/tests.rs +++ b/crates/xfdf/src/tests.rs @@ -352,3 +352,59 @@ fn reimporting_a_note_replaces_its_popup() { let parent = doc.get(popups[0]).as_dict().and_then(|d| d.reference(b"Parent")).unwrap(); assert!(refs.contains(&parent), "the pop-up belongs to a note that is still on the page"); } + +/// The `/Vertices` of every annotation on the document's pages, by subtype. +fn vertices(doc: &Document) -> Vec<(String, Vec)> { + let mut out = Vec::new(); + for p in pdfcraft_model::pages(doc) { + 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 }; + let sub = String::from_utf8_lossy(d.name(b"Subtype").unwrap_or_default()).into_owned(); + let v = nums_of(doc, d.get(b"Vertices")); + if !v.is_empty() { + out.push((sub, v)); + } + } + } + out +} + +/// storytold/pdfcraft#809: XFDF exchange dropped polygon and polyline vertices, so the +/// imported comments had no shape (and no appearance). +#[test] +fn xfdf_keeps_polygon_and_polyline_vertices() { + let mut src = blank(); + let meta = |id: &str| Meta { date: Some("D:20261002120000Z".into()), id: id.into() }; + let polygon = Shape::Polygon { vertices: vec![[100.0, 100.0], [200.0, 120.0], [150.0, 220.5]], cloud: false }; + let polyline = Shape::PolyLine { + vertices: vec![[50.0, 300.0], [120.0, 380.0], [200.0, 310.0]], + start: pdfcraft_annot::LineEnding::None, + end: pdfcraft_annot::LineEnding::ClosedArrow, + }; + for (page, shape, id) in [(0, polygon, "pg"), (1, polyline, "pl")] { + let new = NewAnnotation { page, style: Style::default_for(&shape), shape, contents: id.into(), author: "Ada".into() }; + add_annotation(&mut src, &new, &meta(id)).unwrap(); + } + let xfdf = export_xfdf(&src, true, false, "shapes.pdf"); + assert!(xfdf.contains("100,100;200,120;150,220.5"), "{xfdf}"); + assert!(xfdf.contains("50,300;120,380;200,310"), "{xfdf}"); + let mut dst = blank(); + let r = import(&mut dst, xfdf.as_bytes()).unwrap(); + assert_eq!(r.comments, 2, "{r:?}"); + assert_eq!(vertices(&dst), vertices(&src)); + // The imported shapes are drawn again. + for p in pdfcraft_model::pages(&dst) { + for a in p.dict.get(b"Annots").map(|a| dst.resolve(a)).and_then(|a| a.as_array().cloned()).unwrap_or_default() { + let d = dst.resolve(&a).as_dict().cloned().unwrap(); + assert!(d.get(b"AP").is_some(), "{d:?}"); + } + } + // Acrobat-style input: a stray trailing number is dropped and only whole points are kept. + let odd = r#" + 1,2;3,4;5,6;7 + junk + "#; + let mut dst = blank(); + import(&mut dst, odd.as_bytes()).unwrap(); + assert_eq!(vertices(&dst), vec![("Polygon".to_string(), vec![1.0, 2.0, 3.0, 4.0, 5.0, 6.0])]); +} From f6caba43b2140441aa1b2794109e2811249a1065 Mon Sep 17 00:00:00 2001 From: MarekSpodBiedry <73122968+MarekSpodBiedry@users.noreply.github.com> Date: Sat, 10 Oct 2026 21:38:27 +0200 Subject: [PATCH 23/69] XFDF keeps a callout's leader, text-box inset, intent and arrowhead (#819) (#854) Co-authored-by: Brandon Thomas --- crates/xfdf/README.md | 3 ++- crates/xfdf/src/lib.rs | 31 ++++++++++++++++++++++++++++++- crates/xfdf/src/tests.rs | 37 +++++++++++++++++++++++++++++++++++++ 3 files changed, 69 insertions(+), 2 deletions(-) diff --git a/crates/xfdf/README.md b/crates/xfdf/README.md index fc79172fd..fd2852584 100644 --- a/crates/xfdf/README.md +++ b/crates/xfdf/README.md @@ -12,7 +12,8 @@ let report = import(&mut doc, &bytes)?; // XFDF, FDF, XML, CSV o - **XFDF** (ISO 19444-1): every comment type PdfCraft knows (text, free text, line, square, 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, pop-ups and reply threads (`inreplyto`); field values nested by name. + 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. - **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). diff --git a/crates/xfdf/src/lib.rs b/crates/xfdf/src/lib.rs index f1ee4b156..91c3d2454 100644 --- a/crates/xfdf/src/lib.rs +++ b/crates/xfdf/src/lib.rs @@ -2,7 +2,7 @@ //! //! - **XFDF** (ISO 19444-1, Acrobat's "Export all to data file" / "Import comments", and form //! data): comments with their geometry, colours, flags, authors, dates, replies, pop-ups, -//! ink, quadrilaterals and line ends; field values, nested by name. +//! ink, quadrilaterals, line ends and callouts; field values, nested by name. //! - **FDF** (ISO 32000-2 §12.7.8): fields and comments in PDF syntax. //! - Form data as **XML**, **CSV** and **tab-delimited text** (Acrobat's Export data formats). //! @@ -244,6 +244,21 @@ fn xfdf_annots(doc: &Document, out: &mut String) { let name = |o: &Object| o.as_name().map(|x| String::from_utf8_lossy(x).into_owned()).unwrap_or_else(|| "None".into()); let _ = write!(attrs, " head=\"{}\" tail=\"{}\"", name(&le[0]), name(&le[1])); } + // A callout: its leader path, text-box inset, intent and (single-name) line ending. + let cl = nums_of(doc, d.get(b"CL")); + if cl.len() == 4 || cl.len() == 6 { + let _ = write!(attrs, " callout=\"{}\"", cl.iter().map(|v| n(*v)).collect::>().join(",")); + let rd = nums_of(doc, d.get(b"RD")); + if rd.len() == 4 { + let _ = write!(attrs, " fringe=\"{}\"", rd.iter().map(|v| n(*v)).collect::>().join(",")); + } + if let Some(it) = d.get(b"IT").map(|o| doc.resolve(o)).and_then(|o| o.as_name().map(|x| String::from_utf8_lossy(x).into_owned())) { + let _ = write!(attrs, " intent=\"{}\"", esc(&it)); + } + if let Some(le) = d.get(b"LE").map(|o| doc.resolve(o)).and_then(|o| o.as_name().map(|x| String::from_utf8_lossy(x).into_owned())) { + let _ = write!(attrs, " head=\"{}\"", esc(&le)); + } + } if let Some(irt) = d.get(b"IRT").and_then(Object::as_ref).and_then(|r| names.get(&r)) { let _ = write!(attrs, " inreplyto=\"{}\"", esc(irt)); } @@ -607,6 +622,20 @@ fn annot_from_xml(node: roxmltree::Node, subtype: &str, page_ref: ObjRef) -> Opt Object::Array(vec![Object::name(node.attribute("head").unwrap_or("None")), Object::name(node.attribute("tail").unwrap_or("None"))]), ); } + if let Some(cl) = node.attribute("callout").map(csv).filter(|c| c.len() == 4 || c.len() == 6) { + d.set(b"CL".to_vec(), arr(&cl)); + if let Some(rd) = node.attribute("fringe").map(csv).filter(|r| r.len() == 4) { + d.set(b"RD".to_vec(), arr(&rd)); + } + d.set(b"IT".to_vec(), Object::name(node.attribute("intent").unwrap_or("FreeTextCallout"))); + // A callout's /LE is one name, not the head/tail pair of a line. + match node.attribute("head") { + Some(h) => d.set(b"LE".to_vec(), Object::name(h)), + None => { + d.remove(b"LE"); + } + } + } for child in node.children().filter(|c| c.is_element()) { match child.tag_name().name() { "contents" => { diff --git a/crates/xfdf/src/tests.rs b/crates/xfdf/src/tests.rs index 022b36c66..33b3a5a88 100644 --- a/crates/xfdf/src/tests.rs +++ b/crates/xfdf/src/tests.rs @@ -408,3 +408,40 @@ fn xfdf_keeps_polygon_and_polyline_vertices() { import(&mut dst, odd.as_bytes()).unwrap(); assert_eq!(vertices(&dst), vec![("Polygon".to_string(), vec![1.0, 2.0, 3.0, 4.0, 5.0, 6.0])]); } + +#[test] +fn xfdf_keeps_callout_leader_inset_and_ending() { + let mut src = blank(); + let shape = Shape::Callout { + rect: [40.0, 300.0, 180.0, 360.0], + knee: [260.0, 330.0], + point: [320.0, 200.0], + font_size: 12.0, + ending: pdfcraft_annot::LineEnding::ClosedArrow, + }; + let meta = Meta { date: Some("D:20261002120000Z".into()), id: "c1".into() }; + add_annotation( + &mut src, + &NewAnnotation { page: 0, style: Style::default_for(&shape), shape, contents: "leader".into(), author: "Ada".into() }, + &meta, + ) + .unwrap(); + let xfdf = export_xfdf(&src, true, false, "form.pdf"); + let mut dst = blank(); + import(&mut dst, xfdf.as_bytes()).unwrap(); + let callout_keys = |doc: &Document| { + let page = pdfcraft_model::pages(doc).remove(0); + let annots = page.dict.get(b"Annots").map(|a| doc.resolve(a)).and_then(|a| a.as_array().cloned()).unwrap_or_default(); + let d = annots.iter().filter_map(|a| doc.resolve(a).as_dict().cloned()).find(|d| d.name(b"Subtype") == Some(b"FreeText")).unwrap(); + ( + nums_of(doc, d.get(b"Rect")), + nums_of(doc, d.get(b"CL")), + nums_of(doc, d.get(b"RD")), + d.name(b"IT").map(<[u8]>::to_vec), + d.get(b"LE").and_then(|o| o.as_name()).map(<[u8]>::to_vec), + ) + }; + let want = callout_keys(&src); + assert_eq!(want.1.len(), 6, "{want:?}"); + assert_eq!(callout_keys(&dst), want, "{xfdf}"); +} From f620750934ab76a592e8b54a1dbbe2662305e545 Mon Sep 17 00:00:00 2001 From: MarekSpodBiedry <73122968+MarekSpodBiedry@users.noreply.github.com> Date: Sat, 10 Oct 2026 21:38:42 +0200 Subject: [PATCH 24/69] Web: warn before reloading a document with unsaved changes (#812) (#856) --- apps/pdfcraft-web/Cargo.toml | 2 +- apps/pdfcraft-web/src/main.rs | 17 +++++++++++++++++ crates/ui-egui/src/editing.rs | 19 +++++++++++++++++++ crates/ui-egui/src/lib.rs | 3 +++ 4 files changed, 40 insertions(+), 1 deletion(-) diff --git a/apps/pdfcraft-web/Cargo.toml b/apps/pdfcraft-web/Cargo.toml index a8d472d4f..08d0f4ec0 100644 --- a/apps/pdfcraft-web/Cargo.toml +++ b/apps/pdfcraft-web/Cargo.toml @@ -16,5 +16,5 @@ egui = { workspace = true } [target.'cfg(target_arch = "wasm32")'.dependencies] wasm-bindgen-futures = "0.4" -web-sys = { version = "0.3", features = ["Window", "Document", "Element", "HtmlCanvasElement", "HtmlElement", "Location", "Url", "UrlSearchParams", "Response"] } +web-sys = { version = "0.3", features = ["Window", "Document", "Element", "HtmlCanvasElement", "HtmlElement", "Location", "Url", "UrlSearchParams", "Response", "Event"] } js-sys = "0.3" diff --git a/apps/pdfcraft-web/src/main.rs b/apps/pdfcraft-web/src/main.rs index 1899c2982..1c99eb1dc 100644 --- a/apps/pdfcraft-web/src/main.rs +++ b/apps/pdfcraft-web/src/main.rs @@ -51,6 +51,9 @@ fn main() { ctx.request_repaint(); }); } + // eframe never raises a close request on a browser reload or tab close, so `guard_quit` never + // prompts; ask the browser to confirm instead while a document has unsaved work (#812). + warn_before_unload(app.unsaved_flag.clone()); Ok(Box::new(app)) }), ) @@ -61,6 +64,20 @@ fn main() { }); } +#[cfg(target_arch = "wasm32")] +fn warn_before_unload(unsaved: std::sync::Arc) { + use eframe::wasm_bindgen::{JsCast, closure::Closure}; + let Some(window) = web_sys::window() else { return }; + let handler = Closure::::new(move |event: web_sys::Event| { + if unsaved.load(std::sync::atomic::Ordering::Relaxed) { + event.prevent_default(); + } + }); + if window.add_event_listener_with_callback("beforeunload", handler.as_ref().unchecked_ref()).is_ok() { + handler.forget(); + } +} + #[cfg(target_arch = "wasm32")] fn query_param(key: &str) -> Option { let search = web_sys::window()?.location().search().ok()?; diff --git a/crates/ui-egui/src/editing.rs b/crates/ui-egui/src/editing.rs index 08aab2873..a660f181a 100644 --- a/crates/ui-egui/src/editing.rs +++ b/crates/ui-egui/src/editing.rs @@ -590,8 +590,14 @@ impl PdfCraftApp { ctx.send_viewport_cmd(egui::ViewportCommand::Close); } + /// Publish whether any document has unsaved work, for the browser's reload warning (#812). + pub(crate) fn sync_unsaved_flag(&self) { + self.unsaved_flag.store(self.first_dirty().is_some(), std::sync::atomic::Ordering::Relaxed); + } + /// Intercept window close while documents have unsaved changes. pub(crate) fn guard_quit(&mut self, ctx: &egui::Context) { + self.sync_unsaved_flag(); if !ctx.input(|i| i.viewport().close_requested()) { return; } @@ -926,6 +932,19 @@ mod revert_draft_tests { assert!(app.views[index].forms.committed.is_some() && app.views[index].pending_edit.is_some()); } + #[test] + fn unsaved_flag_follows_dirty_state() { + use std::sync::atomic::Ordering; + // `guard_quit` runs every frame, also on the web, where no close request ever arrives. + let ctx = egui::Context::default(); + let mut app = app(); + app.guard_quit(&ctx); + assert!(!app.unsaved_flag.load(Ordering::Relaxed)); + assert!(app.apply_edit(Edit::SetFieldValue { name: "name".into(), value: FieldValue::Text("Changed".into()) })); + app.guard_quit(&ctx); + assert!(app.unsaved_flag.load(Ordering::Relaxed)); + } + #[test] fn successful_revert_clears_focused_or_queued_drafts_only_on_its_document() { for queued in [false, true] { diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index cef3d242b..157725080 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -424,6 +424,8 @@ pub struct PdfCraftApp { startup_superseded: bool, /// Asynchronous opens that failed (web `?file=` fetches), shown as a notice. pub failed_inbox: FailedInbox, + /// Mirrors "some document has unsaved work" each frame, so the web page's `beforeunload` handler can read it (#812). + pub unsaved_flag: std::sync::Arc, /// Requests from the operating system, polled every frame (macOS Apple events). pub os_events: Option, /// A pending "save changes?" question (closing a dirty tab or quitting). @@ -689,6 +691,7 @@ impl PdfCraftApp { startup_inbox: Default::default(), startup_superseded: false, failed_inbox: Default::default(), + unsaved_flag: Default::default(), os_events: None, close_request: None, save_override: None, From 7a1e0bce86d9851a9dfa44f97207bc32d92ff9ed Mon Sep 17 00:00:00 2001 From: Tristan von Kries Date: Sat, 10 Oct 2026 21:38:59 +0200 Subject: [PATCH 25/69] Issue #813: a cancelled recovery password no longer attaches to the next unlocked document (#858) Recovering an encrypted snapshot parked its metadata in `pending_recovered` until the password prompt was answered. Cancel left it there, and the next encrypted document unlocked in the foreground took the snapshot's original path and recovery key: Save would overwrite the other file and delete its snapshot. The association now travels with the prompt (`PasswordPrompt::recovered`). Cancel puts the entry back on offer and leaves the store alone; a wrong password keeps it on the re-raised prompt; the correct one finishes the recovery of the tab that was just opened. `pending_recovered` is gone. Test: crash with an encrypted snapshot of A, Recover, Cancel, open encrypted B with its password. B keeps its own path and is clean, A's snapshot stays in the store, also after closing B. Fails on main at "B keeps its own path". Co-authored-by: Tristan von Kries Co-authored-by: Claude Fable 5.1 --- crates/ui-egui/src/lib.rs | 33 ++++++++++++++------- crates/ui-egui/src/recovery.rs | 16 +++++----- crates/ui-egui/tests/recovery.rs | 51 ++++++++++++++++++++++++++++---- 3 files changed, 78 insertions(+), 22 deletions(-) diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 157725080..e2dfca1ff 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -349,6 +349,9 @@ pub struct PasswordPrompt { pub error: Option, /// A late startup file must stay in the background even after it is unlocked. activate: bool, + /// The bytes are a recovery snapshot: once the document is open it takes the snapshot's + /// path and recovery entry. Cancelling the prompt drops the association with it (#813). + recovered: Option, } #[derive(serde::Serialize, serde::Deserialize)] @@ -525,7 +528,6 @@ pub struct PdfCraftApp { pub recoverable: Vec, recovery_keys: std::collections::HashMap, last_autosave: f64, - pending_recovered: Option, allow_quit: bool, /// Shortcuts pressed while a text field had the keyboard, run on the next frame (see /// `registry_shortcuts`). @@ -751,7 +753,6 @@ impl PdfCraftApp { recoverable: Vec::new(), recovery_keys: Default::default(), last_autosave: 0.0, - pending_recovered: None, allow_quit: false, deferred_commands: Vec::new(), dialog_seen: None, @@ -869,7 +870,8 @@ impl PdfCraftApp { Ok(id) => id, Err(e @ (OpenError::NeedsPassword | OpenError::WrongPassword)) => { let error = matches!(e, OpenError::WrongPassword).then(|| "Incorrect password. Try again.".to_string()); - self.password_prompt = Some(PasswordPrompt { name: name.to_string(), path, bytes, input: String::new(), error, activate }); + self.password_prompt = + Some(PasswordPrompt { name: name.to_string(), path, bytes, input: String::new(), error, activate, recovered: None }); return Ok(()); } Err(e) => return Err(e.to_string()), @@ -1050,17 +1052,28 @@ impl PdfCraftApp { /// Answer the password prompt (`None` cancels). pub fn submit_password(&mut self, password: Option) { let Some(p) = self.password_prompt.take() else { return }; - let Some(pw) = password else { return }; + let Some(pw) = password else { + // A cancelled recovery goes back on offer; its snapshot stays in the store and must + // not attach itself to whatever document is unlocked next (#813). + if let Some(meta) = p.recovered { + self.recoverable.push(meta); + } + return; + }; match self.try_open(&p.name, p.path, p.bytes, Some(&pw), p.activate) { Err(e) => self.notify_fmt("Couldn't open {name}: {e}", &[("name", &p.name), ("e", &e.to_string())]), - // A recovered encrypted document is open once its foreground prompt is gone. - // Unlocking a background startup file must not finish another file's recovery. - Ok(()) if p.activate && self.password_prompt.is_none() => { - if let Some(meta) = self.pending_recovered.clone() { - self.finish_recovery(&meta); + // A recovered encrypted document is open once its prompt is gone. + Ok(()) if self.password_prompt.is_none() => { + if let Some(meta) = &p.recovered { + self.finish_recovery(meta); + } + } + // Wrong password: the new prompt is for the same bytes, so it keeps the snapshot. + Ok(()) => { + if let Some(prompt) = self.password_prompt.as_mut() { + prompt.recovered = p.recovered; } } - Ok(()) => {} } } diff --git a/crates/ui-egui/src/recovery.rs b/crates/ui-egui/src/recovery.rs index df6b8bddb..e6ae0193a 100644 --- a/crates/ui-egui/src/recovery.rs +++ b/crates/ui-egui/src/recovery.rs @@ -170,13 +170,16 @@ impl PdfCraftApp { continue; } }; - if meta.encrypted { - // The password prompt opens it; mark it recovered once it is open. - self.pending_recovered = Some(meta.clone()); - } match self.open_bytes(&meta.name, None, bytes) { Ok(()) if self.password_prompt.is_none() => self.finish_recovery(&meta), - Ok(()) => {} + // Encrypted: the password prompt opens it, and finishes its recovery once it + // does. The association travels with the prompt, so cancelling it cannot hand + // this snapshot's path to the next document that is unlocked (#813). + Ok(()) => { + if let Some(prompt) = self.password_prompt.as_mut() { + prompt.recovered = Some(meta.clone()); + } + } Err(e) => self.notify_fmt("Couldn't recover {name}: {e}", &[("name", &meta.name), ("e", &e.to_string())]), } } @@ -185,10 +188,9 @@ impl PdfCraftApp { /// The most recently opened tab came from `meta`: keep its recovery entry and path. pub(crate) fn finish_recovery(&mut self, meta: &RecoveryMeta) { - let Some((_, id)) = self.active_ids() else { return }; + let Some(id) = self.views.last().map(|v| v.id) else { return }; self.session.mark_recovered(id, meta.path.clone()); self.recovery_keys.insert(id, meta.key.clone()); - self.pending_recovered = None; } /// Delete recovery entries the user chose not to recover. diff --git a/crates/ui-egui/tests/recovery.rs b/crates/ui-egui/tests/recovery.rs index 8cc9eebfc..8d25965ff 100644 --- a/crates/ui-egui/tests/recovery.rs +++ b/crates/ui-egui/tests/recovery.rs @@ -153,19 +153,24 @@ fn quitting_cleanly_leaves_nothing_behind() { assert_eq!(files_in(&s), 0); } -#[test] -fn encrypted_documents_are_autosaved_encrypted_and_recovered_with_the_password() { - let mut doc = pdfcraft_cos::Document::open(std::sync::Arc::new(fixture(2))).unwrap(); +/// `bytes` protected with an open password (AES-256). +fn encrypted(bytes: Vec, password: &str) -> Vec { + let mut doc = pdfcraft_cos::Document::open(std::sync::Arc::new(bytes)).unwrap(); doc.set_encryption(&pdfcraft_cos::NewEncryption { algorithm: pdfcraft_cos::Algorithm::Aes256, - user_password: "pw", + user_password: password, owner_password: "owner", permissions: -1, encrypt_metadata: true, seed: [2; 32], }) .unwrap(); - let bytes = pdfcraft_cos::write_full(&doc, &Default::default()).unwrap(); + pdfcraft_cos::write_full(&doc, &Default::default()).unwrap() +} + +#[test] +fn encrypted_documents_are_autosaved_encrypted_and_recovered_with_the_password() { + let bytes = encrypted(fixture(2), "pw"); let s = store("encrypted"); crashed_session(&s, bytes, Some("pw"), None); let meta = s.list().remove(0); @@ -190,6 +195,42 @@ fn encrypted_documents_are_autosaved_encrypted_and_recovered_with_the_password() let _ = Modifiers::NONE; } +/// #813: cancelling the password prompt of a recovered snapshot must not hand its path and +/// recovery entry to the next encrypted document that is unlocked (Save would then overwrite +/// the snapshot's original file and delete the snapshot). +#[test] +fn a_cancelled_recovery_password_does_not_attach_to_the_next_unlocked_document() { + let s = store("cancelled"); + crashed_session(&s, encrypted(fixture(2), "pw-a"), Some("pw-a"), Some("/docs/A.pdf")); + assert_eq!(s.list().len(), 1); + let mut h = harness(s.clone()); + h.get_by_label("Recover").click(); + h.run_steps(3); + assert!(h.state().password_prompt.is_some(), "the snapshot asks for its password"); + h.get_by_label("Cancel").click(); + h.run_steps(3); + assert!(h.state().password_prompt.is_none()); + assert!(h.state().views.is_empty()); + + let app = h.state_mut(); + app.open_bytes("B.pdf", Some("/docs/B.pdf".into()), encrypted(fixture(1), "pw-b")).unwrap(); + app.submit_password(Some("pw-b".into())); + h.run_steps(3); + let app = h.state(); + assert_eq!(app.views.len(), 1); + let b = app.session.get(app.views[0].id).unwrap(); + assert_eq!(b.path.as_deref(), Some("/docs/B.pdf"), "B keeps its own path"); + assert!(!b.dirty, "B is clean: it is not the recovered document"); + assert_eq!(s.list().len(), 1, "A's snapshot is still in the store"); + assert_eq!(app.recoverable.len(), 1, "and on offer again"); + + // Closing B (clean, so no prompt) must not remove A's snapshot either. + h.state_mut().execute("file.close"); + h.run_steps(3); + assert!(h.state().views.is_empty()); + assert_eq!(s.list().len(), 1, "A's snapshot survives closing B"); +} + #[test] fn incomplete_entries_are_ignored_and_cleaned_up() { let s = store("partial"); From 3cd619e937d85960e67a3f228a7313945405e433 Mon Sep 17 00:00:00 2001 From: Justin John <34035011+justinjohn0306@users.noreply.github.com> Date: Sun, 11 Oct 2026 01:09:15 +0530 Subject: [PATCH 26/69] Viewer: touchpad scrolling tracks the finger 1:1 (#759) (#868) A precision touchpad sends pixel-precise `MouseWheel` events. On Windows they arrive with no `TouchPhase::Start`, so egui never takes its no-smoothing "in touch" path and eases any delta of 8 px or more over several frames, leaving the page a frame behind the finger. The page view now applies precise (Point-unit) wheel input itself, 1:1, through the same un-eased scroll path as keyboard and middle-button auto-scroll, and suppresses egui's eased copy for a short window so its internal easing tail neither doubles nor trails the motion. A coarse mouse wheel (Line/Page units) keeps egui's smoothing, which is pleasant for chunky notches, and single-page paging still owns the vertical axis. Regression test: one 200 px precise swipe scrolls the full 200 px at once (an eased path would ramp there over ~0.1 s) and leaves no drift. Co-authored-by: Justin John Co-authored-by: Claude Opus 4.8 --- crates/ui-egui/src/canvas.rs | 54 ++++++++++++++++++++++++++++++++++-- crates/ui-egui/tests/view.rs | 24 ++++++++++++++++ 2 files changed, 76 insertions(+), 2 deletions(-) diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 737cad34f..4a84bec90 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -306,6 +306,12 @@ pub struct DocView { shown: bool, /// Turns wheel input into page turns in single-page view. wheel: crate::wheel_pager::WheelPager, + /// Until this time, apply precise (touchpad) wheel input 1:1 and suppress egui's eased copy. + /// A precision touchpad sends pixel-precise `MouseWheel` events; on Windows they arrive with + /// no `TouchPhase::Start`, so egui eases deltas of 8 px or more and the page lags the finger + /// (#759). The window outlasts a single frame so egui's internal easing tail is suppressed as + /// it decays, instead of adding on top of the 1:1 motion. + precise_scroll_until: f64, pub(crate) auto_scroll: crate::autoscroll::AutoScroll, /// Pages selected in the organize grid or the Pages panel (0-based). Empty means "the current page". pub selected: BTreeSet, @@ -503,6 +509,7 @@ impl DocView { zoom_anchor: None, shown: false, wheel: Default::default(), + precise_scroll_until: 0.0, auto_scroll: Default::default(), selected: BTreeSet::new(), select_anchor: None, @@ -1772,10 +1779,12 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) { // instead; zoomed in far enough to pan, it pans. Touch drags are untouched: with nothing // to pan, touch users turn pages with the rail buttons, the page box and the arrow keys. // Runs before `visible_pages` so the frame that turns the page draws it. + let mut single_paging = false; if view.layout == PageLayout::Single { // Within a point, so layout rounding can't stop a fitting page from turning. let fits = rects.get(view.current.min(rects.len().saturating_sub(1))).is_some_and(|r| r.height() + 2.0 * MARGIN <= avail.height() + 1.0); let can_turn = fits && !middle_gesture && unobstructed && ui.rect_contains_pointer(avail); + single_paging = can_turn; // Every wheel event goes to the pager, so it follows each trackpad touch to its end // even while the page can't turn. ui.input(|i| { @@ -1793,6 +1802,46 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) { ui.input_mut(|i| i.smooth_scroll_delta.y = 0.0); } } + // Touchpad scrolling 1:1 (#759). A precision touchpad sends pixel-precise `MouseWheel` + // events; on Windows they carry no `TouchPhase::Start`, so egui never takes its no-smoothing + // path and eases deltas of 8 px or more, leaving the page a frame behind the finger. Apply + // the precise delta ourselves this frame through the un-animated scroll path, and suppress + // egui's eased copy while it decays so it neither doubles nor trails the motion. A coarse + // mouse wheel keeps egui's easing (pleasant for chunky notches). + let now = ui.input(|i| i.time); + let mut precise_scroll = Vec2::ZERO; + if ui.rect_contains_pointer(avail) { + let mut coarse_wheel = false; + ui.input(|i| { + for e in &i.events { + if let egui::Event::MouseWheel { unit, delta, modifiers, .. } = e { + if modifiers.command || modifiers.ctrl { + continue; // Zooming, not scrolling. + } + match unit { + // Shift scrolls sideways, matching egui's own wheel handling. + egui::MouseWheelUnit::Point if modifiers.shift => precise_scroll.x += delta.x + delta.y, + egui::MouseWheelUnit::Point => precise_scroll += *delta, + egui::MouseWheelUnit::Line | egui::MouseWheelUnit::Page => coarse_wheel = true, + } + } + } + }); + if coarse_wheel { + view.precise_scroll_until = 0.0; + } else if precise_scroll != Vec2::ZERO { + view.precise_scroll_until = now + 0.25; + } + } + // Paging owns the vertical axis in single-page view; leave it to the pager. + if single_paging { + precise_scroll.y = 0.0; + } + if now < view.precise_scroll_until { + ui.input_mut(|i| i.smooth_scroll_delta = Vec2::ZERO); + } else { + precise_scroll = Vec2::ZERO; + } let visible_pages: Vec = match view.layout { PageLayout::Single => vec![view.current.min(rects.len() - 1)], _ => (0..rects.len()).collect(), @@ -1922,8 +1971,9 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) { // Only interactions pause; the document must keep its original colours. ui.set_opacity(opacity); } - // Middle-button auto-scroll and the keyboard (positive y moves the content down). - let delta = auto_delta - vec2(0.0, key_scroll); + // Middle-button auto-scroll, the keyboard and precise touchpad input (#759); positive y + // moves the content down. All three scroll without easing, unlike egui's wheel default. + let delta = auto_delta - vec2(0.0, key_scroll) + precise_scroll; if delta != Vec2::ZERO { ui.scroll_with_delta_animation(delta, egui::style::ScrollAnimation::none()); } diff --git a/crates/ui-egui/tests/view.rs b/crates/ui-egui/tests/view.rs index 222df48ad..bc216c235 100644 --- a/crates/ui-egui/tests/view.rs +++ b/crates/ui-egui/tests/view.rs @@ -214,6 +214,30 @@ fn wheel_turns_one_page_per_notch_and_per_trackpad_swipe() { assert_eq!(h.state().views[0].current, 1, "one swipe back turns one page back"); } +#[test] +fn precise_touchpad_wheel_scrolls_one_to_one() { + // A precision touchpad's pixel delta scrolls the page by that amount in the same frame, + // instead of egui easing it over several frames so the page lags the finger (#759). A single + // 200 px precise event is well over egui's 8 px smoothing threshold, so without the fix only + // a fraction would land on the first frame. + let mut h = harness_stepping(1.0 / 60.0, &[("layout", "continuous"), ("zoom", "100")]); + let at = h.state().views[0].viewport_rect().center(); + h.hover_at(at); + h.run_steps(2); + let before = rect(&h, 0).expect("page 1 on screen").min.y; + h.event(wheel(MouseWheelUnit::Point, -200.0, TouchPhase::Move)); + // A few frames for the event to reach the ScrollArea and the new offset to be drawn. The + // page jumps the full 200 px at once; an eased path would ramp there over ~0.1 s (6 frames). + h.run_steps(4); + let moved = before - rect(&h, 0).expect("page 1 still laid out").min.y; + assert!(moved > 190.0, "a 200 px precise swipe scrolls ~200 px at once, not an eased fraction: moved {moved}"); + // And it stops with the finger: idle frames must not drift on a leftover easing tail. + let settled = rect(&h, 0).unwrap().min.y; + h.run_steps(20); + let drift = settled - rect(&h, 0).unwrap().min.y; + assert!(drift.abs() < 2.0, "no easing tail after the swipe: drifted {drift}"); +} + #[test] fn page_display_commands_switch_layouts() { let mut h = harness(&[]); From d258764abc8f8ae3b0a8686a099cce765992195b Mon Sep 17 00:00:00 2001 From: MarekSpodBiedry <73122968+MarekSpodBiedry@users.noreply.github.com> Date: Sat, 10 Oct 2026 21:39:29 +0200 Subject: [PATCH 27/69] Keep every part when bookmark titles repeat in doc_split (#800) (#863) --- crates/automation/src/lib.rs | 10 +++++++++- crates/automation/src/tools.rs | 2 +- crates/automation/tests/automation.rs | 21 +++++++++++++++++++++ crates/ui-egui/src/files.rs | 10 +++++++++- crates/ui-egui/tests/editing.rs | 22 ++++++++++++++++++++++ 5 files changed, 62 insertions(+), 3 deletions(-) diff --git a/crates/automation/src/lib.rs b/crates/automation/src/lib.rs index 746c19ab8..511904b3e 100644 --- a/crates/automation/src/lib.rs +++ b/crates/automation/src/lib.rs @@ -1328,13 +1328,21 @@ impl Automation { let dir = self.resolve(a.str("out_dir")?, true)?; std::fs::create_dir_all(&dir).map_err(|e| failed(format!("{}: {e}", dir.display())))?; let mut files = Vec::new(); + let mut used = std::collections::HashSet::new(); for (i, (first, last, bytes)) in parts.iter().enumerate() { let safe = |t: &str| t.chars().map(|c| if c.is_alphanumeric() || " -_.,()".contains(c) { c } else { '_' }).collect::(); let file = match titles.iter().find(|t| t.0 + 1 == *first) { Some((_, t)) => format!("{stem}-{}.pdf", safe(t)), None => format!("{stem}-part{}.pdf", i + 1), }; - let path = child(&dir, &file); + // Equal titles (or titles that sanitize alike) get -2, -3, ... so no part overwrites another; compared case-insensitively. + let mut unique = file.clone(); + let mut n = 1; + while !used.insert(unique.to_lowercase()) { + n += 1; + unique = format!("{}-{n}.pdf", file.trim_end_matches(".pdf")); + } + let path = child(&dir, &unique); write_atomic(&path, bytes)?; files.push(json!({ "path": path.to_string_lossy(), "first_page": first, "last_page": last })); } diff --git a/crates/automation/src/tools.rs b/crates/automation/src/tools.rs index 16b820b80..6a89e742f 100644 --- a/crates/automation/src/tools.rs +++ b/crates/automation/src/tools.rs @@ -215,7 +215,7 @@ pub fn tools() -> Vec { }), &["paths"], )), - t("doc_split", "Split a document", "Split into several files written to out_dir: every N pages, before given pages, at top-level bookmarks (bookmarks: true; files named after them), or by file size (max_mb). Files are -partK.pdf.") + t("doc_split", "Split a document", "Split into several files written to out_dir: every N pages, before given pages, at top-level bookmarks (bookmarks: true; files named after them, repeated titles get -2, -3), or by file size (max_mb). Files are -partK.pdf.") .cmd("page.split") .with(schema( json!({ "doc": doc(), "every": { "type": "integer", "minimum": 1 }, "before": pages("that start a new part"), "bookmarks": { "type": "boolean" }, "max_mb": { "type": "number", "exclusiveMinimum": 0 }, "out_dir": { "type": "string" } }), diff --git a/crates/automation/tests/automation.rs b/crates/automation/tests/automation.rs index c50966847..40e894ff0 100644 --- a/crates/automation/tests/automation.rs +++ b/crates/automation/tests/automation.rs @@ -2334,6 +2334,27 @@ fn stamps_through_tools() { assert!(matches!(a.call("stamp_custom", &json!({ "doc": doc, "page": 1, "path": "nope.png", "at": [1, 1] })), Err(ToolError::Failed(_)))); } +#[test] +fn bookmark_split_with_equal_titles_keeps_every_part() { + let dir = workdir("organize_dup_titles"); + let mut a = auto(&dir); + let doc = ok(&mut a, "doc_open", json!({ "path": "a.pdf" }))["doc"].as_u64().unwrap(); + ok(&mut a, "bookmark_add", json!({ "doc": doc, "title": "Same", "page": 1 })); + ok(&mut a, "bookmark_add", json!({ "doc": doc, "title": "Same", "page": 2 })); + ok(&mut a, "bookmark_add", json!({ "doc": doc, "title": "same", "page": 3 })); + let s = ok(&mut a, "doc_split", json!({ "doc": doc, "bookmarks": true, "out_dir": "parts" })); + let files: Vec = s["files"] + .as_array() + .unwrap() + .iter() + .map(|f| std::path::Path::new(f["path"].as_str().unwrap()).file_name().unwrap().to_string_lossy().into_owned()) + .collect(); + assert_eq!(files, ["a-Same.pdf", "a-Same-2.pdf", "a-same-3.pdf"]); + for f in &files { + assert!(dir.join("parts").join(f).exists(), "{f} missing"); + } +} + #[test] fn organizing_with_filters_bookmark_splits_and_extract_options() { let dir = workdir("organize2"); diff --git a/crates/ui-egui/src/files.rs b/crates/ui-egui/src/files.rs index 5cdd66e6f..a7854f8fd 100644 --- a/crates/ui-egui/src/files.rs +++ b/crates/ui-egui/src/files.rs @@ -597,6 +597,7 @@ impl PdfCraftApp { } }; let safe = |t: &str| t.chars().map(|c| if c.is_alphanumeric() || " -_.,()".contains(c) { c } else { '_' }).collect::(); + let mut used = std::collections::HashSet::new(); let named: Vec<(String, Arc>)> = parts .into_iter() .map(|(a, b, bytes)| { @@ -607,7 +608,14 @@ impl PdfCraftApp { None if a == b => format!("{stem} (page {a}).pdf"), None => format!("{stem} (pages {a}-{b}).pdf"), }; - (name, bytes) + // Equal titles (or titles that sanitize alike) get (2), (3), ... so no part overwrites another; compared case-insensitively. + let mut unique = name.clone(); + let mut n = 1; + while !used.insert(unique.to_lowercase()) { + n += 1; + unique = format!("{} ({n}).pdf", name.trim_end_matches(".pdf")); + } + (unique, bytes) }) .collect(); self.write_files(&named, "Choose a folder for the split files"); diff --git a/crates/ui-egui/tests/editing.rs b/crates/ui-egui/tests/editing.rs index 2d080a429..c9ffcbd14 100644 --- a/crates/ui-egui/tests/editing.rs +++ b/crates/ui-egui/tests/editing.rs @@ -1380,6 +1380,28 @@ fn split_dialog_writes_one_file_per_part() { let _ = std::fs::remove_dir_all(dir); } +#[test] +fn split_at_bookmarks_with_equal_titles_keeps_every_part() { + let dir = temp_path("split-dup-titles"); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + let mut h = organize(3); + h.state_mut().export_dir_override = Some(dir.to_string_lossy().into_owned()); + for (index, title) in [(0, "Same"), (1, "Same"), (2, "same")] { + h.state_mut().apply_edit(pdfcraft_engine::Edit::AddBookmark { parent: vec![], index, title: title.into(), page: index }); + } + h.state_mut().split_draft.mode = pdfcraft_ui_egui::SplitMode::Bookmarks; + h.state_mut().run_command("page.split"); + h.run_steps(3); + h.get_by_label_contains("Creates 3 files from 3 pages"); + h.get_by_label("Split").click(); + h.run_steps(3); + let mut names: Vec = std::fs::read_dir(&dir).unwrap().map(|e| e.unwrap().file_name().to_string_lossy().into_owned()).collect(); + names.sort(); + assert_eq!(names, ["doc - Same (2).pdf", "doc - Same.pdf", "doc - same (3).pdf"]); + let _ = std::fs::remove_dir_all(dir); +} + #[test] fn split_before_selected_pages() { let dir = temp_path("split-sel"); From ef63ba3680f6599e45bde5071f414f5cd46df3ac Mon Sep 17 00:00:00 2001 From: cyfung1031 <44498510+cyfung1031@users.noreply.github.com> Date: Sun, 11 Oct 2026 04:39:44 +0900 Subject: [PATCH 28/69] Windows: add reproducible security diagnostics for storytold/pdfcraft#747 (#769) Add packaging/windows/security-audit.ps1, a read-only maintainer helper that records a release file's SHA-256, Authenticode status and, with an external YARA binary and rule, every matched string identifier and offset. Document the procedure in docs/releasing.md ("Scanner detections"). No application, dependency, installer or network behaviour changes: the diff touches only the helper and the release documentation. Co-authored-by: Claude Haiku 5.5 --- docs/releasing.md | 41 +++++++++++++ packaging/windows/security-audit.ps1 | 90 ++++++++++++++++++++++++++++ 2 files changed, 131 insertions(+) create mode 100644 packaging/windows/security-audit.ps1 diff --git a/docs/releasing.md b/docs/releasing.md index 06b1c2ce6..8f88a4684 100644 --- a/docs/releasing.md +++ b/docs/releasing.md @@ -204,3 +204,44 @@ unsigned and the run shows a warning. a PowerShell parse, xmllint (WiX, plist, MIME), `desktop-file-validate`, `appstreamcli validate`, and the MSI icon-id check. The FreeBSD and Windows ARM64 workflows also exercise their packaging before a release does. + +## Scanner detections + +Antivirus and reputation scanners sometimes flag a released file. Check the facts before you change +code or answer publicly. The first report was [issue #747](https://github.com/storytold/pdfcraft/issues/747), +a match of the `CobaltStrikeBeacon` family rule. + +1. **Confirm the file is the release.** Compare its SHA-256 with `SHA256SUMS.txt` on the GitHub + Release. Only a file whose hash matches the release says anything about that release. +2. **Record the signature.** `packaging/windows/security-audit.ps1` prints the Authenticode status: + - `Valid`: the signature verifies. + - `NotSigned`: no signature, as in developer and test builds. Not evidence of compromise by itself. + - `HashMismatch`: the file changed after it was signed. Investigate. + - `NotTrusted` or `UnknownError`: a signature exists but did not validate on this machine. Compare + the signer and the hash with the release. +3. **Extract the payloads without running them.** Scan the executables inside the MSI and the + portable zip, not only the outer installer. `7z x -oDIR pdfcraft--windows-x64.msi` + extracts them. The payload names are WiX-internal, so tell the executables apart by their file + type. Don't install or run the MSI or the extracted files. +4. **Scan with YARA and keep every match.** Use your own copies of YARA and the rule. PdfCraft + bundles neither, and the helper never runs in the app, the installer or CI: + + ```powershell + pwsh packaging/windows/security-audit.ps1 -Path .\pdfcraft.exe, .\pdfcraft-cli.exe -YaraExe C:\tools\yara64.exe -YaraRule C:\rules\CobaltStrikeBeacon.yar + ``` + + The output lists each matched identifier with its offset, and the SHA-256 of the rule file. + Record where the rule came from and its revision. For CAPEv2's `CobaltStrikeBeacon` rule, that is + the commit that last changed `data/yara/CAPE/CobaltStrikeBeacon.yar`. +5. **Find the condition branch.** Family rules usually have alternative branches, such as + `all of ($x*) or 2 of ($a*) or ...`. Copy the rule, make one rule per branch with only the strings + it uses, and scan again to see which branch fired. +6. **Interpret with care.** A match on generic repeated-byte patterns says much less than a match on + distinctive code or strings, and it does not show that the software belongs to the family. Rules + change too: CAPEv2 commit `9b599185` (2024-12-05) lengthened this rule's two repeated-byte patterns + from 4 to 16 bytes to reduce false positives, so an older rule revision can match files that the + current one does not. Don't alter PdfCraft's binaries to change a scanner's result. That changes + nothing about the software. +7. **Report.** To the scanner, send the file's SHA-256, the release URL, the rule name and revision, + and the matched identifiers and offsets. Report PdfCraft problems at + with the same facts. diff --git a/packaging/windows/security-audit.ps1 b/packaging/windows/security-audit.ps1 new file mode 100644 index 000000000..8bda7a807 --- /dev/null +++ b/packaging/windows/security-audit.ps1 @@ -0,0 +1,90 @@ +<# +.SYNOPSIS + Record the facts behind a scanner detection of PdfCraft Windows files: SHA-256, Authenticode + status and, with -YaraExe and -YaraRule, the matched strings and offsets of an external YARA rule. + +.DESCRIPTION + Read-only. It never changes, runs or unpacks the files it inspects, makes no network connection + itself and needs no administrator rights. Neither YARA nor any rule is bundled or downloaded: pass + your own copies. See docs/releasing.md ("Scanner detections"). + + A YARA match only means that a byte pattern occurred in the file. It is not proof of malware. + Report the rule file's SHA-256 (printed here), the matched identifiers and offsets, and the + condition branch that fired. + +.EXAMPLE + pwsh packaging/windows/security-audit.ps1 -Path dist\release\pdfcraft-0.5.0-windows-x64-portable\pdfcraft.exe + +.EXAMPLE + pwsh packaging/windows/security-audit.ps1 -Path .\pdfcraft.exe, .\pdfcraft-cli.exe -YaraExe C:\tools\yara64.exe -YaraRule C:\rules\CobaltStrikeBeacon.yar +#> +param( + [Parameter(Mandatory)] [string[]] $Path, + [string] $YaraExe, + [string] $YaraRule +) +$ErrorActionPreference = 'Stop' + +function Invoke-Yara([string[]] $Arguments) { + # Native stderr would end the script under $ErrorActionPreference = 'Stop' (Windows PowerShell 5.1), + # so capture it as text and judge the result by the exit code. + $ErrorActionPreference = 'Continue' + $lines = @(& $YaraExe @Arguments 2>&1 | ForEach-Object { "$_" }) + return [pscustomobject] @{ Code = $LASTEXITCODE; Lines = $lines } +} + +if (([bool] $YaraExe) -ne ([bool] $YaraRule)) { throw 'Pass -YaraExe and -YaraRule together.' } +Write-Output "Scanned (UTC): $((Get-Date).ToUniversalTime().ToString('yyyy-MM-dd HH:mm:ss'))" +if ($YaraExe) { + foreach ($tool in $YaraExe, $YaraRule) { + if (-not (Test-Path -LiteralPath $tool -PathType Leaf)) { throw "not found: $tool" } + } + $YaraExe = (Resolve-Path -LiteralPath $YaraExe).ProviderPath + $YaraRule = (Resolve-Path -LiteralPath $YaraRule).ProviderPath + Write-Output "YARA: $((Invoke-Yara @('--version')).Lines | Select-Object -First 1) ($YaraExe)" + Write-Output "Rule: $YaraRule" + Write-Output "Rule SHA-256: $((Get-FileHash -LiteralPath $YaraRule -Algorithm SHA256).Hash)" +} else { + Write-Output 'YARA: not run (pass -YaraExe and -YaraRule to scan with an external rule)' +} + +$failed = $false +foreach ($item in $Path) { + if (-not (Test-Path -LiteralPath $item -PathType Leaf)) { + Write-Output "error: not a file: $item" + $failed = $true + continue + } + $file = (Resolve-Path -LiteralPath $item).ProviderPath + $sig = Get-AuthenticodeSignature -LiteralPath $file + $signer = if ($sig.SignerCertificate) { $sig.SignerCertificate.Subject } else { '(none)' } + $stamper = if ($sig.TimeStamperCertificate) { $sig.TimeStamperCertificate.Subject } else { '(none)' } + + Write-Output '' + Write-Output "== $file" + Write-Output "Size: $((Get-Item -LiteralPath $file).Length) bytes" + Write-Output "SHA-256: $((Get-FileHash -LiteralPath $file -Algorithm SHA256).Hash)" + Write-Output "Authenticode: $($sig.Status) ($($sig.StatusMessage))" + Write-Output "Signer: $signer" + Write-Output "Timestamper: $stamper" + + if ($YaraExe) { + $scan = Invoke-Yara @('-s', '-m', $YaraRule, $file) + if ($scan.Code -ne 0) { + Write-Output "YARA: failed with exit code $($scan.Code)" + $scan.Lines | ForEach-Object { Write-Output " $_" } + $failed = $true + continue + } + $ruleLines = @($scan.Lines | Where-Object { $_ -and $_ -notmatch '^0x' }) + $stringLines = @($scan.Lines | Where-Object { $_ -match '^0x' }) + Write-Output "YARA: $($ruleLines.Count) rule match(es), $($stringLines.Count) string match(es)" + $scan.Lines | Where-Object { $_ } | ForEach-Object { Write-Output " $_" } + } +} + +if ($YaraExe) { + Write-Output '' + Write-Output 'A YARA match is a pattern match, not a verdict. Keep the rule revision, identifiers, offsets and branch with any report.' +} +if ($failed) { exit 1 } From cac2e8bf4130827bc75de5c2ef04818559943d12 Mon Sep 17 00:00:00 2001 From: DWAA1660 Date: Sat, 10 Oct 2026 13:44:29 -0600 Subject: [PATCH 29/69] Notice bar: the message wraps beside its buttons and lines up with them (#785) (#832) The notice bar above the pages (security, forms, XFA, repaired file, warnings, signatures) laid its message out before its buttons. In a narrow document area (a side panel open, a longer translation) the message ran under "Security settings" and on into the side panel, and it wasn't centred on the buttons. A shared notice_bar helper now lays the buttons out first, right to left, centred on a 28 px row, and gives the message the width that's left: it wraps there (cut short with the full text on hover when less than 120 px is left) and its first line is centred on the buttons' row. Regression test: editing::the_security_notice_wraps_beside_its_buttons_and_lines_up_with_them (one line, wrapped and cut-short widths; fails on main). Co-authored-by: DWAA1660 Co-authored-by: Claude Opus 5.5 --- crates/ui-egui/src/canvas.rs | 85 ++++++++++++++++++++------------- crates/ui-egui/tests/editing.rs | 29 +++++++++++ 2 files changed, 81 insertions(+), 33 deletions(-) diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 4a84bec90..5605292d1 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -2950,16 +2950,10 @@ fn notices( let xfa = doc.xfa.as_ref(); if let Some((icon, color, template, arg)) = signed { let mut open = false; - egui::Frame::NONE.fill(t.accent_soft).inner_margin(egui::Margin::symmetric(14, 7)).show(ui, |ui| { - ui.horizontal(|ui| { - ui.add(icons::image(icon, 16.0, color)); - ui.label(egui::RichText::new(crate::i18n::fmt(tl!(template), &[("by", &arg)])).color(t.text)); - ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { - if crate::widgets::pill_button(ui, tl!("Signature panel"), false).clicked() { - open = true; - } - }); - }); + notice_bar(ui, t, icon, color, crate::i18n::fmt(tl!(template), &[("by", &arg)]), |ui| { + if crate::widgets::pill_button(ui, tl!("Signature panel"), false).clicked() { + open = true; + } }); return open.then_some(Notice::Signatures); } @@ -3010,29 +3004,23 @@ fn notices( None }; let (icon, text, fields) = msg?; - egui::Frame::NONE.fill(t.accent_soft).inner_margin(egui::Margin::symmetric(14, 7)).show(ui, |ui| { - ui.horizontal(|ui| { - ui.add(icons::image(icon, 16.0, t.accent_text)); - ui.label(egui::RichText::new(text).color(t.text)); - ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { - if icons::button(ui, "x", 22.0, false, tl!("Dismiss")).clicked() { - view.notice_dismissed = true; - } - if fields { - let label = if view.highlight_fields { tl!("Hide field highlights") } else { tl!("Highlight fields") }; - if crate::widgets::pill_button(ui, label, view.highlight_fields).clicked() { - view.highlight_fields = !view.highlight_fields; - toggled = Some(Notice::FieldHighlights(view.highlight_fields)); - } - } - if secured && crate::widgets::pill_button(ui, tl!("Security settings"), false).clicked() { - open_security = true; - } - if repaired && !secured && info.fields.is_empty() && crate::widgets::pill_button(ui, tl!("Details"), false).clicked() { - open_repairs = true; - } - }); - }); + notice_bar(ui, t, icon, t.accent_text, text, |ui| { + if icons::button(ui, "x", 22.0, false, tl!("Dismiss")).clicked() { + view.notice_dismissed = true; + } + if fields { + let label = if view.highlight_fields { tl!("Hide field highlights") } else { tl!("Highlight fields") }; + if crate::widgets::pill_button(ui, label, view.highlight_fields).clicked() { + view.highlight_fields = !view.highlight_fields; + toggled = Some(Notice::FieldHighlights(view.highlight_fields)); + } + } + if secured && crate::widgets::pill_button(ui, tl!("Security settings"), false).clicked() { + open_security = true; + } + if repaired && !secured && info.fields.is_empty() && crate::widgets::pill_button(ui, tl!("Details"), false).clicked() { + open_repairs = true; + } }); if open_repairs { return Some(Notice::Repairs); @@ -3040,6 +3028,37 @@ fn notices( open_security.then_some(Notice::Security).or(toggled) } +/// One notice bar: an icon and a message on the left, `buttons` (added right to left) on the +/// right. The buttons are laid out first, so the message only gets the width they leave: it +/// wraps there (or, when very little is left, is cut short with the full text on hover) instead +/// of running under the buttons and past the document area into the side panel (#785). The +/// buttons are centred on a row of button height, and the message's first line is centred on +/// the same row; a wrapped message grows the bar downwards. +fn notice_bar(ui: &mut egui::Ui, t: &Tokens, icon: &str, icon_color: Color32, text: String, buttons: impl FnOnce(&mut egui::Ui)) { + const ROW: f32 = 28.0; + const ICON: f32 = 16.0; + egui::Frame::NONE.fill(t.accent_soft).inner_margin(egui::Margin::symmetric(14, 7)).show(ui, |ui| { + let width = ui.available_width(); + ui.allocate_ui_with_layout(vec2(width, ROW), egui::Layout::right_to_left(egui::Align::Center), |ui| { + ui.set_min_height(ROW); + buttons(ui); + let rest = ui.available_width().max(0.0); + ui.allocate_ui_with_layout(vec2(rest, ROW), egui::Layout::top_down(egui::Align::Min), |ui| { + let font = egui::TextStyle::Body.resolve(ui.style()); + let line = ui.fonts_mut(|f| f.row_height(&font)).max(ICON); + ui.add_space(((ROW - line) / 2.0).max(0.0)); + ui.with_layout(egui::Layout::left_to_right(egui::Align::Min), |ui| { + let (slot, _) = ui.allocate_exact_size(vec2(ICON, line), Sense::hover()); + icons::paint(ui, Rect::from_center_size(slot.center(), vec2(ICON, ICON)), icon, ICON, icon_color); + let label = egui::Label::new(egui::RichText::new(text).color(t.text)); + // Wrapping into a sliver would stack the message a few letters per line. + ui.add(if ui.available_width() >= 120.0 { label.wrap() } else { label.truncate() }); + }); + }); + }); + }); +} + /// The floating quick-action bar at the left edge of the document area. fn quick_bar(app: &mut PdfCraftApp, area: Rect, ui: &mut egui::Ui) { let t = Tokens::get(ui.ctx()); diff --git a/crates/ui-egui/tests/editing.rs b/crates/ui-egui/tests/editing.rs index c9ffcbd14..f1a267ee2 100644 --- a/crates/ui-egui/tests/editing.rs +++ b/crates/ui-egui/tests/editing.rs @@ -1467,6 +1467,35 @@ fn password_prompt_opens_and_security_tab_reports_the_details() { h.get_by_label("User password"); } +/// #785: the notice's message used to be laid out before its buttons, so in a narrow document +/// area (a side panel open) it ran under "Security settings" and on into the panel, and it sat +/// off the buttons' centre line. +#[test] +fn the_security_notice_wraps_beside_its_buttons_and_lines_up_with_them() { + // Wide: one line. Narrower: wrapped. Narrowest: cut short (the full text is on hover). + for (width, wraps) in [(1700.0, false), (1250.0, true), (900.0, false)] { + let mut h = Harness::builder().with_size(egui::vec2(width, 800.0)).build_eframe(|_cc| { + let mut app = PdfCraftApp::new(); + app.set_option("language", "en").unwrap(); + app.open_bytes("locked.pdf", None, protected("", "owner", 0b0100)).unwrap(); + app.set_option("panel", "bookmarks").unwrap(); + app + }); + h.run_steps(4); + let msg = h.get_by_label_contains("This document is secured").rect(); + let button = h.get_by_label("Security settings").rect(); + let dismiss = h.get_by_label("Dismiss").rect(); + assert!(msg.right() <= button.left() + 0.5, "{width}: the message stops before the buttons: {msg:?} vs {button:?}"); + assert!(button.right() <= dismiss.left() + 0.5, "{width}: {button:?} vs {dismiss:?}"); + assert!((button.center().y - dismiss.center().y).abs() <= 0.5, "{width}: the buttons share a centre line"); + assert!(msg.top() >= button.top(), "{width}: the message stays inside the bar: {msg:?} vs {button:?}"); + let lines = (msg.height() / 14.0).floor().max(1.0); + let first_line = msg.top() + msg.height() / lines / 2.0; + assert!((first_line - button.center().y).abs() <= 1.5, "{width}: first line centred on the buttons: {msg:?} vs {button:?}"); + assert_eq!(msg.height() > button.height(), wraps, "{width}: {msg:?}"); + } +} + #[test] fn restricted_documents_show_a_notice_and_block_page_changes() { let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).build_eframe(|_cc| { From 7c386fa4acc59acb91665a8def9dd09758486294 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=89=8D=E5=80=BE=E6=A2=A6=E5=8D=8EV?= <185041488+CQMHV@users.noreply.github.com> Date: Sun, 11 Oct 2026 03:46:22 +0800 Subject: [PATCH 30/69] M10.2: scan JPEG metadata beyond frame headers (#764) Co-authored-by: Codex Co-authored-by: Brandon Thomas --- ROADMAP.md | 1 + crates/create/README.md | 5 +++- crates/create/src/lib.rs | 31 +++++++++++++------- crates/create/src/tests.rs | 58 ++++++++++++++++++++++++++++++++++++++ 4 files changed, 84 insertions(+), 11 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index fc09e2886..5702705af 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -216,6 +216,7 @@ M0 → M1 → M2 → M3 → M4 must happen in order. After M4, M5–M12 can run ## Log Newest first: the date, what moved, and the new overall estimate. +- **2026-10-10 (M10.2, JPEG scanning follow-up to #729):** Skip marker fill bytes and keep reading JFIF, APP14 and ICC metadata after the frame header, stopping before scan data or at EOI. Invalid segment lengths return errors; damaged or mismatched ICC profiles retain #729's device-space fallback. Synthetic regression cases added but not run at the contributor's request. Estimates unchanged. - **2026-10-10 (M13, desktop action outputs):** Desktop Action Wizard keeps every result when input basenames repeat, or when the destination already has a result from an earlier run. It preserves the original filename and extension for a free name and adds numbered suffixes for occupied names; the bounded search returns an error rather than reusing an occupied path. A real-shell regression drives Start twice and reopens the three-page and one-page results, checking that the original inputs and earlier output remain intact. This covers the desktop writer separately from the CLI/MCP action_run repair in #877. Milestone and effort estimates unchanged. - **2026-10-10 (progress docs, hours per audience):** Each readiness number gets its own hours to ≈ 95%: full ≈ 49% / 780–1,400 h, mainstream ≈ 55% / 500–900 h, essentials ≈ 66% / 180–330 h. The full number is now the standard's additive weighted sum over the dimensions (written weights), 47% → 49%: method aligned with the standard, no new evidence. Beta ≈ 26 points, ≈ 400–700 h. diff --git a/crates/create/README.md b/crates/create/README.md index cd20f1959..7cd682775 100644 --- a/crates/create/README.md +++ b/crates/create/README.md @@ -8,7 +8,10 @@ Layer L4. Create a PDF (Acrobat's Create a PDF tool), execution plan M10.2: (`/DCTDecode`, grey, RGB or Adobe-inverted CMYK); PNG is decoded and stored with Flate, with transparency as a soft mask. An ICC profile in the file (JPEG `APP2 ICC_PROFILE`, PNG `iCCP`) that matches the image's colour space is kept as an `/ICCBased` colour space with - the device space as `/Alternate`; images with the same profile share one profile object; + the device space as `/Alternate`; images with the same profile share one profile object. + JPEG metadata scanning skips marker fill bytes and reads application segments after the + frame header, stopping at the first scan or end marker. Broken or mismatched ICC profiles + still fall back to the device colour space; - `from_images_with_resolution(images, ImageResolution::Dpi(dpi))`: override the page resolution (1–1200 dpi), without resampling or recompressing image pixels. Use 72 dpi for one point per pixel, or `ImageResolution::Embedded` for the same behavior as diff --git a/crates/create/src/lib.rs b/crates/create/src/lib.rs index 6e325ef69..bbf937096 100644 --- a/crates/create/src/lib.rs +++ b/crates/create/src/lib.rs @@ -228,18 +228,31 @@ fn jpeg(name: &str, bytes: &[u8]) -> Result { } let (mut i, mut size, mut comps, mut dpi, mut adobe) = (2usize, None, 0u8, (72.0, 72.0), false); let mut icc_chunks = Vec::new(); - while i + 4 <= bytes.len() { - if bytes[i] != 0xFF { + while let Some(&byte) = bytes.get(i) { + if byte != 0xFF { i += 1; continue; } - let marker = bytes[i + 1]; + i += 1; + // Markers may be preceded by any number of 0xFF fill bytes. + while bytes.get(i) == Some(&0xFF) { + i += 1; + } + let marker = *bytes.get(i).ok_or_else(|| bad("truncated JPEG marker"))?; + i += 1; + // Metadata ends at the first scan; entropy-coded bytes are not segments. + if marker == 0xDA || marker == 0xD9 { + break; + } if marker == 0xD8 || marker == 0x01 || (0xD0..=0xD7).contains(&marker) { - i += 2; continue; } - let len = u16::from_be_bytes([bytes[i + 2], bytes[i + 3]]) as usize; - let seg = bytes.get(i + 4..i + 2 + len).ok_or_else(|| bad("truncated"))?; + let len = usize::from(be16(bytes, i).ok_or_else(|| bad("truncated JPEG segment length"))?); + if len < 2 { + return Err(bad("invalid JPEG segment length")); + } + let end = i.checked_add(len).ok_or_else(|| bad("JPEG segment length overflow"))?; + let seg = bytes.get(i + 2..end).ok_or_else(|| bad("truncated JPEG segment"))?; match marker { // APP0 JFIF density. 0xE0 if seg.starts_with(b"JFIF\0") && seg.len() >= 12 => { @@ -259,18 +272,16 @@ fn jpeg(name: &str, bytes: &[u8]) -> Result { icc_chunks.push((*seq, *count, data)); } } - 0xC0..=0xCF if marker != 0xC4 && marker != 0xC8 && marker != 0xCC => { + 0xC0..=0xCF if marker != 0xC4 && marker != 0xC8 && marker != 0xCC && size.is_none() => { if seg.len() < 6 { return Err(bad("bad frame header")); } size = Some((u16::from_be_bytes([seg[3], seg[4]]) as u32, u16::from_be_bytes([seg[1], seg[2]]) as u32)); comps = seg[5]; - break; } - 0xDA => break, _ => {} } - i += 2 + len; + i = end; } let (w, h) = size.filter(|(w, h)| *w > 0 && *h > 0).ok_or_else(|| bad("no image size"))?; let mut d = Dict::new(); diff --git a/crates/create/src/tests.rs b/crates/create/src/tests.rs index e649fdf73..7651fd3c2 100644 --- a/crates/create/src/tests.rs +++ b/crates/create/src/tests.rs @@ -346,6 +346,64 @@ fn icc_of(doc: &Document, image: &Dict) -> Option<(i64, Vec, Vec, Object Some((s.dict.int(b"N")?, s.dict.name(b"Alternate")?.to_vec(), s.decoded().unwrap(), r)) } +/// Move the synthetic fixture's frame before its application segments. +fn jpeg_frame_first(mut bytes: Vec) -> Vec { + let at = bytes.windows(2).position(|w| w == [0xFF, 0xC0]).unwrap(); + let len = usize::from(u16::from_be_bytes([bytes[at + 2], bytes[at + 3]])); + let frame: Vec<_> = bytes.drain(at..at + 2 + len).collect(); + bytes.splice(2..2, frame); + bytes +} + +#[test] +fn jpeg_fill_bytes_and_metadata_after_frame_are_preserved() { + let profile = icc_profile(b"CMYK"); + let mut bytes = jpeg_frame_first(cmyk_jpeg_bytes(&profile)); + // Fill before SOF and APP14, including multiple consecutive fill bytes. + bytes.splice(24..24, [0xFF, 0xFF]); + bytes.insert(2, 0xFF); + let doc = reopen(&from_images(&[("filled.jpg".into(), bytes.clone())]).unwrap()); + let image = image_of(&doc, 0); + let (n, alt, data, _) = icc_of(&doc, &image).unwrap(); + assert_eq!((n, alt.as_slice(), data), (4, &b"DeviceCMYK"[..], profile)); + assert!(image.contains(b"Decode"), "APP14 after SOF still inverts CMYK"); + assert_eq!(extract_images(&doc, &[0], 0).images[0].data, bytes); + + let embedded = jpeg("density.jpg", &jpeg_frame_first(jpeg_bytes())).unwrap(); + assert_eq!(embedded.px, (3, 2)); + assert_eq!(embedded.dpi, (300.0, 300.0), "JFIF after SOF is read"); + + // A mismatched profile found after SOF still falls back leniently, as in #729. + let bytes = jpeg_frame_first(cmyk_jpeg_bytes(&icc_profile(b"RGB "))); + let doc = reopen(&from_images(&[("mismatch.jpg".into(), bytes)]).unwrap()); + assert_eq!(image_of(&doc, 0).name(b"ColorSpace"), Some(&b"DeviceCMYK"[..])); +} + +#[test] +fn jpeg_segment_scan_stops_at_sos_or_eoi() { + let profile = icc_profile(b"CMYK"); + for marker in [0xDA, 0xD9] { + let mut bytes = jpeg_frame_first(cmyk_jpeg_bytes(&profile)); + bytes.truncate(bytes.len() - 2); + // Neither marker needs a segment length to stop metadata scanning. The trailing + // invalid APP2 would fail if entropy data or bytes after EOI were walked. + bytes.extend_from_slice(&[0xFF, 0xFF, marker, 0xFF, 0xE2, 0, 1]); + let embedded = jpeg("boundary.jpg", &bytes).unwrap(); + assert_eq!(embedded.icc, Some(profile.clone())); + assert_eq!(embedded.px, (3, 2)); + } +} + +#[test] +fn jpeg_segment_scan_rejects_truncated_or_invalid_lengths() { + for tail in [&[0xFF][..], &[0xFF, 0xFF], &[0xFF, 0xE2], &[0xFF, 0xE2, 0], &[0xFF, 0xE2, 0, 0], &[0xFF, 0xE2, 0, 1], &[0xFF, 0xE2, 0, 3]] { + let mut bytes = jpeg_bytes(); + bytes.truncate(bytes.len() - 2); + bytes.extend_from_slice(tail); + assert!(jpeg("truncated.jpg", &bytes).is_err(), "tail: {tail:?}"); + } +} + #[test] fn embedded_icc_profiles_tag_the_image_colour_space() { // A CMYK JPEG keeps its profile as /ICCBased (N 4), its data byte for byte and its Decode. From 1845d9260d46a4586c077a02cd7bfaa70570398b Mon Sep 17 00:00:00 2001 From: mlkonrad Date: Sat, 10 Oct 2026 22:46:38 +0300 Subject: [PATCH 31/69] Issue #878: Combine stores an image once when its colour space is an indirect name (#883) ImageMagick writes an image's colour space as its own object (`/ColorSpace 10 0 R`, `10 0 obj /DeviceRGB`). Each source's copy of that object was never merged, since it isn't a resource, so identical images compared unequal and every copy was kept: 8 copies of an 80 MB ImageMagick PDF combined into 648 MB instead of 81 MB. Dedupe now compares a reference to a plain value (name, number, boolean or string) as that value. The value objects themselves are still never merged. Combine, Insert Pages and Reduce File Size share this code. Co-authored-by: Claude Opus 5.5 --- crates/organize/src/dedupe.rs | 74 +++++++++++++++++++++++++++++------ crates/organize/src/tests.rs | 51 ++++++++++++++++++++++++ 2 files changed, 113 insertions(+), 12 deletions(-) diff --git a/crates/organize/src/dedupe.rs b/crates/organize/src/dedupe.rs index 020c0df22..fe5cb0749 100644 --- a/crates/organize/src/dedupe.rs +++ b/crates/organize/src/dedupe.rs @@ -10,7 +10,10 @@ //! encoding dictionaries, and colour-space arrays. Objects whose *identity* matters (pages, //! annotations, form fields, layers, outline items, structure elements) are never merged. //! - Two objects are equal when they are structurally identical with references compared after -//! merging, so a font dictionary merges once its font file has. This runs to a fixpoint. +//! merging, so a font dictionary merges once its font file has. This runs to a fixpoint. A +//! reference to a plain value (a name, number, boolean or string, such as ImageMagick's +//! `/ColorSpace 10 0 R` → `/DeviceRGB`) compares as that value; the value objects themselves +//! are never merged. //! - Only `candidates` (objects created by the operation) are ever replaced or rewritten. Existing //! objects can serve as the merge target but are not changed, so an incremental save stays //! small. @@ -84,7 +87,14 @@ fn canon(map: &HashMap, mut r: ObjRef) -> ObjRef { r } -fn hash_obj(o: &Object, map: &HashMap, h: &mut impl Hasher) { +/// The value behind `r` when it is a plain value (name, number, boolean or string): it then +/// means that value wherever it is used, whichever object holds it (#878). +fn plain_value(doc: &Document, r: ObjRef) -> Option> { + let o = doc.get(r); + matches!(*o, Object::Bool(_) | Object::Int(_) | Object::Real(_) | Object::Name(_) | Object::String(_)).then_some(o) +} + +fn hash_obj(o: &Object, doc: &Document, map: &HashMap, h: &mut impl Hasher) { match o { Object::Null => 0u8.hash(h), Object::Bool(b) => (1u8, b).hash(h), @@ -95,7 +105,7 @@ fn hash_obj(o: &Object, map: &HashMap, h: &mut impl Hasher) { Object::Array(a) => { (6u8, a.len()).hash(h); for x in a { - hash_obj(x, map, h); + hash_obj(x, doc, map, h); } } Object::Dict(d) => { @@ -104,28 +114,39 @@ fn hash_obj(o: &Object, map: &HashMap, h: &mut impl Hasher) { for (k, v) in d.iter() { let mut eh = std::collections::hash_map::DefaultHasher::new(); k.hash(&mut eh); - hash_obj(v, map, &mut eh); + hash_obj(v, doc, map, &mut eh); acc = acc.wrapping_add(eh.finish()); } (7u8, d.len(), acc).hash(h); } Object::Stream(s) => { 8u8.hash(h); - hash_obj(&Object::Dict(s.dict.clone()), map, h); + hash_obj(&Object::Dict(s.dict.clone()), doc, map, h); s.raw[..].hash(h); } Object::Ref(r) => { let c = canon(map, *r); - (9u8, c.num, c.generation).hash(h); + match plain_value(doc, c) { + Some(v) => hash_obj(&v, doc, map, h), + None => (9u8, c.num, c.generation).hash(h), + } } } } -fn eq_obj(a: &Object, b: &Object, map: &HashMap) -> bool { +fn eq_obj(a: &Object, b: &Object, doc: &Document, map: &HashMap) -> bool { + // Plain values behind references compare as the values (as `hash_obj` hashes them). + let plain = |o: &Object| if let Object::Ref(r) = o { plain_value(doc, canon(map, *r)) } else { None }; + match (plain(a), plain(b)) { + (Some(x), Some(y)) => return eq_obj(&x, &y, doc, map), + (Some(x), None) => return eq_obj(&x, b, doc, map), + (None, Some(y)) => return eq_obj(a, &y, doc, map), + (None, None) => {} + } match (a, b) { - (Object::Array(x), Object::Array(y)) => x.len() == y.len() && x.iter().zip(y).all(|(p, q)| eq_obj(p, q, map)), - (Object::Dict(x), Object::Dict(y)) => x.len() == y.len() && x.iter().all(|(k, v)| y.get(k).is_some_and(|w| eq_obj(v, w, map))), - (Object::Stream(x), Object::Stream(y)) => x.raw == y.raw && eq_obj(&Object::Dict(x.dict.clone()), &Object::Dict(y.dict.clone()), map), + (Object::Array(x), Object::Array(y)) => x.len() == y.len() && x.iter().zip(y).all(|(p, q)| eq_obj(p, q, doc, map)), + (Object::Dict(x), Object::Dict(y)) => x.len() == y.len() && x.iter().all(|(k, v)| y.get(k).is_some_and(|w| eq_obj(v, w, doc, map))), + (Object::Stream(x), Object::Stream(y)) => x.raw == y.raw && eq_obj(&Object::Dict(x.dict.clone()), &Object::Dict(y.dict.clone()), doc, map), (Object::Ref(x), Object::Ref(y)) => canon(map, *x) == canon(map, *y), (Object::Real(x), Object::Real(y)) => x.to_bits() == y.to_bits(), (Object::String(x), Object::String(y)) => x.bytes == y.bytes, @@ -201,9 +222,9 @@ pub fn dedupe_resources(doc: &mut Document, candidates: &[ObjRef], index_existin continue; } let mut h = std::collections::hash_map::DefaultHasher::new(); - hash_obj(o, &map, &mut h); + hash_obj(o, doc, &map, &mut h); let bucket = buckets.entry(h.finish()).or_default(); - let target = bucket.iter().map(|j| &universe[*j]).find(|(_, other)| eq_obj(o, other, &map)).map(|(t, _)| *t); + let target = bucket.iter().map(|j| &universe[*j]).find(|(_, other)| eq_obj(o, other, doc, &map)).map(|(t, _)| *t); match target { Some(t) if cand.contains(r) => { map.insert(*r, t); @@ -304,6 +325,35 @@ mod tests { assert_eq!(doc.get(p1), doc.get(p2), "both pages now use the one graphics state"); } + fn image(doc: &mut Document, colour_space: Object) -> ObjRef { + let mut d = Dict::new(); + d.set(b"Subtype".to_vec(), Object::name("Image")); + d.set(b"ColorSpace".to_vec(), colour_space); + doc.add(Object::Stream(Stream { dict: d, raw: b"pixels".to_vec().into() })) + } + + #[test] + fn references_to_plain_values_compare_as_the_values() { + // ImageMagick writes `/ColorSpace 10 0 R` with `10 0 obj /DeviceRGB`, one per file (#878). + let mut doc = Document::new_empty(); + let rgb1 = doc.add(Object::name("DeviceRGB")); + let rgb2 = doc.add(Object::name("DeviceRGB")); + let grey = doc.add(Object::name("DeviceGray")); + let a = image(&mut doc, Object::Ref(rgb1)); + let b = image(&mut doc, Object::Ref(rgb2)); + let c = image(&mut doc, Object::name("DeviceRGB")); // written directly: the same image + let d = image(&mut doc, Object::Ref(grey)); // same pixels, another colour space + let mut page = Dict::new(); + page.set(b"Images".to_vec(), Object::Array([a, b, c, d].map(Object::Ref).to_vec())); + let p = doc.add(Object::Dict(page)); + assert_eq!(dedupe_resources(&mut doc, &[rgb1, rgb2, grey, a, b, c, d, p], false), 2); + let page = doc.get(p); + let images = page.as_dict().unwrap().get(b"Images").unwrap().as_array().unwrap().clone(); + assert_eq!(images, [a, a, a, d].map(Object::Ref).to_vec()); + let nums = doc.object_numbers(); + assert!(nums.contains(&rgb1.num) && nums.contains(&rgb2.num), "the values themselves are never merged"); + } + #[test] fn existing_objects_are_targets_but_never_changed() { let mut doc = Document::new_empty(); diff --git a/crates/organize/src/tests.rs b/crates/organize/src/tests.rs index a6d4d978a..baa070998 100644 --- a/crates/organize/src/tests.rs +++ b/crates/organize/src/tests.rs @@ -734,6 +734,57 @@ fn combine_and_insert_share_identical_resources() { assert_eq!(labels(&d), ["A1", "A2", "A3", "A1", "A2"]); } +/// A one-page document drawing one image, written the way ImageMagick writes it: the colour +/// space is an indirect `/DeviceRGB` object of its own (#878). +fn indirect_colour_space_doc() -> Document { + let objs: Vec> = vec![ + b"<< /Type /Catalog /Pages 2 0 R >>".to_vec(), + b"<< /Type /Pages /Kids [3 0 R] /Count 1 >>".to_vec(), + b"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 100 100] /Resources << /XObject << /Im0 5 0 R >> >> /Contents 4 0 R >>".to_vec(), + stream(b"q 100 0 0 100 0 0 cm /Im0 Do Q"), + [ + b"<< /Type /XObject /Subtype /Image /Width 2 /Height 1 /ColorSpace 6 0 R /BitsPerComponent 8 /Length 6 >>\nstream\n".as_slice(), + &[255, 0, 0, 0, 0, 255], + b"\nendstream", + ] + .concat(), + b"/DeviceRGB".to_vec(), + ]; + let mut out = b"%PDF-1.7\n".to_vec(); + let mut offsets = Vec::new(); + for (i, o) in objs.iter().enumerate() { + offsets.push(out.len()); + out.extend_from_slice(format!("{} 0 obj\n", i + 1).as_bytes()); + out.extend_from_slice(o); + out.extend_from_slice(b"\nendobj\n"); + } + let xref = out.len(); + out.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objs.len() + 1).as_bytes()); + for o in offsets { + out.extend_from_slice(format!("{o:010} 00000 n \n").as_bytes()); + } + out.extend_from_slice(format!("trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n", objs.len() + 1).as_bytes()); + open(out) +} + +#[test] +fn combine_stores_an_image_once_when_its_colour_space_is_an_indirect_name() { + let images = |d: &Document| { + d.object_numbers() + .into_iter() + .filter(|n| matches!(&*d.get(ObjRef::new(*n, d.generation(*n))), Object::Stream(s) if s.dict.name(b"Subtype") == Some(b"Image"))) + .count() + }; + let src = indirect_colour_space_doc(); + let out = full_roundtrip(&crate::combine(&[("one", &src), ("two", &src), ("three", &src)]).unwrap()); + assert_eq!(images(&out), 1); + assert_eq!(pages(&out).unwrap().len(), 3); + // Inserting the same pages again reuses the image already there. + let mut d = indirect_colour_space_doc(); + crate::import_pages(&mut d, &indirect_colour_space_doc(), &[0], 1).unwrap(); + assert_eq!(images(&full_roundtrip(&d)), 1); +} + // ---- bookmarks (M4.6) -------------------------------------------------------------------------- fn titles(b: &[crate::Bookmark]) -> Vec { From 265ddca58c8f123021e324f62b82363c0ab0ca18 Mon Sep 17 00:00:00 2001 From: MarekSpodBiedry <73122968+MarekSpodBiedry@users.noreply.github.com> Date: Sat, 10 Oct 2026 21:46:52 +0200 Subject: [PATCH 32/69] organize: drop named destinations and their links when a page is deleted (#817) (#851) --- crates/organize/src/lib.rs | 134 ++++++++++++++++++++++++++++++++++- crates/organize/src/tests.rs | 36 ++++++++++ 2 files changed, 169 insertions(+), 1 deletion(-) diff --git a/crates/organize/src/lib.rs b/crates/organize/src/lib.rs index 4d6cacb17..fc383faf1 100644 --- a/crates/organize/src/lib.rs +++ b/crates/organize/src/lib.rs @@ -197,6 +197,7 @@ pub fn delete_pages(doc: &mut Document, indices: &[usize]) -> Result<(), Organiz /// Bookmarks, and links on the `keep` pages, that go to one of the `gone` pages lose that /// destination (/Dest, or a GoTo /A), rather than pointing at nothing; so does the catalog's /OpenAction. fn drop_destinations_to(doc: &mut Document, gone: &[ObjRef], keep: &[(ObjRef, Dict)]) -> Result<(), OrganizeError> { + let dead_names = drop_named_destinations(doc, gone)?; let mut holders = outline::items(doc); for (p, _) in keep { let annots = doc.get(*p).as_dict().and_then(|d| d.get(b"Annots").cloned()).map(|a| doc.resolve(&a).as_array().cloned().unwrap_or_default()); @@ -209,7 +210,12 @@ fn drop_destinations_to(doc: &mut Document, gone: &[ObjRef], keep: &[(ObjRef, Di ); } let to_gone = |o: Option<&Object>| { - o.map(|o| doc.resolve(o)).and_then(|o| o.as_array().and_then(|a| a.first()).and_then(Object::as_ref)).is_some_and(|r| gone.contains(&r)) + let Some(o) = o.map(|o| doc.resolve(o)) else { return false }; + match &*o { + Object::Name(n) => dead_names.contains(n), + Object::String(s) => dead_names.contains(&s.bytes), + _ => goes_to(doc, &o, gone), + } }; let mut dead: Vec<(ObjRef, &[u8])> = Vec::new(); for h in holders { @@ -242,6 +248,132 @@ fn drop_destinations_to(doc: &mut Document, gone: &[ObjRef], keep: &[(ObjRef, Di Ok(()) } +/// True when a destination (an array, or a dictionary whose /D is one) opens one of the `gone` pages. +fn goes_to(doc: &Document, dest: &Object, gone: &[ObjRef]) -> bool { + let dest = doc.resolve(dest); + let array = match dest.as_dict() { + Some(d) => d.get(b"D").map(|d| doc.resolve(d)), + None => Some(dest.clone()), + }; + array.and_then(|a| a.as_array().and_then(|a| a.first()).and_then(Object::as_ref)).is_some_and(|r| gone.contains(&r)) +} + +/// Remove the named destinations (the catalog's /Names /Dests tree and the legacy /Dests +/// dictionary) that open one of the `gone` pages. Returns the removed names, so that the links +/// using them can lose their destination too. Names of surviving pages are left alone. +fn drop_named_destinations(doc: &mut Document, gone: &[ObjRef]) -> Result>, OrganizeError> { + let mut dead: Vec> = Vec::new(); + let Some(root) = doc.root() else { return Ok(dead) }; + let Some(catalog) = doc.get(root).as_dict().cloned() else { return Ok(dead) }; + match catalog.get(b"Dests").cloned() { + Some(Object::Ref(r)) => { + if let Some(mut d) = doc.get(r).as_dict().cloned() + && prune_legacy(doc, &mut d, gone, &mut dead) + { + doc.update_dict(r, |x| *x = d)?; + } + } + Some(Object::Dict(mut d)) => { + if prune_legacy(doc, &mut d, gone, &mut dead) { + doc.update_dict(root, |c| c.set(b"Dests".to_vec(), Object::Dict(d)))?; + } + } + _ => {} + } + match catalog.get(b"Names").cloned() { + Some(Object::Ref(n)) => { + let tree = doc.get(n).as_dict().and_then(|d| d.get(b"Dests").cloned()); + if let Some(new) = tree.and_then(|t| prune_tree_slot(doc, &t, gone, &mut dead).transpose()).transpose()? { + doc.update_dict(n, |d| d.set(b"Dests".to_vec(), Object::Dict(new)))?; + } + } + Some(Object::Dict(mut names)) => { + let tree = names.get(b"Dests").cloned(); + if let Some(new) = tree.and_then(|t| prune_tree_slot(doc, &t, gone, &mut dead).transpose()).transpose()? { + names.set(b"Dests".to_vec(), Object::Dict(new)); + doc.update_dict(root, |c| c.set(b"Names".to_vec(), Object::Dict(names)))?; + } + } + _ => {} + } + Ok(dead) +} + +/// Drop the entries of a legacy /Dests dictionary that go to a `gone` page. True when any went. +fn prune_legacy(doc: &Document, dests: &mut Dict, gone: &[ObjRef], dead: &mut Vec>) -> bool { + let keys: Vec> = dests.iter().filter(|(_, v)| goes_to(doc, v, gone)).map(|(k, _)| k.clone()).collect(); + for k in &keys { + dests.remove(k); + } + dead.extend(keys.iter().cloned()); + !keys.is_empty() +} + +/// Prune a name tree held in `slot` (a reference, or a dictionary written inline). A reference is +/// updated in place and yields `None`; an inline dictionary that changed is handed back. +fn prune_tree_slot(doc: &mut Document, slot: &Object, gone: &[ObjRef], dead: &mut Vec>) -> Result, OrganizeError> { + let mut seen = Vec::new(); + match slot { + Object::Ref(r) => { + prune_tree_ref(doc, *r, gone, dead, &mut seen)?; + Ok(None) + } + Object::Dict(d) => { + let mut node = d.clone(); + Ok(prune_tree_node(doc, &mut node, gone, dead, &mut seen)?.then_some(node)) + } + _ => Ok(None), + } +} + +fn prune_tree_ref(doc: &mut Document, r: ObjRef, gone: &[ObjRef], dead: &mut Vec>, seen: &mut Vec) -> Result<(), OrganizeError> { + if seen.contains(&r) { + return Ok(()); + } + seen.push(r); + let Some(mut node) = doc.get(r).as_dict().cloned() else { return Ok(()) }; + if prune_tree_node(doc, &mut node, gone, dead, seen)? { + doc.update_dict(r, |d| *d = node)?; + } + Ok(()) +} + +/// Drop the /Names pairs of one name-tree node that go to a `gone` page, then do the same for its +/// /Kids. True when `node` itself changed (kids held by reference are written back here). +fn prune_tree_node( + doc: &mut Document, + node: &mut Dict, + gone: &[ObjRef], + dead: &mut Vec>, + seen: &mut Vec, +) -> Result { + let mut changed = false; + if let Some(pairs) = node.get(b"Names").map(|n| doc.resolve(n)).and_then(|n| n.as_array().cloned()) { + let mut kept = Vec::with_capacity(pairs.len()); + for pair in pairs.chunks(2) { + match pair { + [key, value] if goes_to(doc, value, gone) => { + if let Some(s) = doc.resolve(key).as_string() { + dead.push(s.bytes.clone()); + } + } + _ => kept.extend_from_slice(pair), + } + } + if kept.len() != pairs.len() { + node.set(b"Names".to_vec(), Object::Array(kept)); + changed = true; + } + } + let kids = node.get(b"Kids").map(|k| doc.resolve(k)).and_then(|k| k.as_array().cloned()).unwrap_or_default(); + for kid in kids { + if let Some(r) = kid.as_ref() { + prune_tree_ref(doc, r, gone, dead, seen)?; + } + } + Ok(changed) +} + /// Take widgets out of the form: out of their field's /Kids, or out of /AcroForm /Fields when /// they are fields themselves. A field left without kids goes too. fn drop_widgets(doc: &mut Document, mut widgets: Vec) -> Result<(), OrganizeError> { diff --git a/crates/organize/src/tests.rs b/crates/organize/src/tests.rs index baa070998..6f139d825 100644 --- a/crates/organize/src/tests.rs +++ b/crates/organize/src/tests.rs @@ -278,6 +278,42 @@ fn full_save_drops_unreachable_objects() { assert_eq!(hayro_syntax::Pdf::new(bytes).unwrap().pages().len(), 1); } +#[test] +fn deleting_a_page_drops_the_named_destinations_to_it() { + // Four names (two in the /Names tree, two in the legacy /Dests dictionary) go to P1 or P3; + // P2 has a link for each. Deleting P1 must drop its names and their links, keep P3's. + let b: Vec = vec![ + "<< /Type /Catalog /Pages 2 0 R /Names << /Dests 6 0 R >> /Dests 7 0 R >>".into(), // 1 + "<< /Type /Pages /Kids [3 0 R 4 0 R 5 0 R] /Count 3 /MediaBox [0 0 300 400] >>".into(), // 2 + "<< /Type /Page /Parent 2 0 R >>".into(), // 3 + "<< /Type /Page /Parent 2 0 R /Annots [8 0 R 9 0 R 10 0 R 11 0 R] >>".into(), // 4 + "<< /Type /Page /Parent 2 0 R >>".into(), // 5 + "<< /Names [(treeFirst) [3 0 R /Fit] (treeLast) [5 0 R /Fit]] >>".into(), // 6 + "<< /legacyFirst [3 0 R /Fit] /legacyLast << /D [5 0 R /Fit] >> >>".into(), // 7 + "<< /Type /Annot /Subtype /Link /Rect [0 0 9 9] /Dest (treeFirst) >>".into(), // 8 + "<< /Type /Annot /Subtype /Link /Rect [0 0 9 9] /Dest (treeLast) >>".into(), // 9 + "<< /Type /Annot /Subtype /Link /Rect [0 0 9 9] /Dest /legacyFirst >>".into(), // 10 + "<< /Type /Annot /Subtype /Link /Rect [0 0 9 9] /A << /S /GoTo /D /legacyLast >> >>".into(), // 11 + ]; + let mut doc = open(build(&b, "/Root 1 0 R")); + delete_pages(&mut doc, &[0]).unwrap(); + let out = full_roundtrip(&doc); + let cat = out.get(out.root().unwrap()).as_dict().cloned().unwrap(); + let tree = out.resolve(out.resolve(cat.get(b"Names").unwrap()).as_dict().unwrap().get(b"Dests").unwrap()); + let pairs = tree.as_dict().unwrap().get(b"Names").and_then(Object::as_array).cloned().unwrap(); + assert_eq!(pairs.len(), 2, "{pairs:?}"); + assert_eq!(pairs[0].as_string().map(|s| s.bytes.clone()), Some(b"treeLast".to_vec())); + let page3 = pages(&out).unwrap()[1].obj; + assert_eq!(out.resolve(&pairs[1]).as_array().and_then(|a| a.first()).and_then(Object::as_ref), Some(page3)); + let legacy = out.resolve(cat.get(b"Dests").unwrap()).as_dict().cloned().unwrap(); + assert!(!legacy.contains(b"legacyFirst") && legacy.contains(b"legacyLast"), "{legacy:?}"); + let links = annots(&out, 0); + assert!(!links[0].contains(b"Dest"), "{:?}", links[0]); + assert!(links[1].contains(b"Dest"), "{:?}", links[1]); + assert!(!links[2].contains(b"Dest"), "{:?}", links[2]); + assert!(links[3].contains(b"A"), "{:?}", links[3]); +} + // ── Combine / extract / split ────────────────────────────────────────────────────────────────── /// Build a classic-xref PDF from object bodies (object i+1 = bodies[i]). From 1b6e144c574f89d7dd6e836faf87e59ca54587ed Mon Sep 17 00:00:00 2001 From: Ikki Nishihara Date: Sun, 11 Oct 2026 04:47:05 +0900 Subject: [PATCH 33/69] JavaScript: limit util.printf widths and output, and array arguments (#770) util.printf accumulated a conversion's width and precision with unchecked arithmetic and no maximum, and its output had no bound; the array arguments of resetForm and the field value/colour setters were read for as many items as the array's length says, which a sparse array makes free. Saturate the numbers and refuse widths and precisions above 4096, output above 1 MiB and arrays longer than 2^20 with a script error, before anything is allocated from them. Co-authored-by: Claude Sonnet 5.5 --- crates/js/src/lib.rs | 26 ++++++++- crates/js/src/tests.rs | 118 +++++++++++++++++++++++++++++++++++++++++ 2 files changed, 142 insertions(+), 2 deletions(-) diff --git a/crates/js/src/lib.rs b/crates/js/src/lib.rs index d265a8b82..fd6ff82d5 100644 --- a/crates/js/src/lib.rs +++ b/crates/js/src/lib.rs @@ -247,6 +247,9 @@ fn strings(v: &JsValue, ctx: &mut Context) -> JsResult> { { let a = JsArray::from_object(o.clone())?; let n = a.length(ctx)?; + if n > MAX_ARRAY_LENGTH { + return Err(error(format!("array length exceeds {MAX_ARRAY_LENGTH}"))); + } let mut out = Vec::new(); for i in 0..n { let x = a.get(i, ctx)?; @@ -589,15 +592,23 @@ fn timer(_: &JsValue, _: &[JsValue], ctx: &mut Context) -> JsResult { fn printf(_: &JsValue, args: &[JsValue], ctx: &mut Context) -> JsResult { let fmt = text(&arg(args, 0), ctx)?; let mut out = String::new(); + let check_output = |len: usize| -> JsResult<()> { + if len > MAX_PRINTF_OUTPUT_BYTES { + return Err(error(format!("util.printf output exceeds {MAX_PRINTF_OUTPUT_BYTES} bytes"))); + } + Ok(()) + }; let mut next = 1; let mut chars = fmt.chars().peekable(); while let Some(c) = chars.next() { if c != '%' { + check_output(out.len().saturating_add(c.len_utf8()))?; out.push(c); continue; } if chars.peek() == Some(&'%') { chars.next(); + check_output(out.len().saturating_add(1))?; out.push('%'); continue; } @@ -619,7 +630,7 @@ fn printf(_: &JsValue, args: &[JsValue], ctx: &mut Context) -> JsResult } let mut width = 0usize; while let Some(d) = chars.peek().and_then(|d| d.to_digit(10)) { - width = width * 10 + d as usize; + width = width.saturating_mul(10).saturating_add(d as usize); chars.next(); } let mut prec = None; @@ -627,11 +638,14 @@ fn printf(_: &JsValue, args: &[JsValue], ctx: &mut Context) -> JsResult chars.next(); let mut p = 0usize; while let Some(d) = chars.peek().and_then(|d| d.to_digit(10)) { - p = p * 10 + d as usize; + p = p.saturating_mul(10).saturating_add(d as usize); chars.next(); } prec = Some(p); } + if width > MAX_PRINTF_WIDTH_PRECISION || prec.is_some_and(|p| p > MAX_PRINTF_WIDTH_PRECISION) { + return Err(error(format!("util.printf width or precision exceeds {MAX_PRINTF_WIDTH_PRECISION}"))); + } let conv = chars.next().unwrap_or('s'); let v = arg(args, next); next += 1; @@ -660,6 +674,7 @@ fn printf(_: &JsValue, args: &[JsValue], ctx: &mut Context) -> JsResult } }; let pad = width.saturating_sub(body.chars().count()); + check_output(out.len().saturating_add(pad).saturating_add(body.len()))?; if zero && matches!(conv, 'd' | 'f') { let (sign, digits) = if body.starts_with(['-', '+', ' ']) { body.split_at(1) } else { ("", body.as_str()) }; out.push_str(sign); @@ -992,6 +1007,13 @@ fn event_object(ctx: &mut Context, e: &Event) -> JsObject { /// Longest script, in bytes. pub(crate) const MAX_SCRIPT_BYTES: usize = 256 * 1024; +/// Largest width or precision accepted by `util.printf`. +const MAX_PRINTF_WIDTH_PRECISION: usize = 4096; +/// Longest `util.printf` output, in bytes. +const MAX_PRINTF_OUTPUT_BYTES: usize = 1 << 20; +/// Longest array read item by item (`resetForm`, a field's `value` or colours). A sparse array's +/// length costs the script nothing but the host one string per item; real lists are tiny. +const MAX_ARRAY_LENGTH: u64 = 1 << 20; /// Deepest nesting of `(`, `[` and `{`. const MAX_BRACKET_DEPTH: usize = 64; /// Longest run of prefix operators (`!`, `~`, `+`, `-`). diff --git a/crates/js/src/tests.rs b/crates/js/src/tests.rs index e23915eac..cb8b99749 100644 --- a/crates/js/src/tests.rs +++ b/crates/js/src/tests.rs @@ -94,6 +94,124 @@ fn util_printf_printd_and_printx() { assert_eq!(o.value, "1,234,567.89|00042|hi|FF|1.234,50|Jan 5, 2024 09:07|Friday|(555) 123-4567|ABC"); } +fn assert_printf_error(script: &str, message: &str) { + let o = go(script, &Event::doc("Open")); + assert!(o.error.as_deref().is_some_and(|e| e.starts_with(message)), "{script}: {:?}", o.error); + let o = xfa::run_xfa( + script, + &xfa::XfaEvent { activity: "initialize".into(), target: "form1[0]".into(), ..Default::default() }, + &xfa::XfaDoc::default(), + &xfa_model::form(), + Limits::default(), + ); + assert!(o.error.as_deref().is_some_and(|e| e.starts_with(message)), "XFA: {script}: {:?}", o.error); +} + +#[test] +fn util_printf_rejects_large_width() { + assert_printf_error("util.printf('%99999999999d', 1)", "TypeError: util.printf width or precision exceeds 4096"); +} + +#[test] +fn util_printf_rejects_large_precision() { + assert_printf_error("util.printf('%.99999999999f', 1)", "TypeError: util.printf width or precision exceeds 4096"); +} + +#[test] +fn util_printf_rejects_overflowing_width() { + assert_printf_error("util.printf('%9999999999999999999999999999999999999999d', 1)", "TypeError: util.printf width or precision exceeds 4096"); +} + +#[test] +fn util_printf_rejects_overflowing_precision() { + assert_printf_error("util.printf('%.9999999999999999999999999999999999999999f', 1)", "TypeError: util.printf width or precision exceeds 4096"); +} + +#[test] +fn util_printf_rejects_limits_for_all_conversions() { + for conv in ['d', 'f', 's', 'x'] { + for spec in ["4097", ".4097", "99999999999", ".99999999999"] { + assert_printf_error(&format!("util.printf('%{spec}{conv}', 1)"), "TypeError: util.printf width or precision exceeds 4096"); + } + } +} + +#[test] +fn util_printf_bounds_total_output() { + for script in [ + "util.printf('%4096d'.repeat(300), 1)", + "util.printf('%04096d'.repeat(300), -1)", + "util.printf('aa'.repeat(524288) + 'a')", + "util.printf('é'.repeat(524289))", + "util.printf('%%%%'.repeat(524288) + '%%')", + "util.printf('%s', 'aa'.repeat(524288) + 'a')", + "util.printf('%s%s', 'aa'.repeat(524288), 'b')", + ] { + assert_printf_error(script, "TypeError: util.printf output exceeds 1048576 bytes"); + } +} + +#[test] +fn util_printf_accepts_limits_and_preserves_formats() { + let o = go( + "event.value = [util.printf('%4096d', 1).length, util.printf('%04096d', -1).length, \ + util.printf('%.4096f', 1).length, util.printf('%.4096s', 'a'.repeat(4097)).length, \ + util.printf('%4096x', 255).length, util.printf('%.4096x', 255), \ + util.printf('%4096d'.repeat(255) + '%4095d', 1).length, util.printf('%4096d'.repeat(256), 1).length, \ + util.printf('é'.repeat(524288)).length, util.printf('a%%b:%+06d|%4s|%.2s|%x', 42, 'é', 'é界a', 255)].join('|');", + &Event::field("Calculate", "total", ""), + ); + assert_eq!(o.error, None); + assert_eq!(o.value, "4096|4096|4098|4096|4096|FF|1048575|1048576|524288|a%b:+00042| é|é界|FF"); +} + +fn assert_array_error(script: &str) { + let o = go(script, &Event::field("Calculate", "total", "")); + assert!(o.error.as_deref().is_some_and(|e| e.starts_with("TypeError: array length exceeds 1048576")), "{script}: {:?}", o.error); + assert!(o.requests.is_empty() && o.changed.is_empty(), "{script}"); +} + +#[test] +fn reset_form_and_field_setters_refuse_huge_arrays() { + for script in [ + "this.resetForm(new Array(4294967295))", + "getField('total').value = new Array(4294967295)", + "event.target.value = new Array(4294967295)", + "getField('total').textColor = new Array(4294967295)", + "getField('total').fillColor = new Array(4294967295)", + ] { + assert_array_error(script); + } +} + +#[test] +fn reset_form_and_field_setters_take_arrays_up_to_the_limit() { + let o = go("this.resetForm(new Array(1 << 20)); getField('total').value = new Array(1 << 20);", &Event::field("Calculate", "total", "")); + assert_eq!(o.error, None); + assert!(matches!(&o.requests[..], [Request::Reset(names)] if names.len() == 1 << 20 && names.iter().all(String::is_empty))); + assert_eq!(o.changed.iter().find(|f| f.name == "total").unwrap().value, [""]); + assert_array_error("this.resetForm(new Array((1 << 20) + 1))"); + assert_array_error("getField('total').value = new Array((1 << 20) + 1)"); +} + +#[test] +fn reset_form_and_field_setters_still_read_short_arrays() { + let mut f = fields(); + f.push(FieldState::new("pick", FieldType::ListBox, vec![])); + let o = run( + "this.resetForm(['a', 'b']); getField('pick').value = ['x', 'y', 'z']; getField('total').value = ['x', 'y', 'z'];", + &Event::field("Calculate", "total", ""), + &doc(), + &f, + &[], + Limits::default(), + ); + assert_eq!(o.error, None); + assert_eq!(o.requests, [Request::Reset(vec!["a".into(), "b".into()])]); + assert_eq!(o.changed.iter().find(|f| f.name == "pick").unwrap().value, ["x", "y", "z"]); + assert_eq!(o.changed.iter().find(|f| f.name == "total").unwrap().value, ["x"]); +} + #[test] fn document_requests_and_console() { let o = go( From c93378203956d9ec699fc1194f18302ab1529f77 Mon Sep 17 00:00:00 2001 From: gyurix Date: Sat, 10 Oct 2026 21:47:33 +0200 Subject: [PATCH 34/69] Linux: "Use system setting" asks the desktop, not winit (#881) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit winit reports no system theme on Linux — `ActiveEventLoop::system_theme` is `None` there on every desktop — so `ThemePreference::System` never saw the desktop's choice and silently fell back to whatever was on screen: PdfCraft came up light on a dark desktop, and picking "Use system setting" after Dark kept it dark. The default setting did not follow the system. `pdfcraft-platform` now reads the XDG Desktop Portal's Settings interface off the UI thread and the app follows the answer, including a change made while it runs. The colour scheme is asked for under its GNOME schema name first, which GNOME's and XFCE's portals both answer, then under the older freedesktop one, which KDE answers. A desktop that answers neither leaves the user's own choice alone, exactly as before. Co-authored-by: GyuriX --- Cargo.lock | 3 + crates/platform/Cargo.toml | 6 ++ crates/platform/src/desktop_theme.rs | 136 +++++++++++++++++++++++++++ crates/platform/src/lib.rs | 1 + crates/ui-egui/src/lib.rs | 21 ++++- crates/ui-egui/src/theme.rs | 26 ++++- 6 files changed, 188 insertions(+), 5 deletions(-) create mode 100644 crates/platform/src/desktop_theme.rs diff --git a/Cargo.lock b/Cargo.lock index 3523ac1d4..7c3064e72 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3814,6 +3814,9 @@ dependencies = [ [[package]] name = "pdfcraft-platform" version = "0.5.0" +dependencies = [ + "zbus", +] [[package]] name = "pdfcraft-preflight" diff --git a/crates/platform/Cargo.toml b/crates/platform/Cargo.toml index 54e7030a3..d3644b913 100644 --- a/crates/platform/Cargo.toml +++ b/crates/platform/Cargo.toml @@ -9,3 +9,9 @@ license.workspace = true workspace = true [dependencies] + +[target.'cfg(target_os = "linux")'.dependencies] +# The desktop's light/dark choice (see `desktop_theme.rs`). Already in the tree, with the features +# this needs, through accesskit's accessibility bus; pinned to the same version so nothing is +# built twice. +zbus = "=5.19.0" diff --git a/crates/platform/src/desktop_theme.rs b/crates/platform/src/desktop_theme.rs new file mode 100644 index 000000000..9b88f9efe --- /dev/null +++ b/crates/platform/src/desktop_theme.rs @@ -0,0 +1,136 @@ +//! The desktop's own light/dark choice, for an interface setting that follows it. +//! +//! winit reports no system theme on Linux — `ActiveEventLoop::system_theme` is `None` on every +//! desktop there — so "Use system setting" has to ask the desktop itself. The XDG Desktop Portal's +//! Settings interface answers on GNOME, KDE and XFCE alike, over X11 and Wayland alike. + +use std::sync::mpsc::{Receiver, TryRecvError, channel}; +use std::time::Duration; + +/// How often [`DesktopTheme::start`] re-reads the desktop's answer. The portal also publishes a +/// change signal, but subscribing needs a connection kept alive for the life of every window; one +/// round trip every few seconds is cheap and reacts soon enough after a theme switch. +const POLL: Duration = Duration::from_secs(4); + +/// Whether the desktop asks applications to draw dark. `None` when it says nothing, and when +/// there is no session bus or no portal to ask (a bare X server, a container), so the caller +/// keeps whatever it chose itself. +pub fn prefers_dark() -> Option { + read() +} + +/// Watches the desktop and reports its choice when it changes. [`prefers_dark`] covers the +/// answer at start-up; this covers the user switching theme while the app runs. +#[derive(Default)] +pub struct DesktopTheme { + /// `None` when no watcher thread could be started; the one-shot read still works. + changes: Option>>, +} + +impl DesktopTheme { + /// Start watching. Nothing runs before this is called, and calling it once is enough. The + /// first answer is sent straight away, so the caller does not have to ask for it separately. + pub fn start() -> Self { + let (tx, rx) = channel(); + let started = std::thread::Builder::new().name("pdfcraft-desktop-theme".into()).spawn(move || { + let mut last = read(); + if tx.send(last).is_err() { + return; + } + loop { + std::thread::sleep(POLL); + let now = read(); + if now != last { + last = now; + if tx.send(now).is_err() { + return; + } + } + } + }); + Self { changes: started.ok().map(|_| rx) } + } + + /// The desktop's new answer if it changed since the last call, to be polled once a frame. + /// Several changes in one frame collapse to the newest, and a desktop that stops answering + /// leaves the last answer in place. + pub fn take_change(&mut self) -> Option { + let Some(changes) = &self.changes else { return None }; + let mut newest = None; + loop { + match changes.try_recv() { + Ok(changed) => newest = changed, + Err(TryRecvError::Empty) | Err(TryRecvError::Disconnected) => return newest, + } + } + } +} + +/// Ask the portal once. D-Bus calls block, so this belongs off the UI thread: the watcher's +/// thread is its only caller. +#[cfg(target_os = "linux")] +fn read() -> Option { + const PORTAL: &str = "org.freedesktop.portal.Desktop"; + const SETTINGS: &str = "org.freedesktop.portal.Settings"; + // The colour scheme under its GNOME schema name first: GNOME's portal answers it, and so + // does XFCE's, which proxies the GSettings schemas but not the older freedesktop one. KDE + // answers only the second. + const SCHEMAS: [(&str, &str); 2] = [("org.gnome.desktop.interface", "color-scheme"), ("org.freedesktop.interface.color-scheme", "color-scheme")]; + let connection = zbus::blocking::Connection::session().ok()?; + for (schema, key) in SCHEMAS { + let Ok(reply) = connection.call_method(Some(PORTAL), "/org/freedesktop/portal/desktop", Some(SETTINGS), "ReadOne", &(schema, key)) else { + continue; + }; + let Ok((answer,)) = reply.body().deserialize::<(zbus::zvariant::OwnedValue,)>() else { continue }; + // The reply is one variant wrapping a string. + if let Ok(scheme) = String::try_from(&*answer) { + return parse(&scheme); + } + } + None +} + +/// The portal's `color-scheme` answer. `default` means the desktop states no preference, so +/// neither does the application. +#[cfg(target_os = "linux")] +fn parse(scheme: &str) -> Option { + match scheme { + "prefer-dark" => Some(true), + "prefer-light" => Some(false), + _ => None, + } +} + +/// macOS and Windows answer through winit, which the caller already has. +#[cfg(not(target_os = "linux"))] +fn read() -> Option { + None +} + +#[cfg(test)] +mod tests { + use super::*; + + /// The portal answers with one of three strings; only the two preferences are a choice. + #[test] + #[cfg(target_os = "linux")] + fn the_portal_answer_reads_as_a_choice_or_as_none() { + assert_eq!(parse("prefer-dark"), Some(true)); + assert_eq!(parse("prefer-light"), Some(false)); + assert_eq!(parse("default"), None); + assert_eq!(parse(""), None); + } + + /// Off Linux the caller already has winit's answer, so this adds nothing. + #[test] + #[cfg(not(target_os = "linux"))] + fn other_platforms_answer_through_winit() { + assert_eq!(prefers_dark(), None); + } + + /// A watcher that was never started is inert rather than a panic. + #[test] + fn a_watcher_without_a_thread_is_inert() { + assert_eq!(DesktopTheme::default().take_change(), None); + } +} diff --git a/crates/platform/src/lib.rs b/crates/platform/src/lib.rs index 799eb5669..684f100d3 100644 --- a/crates/platform/src/lib.rs +++ b/crates/platform/src/lib.rs @@ -3,4 +3,5 @@ #![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] +pub mod desktop_theme; pub mod staging; diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index e2dfca1ff..db2520e7b 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -393,6 +393,10 @@ pub struct PdfCraftApp { /// Resolved colours, including the current OS theme when following the system. pub theme: ThemeKind, pub theme_preference: ThemePreference, + /// The desktop's light/dark choice, watched while the app runs (Linux has no winit answer). + desktop_theme: pdfcraft_platform::desktop_theme::DesktopTheme, + /// The last answer [`desktop_theme`] gave, so a frame that asks costs nothing. + desktop_dark: Option, /// Interface language preference: `auto` (follow the system) or a code from [`i18n::LANGUAGES`]. pub language: String, /// Preferences: bake Fill & Sign marks into the page when saving. Off, so a normal save stays editable. @@ -633,6 +637,12 @@ impl Default for PdfCraftApp { } } +/// What the operating system says about light and dark, as "draw dark". `None` means nobody +/// answered, not a preference, so the caller keeps what it chose. +fn system_theme(ctx: &egui::Context, desktop_dark: Option) -> Option { + ctx.system_theme().map(|theme| theme == egui::Theme::Dark).or(desktop_dark) +} + /// A restored colour: an `[r, g, b]` array of finite numbers, each clamped to 0–1 (restored /// settings are untrusted). Anything else (a string, a wrong length, a null or a non-finite /// number) is refused whole, so the caller keeps its default. @@ -672,6 +682,8 @@ impl PdfCraftApp { comment_prefs: Default::default(), theme: ThemeKind::Light, theme_preference: ThemePreference::Light, + desktop_theme: pdfcraft_platform::desktop_theme::DesktopTheme::start(), + desktop_dark: None, language: i18n::AUTO.to_string(), flatten_fill_sign_on_save: false, dialog: None, @@ -1224,7 +1236,9 @@ impl PdfCraftApp { pub fn set_theme_preference(&mut self, preference: ThemePreference) { self.theme_preference = preference; - self.theme = preference.resolve(self.ctx.as_ref().and_then(egui::Context::system_theme), self.theme); + // Before the first frame there is no context to ask, so the last choice stands. + let system = self.ctx.as_ref().and_then(|ctx| system_theme(ctx, self.desktop_dark)); + self.theme = preference.resolve(system, self.theme); if let Some(ctx) = &self.ctx { theme::apply(ctx, self.theme); } @@ -1238,7 +1252,10 @@ impl PdfCraftApp { } fn sync_theme(&mut self, ctx: &egui::Context) { - let kind = self.theme_preference.resolve(ctx.system_theme(), self.theme); + if let Some(dark) = self.desktop_theme.take_change() { + self.desktop_dark = Some(dark); + } + let kind = self.theme_preference.resolve(system_theme(ctx, self.desktop_dark), self.theme); if kind != self.theme { self.theme = kind; theme::apply(ctx, kind); diff --git a/crates/ui-egui/src/theme.rs b/crates/ui-egui/src/theme.rs index 5b74b6422..1691f8328 100644 --- a/crates/ui-egui/src/theme.rs +++ b/crates/ui-egui/src/theme.rs @@ -22,13 +22,16 @@ pub enum ThemePreference { } impl ThemePreference { - pub fn resolve(self, system: Option, fallback: ThemeKind) -> ThemeKind { + /// The colours to draw. `system` is what the operating system says, `None` when it says + /// nothing; `fallback` is then what the app last drew, so an unanswering desktop leaves the + /// user's choice alone. + pub fn resolve(self, system: Option, fallback: ThemeKind) -> ThemeKind { match self { Self::Light => ThemeKind::Light, Self::Dark => ThemeKind::Dark, Self::System => match system { - Some(egui::Theme::Light) => ThemeKind::Light, - Some(egui::Theme::Dark) => ThemeKind::Dark, + Some(true) => ThemeKind::Dark, + Some(false) => ThemeKind::Light, None => fallback, }, } @@ -285,6 +288,23 @@ pub fn apply(ctx: &egui::Context, kind: ThemeKind) { mod tests { use super::*; + /// "Use system setting" draws what the operating system says, dark or light. + #[test] + fn following_the_system_draws_what_the_system_says() { + assert_eq!(ThemePreference::System.resolve(Some(true), ThemeKind::Light), ThemeKind::Dark); + assert_eq!(ThemePreference::System.resolve(Some(false), ThemeKind::Dark), ThemeKind::Light); + } + + /// An operating system that says nothing leaves the last choice standing, and an explicit + /// Light or Dark is never overridden by what the system says. + #[test] + fn an_answerless_system_leaves_the_choice_and_an_explicit_one_stands() { + assert_eq!(ThemePreference::System.resolve(None, ThemeKind::Light), ThemeKind::Light); + assert_eq!(ThemePreference::System.resolve(None, ThemeKind::Dark), ThemeKind::Dark); + assert_eq!(ThemePreference::Light.resolve(Some(true), ThemeKind::Dark), ThemeKind::Light); + assert_eq!(ThemePreference::Dark.resolve(Some(false), ThemeKind::Light), ThemeKind::Dark); + } + /// WCAG 2 contrast ratio between two opaque colours. fn contrast(a: Color32, b: Color32) -> f32 { let lum = |c: Color32| { From c49148a2907e3daa61321ab51267798faa0b3d22 Mon Sep 17 00:00:00 2001 From: MarekSpodBiedry <73122968+MarekSpodBiedry@users.noreply.github.com> Date: Sat, 10 Oct 2026 21:47:45 +0200 Subject: [PATCH 35/69] Extract/Combine: a page repeated in one selection gets its own annotations (#818) (#852) --- crates/organize/src/import.rs | 12 ++++++++++-- crates/organize/src/tests.rs | 29 +++++++++++++++++++++++++++++ 2 files changed, 39 insertions(+), 2 deletions(-) diff --git a/crates/organize/src/import.rs b/crates/organize/src/import.rs index 9b68dc2ed..4d6c5957a 100644 --- a/crates/organize/src/import.rs +++ b/crates/organize/src/import.rs @@ -237,8 +237,16 @@ fn import_pages_mapped(dst: &mut Document, src: &Document, src_pages: &[usize], let mut new_pages = Vec::new(); let mut done: HashMap = HashMap::new(); for (page, new, inherited) in targets { - // A page listed twice: make a second, independent page object sharing resources. - let new = if done.contains_key(&page) { dst.add(Object::Null) } else { new }; + // A page listed twice: make a second, independent page object sharing resources. Its + // annotations (with their popups and form fields) are copied again, so each page owns its + // copies and their `/P` names it, as Duplicate Pages does (#818). + let new = if done.contains_key(&page) { + let copies: std::collections::HashSet = copier.annots.iter().chain(&copier.fields).copied().collect(); + copier.map.retain(|_, &mut d| !copies.contains(&d)); + dst.add(Object::Null) + } else { + new + }; let src_dict = src.get(page).as_dict().cloned().unwrap_or_default(); let mut d = Dict::new(); for (k, v) in src_dict.iter() { diff --git a/crates/organize/src/tests.rs b/crates/organize/src/tests.rs index 6f139d825..55815bd14 100644 --- a/crates/organize/src/tests.rs +++ b/crates/organize/src/tests.rs @@ -443,6 +443,35 @@ fn links_are_rewired_to_copied_pages_or_dropped() { assert!(!link.contains(b"Dest") && !link.contains(b"A")); } +#[test] +fn repeated_page_gets_independent_annotations() { + // A1 has a link, A2 a link and a form field, A3 a note with a popup; each is listed twice. + let out = full_roundtrip(&extract_pages(&doc_a(), &[0, 0, 1, 1, 2, 2]).unwrap()); + let ps = pages(&out).unwrap(); + let refs: Vec> = (0..6) + .map(|i| page_dict(&out, i).get(b"Annots").map(|a| out.resolve(a).as_array().cloned().unwrap_or_default()).unwrap_or_default()) + .map(|list| list.iter().filter_map(|a| a.as_ref()).collect()) + .collect(); + for i in (0..6).step_by(2) { + assert!(!refs[i].is_empty()); + assert_eq!(refs[i].len(), refs[i + 1].len()); + for (a, b) in refs[i].iter().zip(&refs[i + 1]) { + assert_ne!(a, b, "each copy of the page has its own annotation objects"); + } + } + for (i, list) in refs.iter().enumerate() { + for a in list { + assert_eq!(out.get(*a).as_dict().unwrap().reference(b"P"), Some(ps[i].obj), "/P names the page holding the copy"); + } + } + // Each note keeps its own popup, pointing back at that note. + let popups: Vec = [4, 5].iter().map(|&i| out.get(refs[i][0]).as_dict().unwrap().reference(b"Popup").unwrap()).collect(); + assert_ne!(popups[0], popups[1]); + for (k, p) in popups.iter().enumerate() { + assert_eq!(out.get(*p).as_dict().unwrap().reference(b"Parent"), Some(refs[4 + k][0])); + } +} + #[test] fn form_fields_come_along_and_are_registered() { let out = full_roundtrip(&extract_pages(&doc_a(), &[1]).unwrap()); From 5ad2ecfef9791bf3e7a91550d29435d55ace3a7f Mon Sep 17 00:00:00 2001 From: LloydNicholson <34131158+LloydNicholson@users.noreply.github.com> Date: Sat, 10 Oct 2026 21:47:58 +0200 Subject: [PATCH 36/69] M5.7: Fill & Sign tools pick up placed signatures to move and resize them (#822) A signature placed with Fill & Sign could only be moved with the Select tool. While a Fill & Sign tool was active (Sign, Initials, Add text, Check, Date...), hovering the signature showed the placement crosshair, a drag did nothing and a click stamped another mark on top of it. Now, as in Acrobat, a Fill & Sign tool picks up the Fill & Sign marks already on the page: hovering one shows the move cursor, a click selects it, a drag moves it and the selected mark's handles resize it. Away from them the tool places as before, including on other comments. Image signatures keep their live move/resize preview. Comments now carry a fill_sign flag (pdfcraft_annot::is_fill_sign for the engine's list, the same rules in the viewer's inspector), so this also works on reopened files. Co-authored-by: lloyd Co-authored-by: Claude Opus 5.5 --- crates/annot/src/lib.rs | 3 + crates/engine/src/lib.rs | 1 + crates/render/src/inspect.rs | 24 +++++++ crates/ui-egui/src/canvas.rs | 5 +- crates/ui-egui/src/comments.rs | 30 +++++++- crates/ui-egui/src/signature_drag.rs | 2 +- crates/ui-egui/tests/fill_sign.rs | 101 +++++++++++++++++++++++++++ parity/acrobat-features.toml | 4 +- 8 files changed, 164 insertions(+), 6 deletions(-) diff --git a/crates/annot/src/lib.rs b/crates/annot/src/lib.rs index 4e8aceb2a..121076791 100644 --- a/crates/annot/src/lib.rs +++ b/crates/annot/src/lib.rs @@ -1784,6 +1784,8 @@ pub struct Summary { pub quads: Vec<[f32; 8]>, pub locked: bool, pub intent: Option, + /// A Fill & Sign mark ([`is_fill_sign`]). + pub fill_sign: bool, } fn text_value(doc: &Document, d: &Dict, key: &[u8]) -> Option { @@ -1854,6 +1856,7 @@ pub fn summaries(doc: &Document) -> Vec { quads, locked: annotation_flags(doc, d) & FLAG_LOCKED != 0, intent: d.get(b"IT").and_then(|o| doc.resolve(o).as_name().map(|n| String::from_utf8_lossy(n).into_owned())), + fill_sign: is_fill_sign(doc, d), }); } } diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 1e2979d2f..5ff685dbd 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -1772,6 +1772,7 @@ fn comment_list(doc: &pdfcraft_cos::Document) -> Vec, + /// A Fill & Sign mark: typed text, a check, cross, dot or line, or a typed, drawn or picture + /// signature or initials (the rules of `pdfcraft_annot::is_fill_sign`). + pub fill_sign: bool, } impl Annotation { @@ -553,6 +556,25 @@ impl<'a> Inspector<'a> { self.resolve(d.get(key).ok()?).as_name().ok().map(|n| String::from_utf8_lossy(n).into_owned()) } + /// Whether an annotation is a Fill & Sign mark. Keep in step with + /// `pdfcraft_annot::is_fill_sign`, which the engine's comment list uses. + fn is_fill_sign(&self, d: &Dictionary, subtype: &str) -> bool { + let yes = |key: &[u8]| matches!(d.get(key).map(|o| self.resolve(o)), Ok(Object::Boolean(true))); + if yes(b"PCFillSign") { + return true; + } + match subtype { + "FreeText" => self.name(d, b"IT").as_deref() == Some("FreeTextTypeWriter"), + "Stamp" => match self.name(d, b"Name").as_deref() { + Some("PCCheck" | "PCCross" | "PCDot" | "PCLine" | "PCTypedSignature") => true, + Some("PCCustomSignature" | "PCCustomInitials") => yes(b"PCPictureImage"), + _ => false, + }, + "Ink" => self.text(d, b"Subj").as_deref() == Some("Signature"), + _ => false, + } + } + fn page_of(&self, o: &Object) -> Option { match o { Object::Reference(id) => self.page_index.get(id).copied(), @@ -871,6 +893,7 @@ impl<'a> Inspector<'a> { _ => Vec::new(), }; let in_reply_to = d.get(b"IRT").ok().and_then(|o| self.dict(o)).and_then(|p| self.text(p, b"NM")); + let fill_sign = self.is_fill_sign(d, &subtype); info.annotations.push(Annotation { page, subtype, @@ -886,6 +909,7 @@ impl<'a> Inspector<'a> { quads, locked: d.get(b"F").ok().and_then(|f| self.resolve(f).as_i64().ok()).unwrap_or(0) & 128 != 0, intent: self.name(d, b"IT"), + fill_sign, }); } } diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 5605292d1..e5eaec2d8 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -2179,8 +2179,10 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) { stamp_placed = true; } } + // Over a mark already placed, the Fill & Sign tool picks it up (comments, below). if let QuickTool::Fill(ft) = tool && allowed + && !comments::fill_grabs(ui, &pcx, view) { match crate::fill_sign::page_input( ui, @@ -2268,7 +2270,8 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) { let on_link = tool == QuickTool::Link && can_modify && crate::link_ui::page_input(ui, &resp, &xf, i, info, &doc_links, view); let consumed = on_edit_text || on_link || on_content || boxing || on_field || comments::page_input(ui, &resp, &pcx, view); - let preview_target = (tool == QuickTool::Select && !comments_hidden).then_some(view.comments.selected).flatten(); + let preview_target = + (matches!(tool, QuickTool::Select | QuickTool::Fill(_)) && !comments_hidden).then_some(view.comments.selected).flatten(); view.signature_drag.prepare(ui.ctx(), doc, preview_target, scale); view.signature_drag.paint(painter, &pcx, &view.comments, view.pending_edit.as_ref()); diff --git a/crates/ui-egui/src/comments.rs b/crates/ui-egui/src/comments.rs index 1f195784c..4de1e2c0a 100644 --- a/crates/ui-egui/src/comments.rs +++ b/crates/ui-egui/src/comments.rs @@ -576,6 +576,7 @@ pub(crate) fn page_input(ui: &egui::Ui, resp: &egui::Response, cx: &PageCx<'_>, let page_rect = cx.xf.rect; let pressed_here = origin.is_some_and(|o| page_rect.contains(o)); let over_page = pointer.is_some_and(|p| page_rect.contains(p)); + let fill_grab = fill_grabs(ui, cx, view); let cv = &mut view.comments; if cv.gesture.is_some() && ui.input_mut(|i| i.consume_key(egui::Modifiers::NONE, egui::Key::Escape)) { cv.gesture = None; @@ -768,10 +769,35 @@ pub(crate) fn page_input(ui: &egui::Ui, resp: &egui::Response, cx: &PageCx<'_>, true } QuickTool::Select => select_input(ui, resp, cx, view, pointer, origin, pressed_here), + QuickTool::Fill(_) if fill_grab => select_input(ui, resp, cx, view, pointer, origin, pressed_here), _ => false, } } +/// With a Fill & Sign tool, the Fill & Sign marks already on the page are picked up as with the +/// Select tool, as in Acrobat: hovering one shows the move cursor, a click selects it, a drag +/// moves it and the selected one's handles resize it. Elsewhere the tool places its mark. While +/// the button is down the press decides; otherwise the pointer (egui clears the press origin on +/// release, so a click is located by the pointer). +pub(crate) fn fill_grabs(ui: &egui::Ui, cx: &PageCx<'_>, view: &DocView) -> bool { + if !matches!(cx.tool, QuickTool::Fill(_)) || cx.hidden { + return false; + } + let cv = &view.comments; + if matches!(cv.gesture, Some(Gesture::Move { page, .. } | Gesture::Resize { page, .. }) if page == cx.page) { + return true; + } + let (pointer, origin, down) = ui.input(|i| (i.pointer.hover_pos(), i.pointer.press_origin(), i.pointer.any_down())); + let Some(p) = (if down { origin } else { pointer }).filter(|p| cx.xf.rect.contains(*p)) else { return false }; + let on_handle = cv + .selected + .filter(|(page, _)| *page == cx.page) + .and_then(|(_, i)| cx.get(i)) + .filter(|a| a.fill_sign && cx.allowed && resizable(a)) + .is_some_and(|a| HANDLES.into_iter().any(|h| handle_pos(cx.screen_rect(a), h).distance(p) <= 7.0)); + on_handle || cx.hit(p).is_some_and(|a| a.fill_sign) +} + fn clamp_to(r: Rect, p: Pos2) -> Pos2 { pos2(p.x.clamp(r.left(), r.right()), p.y.clamp(r.top(), r.bottom())) } @@ -917,9 +943,9 @@ pub(crate) fn page_after_text(resp: &egui::Response, cx: &PageCx<'_>, view: &mut pub(crate) fn paint_page(ui: &egui::Ui, painter: &egui::Painter, cx: &PageCx<'_>, view: &DocView) { let cv = &view.comments; let pointer = ui.input(|i| i.pointer.hover_pos()); - if cx.tool == QuickTool::Select - && cv.gesture.is_none() + if cv.gesture.is_none() && let Some(a) = pointer.and_then(|p| cx.hit(p)) + && (cx.tool == QuickTool::Select || matches!(cx.tool, QuickTool::Fill(_)) && a.fill_sign) && cv.selected != Some((cx.page, a.index)) { for r in cx.screen_rects(a) { diff --git a/crates/ui-egui/src/signature_drag.rs b/crates/ui-egui/src/signature_drag.rs index b65d6cc60..fb683f1fa 100644 --- a/crates/ui-egui/src/signature_drag.rs +++ b/crates/ui-egui/src/signature_drag.rs @@ -124,7 +124,7 @@ impl SignatureDrag { pub fn paint(&self, painter: &egui::Painter, cx: &PageCx<'_>, cv: &CommentView, pending: Option<&Edit>) { let (Some((_, page, index)), Some(layers)) = (self.key, &self.layers) else { return }; - if page != cx.page || cx.tool != QuickTool::Select || cx.hidden { + if page != cx.page || !matches!(cx.tool, QuickTool::Select | QuickTool::Fill(_)) || cx.hidden { return; } let dragging = matches!(cv.gesture, Some(Gesture::Move { page: p, index: i, .. } | Gesture::Resize { page: p, index: i, .. }) if (p, i) == (page, index)); diff --git a/crates/ui-egui/tests/fill_sign.rs b/crates/ui-egui/tests/fill_sign.rs index 96df83af2..6f25b13c3 100644 --- a/crates/ui-egui/tests/fill_sign.rs +++ b/crates/ui-egui/tests/fill_sign.rs @@ -1016,3 +1016,104 @@ fn drawn_and_typed_signatures_stay_upright_on_rotated_pages() { assert!(differ * 10 < ink, "rotation {rotation}: {differ} of {ink} ink pixels differ from the unturned page"); } } + +/// Press, move in steps and release, from one user-space point to another. +fn drag(h: &mut Harness<'static, PdfCraftApp>, from: (f32, f32), to: (f32, f32)) { + let (a, b) = (at(h, from.0, from.1), at(h, to.0, to.1)); + h.hover_at(a); + h.run_steps(1); + h.drag_at(a); + h.run_steps(1); + for k in 1..=5 { + h.hover_at(a + (b - a) * (k as f32 / 5.0)); + h.run_steps(1); + } + h.drop_at(b); + h.run_steps(4); +} + +fn rects(h: &Harness<'static, PdfCraftApp>) -> Vec<[f32; 4]> { + let s = h.state(); + s.session.get(s.views[0].id).unwrap().info.annotations.iter().map(|a| a.rect).collect() +} + +/// A placed signature moves while a Fill & Sign tool is still active (as in Acrobat), instead of +/// the drag doing nothing and a click stamping another mark on top of it. Elsewhere on the page +/// the tool still places its mark. +#[test] +fn fill_and_sign_tools_pick_up_placed_marks_to_move_and_resize_them() { + for tool in ["sign.fill.signature", "sign.fill.initials", "sign.fill.text", "sign.fill.check", "sign.fill.date"] { + let mut h = harness(); + h.state_mut().signature = Some(SavedSig::Typed("Ada Lovelace".into())); + h.state_mut().initials = Some(SavedSig::Typed("AL".into())); + h.state_mut().execute("sign.fill.signature"); + click(&mut h, 40.0, 250.0); + let [placed] = rects(&h)[..] else { panic!("one signature: {:?}", rects(&h)) }; + assert!(h.state_mut().execute(tool)); + h.run_steps(2); + let mid = ((placed[0] + placed[2]) / 2.0, (placed[1] + placed[3]) / 2.0); + h.hover_at(at(&h, mid.0, mid.1)); + h.run_steps(2); + assert_eq!(h.output().platform_output.cursor_icon, egui::CursorIcon::Move, "{tool}: a placed mark can be picked up"); + + drag(&mut h, mid, (mid.0 + 40.0, mid.1 - 60.0)); + assert!(matches!(h.state().quick_tool, QuickTool::Fill(_)), "{tool}: the tool stays"); + let moved = [placed[0] + 40.0, placed[1] - 60.0, placed[2] + 40.0, placed[3] - 60.0]; + let [now] = rects(&h)[..] else { panic!("{tool}: still one mark: {:?}", rects(&h)) }; + assert_rect(now, moved); + let doc = h.state().session.get(h.state().views[0].id).unwrap(); + assert_eq!(doc.can_undo(), Some("Move comment"), "{tool}"); + assert_eq!(h.state().views[0].comments.selected, Some((0, 0)), "{tool}"); + + // The selected signature's handle resizes it. + let corner = at(&h, moved[2], moved[1]); + h.hover_at(corner); + h.run_steps(2); + h.drag_at(corner); + h.run_steps(1); + h.hover_at(corner + egui::vec2(30.0, 10.0)); + h.run_steps(2); + h.drop_at(corner + egui::vec2(30.0, 10.0)); + h.run_steps(3); + assert_eq!(h.state().session.get(h.state().views[0].id).unwrap().can_undo(), Some("Resize comment"), "{tool}"); + assert_eq!(rects(&h).len(), 1, "{tool}"); + + // A click on it selects it rather than placing another mark on top. + h.state_mut().views[0].comments.selected = None; + let r = rects(&h)[0]; + click(&mut h, (r[0] + r[2]) / 2.0, (r[1] + r[3]) / 2.0); + assert_eq!(rects(&h).len(), 1, "{tool}: no mark stamped on the signature"); + assert_eq!(h.state().views[0].comments.selected, Some((0, 0)), "{tool}"); + assert!(h.state().views[0].fill_text.is_none(), "{tool}: no text box opened on the signature"); + } + // A drawn signature in a saved and reopened file (read by the viewer, not the editor). + let mut h = harness(); + h.state_mut().signature = Some(SavedSig::Drawn(vec![vec![[0.05, 0.1], [0.3, 0.2], [0.55, 0.05], [0.95, 0.15]]])); + h.state_mut().execute("sign.fill.signature"); + click(&mut h, 40.0, 250.0); + let saved = h.state().session.save_bytes(h.state().views[0].id).unwrap(); + let mut h = harness_bytes(&saved); + let [placed] = rects(&h)[..] else { panic!("one signature: {:?}", rects(&h)) }; + h.state_mut().execute("sign.fill.check"); + let mid = ((placed[0] + placed[2]) / 2.0, (placed[1] + placed[3]) / 2.0); + drag(&mut h, mid, (mid.0 + 40.0, mid.1 - 60.0)); + assert_rect(rects(&h)[0], [placed[0] + 40.0, placed[1] - 60.0, placed[2] + 40.0, placed[3] - 60.0]); + + // Away from Fill & Sign marks, the tool still places (checks stay repeatable), also on other + // comments. + let mut h = harness(); + h.state_mut().signature = Some(SavedSig::Typed("Ada Lovelace".into())); + h.state_mut().execute("sign.fill.signature"); + click(&mut h, 40.0, 250.0); + let id = h.state().views[0].id; + let square = pdfcraft_engine::Shape::Rectangle { rect: [100.0, 80.0, 200.0, 140.0] }; + let style = pdfcraft_engine::Style::default_for(&square); + let new = pdfcraft_engine::NewAnnotation { page: 0, shape: square, style, contents: String::new(), author: "Ada".into() }; + h.state_mut().session.apply(id, pdfcraft_engine::Edit::AddAnnotation(new)).unwrap(); + h.run_steps(2); + h.state_mut().execute("sign.fill.check"); + click(&mut h, 60.0, 100.0); + click(&mut h, 150.0, 110.0); + assert_eq!(items(&h).len(), 4, "{:?}", items(&h)); + assert_eq!(h.state().quick_tool, QuickTool::Fill(FillTool::Check)); +} diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index e55bff060..f95e15a57 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -5226,8 +5226,8 @@ milestone = "M5" status = "shipped" commands = ["sign.fill.signature", "sign.fill.initials", "sign.fill.signature.change", "sign.fill.initials.change", "sign.fill.signature.remove", "sign.fill.initials.remove"] tools = ["fill_sign_add", "comment_edit", "comment_image_preview"] -evidence = ["crates/ui-egui/tests/fill_sign.rs::drawn_and_typed_signatures_stay_upright_on_rotated_pages", "crates/automation/tests/automation.rs::typed_signatures_stay_upright_on_rotated_pages", "crates/ui-egui/tests/fill_sign.rs::image_signature_corners_restore_original_aspect_after_edge_resize_and_reopen", "crates/ui-egui/src/comments.rs::proportional_corners_keep_the_opposite_anchor_and_edges_stretch_one_axis", "crates/ui-egui/tests/fill_sign.rs::image_signature_live_resize_and_move_preserve_background_and_hide_moving_handles", "crates/ui-egui/tests/fill_sign.rs::embedded_image_signature_live_gestures_follow_document_and_view_rotation", "crates/automation/tests/automation.rs::image_signature_preview_layers_are_read_only_and_survive_encrypted_save", "crates/engine/src/signature_image.rs::embedded_signature_preview_preserves_alpha_without_editing_the_document", "crates/engine/src/signature_image.rs::oversized_embedded_signature_preview_is_rejected_before_decoding", "crates/ui-egui/tests/fill_sign.rs::image_signature_preview_follows_pointer_at_page_size_with_zoom_and_rotation", "crates/ui-egui/tests/fill_sign.rs::image_signature_placement_selects_resize_handles_and_requires_reselecting_to_repeat", "crates/ui-egui/tests/fill_sign.rs::typed_signatures_and_initials", "crates/ui-egui/tests/fill_sign.rs::signing_draws_a_signature_once_and_places_it", "crates/ui-egui/tests/fill_sign.rs::changing_saved_signatures_and_initials_preserves_placed_marks", "crates/ui-egui/tests/fill_sign.rs::changing_drawn_signatures_can_be_cancelled_or_switched_to_type", "crates/ui-egui/tests/fill_sign.rs::saved_signature_cards_remove_and_add_without_changing_the_document", "crates/ui-egui/tests/fill_sign.rs::long_typed_names_fit_the_placed_signature_and_keep_every_outline", "crates/ui-egui/src/fill_sign.rs::long_signature_previews_have_clear_margins", "crates/ui-egui/tests/control.rs::saved_signature_can_be_changed_through_the_control_channel", "crates/fonts/src/script.rs::a_name_becomes_outlines", "crates/fonts/src/script.rs::long_names_keep_every_glyph_with_bounded_outline_work", "crates/automation/tests/automation.rs::fill_and_sign_through_tools", "crates/ui-egui/tests/fill_sign.rs::image_signatures_and_initials_can_be_imported_placed_and_remembered", "crates/ui-egui/tests/fill_sign.rs::image_import_cancel_clear_and_errors_preserve_saved_signatures", "crates/engine/src/signature_image.rs::image_signatures_preserve_alpha_and_fit_the_signature_box", "crates/engine/src/signature_image.rs::invalid_and_oversized_signature_images_are_rejected", "crates/engine/src/signature_image.rs::jpeg_signatures_are_normalized_to_png", "crates/engine/src/signature_image.rs::jpeg_signature_orientation_is_applied_before_preview_and_embedding", "crates/engine/src/signature_image.rs::very_wide_image_signatures_keep_the_requested_rectangle", "crates/automation/tests/automation.rs::image_signatures_through_tools_preserve_transparency_and_survive_save"] -notes = "signature and initials, typed (Dancing Script outlines as filled paths, no font embedded), drawn or imported from local PNG/JPEG images; all three are placed upright and anchored as displayed on pages with /Rotate (#299); saved in settings with preview cards and add/change/remove controls; changes leave placed annotations untouched; images preserve alpha, aspect ratio and EXIF orientation, fit within 150 pt by 32 pt (24 pt for initials), and are normalized without source paths or metadata; image previews follow the pointer at placement size with zoom and rotation; signatures/initials place once then select the new annotation; embedded image signatures resize and move live, hiding the frame during movement and restoring it on release; corner handles preserve the original aspect ratio of the embedded image with the opposite corner anchored, while edge midpoint handles stretch width or height independently; cached background layers preserve page content and alpha, including reopened/encrypted documents; Escape cancels a gesture without committing; stamps resize through selection handles or comment_edit with their original appearance; native and web asynchronous pickers; fill_sign_add accepts path instead of text; image imports capped at 4 MiB, 4 megapixels and 4096 pixels per side" +evidence = ["crates/ui-egui/tests/fill_sign.rs::drawn_and_typed_signatures_stay_upright_on_rotated_pages", "crates/automation/tests/automation.rs::typed_signatures_stay_upright_on_rotated_pages", "crates/ui-egui/tests/fill_sign.rs::image_signature_corners_restore_original_aspect_after_edge_resize_and_reopen", "crates/ui-egui/src/comments.rs::proportional_corners_keep_the_opposite_anchor_and_edges_stretch_one_axis", "crates/ui-egui/tests/fill_sign.rs::image_signature_live_resize_and_move_preserve_background_and_hide_moving_handles", "crates/ui-egui/tests/fill_sign.rs::embedded_image_signature_live_gestures_follow_document_and_view_rotation", "crates/automation/tests/automation.rs::image_signature_preview_layers_are_read_only_and_survive_encrypted_save", "crates/engine/src/signature_image.rs::embedded_signature_preview_preserves_alpha_without_editing_the_document", "crates/engine/src/signature_image.rs::oversized_embedded_signature_preview_is_rejected_before_decoding", "crates/ui-egui/tests/fill_sign.rs::image_signature_preview_follows_pointer_at_page_size_with_zoom_and_rotation", "crates/ui-egui/tests/fill_sign.rs::image_signature_placement_selects_resize_handles_and_requires_reselecting_to_repeat", "crates/ui-egui/tests/fill_sign.rs::typed_signatures_and_initials", "crates/ui-egui/tests/fill_sign.rs::signing_draws_a_signature_once_and_places_it", "crates/ui-egui/tests/fill_sign.rs::changing_saved_signatures_and_initials_preserves_placed_marks", "crates/ui-egui/tests/fill_sign.rs::changing_drawn_signatures_can_be_cancelled_or_switched_to_type", "crates/ui-egui/tests/fill_sign.rs::saved_signature_cards_remove_and_add_without_changing_the_document", "crates/ui-egui/tests/fill_sign.rs::long_typed_names_fit_the_placed_signature_and_keep_every_outline", "crates/ui-egui/src/fill_sign.rs::long_signature_previews_have_clear_margins", "crates/ui-egui/tests/control.rs::saved_signature_can_be_changed_through_the_control_channel", "crates/fonts/src/script.rs::a_name_becomes_outlines", "crates/fonts/src/script.rs::long_names_keep_every_glyph_with_bounded_outline_work", "crates/automation/tests/automation.rs::fill_and_sign_through_tools", "crates/ui-egui/tests/fill_sign.rs::image_signatures_and_initials_can_be_imported_placed_and_remembered", "crates/ui-egui/tests/fill_sign.rs::image_import_cancel_clear_and_errors_preserve_saved_signatures", "crates/engine/src/signature_image.rs::image_signatures_preserve_alpha_and_fit_the_signature_box", "crates/engine/src/signature_image.rs::invalid_and_oversized_signature_images_are_rejected", "crates/engine/src/signature_image.rs::jpeg_signatures_are_normalized_to_png", "crates/engine/src/signature_image.rs::jpeg_signature_orientation_is_applied_before_preview_and_embedding", "crates/engine/src/signature_image.rs::very_wide_image_signatures_keep_the_requested_rectangle", "crates/automation/tests/automation.rs::image_signatures_through_tools_preserve_transparency_and_survive_save", "crates/ui-egui/tests/fill_sign.rs::fill_and_sign_tools_pick_up_placed_marks_to_move_and_resize_them"] +notes = "signature and initials, typed (Dancing Script outlines as filled paths, no font embedded), drawn or imported from local PNG/JPEG images; all three are placed upright and anchored as displayed on pages with /Rotate (#299); saved in settings with preview cards and add/change/remove controls; changes leave placed annotations untouched; images preserve alpha, aspect ratio and EXIF orientation, fit within 150 pt by 32 pt (24 pt for initials), and are normalized without source paths or metadata; image previews follow the pointer at placement size with zoom and rotation; signatures/initials place once then select the new annotation; while a Fill & Sign tool is active, marks already placed are picked up as with Select (hover shows the move cursor, a click selects, a drag moves, the selected mark's handles resize) and the tool places only away from them; embedded image signatures resize and move live, hiding the frame during movement and restoring it on release; corner handles preserve the original aspect ratio of the embedded image with the opposite corner anchored, while edge midpoint handles stretch width or height independently; cached background layers preserve page content and alpha, including reopened/encrypted documents; Escape cancels a gesture without committing; stamps resize through selection handles or comment_edit with their original appearance; native and web asynchronous pickers; fill_sign_add accepts path instead of text; image imports capped at 4 MiB, 4 megapixels and 4096 pixels per side" [[feature]] id = "form.fill-sign-flatten" From b34b3263d616f81a99cf2c2adf95fbdab53eab09 Mon Sep 17 00:00:00 2001 From: CyberFox-81 Date: Sat, 10 Oct 2026 15:50:06 -0400 Subject: [PATCH 37/69] Web: opt-in embedding bridge for host pages (?host=parent) (#842) A page that frames PdfCraft on the same origin can now pass it PDFs and receive saves through postMessage, instead of Save becoming a browser download. Built for a Nextcloud integration, but nothing in it is host-specific. - ui-egui: HostSaveFn/HostDirtyFn hooks (`host_save`, `host_dirty`), None by default. On wasm, Save hands the bytes to `host_save` when set; `host_dirty` hears each frame whether any tab has unsaved work. - pdfcraft-web: `host::Bridge`, enabled only by `?host=parent` inside a frame. Messages: pdfcraft:ready / open / save / dirty / failed; only the parent window on our own origin is heard or answered. `?author=` names comment authors unless the user chose a name. - Test: host_dirty_hears_when_unsaved_work_appears_and_goes. Without `?host=parent` nothing changes. Claude-Session: https://claude.ai/code/session_01G6GtUCaPiCMEQCN4QcdQnp Co-authored-by: Claude Opus 5.5 Co-authored-by: Brandon Thomas --- apps/pdfcraft-web/Cargo.toml | 2 +- apps/pdfcraft-web/src/main.rs | 148 ++++++++++++++++++++++++++++++++ crates/ui-egui/src/editing.rs | 26 ++++-- crates/ui-egui/src/lib.rs | 20 +++++ crates/ui-egui/tests/editing.rs | 21 +++++ 5 files changed, 211 insertions(+), 6 deletions(-) diff --git a/apps/pdfcraft-web/Cargo.toml b/apps/pdfcraft-web/Cargo.toml index 08d0f4ec0..a64d729dc 100644 --- a/apps/pdfcraft-web/Cargo.toml +++ b/apps/pdfcraft-web/Cargo.toml @@ -16,5 +16,5 @@ egui = { workspace = true } [target.'cfg(target_arch = "wasm32")'.dependencies] wasm-bindgen-futures = "0.4" -web-sys = { version = "0.3", features = ["Window", "Document", "Element", "HtmlCanvasElement", "HtmlElement", "Location", "Url", "UrlSearchParams", "Response", "Event"] } +web-sys = { version = "0.3", features = ["Window", "Document", "Element", "HtmlCanvasElement", "HtmlElement", "Location", "Url", "UrlSearchParams", "Response", "Event", "MessageEvent", "EventTarget"] } js-sys = "0.3" diff --git a/apps/pdfcraft-web/src/main.rs b/apps/pdfcraft-web/src/main.rs index 1c99eb1dc..a46a4674b 100644 --- a/apps/pdfcraft-web/src/main.rs +++ b/apps/pdfcraft-web/src/main.rs @@ -54,12 +54,34 @@ fn main() { // eframe never raises a close request on a browser reload or tab close, so `guard_quit` never // prompts; ask the browser to confirm instead while a document has unsaved work (#812). warn_before_unload(app.unsaved_flag.clone()); + // `?host=parent`: a same-origin page embedding PdfCraft in an iframe (such + // as the Nextcloud app) passes files in and takes saves back (`host` below). + if query_param("host").as_deref() == Some("parent") + && let Some(bridge) = host::Bridge::new() + { + // `?author=`: the host's name for the user signs their comments, + // unless they chose a name in PdfCraft (the web default is "Guest"). + if let Some(author) = query_param("author").map(|a| a.trim().to_string()).filter(|a| !a.is_empty()) + && app.comment_prefs.author == "Guest" + { + app.comment_prefs.author = author.chars().take(200).collect(); + } + bridge.listen(app.startup_inbox.clone(), cc.egui_ctx.clone()); + app.host_save = Some(bridge.save_fn()); + app.host_dirty = Some(bridge.dirty_fn()); + bridge.post_ready(); + } Ok(Box::new(app)) }), ) .await; if let Err(e) = started { eframe::web_sys::console::error_1(&e); + if query_param("host").as_deref() == Some("parent") + && let Some(bridge) = host::Bridge::new() + { + bridge.post_failed(&e.as_string().unwrap_or_else(|| format!("{e:?}"))); + } } }); } @@ -97,6 +119,132 @@ async fn fetch_bytes(url: &str) -> Result, String> { Ok(js_sys::Uint8Array::new(&buf).to_vec()) } +/// The `postMessage` bridge to a same-origin parent page. Messages are plain objects with a +/// `type`: +/// +/// - `pdfcraft:ready` (to the parent): the app is listening; send files now. +/// - `pdfcraft:open` (from the parent): `{ name, bytes }`, where `bytes` is an `ArrayBuffer` or +/// `Uint8Array` holding a PDF. It opens in a tab, like a `?file=` URL. +/// - `pdfcraft:save` (to the parent): `{ name, saveAs, bytes }` when the user saves; `bytes` is +/// a transferred `ArrayBuffer`. The parent stores the file (and reports its own errors). +/// - `pdfcraft:dirty` (to the parent): `{ dirty }` whenever unsaved work appears or goes away. +/// - `pdfcraft:failed` (to the parent): `{ error }` when the app couldn't start. +/// +/// Only the parent window on the app's own origin is heard or answered. +#[cfg(target_arch = "wasm32")] +mod host { + use eframe::wasm_bindgen::{JsCast, JsValue, closure::Closure}; + + #[derive(Clone)] + pub struct Bridge { + window: web_sys::Window, + parent: web_sys::Window, + origin: String, + } + + impl Bridge { + /// `None` when the page isn't framed (there is no parent to talk to). + pub fn new() -> Option { + let window = web_sys::window()?; + let parent = window.parent().ok().flatten()?; + if js_sys::Object::is(&parent, &window) { + return None; + } + let origin = window.location().origin().ok()?; + Some(Self { window, parent, origin }) + } + + fn post(&self, message: &js_sys::Object, transfer: Option<&JsValue>) -> Result<(), String> { + let sent = match transfer { + Some(t) => self.parent.post_message_with_transfer(message, &self.origin, &js_sys::Array::of1(t)), + None => self.parent.post_message(message, &self.origin), + }; + sent.map_err(|e| e.as_string().unwrap_or_else(|| format!("{e:?}"))) + } + + pub fn post_ready(&self) { + if let Some(m) = message(&[("type", "pdfcraft:ready".into()), ("version", env!("CARGO_PKG_VERSION").into())]) { + let _ = self.post(&m, None); + } + } + + /// The app couldn't start (no WebGL, for example): let the parent say so. + pub fn post_failed(&self, error: &str) { + if let Some(m) = message(&[("type", "pdfcraft:failed".into()), ("error", error.into())]) { + let _ = self.post(&m, None); + } + } + + /// Hear `pdfcraft:open` from the parent and queue the file like a `?file=` download. + pub fn listen(&self, inbox: pdfcraft_ui_egui::Inbox, ctx: egui::Context) { + let parent = self.parent.clone(); + let origin = self.origin.clone(); + let on_message = Closure::::new(move |event: web_sys::MessageEvent| { + if event.origin() != origin || !event.source().is_some_and(|s| js_sys::Object::is(&s, &parent)) { + return; + } + let data = event.data(); + if string(&data, "type").as_deref() != Some("pdfcraft:open") { + return; + } + let name = string(&data, "name").filter(|n| !n.trim().is_empty()).unwrap_or_else(|| "document.pdf".to_string()); + let Ok(bytes) = js_sys::Reflect::get(&data, &JsValue::from_str("bytes")) else { return }; + let bytes = if let Some(array) = bytes.dyn_ref::() { + array.to_vec() + } else if bytes.is_instance_of::() { + js_sys::Uint8Array::new(&bytes).to_vec() + } else { + return; + }; + if let Ok(mut q) = inbox.lock() { + q.push((name, bytes)); + } + ctx.request_repaint(); + }); + if self.window.add_event_listener_with_callback("message", on_message.as_ref().unchecked_ref()).is_ok() { + // The listener lives as long as the page. + on_message.forget(); + } + } + + pub fn save_fn(&self) -> pdfcraft_ui_egui::HostSaveFn { + let bridge = self.clone(); + Box::new(move |name: &str, bytes: &[u8], save_as: bool| { + let buffer = js_sys::Uint8Array::from(bytes).buffer(); + let m = + message(&[("type", "pdfcraft:save".into()), ("name", name.into()), ("saveAs", save_as.into()), ("bytes", buffer.clone().into())]) + .ok_or_else(|| "couldn't build the message".to_string())?; + bridge.post(&m, Some(&buffer)) + }) + } + + pub fn dirty_fn(&self) -> pdfcraft_ui_egui::HostDirtyFn { + let bridge = self.clone(); + let mut last = false; + Box::new(move |dirty: bool| { + if dirty != last { + last = dirty; + if let Some(m) = message(&[("type", "pdfcraft:dirty".into()), ("dirty", dirty.into())]) { + let _ = bridge.post(&m, None); + } + } + }) + } + } + + fn message(fields: &[(&str, JsValue)]) -> Option { + let m = js_sys::Object::new(); + for (key, value) in fields { + js_sys::Reflect::set(&m, &JsValue::from_str(key), value).ok()?; + } + Some(m) + } + + fn string(data: &JsValue, key: &str) -> Option { + js_sys::Reflect::get(data, &JsValue::from_str(key)).ok()?.as_string() + } +} + #[cfg(not(target_arch = "wasm32"))] fn main() { eprintln!("pdfcraft-web targets wasm32: run `trunk serve` or `trunk build --release` in apps/pdfcraft-web"); diff --git a/crates/ui-egui/src/editing.rs b/crates/ui-egui/src/editing.rs index a660f181a..a250167ed 100644 --- a/crates/ui-egui/src/editing.rs +++ b/crates/ui-egui/src/editing.rs @@ -404,7 +404,7 @@ impl PdfCraftApp { } #[cfg(target_arch = "wasm32")] { - let _ = (target, path); + let _ = path; if !self.flatten_fill_sign_for_save(id) { return false; } @@ -415,16 +415,32 @@ impl PdfCraftApp { return false; } }; - match download(&name, &bytes) { + // An embedding page that asked for saves (`?host=parent`) gets the bytes; otherwise + // the browser downloads them. + let to_host = self.host_save.is_some(); + let delivered = match &self.host_save { + Some(save) => save(&name, &bytes, matches!(target, SaveTarget::As)), + None => download(&name, &bytes), + }; + match delivered { Ok(()) => { let _ = self.session.mark_saved(id, bytes, None); - if let Some(doc) = self.session.get(id) { - self.views[index].document_changed(&doc.info); + if let Some(doc) = self.session.get(id) + && let Some(view) = self.views.get_mut(index) + { + view.document_changed(&doc.info); + } + // The host reports where the file went (and any failure) itself. + if !to_host { + self.notify_fmt("Downloaded {name}", &[("name", &name)]); } - self.notify_fmt("Downloaded {name}", &[("name", &name)]); after(self); true } + Err(e) if to_host => { + self.notify_fmt("Couldn't save {name}: {e}", &[("name", &name), ("e", &e)]); + false + } Err(e) => { self.notify_fmt("Couldn't download {name}: {e}", &[("name", &name), ("e", &e.to_string())]); false diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index db2520e7b..64661d8f7 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -341,6 +341,14 @@ pub enum OsEvent { /// Returns the [`OsEvent`]s that arrived since it was last called (set by the desktop app). pub type OsEventsFn = Box Vec>; +/// Hands a saved PDF to the page embedding PdfCraft (web): `(name, bytes, save_as)`. `save_as` +/// is `true` for Save As. Set by the web app when a host page (such as a Nextcloud app) asked +/// for saves; without it the browser downloads the file. +pub type HostSaveFn = Box Result<(), String>>; + +/// Told every frame whether any tab has unsaved work, so a host page can warn before leaving. +pub type HostDirtyFn = Box; + pub struct PasswordPrompt { pub name: String, pub path: Option, @@ -435,6 +443,10 @@ pub struct PdfCraftApp { pub unsaved_flag: std::sync::Arc, /// Requests from the operating system, polled every frame (macOS Apple events). pub os_events: Option, + /// Save delivers to the embedding page instead of downloading (web, see [`HostSaveFn`]). + pub host_save: Option, + /// Reports unsaved work to the embedding page (web, see [`HostDirtyFn`]). + pub host_dirty: Option, /// A pending "save changes?" question (closing a dirty tab or quitting). pub close_request: Option, /// Save to this path instead of asking (tests and automation). @@ -707,6 +719,8 @@ impl PdfCraftApp { failed_inbox: Default::default(), unsaved_flag: Default::default(), os_events: None, + host_save: None, + host_dirty: None, close_request: None, save_override: None, props_draft: None, @@ -1895,6 +1909,12 @@ impl eframe::App for PdfCraftApp { self.guard_quit(ctx); let now = ctx.input(|i| i.time); self.autosave_tick(now); + if self.host_dirty.is_some() { + let dirty = self.first_dirty().is_some(); + if let Some(report) = self.host_dirty.as_mut() { + report(dirty); + } + } self.poll_updates(); // Shortcuts deferred last frame: the text field has taken that frame's typing since. let deferred = std::mem::take(&mut self.deferred_commands); diff --git a/crates/ui-egui/tests/editing.rs b/crates/ui-egui/tests/editing.rs index f1a267ee2..99a5d9c00 100644 --- a/crates/ui-egui/tests/editing.rs +++ b/crates/ui-egui/tests/editing.rs @@ -2186,3 +2186,24 @@ fn the_delete_key_deletes_pages_picked_in_the_pages_panel_but_never_every_page() assert_eq!(page_texts(h.state()), ["Page 1"]); assert_eq!(h.state().views[0].current, 0); } + +#[test] +fn host_dirty_hears_when_unsaved_work_appears_and_goes() { + let reports = std::sync::Arc::new(std::sync::Mutex::new(Vec::new())); + let sink = reports.clone(); + let mut h = harness(2, move |app| { + app.set_option("organize", "on").unwrap(); + app.host_dirty = Some(Box::new(move |dirty| sink.lock().unwrap().push(dirty))); + }); + assert_eq!(reports.lock().unwrap().last(), Some(&false), "a freshly opened document has nothing unsaved"); + h.get_by_label("Rotate clockwise").click(); + h.run_steps(3); + assert_eq!(reports.lock().unwrap().last(), Some(&true), "an edit is unsaved work"); + + let out = temp_path("host-dirty.pdf"); + h.state_mut().save_override = Some(out.to_string_lossy().into_owned()); + h.key_press_modifiers(Modifiers::COMMAND, Key::S); + h.run_steps(3); + assert_eq!(reports.lock().unwrap().last(), Some(&false), "saving clears it"); + let _ = std::fs::remove_file(out); +} From 2a909f361cfc22104b15ca72a9dcafd4cef83a77 Mon Sep 17 00:00:00 2001 From: MarekSpodBiedry <73122968+MarekSpodBiedry@users.noreply.github.com> Date: Sat, 10 Oct 2026 21:50:20 +0200 Subject: [PATCH 38/69] Print: a scaled annotation appearance no longer prints larger than its rectangle (#802) (#861) --- crates/print/src/lib.rs | 3 ++- crates/print/src/tests.rs | 13 +++++++++++++ 2 files changed, 15 insertions(+), 1 deletion(-) diff --git a/crates/print/src/lib.rs b/crates/print/src/lib.rs index 35e4661c3..7566f8caf 100644 --- a/crates/print/src/lib.rs +++ b/crates/print/src/lib.rs @@ -478,7 +478,8 @@ fn appearance_matrix(doc: &Document, form: &Dict, rect: [f64; 4]) -> Option Date: Sun, 11 Oct 2026 01:20:42 +0530 Subject: [PATCH 39/69] Single instance: enable tab handoff in Windows portable mode (#889) (#891) * Single instance: enable tab handoff in Windows portable mode (#889) * Single instance: a portable copy's socket stays in %LOCALAPPDATA%, under its own name (cherry picked from commit c7db1e80a374e80a11504efefbaac680dfa5e872) --------- Co-authored-by: Brandon Thomas --- apps/pdfcraft/src/single_instance.rs | 15 +++++++-------- 1 file changed, 7 insertions(+), 8 deletions(-) diff --git a/apps/pdfcraft/src/single_instance.rs b/apps/pdfcraft/src/single_instance.rs index 73147d4a2..9d15f5b26 100644 --- a/apps/pdfcraft/src/single_instance.rs +++ b/apps/pdfcraft/src/single_instance.rs @@ -135,17 +135,16 @@ pub fn claim(files: &[String], may_hand_off: bool) -> Claim { } } -/// The socket in `%LOCALAPPDATA%\PdfCraft` (beside the crash-recovery folder); none for a portable -/// copy. +/// The socket in `%LOCALAPPDATA%\PdfCraft` (beside the crash-recovery folder); none when +/// `%LOCALAPPDATA%` is unset. #[cfg(windows)] fn socket_path() -> Option { let name = socket_name(std::env::var("SESSIONNAME").ok().as_deref()); - // A portable copy may sit in a folder other accounts can write to (`C:\Tools`): a socket - // there would let them receive this user's file paths or send it files to open. Portable - // launches each open their own window instead, and write nothing outside their folder. - if pdfcraft_ui_egui::portable::data_dir().is_some() { - return None; - } + // %LOCALAPPDATA% is private to the user's sign-in, so other accounts can't receive this + // user's file paths or send it files to open. A portable copy uses it too (#889), under its + // own name so it never hands files to an installed copy (which keeps its settings elsewhere). + // Never the portable folder itself: it may sit where other accounts can write (`C:\Tools`). + let name = if pdfcraft_ui_egui::portable::data_dir().is_some() { format!("portable-{name}") } else { name }; std::env::var_os("LOCALAPPDATA").filter(|v| !v.is_empty()).map(|d| std::path::PathBuf::from(d).join("PdfCraft").join(name)) } From 8c182087dfa6a189c55749c3b1b75623a8627e5d Mon Sep 17 00:00:00 2001 From: Tristan von Kries Date: Sat, 10 Oct 2026 21:50:55 +0200 Subject: [PATCH 40/69] Issue #390: build the DMG with hdiutil create so the app passes codesign --strict (#823) * Issue #390: build the DMG with hdiutil create so the app passes codesign --strict makehybrid -hfs wrote a com.apple.FinderInfo (Finder location -1,-1) on every file in the image, and codesign --verify --deep --strict rejects that as "Finder information, or similar detritus", on the app in the DMG and on a copy installed from it. hdiutil create -srcfolder writes the files as they are; it attaches a device while copying, so it gets up to five tries. A gate mounts the finished image read-only and verifies the app inside with --strict. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01REz7wTfQgnopyvN9eQJJVu * Issue #390: retry hdiutil attach and detach in the DMG gate too As suggested in review: the gate mounts the signed image under set -e, so a "Resource busy" there would stop a release after the DMG was built. The same five tries with growing pauses now cover create, attach and detach. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01REz7wTfQgnopyvN9eQJJVu --------- Co-authored-by: Tristan von Kries Co-authored-by: Claude Fable 5.1 --- packaging/macos/dmg/README.md | 4 ++-- packaging/macos/package.sh | 39 ++++++++++++++++++++++++++++------- 2 files changed, 34 insertions(+), 9 deletions(-) diff --git a/packaging/macos/dmg/README.md b/packaging/macos/dmg/README.md index ec902e30f..460f7bdeb 100644 --- a/packaging/macos/dmg/README.md +++ b/packaging/macos/dmg/README.md @@ -2,8 +2,8 @@ What Finder shows when the DMG opens: a 660 × 400 pt background with the app icon and the `Applications` link side by side. `package.sh` copies these files into the image; nothing here is -generated at build time, so the DMG still builds with `hdiutil makehybrid` (no mounted device, no -Finder scripting on CI, nothing extra installed in the signing job). +generated at build time, so the DMG builds with a plain `hdiutil create -srcfolder` (no Finder +scripting on CI, nothing extra installed in the signing job). | File | What | |---|---| diff --git a/packaging/macos/package.sh b/packaging/macos/package.sh index c73581a24..e04be13c8 100755 --- a/packaging/macos/package.sh +++ b/packaging/macos/package.sh @@ -137,15 +137,40 @@ ln -s /Applications "$STAGE/Applications" mkdir -p "$STAGE/.background" cp "$HERE/dmg/background.tiff" "$STAGE/.background/background.tiff" cp "$HERE/dmg/dmg-layout.DS_Store" "$STAGE/.DS_Store" -rm -f "$DMG" "$WORK/raw.dmg" -# makehybrid + convert builds the image without attaching a device, unlike `create -srcfolder`, -# which is flaky on CI runners ("Resource busy") and hangs in sandboxed sessions. -# The volume name has no version: .DS_Store finds the background through an alias that includes it. -hdiutil makehybrid -hfs -hfs-volume-name "PdfCraft" -hfs-openfolder "$STAGE" -o "$WORK/raw.dmg" "$STAGE" -hdiutil convert "$WORK/raw.dmg" -format UDZO -imagekey zlib-level=9 -o "$DMG" -rm -f "$WORK/raw.dmg" +rm -f "$DMG" +# `hdiutil create -srcfolder` writes the files as they are. `makehybrid -hfs` gave every file a +# com.apple.FinderInfo (a Finder location of -1,-1), which `codesign --verify --strict` rejects as +# "Finder information, or similar detritus" on the app in the image and on a copy installed from it, +# so MDM and compliance checks reported a correctly notarized app as damaged (#390). `create` +# attaches a device while it copies, which fails now and then on CI runners ("Resource busy"), so +# it gets a few tries. The volume name has no version: .DS_Store finds the background through an +# alias that includes it. +# Usage: hdiutil_retry ; tries five times with growing pauses. +hdiutil_retry() { + local what="$1" attempt + shift + for attempt in 1 2 3 4 5; do + if hdiutil "$@"; then + return 0 + fi + [ "$attempt" = 5 ] && { echo "hdiutil $what failed 5 times" >&2; return 1; } + warn "hdiutil $what failed (attempt $attempt of 5); retrying in $((attempt * 10))s" + sleep $((attempt * 10)) + done +} +hdiutil_retry create create -srcfolder "$STAGE" -volname "PdfCraft" -fs HFS+ -format UDZO -imagekey zlib-level=9 -ov "$DMG" sign "$DMG" codesign --verify --strict --verbose=2 "$DMG" +# The app inside the image must still pass --strict, so a makehybrid-style regression can't ship. +MOUNT="$WORK/dmg-check" +rm -rf "$MOUNT" +mkdir -p "$MOUNT" +hdiutil_retry attach attach -nobrowse -readonly -mountpoint "$MOUNT" "$DMG" >/dev/null +if ! codesign --verify --strict --deep --verbose=2 "$MOUNT/PdfCraft.app"; then + hdiutil_retry detach detach "$MOUNT" >/dev/null || true + exit 1 +fi +hdiutil_retry detach detach "$MOUNT" >/dev/null if [ "$NOTARIZE" = 1 ]; then notarize "$DMG" xcrun stapler staple "$DMG" From c8c77efb14445db24dd57955e06a50f500740274 Mon Sep 17 00:00:00 2001 From: cyfung1031 <44498510+cyfung1031@users.noreply.github.com> Date: Sun, 11 Oct 2026 04:51:10 +0900 Subject: [PATCH 41/69] M4: make page-label generation lazy across large documents (#845) * M4: make page-label generation lazy across large documents Page labels were expanded into one String per page, and the whole sequence was refused once the labels passed 4 MiB in total. A valid document with long labels could therefore neither be read nor edited. Add a PageLabels view over the label ranges: label(page) builds one label, and iter() builds the labels one at a time, so no string is kept per page. Edits validate each range from its largest number (Roman numerals by their thousands) instead of building every label, so the check no longer depends on the page count. The per-label length limit, the number-tree work and depth limits and the overflow checks are unchanged. page_labels() keeps its signature and returns the same labels as before for ordinary documents. Co-Authored-By: Claude Haiku 5.5 * M4: drop the aggregate page-label cap from document inspection Document inspection refused every custom label once their total passed 4 MiB, so a valid document with long labels showed physical page numbers and a warning. Labels are now applied to every page. The per-label length limit, the number-tree work, depth and cycle limits and the prefix bounds are unchanged, and labels are still applied only after every range is accepted. MAX_LABEL_TOTAL_BYTES is removed. The inspector keeps one label per page (PageInfo.label), so its label memory grows with the page count times the label length. The page count is bounded by the objects in the file, and each label by 1,024 bytes. Co-Authored-By: Claude Haiku 5.5 * M4: keep the inspector's 4 MiB page-label cap until its labels are lazy Commit a096ece removed the aggregate page-label cap from document inspection. Inspector::fill keeps one label String per page on open, and inspection does not read labels from the lazy PageLabels view yet, so a hostile file with a million labelled pages would allocate on the order of a gigabyte when it is opened (measured here at about 1.8 KiB per labelled page). This reverts that commit. The cap stays until inspection reads labels lazily, as a separate change. The organizer change in 5253124 does not depend on it. Co-Authored-By: Claude Haiku 5.5 --------- Co-authored-by: Claude Haiku 5.5 --- crates/organize/src/labels.rs | 213 ++++++++++++++++++++++++++++------ crates/organize/src/lib.rs | 2 +- crates/organize/src/tests.rs | 47 ++++++++ 3 files changed, 224 insertions(+), 38 deletions(-) diff --git a/crates/organize/src/labels.rs b/crates/organize/src/labels.rs index 46211a984..c351adc04 100644 --- a/crates/organize/src/labels.rs +++ b/crates/organize/src/labels.rs @@ -7,7 +7,7 @@ use std::collections::HashSet; -use pdfcraft_cos::page_labels::{MAX_LABEL_BYTES, MAX_LABEL_TOTAL_BYTES, MAX_LABEL_TREE_DEPTH, MAX_LABEL_TREE_WORK, MAX_PREFIX_BYTES, alpha, roman}; +use pdfcraft_cos::page_labels::{MAX_LABEL_BYTES, MAX_LABEL_TREE_DEPTH, MAX_LABEL_TREE_WORK, MAX_PREFIX_BYTES, alpha, roman}; use pdfcraft_cos::{Dict, Document, ObjRef, Object, PdfString}; use crate::{OrganizeError, page_count, pages_root}; @@ -232,40 +232,114 @@ fn checked_ranges(doc: &Document) -> Result, OrganizeError> { Ok(unique) } -/// Every page's label. `lenient` (reading a file): a range whose label can't be formatted -/// shows physical page numbers for its pages instead of failing every label. -fn labels_from_ranges(n: usize, ranges: &[LabelRange], lenient: bool) -> Result, OrganizeError> { - let mut labels = Vec::new(); - let mut total = 0usize; - let mut ranges = ranges.iter().peekable(); - let mut active = None; - for p in 0..n { - while ranges.peek().is_some_and(|r| r.start <= p) { - active = ranges.next(); - } - let label = match active { - Some(r) => { - let label = match r.label(p) { - Ok(label) => label, - Err(_) if lenient => String::new(), - Err(e) => return Err(e), - }; - total = total.checked_add(label.len()).ok_or_else(|| invalid_label("the total label size overflows"))?; - if total > MAX_LABEL_TOTAL_BYTES { - return Err(invalid_label("custom labels exceed 4 MiB in total")); - } - if label.is_empty() { (p + 1).to_string() } else { label } - } - None => (p + 1).to_string(), - }; - labels.push(label); - } - Ok(labels) +/// A view of every page's label: the sorted ranges and the page count, with no string kept per +/// page. [`PageLabels::label`] builds one label; [`PageLabels::iter`] builds them one at a time, so +/// memory follows the ranges, not the pages. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct PageLabels { + ranges: Vec, + pages: usize, } -/// Every page's label, as a viewer shows it. Resource limits return an actionable error. +impl PageLabels { + /// Reads the document's `/PageLabels` and page count. A tree that is cyclic, too deep or too + /// large is an error. + pub fn new(doc: &Document) -> Result { + Ok(Self { ranges: checked_ranges(doc)?, pages: page_count(doc)? }) + } + + /// The number of pages the labels cover. + pub fn pages(&self) -> usize { + self.pages + } + + /// The label of page `page` (0-based), as a viewer shows it: a page with no range, or with an + /// empty range label, shows its physical number. Errors when the range's label cannot be + /// formatted (over 1,024 UTF-8 bytes, or a number past 32 bits). + pub fn label(&self, page: usize) -> Result { + if page >= self.pages { + return Err(OrganizeError::NoSuchPage(page)); + } + let covering = self.ranges.partition_point(|r| r.start <= page); + let label = match covering.checked_sub(1).and_then(|i| self.ranges.get(i)) { + Some(range) => range.label(page)?, + None => String::new(), + }; + Ok(if label.is_empty() { (page + 1).to_string() } else { label }) + } + + /// Every page's label, in page order, one at a time. Reading is lenient: a label that cannot + /// be formatted shows its physical page number, and the other ranges keep their labels. + pub fn iter(&self) -> impl Iterator + '_ { + (0..self.pages).map(|page| self.label(page).unwrap_or_else(|_| (page + 1).to_string())) + } +} + +/// Every page's label, as a viewer shows it. This builds one string per page; for large +/// documents, read single labels or stream them through [`PageLabels`]. pub fn page_labels(doc: &Document) -> Result, OrganizeError> { - labels_from_ranges(page_count(doc)?, &checked_ranges(doc)?, true) + Ok(PageLabels::new(doc)?.iter().collect()) +} + +/// Strict check for an edit: every label that the sorted, unique `ranges` produce over `pages` +/// pages must fit. A range is checked from its largest number, so the work follows the ranges +/// (and at most a thousand numbers at each end of a Roman range), not the pages. +fn check_spans(ranges: &[LabelRange], pages: usize) -> Result<(), OrganizeError> { + let Some(last_page) = pages.checked_sub(1) else { return Ok(()) }; + for (i, r) in ranges.iter().enumerate() { + // Prefix-only labels have no number to check; a range past the last page is never shown. + if r.start > last_page || r.style == LabelStyle::None { + continue; + } + let end = ranges.get(i + 1).map_or(last_page, |next| next.start.saturating_sub(1).min(last_page)); + let lo = u64::from(r.first); + let hi = lo.saturating_add(u64::try_from(end.saturating_sub(r.start)).unwrap_or(u64::MAX)); + let prefix = u64::try_from(r.prefix.len()).unwrap_or(u64::MAX); + let remaining = (MAX_LABEL_BYTES as u64).checked_sub(prefix).ok_or_else(|| invalid_label("a prefix exceeds 1024 UTF-8 bytes"))?; + // Numbers past u32::MAX are never formatted, so a label that is too long comes first. + if longest_numeral(r.style, lo, hi.min(u64::from(u32::MAX))) > remaining { + return Err(invalid_label("a label exceeds 1024 UTF-8 bytes")); + } + if hi > u64::from(u32::MAX) { + return Err(invalid_label("a page number overflows")); + } + } + Ok(()) +} + +/// The longest numeral `style` writes for the numbers `lo..=hi` (`1 <= lo <= hi`), in bytes. +fn longest_numeral(style: LabelStyle, lo: u64, hi: u64) -> u64 { + match style { + // Decimal and alphabetic numerals only grow with the number, so the last one is longest. + LabelStyle::Decimal => u64::from(hi.checked_ilog10().unwrap_or(0)) + 1, + LabelStyle::UpperAlpha | LabelStyle::LowerAlpha => hi.saturating_sub(1) / 26 + 1, + LabelStyle::UpperRoman | LabelStyle::LowerRoman => longest_roman(lo, hi), + LabelStyle::None => 0, + } +} + +/// The byte length of [`roman`]'s numeral for `n`: `n / 1000` copies of `m`, then the hundreds, +/// tens and units, each a table lookup. +fn roman_len(n: u64) -> u64 { + // Lengths of i, ii, iii, iv, v, vi, vii, viii, ix (and the same pattern for tens and hundreds). + const UNITS: [u64; 10] = [0, 1, 2, 3, 2, 1, 2, 3, 4, 2]; + let digit = |place: u64| UNITS.get(usize::try_from(place % 10).unwrap_or_default()).copied().unwrap_or_default(); + n / 1000 + digit(n / 100) + digit(n / 10) + digit(n) +} + +/// The longest Roman numeral among `lo..=hi`, in bytes. Within one thousand the longest numeral is +/// the one ending in 888, and every whole thousand between the ends contains it, so only the +/// partial thousands at either end are read. +fn longest_roman(lo: u64, hi: u64) -> u64 { + let (first, last) = (lo / 1000, hi / 1000); + if first == last { + return (lo..=hi).map(roman_len).max().unwrap_or_default(); + } + let head = (lo..=first.saturating_mul(1000).saturating_add(999)).map(roman_len).max().unwrap_or_default(); + let tail = (last.saturating_mul(1000)..=hi).map(roman_len).max().unwrap_or_default(); + // Each whole thousand between the ends peaks at its thousand plus 12 (for 888); the last is largest. + let middle = if last.saturating_sub(first) >= 2 { last.saturating_sub(1).saturating_add(12) } else { 0 }; + head.max(tail).max(middle) } /// Replace all label ranges (an empty list removes `/PageLabels`). @@ -293,7 +367,7 @@ pub fn set_page_label_ranges(doc: &mut Document, ranges: &[LabelRange]) -> Resul if sorted.windows(2).any(|pair| pair.first().zip(pair.get(1)).is_some_and(|(a, b)| a.start == b.start)) { return Err(invalid_label("range start pages must be unique")); } - labels_from_ranges(page_count(doc)?, &sorted, false)?; + check_spans(&sorted, page_count(doc)?)?; let mut nums = Vec::new(); for r in &sorted { let mut spec = Dict::new(); @@ -366,12 +440,77 @@ pub fn number_pages(doc: &mut Document, from: usize, to: usize, style: LabelStyl mod tests { use super::*; + /// No limit on the whole sequence: 4,097 labels of 1,024 bytes (over 4 MiB in total) are all + /// readable, one at a time or by page. #[test] - fn page_label_limits_bound_organizer_total_output() { + fn label_view_has_no_aggregate_limit() { let range = LabelRange { start: 0, style: LabelStyle::None, prefix: "x".repeat(MAX_LABEL_BYTES), first: u32::MAX }; - let count = MAX_LABEL_TOTAL_BYTES / MAX_LABEL_BYTES; - assert_eq!(labels_from_ranges(count, std::slice::from_ref(&range), false).unwrap().len(), count); - assert!(labels_from_ranges(count + 1, &[range], false).unwrap_err().to_string().contains("4 MiB")); + let view = PageLabels { ranges: vec![range], pages: 4_097 }; + assert_eq!(view.iter().map(|label| label.len()).sum::(), 4_097 * MAX_LABEL_BYTES); + assert_eq!(view.label(4_096).unwrap(), "x".repeat(MAX_LABEL_BYTES)); + } + + /// The view keeps the ranges, not one label per page: a billion-page document costs no more to + /// view than a short one, and a late label is built without the labels before it. + #[test] + fn label_view_does_not_grow_with_the_page_count() { + let prefix = "x".repeat(1000); + let range = LabelRange { start: 0, style: LabelStyle::Decimal, prefix: prefix.clone(), first: 1 }; + let view = PageLabels { ranges: vec![range], pages: 1_000_000_000 }; + assert_eq!(view.label(999_999_999).unwrap(), format!("{prefix}1000000000")); + assert_eq!(view.iter().take(2).collect::>(), [format!("{prefix}1"), format!("{prefix}2")]); + assert_eq!(view.label(1_000_000_000), Err(OrganizeError::NoSuchPage(1_000_000_000))); + } + + /// The numeral length formula and the bounded Roman search agree with the formatter. + #[test] + fn numeral_bounds_match_the_formatters() { + for n in (1..=3_000u64).chain([9_999, 10_000, 999_999, 1_000_000, 1_012_888, 1_013_888, u64::from(u32::MAX)]) { + match roman(n, MAX_LABEL_BYTES) { + Some(numeral) => assert_eq!(roman_len(n), numeral.len() as u64, "{n}"), + None => assert!(roman_len(n) > MAX_LABEL_BYTES as u64, "{n}"), + } + } + assert_eq!(longest_numeral(LabelStyle::UpperRoman, 888, 888), 12); + assert_eq!(longest_numeral(LabelStyle::Decimal, 1, 1_000), 4); + assert_eq!(longest_numeral(LabelStyle::LowerAlpha, 1, 27), 2); + let mut seed = 0x2545_f491_4f6c_dd1du64; + for _ in 0..400 { + seed = seed.wrapping_mul(6_364_136_223_846_793_005).wrapping_add(1_442_695_040_888_963_407); + let lo = 1 + (seed >> 33) % 3_000_000; + let hi = lo + (seed >> 40) % 2_500; + assert_eq!(longest_roman(lo, hi), (lo..=hi).map(roman_len).max().unwrap_or_default(), "{lo}..={hi}"); + } + } + + /// The edit check accepts exactly the ranges whose every label the per-page check accepts, + /// including spans that cross a thousand, numbers past 32 bits and long prefixes. + #[test] + fn span_check_agrees_with_per_label_checks() { + let styles = [LabelStyle::None, LabelStyle::Decimal, LabelStyle::UpperRoman, LabelStyle::LowerAlpha]; + let firsts = [1, 3, 990, 999, 1_012_880, 1_013_990, 1_014_000, u32::MAX - 30, u32::MAX]; + let prefixes = [0, 500, 1000, 1010, 1020, 1024]; + let mut seed = 0x9e37_79b9_7f4a_7c15u64; + let mut next = |bound: usize| { + seed = seed.wrapping_mul(6_364_136_223_846_793_005).wrapping_add(1_442_695_040_888_963_407); + usize::try_from((seed >> 33) % bound as u64).unwrap_or_default() + }; + for _ in 0..3_000 { + let pages = next(60); + let count = 1 + next(3); + let mut starts: Vec = (0..count).map(|_| next(pages + 3)).collect(); + starts.sort_unstable(); + starts.dedup(); + let mut ranges = Vec::new(); + for start in starts { + let style = styles[next(styles.len())]; + let prefix = "p".repeat(prefixes[next(prefixes.len())]); + let first = firsts[next(firsts.len())]; + ranges.push(LabelRange { start, style, prefix, first }); + } + let per_label = (0..pages).all(|p| ranges.iter().rev().find(|r| r.start <= p).is_none_or(|r| r.label(p).is_ok())); + assert_eq!(check_spans(&ranges, pages).is_ok(), per_label, "{ranges:?} over {pages} pages"); + } } #[test] diff --git a/crates/organize/src/lib.rs b/crates/organize/src/lib.rs index fc383faf1..6d40fed12 100644 --- a/crates/organize/src/lib.rs +++ b/crates/organize/src/lib.rs @@ -25,7 +25,7 @@ pub mod view; pub use boxes::{BoxSpec, PageBox, page_boxes, set_page_box}; pub use dedupe::dedupe_resources; pub use import::{SplitBy, combine, combine_selected, extract_pages, import_pages, page_as_form, split, split_ranges}; -pub use labels::{LabelRange, LabelStyle, number_pages, page_label_ranges, page_labels, set_page_label_ranges}; +pub use labels::{LabelRange, LabelStyle, PageLabels, number_pages, page_label_ranges, page_labels, set_page_label_ranges}; pub use outline::{ Bookmark, OutlineEntry, OutlineError, add_bookmark, add_bookmark_tree, bookmarks, delete_bookmark, move_bookmark, rename_bookmark, set_bookmark_open, set_bookmark_page, diff --git a/crates/organize/src/tests.rs b/crates/organize/src/tests.rs index 55815bd14..ab93f9353 100644 --- a/crates/organize/src/tests.rs +++ b/crates/organize/src/tests.rs @@ -1154,6 +1154,53 @@ fn page_label_too_long_range_falls_back_alone() { assert_eq!(labels[1], "2"); } +/// A document whose page tree is one flat node over `pages` leaves. The label tests below need +/// more pages than `doc_a` has. +fn flat_doc(pages: usize) -> Document { + let kids = (0..pages).map(|i| format!("{} 0 R", i + 3)).collect::>().join(" "); + let mut objs = vec!["<< /Type /Catalog /Pages 2 0 R >>".to_string(), format!("<< /Type /Pages /Kids [{kids}] /Count {pages} >>")]; + objs.extend((0..pages).map(|_| "<< /Type /Page /Parent 2 0 R >>".to_string())); + let mut out = b"%PDF-1.7\n".to_vec(); + let mut offsets = Vec::new(); + for (i, body) in objs.iter().enumerate() { + offsets.push(out.len()); + out.extend_from_slice(format!("{} 0 obj\n{body}\nendobj\n", i + 1).as_bytes()); + } + let xref = out.len(); + out.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objs.len() + 1).as_bytes()); + for offset in offsets { + out.extend_from_slice(format!("{offset:010} 00000 n \n").as_bytes()); + } + out.extend_from_slice(format!("trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n", objs.len() + 1).as_bytes()); + open(out) +} + +/// Each label is valid (1,004 bytes), but the 5,000 of them total about 5 MB, past the old 4 MiB +/// limit on the whole sequence. Every label is readable. +#[test] +fn page_labels_past_four_mib_in_total_stay_readable() { + let mut d = flat_doc(5_000); + let mut spec = Dict::new(); + spec.set(b"P".to_vec(), Object::String(PdfString::text(&"x".repeat(1000)))); + spec.set(b"S".to_vec(), Object::name("D")); + let mut tree = Dict::new(); + tree.set(b"Nums".to_vec(), Object::Array(vec![Object::Int(0), Object::Dict(spec)])); + let tree = d.add(Object::Dict(tree)); + d.update_dict(d.root().unwrap(), |c| c.set(b"PageLabels".to_vec(), Object::Ref(tree))).unwrap(); + let labels = crate::page_labels(&d).unwrap(); + assert_eq!(labels.len(), 5_000); + assert_eq!(labels[0], format!("{}1", "x".repeat(1000))); + assert_eq!(labels[4_999], format!("{}5000", "x".repeat(1000))); +} + +/// The same 5,000 labels written by an edit: the edit is accepted and reads back. +#[test] +fn number_pages_past_four_mib_in_total_is_accepted() { + let mut d = flat_doc(5_000); + crate::number_pages(&mut d, 0, 4_999, crate::LabelStyle::Decimal, &"x".repeat(1000), 1).unwrap(); + assert_eq!(crate::page_labels(&d).unwrap()[4_999], format!("{}5000", "x".repeat(1000))); +} + #[test] fn page_boxes_default_inherit_and_set() { let mut doc = open(fixture()); From 559859ca61b0dbae0b23d31a7ff76410854f404d Mon Sep 17 00:00:00 2001 From: Ikki Nishihara Date: Sun, 11 Oct 2026 04:51:25 +0900 Subject: [PATCH 42/69] lopdf: bound object lookups through compressed containers and object-stream members (#771) A compressed object's container was resolved with a fresh cycle set and without checking that it is an ordinary object, and indirect /Length chains had no depth limit, so self-containing or chained lookups overflowed the stack. Pass the caller's set on, require an in-file container and cap the chain at 16 lookups. Object-stream members were each parsed to the end of the decoded stream, so repeated or nested offsets kept many copies of one large object: parse each member within its own extent and skip repeated offsets. Co-authored-by: Claude Sonnet 5.5 --- crates/render/src/inspect.rs | 198 ++++++++++++++++++++++++++++++ vendor/README.md | 2 +- vendor/lopdf/src/object_stream.rs | 44 +++++-- vendor/lopdf/src/reader.rs | 27 +++- 4 files changed, 259 insertions(+), 12 deletions(-) diff --git a/crates/render/src/inspect.rs b/crates/render/src/inspect.rs index e106b8a4a..2e066b81c 100644 --- a/crates/render/src/inspect.rs +++ b/crates/render/src/inspect.rs @@ -1334,6 +1334,204 @@ mod tests { } } + // Xref-stream fixtures exercise indirect /Length lookup during document loading: stream 2's /Length is `3 0 R`, + // `containers` lists compressed (id, container, index) entries and `objects` the other objects in the file. + fn compressed_length_pdf(containers: &[(u32, u32, u16)], objects: &[(u32, &[u8])]) -> Vec { + let mut pdf = b"%PDF-1.7\n".to_vec(); + let ids = containers.iter().flat_map(|&(id, container, _)| [id, container]).chain(objects.iter().map(|&(id, _)| id)); + let xref_id = ids.max().unwrap_or(3) + 1; + let mut records = vec![[0u8; 7]; (xref_id + 1) as usize]; + records[0] = [0, 0, 0, 0, 0, 255, 255]; + let mut append = |id: u32, body: &[u8]| { + records[id as usize][0] = 1; + records[id as usize][1..5].copy_from_slice(&(pdf.len() as u32).to_be_bytes()); + pdf.extend_from_slice(format!("{id} 0 obj\n").as_bytes()); + pdf.extend_from_slice(body); + pdf.extend_from_slice(b"\nendobj\n"); + }; + append(1, b"<< /Type /Catalog >>"); + append(2, b"<< /Length 3 0 R >>\nstream\nhello\nendstream"); + for &(id, body) in objects { + append(id, body); + } + for &(id, container, index) in containers { + records[id as usize][0] = 2; + records[id as usize][1..5].copy_from_slice(&container.to_be_bytes()); + records[id as usize][5..7].copy_from_slice(&index.to_be_bytes()); + } + let xref_offset = pdf.len(); + records[xref_id as usize][0] = 1; + records[xref_id as usize][1..5].copy_from_slice(&(xref_offset as u32).to_be_bytes()); + pdf.extend_from_slice( + format!("{xref_id} 0 obj\n<< /Type /XRef /Size {} /Root 1 0 R /W [1 4 2] /Length {} >>\nstream\n", records.len(), records.len() * 7) + .as_bytes(), + ); + for record in records { + pdf.extend_from_slice(&record); + } + pdf.extend_from_slice(format!("\nendstream\nendobj\nstartxref\n{xref_offset}\n%%EOF\n").as_bytes()); + pdf + } + + // Stream 2's /Length starts a chain of `hops` indirect /Length lookups, each naming a new object; only the last + // link's /Length is direct. Compressed: link k is the integer 3 + 2k (the length it measures) alone in the object + // stream 4 + 2k, whose own /Length is link k + 1. Plain: link k is the stream 3 + k, whose /Length is link k + 1. + fn length_chain_pdf(hops: u32, compressed: bool) -> Vec { + let length = |hop: u32, next: u32, data: &str| if hop + 1 < hops { format!("{next} 0 R") } else { data.len().to_string() }; + let (mut containers, mut objects) = (Vec::new(), Vec::new()); + let mut value = 5; // the length of stream 2's data, "hello" + for hop in 0..hops { + if compressed { + let (id, container) = (3 + 2 * hop, 4 + 2 * hop); + let index = format!("{id} 0 "); + let data = format!("{index}{value}"); + let dict = format!("/Type /ObjStm /N 1 /First {} /Length {}", index.len(), length(hop, id + 2, &data)); + objects.push((container, format!("<< {dict} >>\nstream\n{data}\nendstream"))); + containers.push((id, container, 0)); + value = data.len(); + } else { + objects.push((3 + hop, format!("<< /Length {} >>\nstream\n\nendstream", length(hop, 4 + hop, "")))); + } + } + let objects: Vec<_> = objects.iter().map(|(id, body)| (*id, body.as_bytes())).collect(); + compressed_length_pdf(&containers, &objects) + } + + // Lenient loading keeps a document whose stream 2 has an unresolvable /Length: the catalog, and stream 2's + // dictionary without its data. + fn load_without_stream_2_data(pdf: &[u8]) -> Document { + let doc = Document::load_mem(pdf).unwrap(); + assert!(doc.catalog().unwrap().has_type(b"Catalog")); + assert!(doc.get_object((2, 0)).unwrap().as_stream().unwrap().content.is_empty()); + doc + } + + #[test] + fn compressed_object_self_container_does_not_recurse() { + let doc = load_without_stream_2_data(&compressed_length_pdf(&[(3, 3, 0)], &[])); + assert!(doc.get_object((3, 0)).is_err()); + } + + #[test] + fn compressed_object_mutual_containers_do_not_recurse() { + let doc = load_without_stream_2_data(&compressed_length_pdf(&[(3, 4, 0), (4, 3, 0)], &[])); + assert!(doc.get_object((3, 0)).is_err()); + assert!(doc.get_object((4, 0)).is_err()); + } + + #[test] + fn object_stream_length_cycle_does_not_recurse() { + let pdf = compressed_length_pdf(&[(3, 4, 0)], &[(4, b"<< /Type /ObjStm /N 1 /First 4 /Length 3 0 R >>\nstream\n3 0 5\nendstream")]); + let doc = load_without_stream_2_data(&pdf); + // The container's /Length is the object it holds: the container is kept without data, so 3 never loads. + assert!(doc.get_object((4, 0)).unwrap().as_stream().unwrap().content.is_empty()); + assert!(doc.get_object((3, 0)).is_err()); + } + + #[test] + fn compressed_object_container_chain_does_not_recurse() { + let containers: Vec<_> = (3..100_003).map(|id| (id, id + 1, 0)).collect(); + let pdf = compressed_length_pdf(&containers, &[(100_003, b"<< /Type /ObjStm /N 1 /First 4 /Length 5 >>\nstream\n3 0 5\nendstream")]); + let doc = load_without_stream_2_data(&pdf); + // The object stream in the file loads, but the xref puts 3 in the compressed container 4, which is refused. + assert_eq!(doc.get_object((100_003, 0)).unwrap().as_stream().unwrap().content, b"3 0 5"); + assert!(doc.get_object((3, 0)).is_err()); + } + + #[test] + fn compressed_objects_and_indirect_stream_length_still_load() { + let pdf = compressed_length_pdf( + &[(3, 4, 0), (5, 4, 1)], + &[(4, b"<< /Type /ObjStm /N 2 /First 8 /Length 17 >>\nstream\n3 0 5 2 5 (value)\nendstream")], + ); + let doc = Document::load_mem_with_options(&pdf, LoadOptions { strict: true, ..Default::default() }).unwrap(); + assert_eq!(doc.get_object((3, 0)).unwrap().as_i64().unwrap(), 5); + assert_eq!(doc.get_object((5, 0)).unwrap().as_str().unwrap(), b"value"); + assert_eq!(doc.get_object((2, 0)).unwrap().as_stream().unwrap().content, b"hello"); + } + + #[test] + fn compressed_length_in_a_container_with_an_indirect_length_still_loads() { + // Two hops, as in a valid file: stream 2's /Length (3) is compressed in container 4, whose own /Length (5) is + // a plain indirect integer. + let pdf = + compressed_length_pdf(&[(3, 4, 0)], &[(4, b"<< /Type /ObjStm /N 1 /First 4 /Length 5 0 R >>\nstream\n3 0 5\nendstream"), (5, b"5")]); + let doc = Document::load_mem_with_options(&pdf, LoadOptions { strict: true, ..Default::default() }).unwrap(); + assert_eq!(doc.get_object((3, 0)).unwrap().as_i64().unwrap(), 5); + assert_eq!(doc.get_object((5, 0)).unwrap().as_i64().unwrap(), 5); + assert_eq!(doc.get_object((2, 0)).unwrap().as_stream().unwrap().content, b"hello"); + } + + #[test] + fn compressed_length_chain_does_not_recurse() { + // Every container is an ordinary object in the file and every hop names a new object, so neither the + // container check nor the cycle history stops the chain; each hop recursed once more (a 512 KiB stack + // overflowed within 150 hops). Lookups past the cap fail: the first link never loads, while the last one, + // next to the direct /Length, does. + let hops = 5_000; + let doc = load_without_stream_2_data(&length_chain_pdf(hops, true)); + assert!(doc.get_object((3, 0)).is_err()); + assert!(doc.get_object((3 + 2 * (hops - 1), 0)).unwrap().as_i64().is_ok()); + // The cap itself: loading link j reads its container's /Length, a chain of 2 * (hops - 1 - j) lookups, and + // 16 are allowed, so the 9th link from the end loads and the 10th does not. + assert!(doc.get_object((3 + 2 * (hops - 9), 0)).is_ok()); + assert!(doc.get_object((3 + 2 * (hops - 10), 0)).is_err()); + } + + #[test] + fn plain_stream_length_chain_does_not_recurse() { + // The same recursion without object streams: each stream's /Length names the next stream (a 512 KiB stack + // overflowed within 200 hops). A stream is no length, so every link is kept as a stream without data. + let hops = 5_000; + let doc = load_without_stream_2_data(&length_chain_pdf(hops, false)); + assert!((3..3 + hops).all(|id| doc.get_object((id, 0)).is_ok_and(|link| link.as_stream().is_ok_and(|s| s.content.is_empty())))); + } + + fn indexed_object_stream(pairs: &[(u32, usize)], members: &[u8]) -> lopdf::Stream { + let mut content = Vec::new(); + for &(id, offset) in pairs { + content.extend_from_slice(format!("{id} {offset} ").as_bytes()); + } + let mut dict = Dictionary::new(); + dict.set("Type", "ObjStm"); + dict.set("N", pairs.len() as i64); + dict.set("First", content.len() as i64); + content.extend_from_slice(members); + lopdf::Stream::new(dict, content) + } + + #[test] + fn object_stream_member_extents_bound_owned_strings() { + let mut members = vec![b'('; 32]; + members.extend(vec![b'x'; 1 << 20]); + members.extend([b')'; 32]); + // Put the innermost (complete) string first in index order, then overlapping starts + // and duplicates. Only this first member at offset 31 may own the large string. + let mut pairs = vec![(100, 31)]; + pairs.extend((0..31).map(|offset| (offset as u32 + 1, offset))); + pairs.extend([(200, 31), (201, 31)]); + let stream = indexed_object_stream(&pairs, &members); + let parsed = lopdf::ObjectStream::new_with_limit(&stream, Some(2 << 20)).unwrap(); + assert_eq!(parsed.objects.get(&(100, 0)).unwrap().as_str().unwrap().len(), 1 << 20); + let total: usize = parsed.objects.values().filter_map(|object| object.as_str().ok()).map(<[u8]>::len).sum(); + assert!(total <= stream.content.len(), "owned {total} string bytes from {} decoded bytes", stream.content.len()); + assert!(!parsed.objects.contains_key(&(200, 0))); + assert!(!parsed.objects.contains_key(&(201, 0))); + } + + #[test] + fn object_stream_member_extents_preserve_index_order_and_whitespace() { + let members = b" \n42\n /Name \n[true 7]\n << /Key (value) >>\n (last)"; + // Unsorted offsets, and object 9 appears twice: its last index entry still wins. + let pairs = [(9, 0), (12, 21), (10, 5), (11, 12), (9, 41)]; + let parsed = lopdf::ObjectStream::new(&indexed_object_stream(&pairs, members)).unwrap(); + assert_eq!(parsed.objects.len(), 4); + assert_eq!(parsed.objects.get(&(9, 0)).unwrap().as_str().unwrap(), b"last"); + assert_eq!(parsed.objects.get(&(10, 0)).unwrap().as_name().unwrap(), b"Name"); + assert_eq!(parsed.objects.get(&(11, 0)).unwrap().as_array().unwrap(), &[Object::Boolean(true), Object::Integer(7)]); + assert_eq!(parsed.objects.get(&(12, 0)).unwrap().as_dict().unwrap().get(b"Key").unwrap().as_str().unwrap(), b"value"); + } + #[test] fn view_and_user_space_round_trip_for_every_rotation() { for rotation in [0u16, 90, 180, 270] { diff --git a/vendor/README.md b/vendor/README.md index 429d68dc3..cb8e6931a 100644 --- a/vendor/README.md +++ b/vendor/README.md @@ -11,7 +11,7 @@ the fix. Vendoring copyleft code is never allowed (plan/adr/0001). | hayro | 0.7.1 | Apache-2.0 OR MIT | (1) `RenderSettings::{x_offset, y_offset}` to render one tile of a large page; (2) images over `MAX_IMAGE_PIXELS` (2^28) are skipped (a fuzzed `/W 4294967295` hung in resampling); (3) `tiling_cell_scale` keeps a tiling pattern's cell pixmap within 3000 px a side whatever its `/XStep`/`/YStep` (a fuzzed step far beyond `/BBox` allocated 2 GB); (4) stroke widths clamped to 4 × (canvas width + height) device pixels (a fuzzed `/LW 9223372036854775807` allocated 10 GB in stroke expansion); (5) `usable_dash_pattern`: a stroke whose dash pattern could cut it into more than `MAX_DASHES_PER_STROKE` (250,000) pieces, counting kurbo's restart of the pattern at every subpath and `ClosePath`, is drawn solid (a fuzzed `/D [[11] 0]` on a line from x = 92234775807 allocated over 5 GB, `[0.000001] 0 d` on a 20 pt line over 10 GB); (6) a dash pattern with a negative or non-finite entry, or a period that isn't positive, is drawn solid (`[-1 -1] 0 d` looped for ever in kurbo); (7) `render_size` and `render_into`: render into a buffer the caller allocates (PdfCraft renders into pixel-aligned memory that the GUI takes over as texture data); `render` is `render_into` on a new pixmap; (8) checked image source/target/RGBA, alpha-premultiplication scratch and intermediate sizes before resampling, finite transforms and exact source/mask buffers; sources wider than u16 may shrink before backend limits, with a 2^20 source-axis cap and conservative 128 MiB filter-planning estimate; an over-limit or failed resize keeps the original image resolution and geometry when the source fits the backend, otherwise skips it, with a logged warning; staging, resampling output/scratch, RGBA conversion and padding buffers use fallible reserves; (9) `may_paint_canvas`: a fill or stroke whose control-point box, grown by how far its paint reaches past the path (half the stroke width times the larger of the miter limit and √2) plus 0.5 user units and 2 pixels, misses the canvas is not drawn, so a tile no longer strokes and fills every path of its page (item 5 of #623); `set_skip_offscreen_paths` turns this off for the regression test | (1) `raster::tests::tiles_match_full_render`; (2) `raster::tests::absurd_image_dimensions_are_skipped`; (3) `raster::tests::tiling_cells_stay_small_whatever_the_step`; (4) `raster::tests::absurd_predictor_columns_and_line_widths_render`; (5) `raster::tests::dash_patterns_that_cut_a_stroke_into_billions_of_pieces_render`; (6) `raster::tests::invalid_dash_patterns_terminate_and_are_drawn_solid`; (7) `raster::tests::pixels_are_a_plain_renders_bytes_in_a_buffer_a_gui_can_take_over`; (8) `raster::tests::image_resampling_rejects_anisotropic_target_growth`, `raster::tests::image_resampling_bounds_intermediate_before_planning`, `raster::tests::image_resampling_rejects_invalid_sources_and_scales`, `raster::tests::image_resampling_keeps_valid_sizes_and_exact_limits`, `raster::tests::resampling_fallback_preserves_original_geometry_and_pixels`, `raster::tests::image_resampling_wide_sources_shrink_before_backend_side_limits`, `raster::tests::image_resampling_wide_transparent_sources_still_shrink`, `raster::tests::image_resampling_padded_backend_dimensions_stay_checked`, `raster::tests::image_resampling_mismatched_alpha_mask_keeps_pixels_and_placement`; (9) `raster::tests::skipping_paths_off_the_canvas_changes_no_pixel` | | hayro-jbig2 | 0.3.0 | Apache-2.0 OR MIT | (1) at most 2^28 pixels per bitmap, page bitmap, halftone grid and grayscale image (each side could be 65535: 4.3 billion pixels decoded one by one); (2) at most 2^26 symbol pixels per symbol dictionary (a fuzzed dictionary decoded for minutes); (3) a symbol dictionary's height classes are capped at one per new symbol and its export runs at `2 × symbols + 1`, each plus `EXTRA_EMPTY_STEPS` (1024): an empty height class or a zero-length export run doesn't advance, and past the end of its data the arithmetic decoder can decode them for ever (one changed byte hung the renderer) | (1) `raster::tests::jbig2_regions_of_billions_of_pixels_are_refused`; (2) the nightly `cargo xtask fuzz` finding, which now finishes in about a second; (3) `raster::tests::jbig2_symbol_dictionaries_that_never_advance_terminate` | -| lopdf | 0.45.0 | MIT | (1) PNG predictor rows longer than the data are refused before their buffers are allocated (a fuzzed `/Columns 4294967295` allocated two 4 GiB rows), and empty frames return without row buffers; (2) predictor row widths use saturating arithmetic or reject overflow; (3) sub-byte TIFF Predictor 2 scratch is bounded by the samples present in the input instead of the declared `/Colors`, aligned with [lopdf#589](https://github.com/J-F-Liu/lopdf/pull/589) | (1) `inspect::tests::png_predictor_rows_longer_than_the_data_are_refused_before_allocating`, `inspect::tests::empty_png_predictor_frames_do_not_allocate_rows`; (2) `inspect::tests::tiff_subbyte_predictor_row_width_overflow_is_refused`; (3) `inspect::tests::tiff_subbyte_predictor_scratch_is_bounded_by_available_samples`, `inspect::tests::tiff_subbyte_predictors_keep_components_rows_and_padding` | +| lopdf | 0.45.0 | MIT | (1) PNG predictor rows longer than the data are refused before their buffers are allocated (a fuzzed `/Columns 4294967295` allocated two 4 GiB rows), and empty frames return without row buffers; (2) predictor row widths use saturating arithmetic or reject overflow; (3) sub-byte TIFF Predictor 2 scratch is bounded by the samples present in the input instead of the declared `/Colors`, aligned with [lopdf#589](https://github.com/J-F-Liu/lopdf/pull/589); (4) the object lookup keeps its cycle history through compressed objects, requires an ordinary in-file container, and caps the chain of indirect lookups at 16 (a chain of new objects, each a `/Length` compressed in a container whose own `/Length` names the next, or a stream whose `/Length` names the next stream, overflowed the stack: a dev build's 2 MiB thread within 800 hops); (5) object-stream members are parsed within their own extent (up to the next distinct offset), an entry whose offset repeats an earlier entry's is skipped (so a repeated object number can resolve to its earlier entry), and offsets are processed in index order | (1) `inspect::tests::png_predictor_rows_longer_than_the_data_are_refused_before_allocating`, `inspect::tests::empty_png_predictor_frames_do_not_allocate_rows`; (2) `inspect::tests::tiff_subbyte_predictor_row_width_overflow_is_refused`; (3) `inspect::tests::tiff_subbyte_predictor_scratch_is_bounded_by_available_samples`, `inspect::tests::tiff_subbyte_predictors_keep_components_rows_and_padding`; (4) `inspect::tests::compressed_object_self_container_does_not_recurse`, `inspect::tests::compressed_object_mutual_containers_do_not_recurse`, `inspect::tests::object_stream_length_cycle_does_not_recurse`, `inspect::tests::compressed_object_container_chain_does_not_recurse`, `inspect::tests::compressed_length_chain_does_not_recurse`, `inspect::tests::plain_stream_length_chain_does_not_recurse`, `inspect::tests::compressed_objects_and_indirect_stream_length_still_load`, `inspect::tests::compressed_length_in_a_container_with_an_indirect_length_still_loads`; (5) `inspect::tests::object_stream_member_extents_bound_owned_strings`, `inspect::tests::object_stream_member_extents_preserve_index_order_and_whitespace` | | hayro-syntax | 0.7.2 | Apache-2.0 OR MIT | (1) Page-tree cycle guard in `resolve_pages` (visited set + depth cap): a `/Kids` loop among compressed objects overflowed the stack and aborted the process; (2) `ccitt_size_ok`: CCITT images wider than 2^20 columns or over 2^28 pixels are not decoded (a fuzzed `/Columns 4294967295` allocated 4 GiB); (3) Flate/LZW predictor row widths are saturating `usize`, a zero width is refused and a row longer than the data is not allocated (a fuzzed `/Columns 9223372036854775807` wrapped to a 2^61-byte allocation and aborted); (4) the inline-image end-of-data check (`EI`) doesn't nest inside its own look-ahead and examines at most 256 candidates and steps (50 images with "EI" in their data ran for over ten minutes); (5) a stream whose `/Length` is wrong is recovered only when `stream` is the next token — scanning ahead to a later object's `stream` made an indirect appearance-state dictionary (Quartz checkboxes: `/AP /N` → `<< /Yes … /Off … >>`) render as a non-form, so the widget was skipped; (6) JPX rescaling uses an exact integer sample range for 1–32-bit components (32 shifted past u32, and floating-point maxima at 25–31 bits wrapped to zero), and refuses zero-sized rows; (7) `decode_limit`: Flate (through flate2 and the fallback inflater), LZW and RunLength never write past `MAX_DECODED_STREAM` (256 MiB), or, for the last filter of an image of known size, past twice its pixel bytes (a predictor tag byte per byte), at most 1 GiB; `Stream::decoded_within` for budgeted callers; a page's `/Contents` array shares one `MAX_DECODED_STREAM` (a 2 MB page whose content stream or 8 × 8 image inflated to 2 GB took the renderer past 3 GB in under a second; naming the stream twenty times in `/Contents`, 5 GB); (8) high-precision numbers (jsPDF MediaBox coordinates with over 19 digits) parse without overflowing the u64 mantissa: fractional digits past it are dropped, integer digits past it scale the value (a real, kept finite); (9) large dictionary indexes are built on first use and shared across repeated reads within each PDF (#307); the validated-byte cache is bounded, reader contexts remain separate, repairs and inline/content dictionaries bypass sharing, and small dictionaries stay eager; (10) decoded object-stream slots publish once under contention, cold loading rejects recursive dependencies, and xref repair serializes without holding recursive read guards; this permits one parser per native render pool with private worker caches; (11) `sync.rs`: a poisoned lock is recovered instead of unwrapped (a worker panic no longer cascades into every reader of the same PDF), and xref repair takes a blocking write lock instead of asserting a non-blocking one; (12) `SegmentList` (`util.rs`) returns `None` for an out-of-range index instead of panicking; (13) a page's `/UserUnit` scales `render_dimensions` and `initial_transform`, so the page and everything on it are sized in points, as Acrobat shows them; it is read as Acrobat reads it (the page's own entry, not inherited; a number from 1 up, at most 75,000; anything else counts as 1), and a page without it keeps exactly the transform it had | (1) `cos/tests/writer.rs::page_tree_loops_in_object_streams_do_not_crash_readers`; (2) `raster::tests::absurd_mask_and_fax_sizes_are_refused_before_decoding`; (3) `raster::tests::absurd_predictor_columns_and_line_widths_render`; (4) `raster::tests::inline_images_with_ei_in_their_data_parse_in_linear_time`; (5) `raster::tests::indirect_appearance_state_dict_is_not_another_stream`; (6) `raster::tests::jpx_stream_bit_depth_boundaries_do_not_panic`, `raster::tests::jpx_32_bit_image_does_not_prevent_other_page_content_from_rendering`, `raster::tests::jpx_gray_rgb_and_alpha_keep_eight_bit_samples`, `raster::tests::jpx_streams_reject_out_of_range_bit_depths`; (7) `raster::tests::decompression_bombs_stop_at_the_stream_limit`, `raster::tests::page_contents_and_nested_forms_share_one_budget`; (8) `inspect::tests::jspdf_high_precision_mediabox_does_not_overflow_dimensions`, `object::number::tests::high_precision_float_does_not_overflow_mantissa` (hayro-syntax); (9) `object::dict::lazy_index_tests`, `raster::tests::large_resource_indexes_preserve_pixels_and_text`; (10) `data::tests`, `xref::tests::concurrent_cold_object_stream_reads_all_succeed`, `raster::tests::shared_parser_is_lazy_and_initializes_once_under_contention`; (11) `data::tests::panicking_initializer_wakes_waiters_and_prevents_retries`, `xref::tests::concurrent_cold_object_stream_reads_all_succeed`; (12) `util::tests::segment_list_rejects_out_of_range_indices`; (13) `raster::tests::user_unit_scales_the_page_and_everything_on_it` | | winit | 0.30.13 | Apache-2.0 | (1) on Windows 11 (build 22000 and later), `WM_DPICHANGED` applies the rect Windows suggests. It used to nudge the window onto the monitor `MonitorFromWindow` names, which during a drag is still the monitor being left; the nudge sent another `WM_DPICHANGED`, so a window dragged to a monitor with another scale factor grew and stayed on the wrong monitor at the wrong scale (#324). Backport of winit master 488c036a ([winit#4341](https://github.com/rust-windowing/winit/pull/4341)), which no 0.30 release has; Windows 10 keeps the old adjustment, as on winit master. (2) on Wayland, a seat's text input is destroyed when the seat goes away, not whenever it loses a capability: losing any capability (a touch screen or tablet unplugged, a remote-desktop session ending) destroyed it, and only an added capability recreated it, so IME stopped working until restart. Port of [winit#4747](https://github.com/rust-windowing/winit/pull/4747); (3) on Windows, an IME composition reports its cursor from `GCS_CURSORPOS` only when `WM_IME_COMPOSITION` says it is set, and at the end of the composition otherwise: the Microsoft Korean IME never sets it, so the cursor read as 0 (egui then drew no caret while composing; it now draws it after the syllable). Port of [winit#4746](https://github.com/rust-windowing/winit/pull/4746); (4) on Windows, a hidden window is not maximized until it is shown, and a hidden window shown while maximized gets one `ShowWindow(SW_MAXIMIZE)`: `SW_MAXIMIZE` shows a hidden window, so PdfCraft, which eframe keeps hidden until its first frame is drawn, appeared unpainted (white) when it restored a maximized window, and could lose its focus. Port of [winit#4587](https://github.com/rust-windowing/winit/pull/4587) (the later, focus-safe form of [winit#4575](https://github.com/rust-windowing/winit/pull/4575)); (5) on X11, when allowing or disallowing IME replaces the focused window's input context (egui does it each time a text field gains or loses focus), the new context is focused: no focus event follows, so it stayed unfocused until the window lost and regained focus. Port of [winit#4727](https://github.com/rust-windowing/winit/pull/4727); (6) on macOS, `insertText` commits through IME when a composition was started (`pending_commit`, set by `setMarkedText`) rather than when marked text is left: input methods that end a composition with `unmarkText` before `insertText` lost the text, and Apple Korean dropped the key typed after a syllable (`한` then `5` or `;`); other `insertText` calls stay `KeyboardInput`; a commit outside a key press (a candidate clicked with the mouse, dictation) clears the marked text and leaves no key to swallow, as in [winit#4748](https://github.com/rust-windowing/winit/pull/4748) (without it the next key, such as Backspace, was lost). `validAttributesForMarkedText` lists the underline and marked-clause attributes, without which some input methods don't compose. Port of [winit#4650](https://github.com/rust-windowing/winit/pull/4650), the 0.30 backport of [winit#4651](https://github.com/rust-windowing/winit/pull/4651) (merged on winit master), with the underline attribute spelled as AppKit's `NSUnderlineStyleAttributeName` (`NSUnderline`); (7) on macOS, disallowing IME with a composition in progress (focus leaving a text field) tells the input method to discard its marked text (`discardMarkedText`): it kept composing it once IME was allowed again, so the abandoned syllable came back (`하`, then `가` typed in the next field, became `학`, then `하` + `가`; Japanese `か` then `さ` became `傘`). Port of [winit#4745](https://github.com/rust-windowing/winit/pull/4745); (8) on macOS, text inserted outside a key press without a composition (the emoji picker) is reported as `Ime::Commit`; it was dropped. Port of the emoji-picker part of [winit#4749](https://github.com/rust-windowing/winit/pull/4749) (0.30 backport of [winit#4748](https://github.com/rust-windowing/winit/pull/4748)); its `selectedRange` change for dictation is left out because it switches on press-and-hold for every held key (an accent menu instead of key repeat, and the chosen accent added after the repeated letters); (9) on macOS, a key whose text was committed through IME isn't also sent as `KeyboardInput` unless AppKit forwards it as a command (Enter): Apple Korean answers Space with no syllable in progress with `setMarkedText(" ")`, `insertText(" ")`, `setMarkedText("")`, and the last call reset winit's state, so the space arrived twice. Same goal as [winit#4478](https://github.com/rust-windowing/winit/pull/4478), whose changes don't reach this call order (macOS 14); to be proposed upstream there; (10) on macOS, when the first key in a fresh app reaches Apple Korean's `insertText` as a raw compatibility jamo instead of starting a composition, that key is interpreted once more, synchronously, before it is sent as `KeyboardInput` (g k s typed ㅎㅏㄴ instead of 한); only an unmodified, non-repeated first key whose one callback inserts that same jamo, with IME enabled and nothing composed, focus and input source unchanged, is retried (`ime_startup.rs`). Port of [winit#4744](https://github.com/rust-windowing/winit/pull/4744), with its policy tests. The published crate's asset files (`docs/res/*.svg`, `examples/data/*.png`) are left out, so its `window` example doesn't build | (1) `pdfcraft` `tests::winit_carries_the_windows_11_monitor_scale_fix` keeps the patch in the build; the drag itself needs two monitors with different scale factors on Windows 11; (2) `pdfcraft` `tests/winit_wayland_text_input.rs`; the capability change itself needs a Wayland session and an input method; (3) `pdfcraft` `tests/winit_windows_ime_cursor.rs`; composing itself needs the Microsoft Korean IME; (4) `pdfcraft` `tests/winit_windows_hidden_maximized.rs`; the start-up itself needs Windows and a window that was closed maximized; (5) `pdfcraft` `tests/winit_x11_ime_focus.rs`; switching fields itself needs an X11 session and an input method (IBus, fcitx5); (6) `pdfcraft` `tests/winit_macos_ime_commit.rs`; composing itself needs macOS and an input method; (7) `pdfcraft` `tests/winit_macos_ime_discard.rs`; composing itself needs macOS and an input method; (8) `pdfcraft` `tests/winit_macos_emoji_picker.rs`; the picker itself needs macOS; (9) `pdfcraft` `tests/winit_macos_korean_space.rs`; composing itself needs macOS and Apple Korean; (10) `pdfcraft` `tests/winit_macos_korean_first_key.rs` and `ime_startup.rs`'s own tests (run them with `rustc --test`); typing itself needs macOS and Apple Korean | | egui-wgpu | 0.36.2 | MIT OR Apache-2.0 | (1) `winit::surface_fit`: a window's surface is configured within the device's `max_texture_dimension_2d`, both sides shrunk by one factor that egui then draws at (`pixels_per_point × render_scale`), so the whole window is drawn into the surface (on DX12 it is stretched over the window and lines up with the pointer; wgpu's GL backend copies it unscaled into a corner); the longer side becomes exactly the limit; the depth and MSAA textures follow, and a recreated surface starts again from the window's own size. egui-wgpu configured the window's size as is, and `Surface::configure` panics beyond the limit: a window restored at another monitor's scale (opening an Outlook attachment on a mixed-DPI laptop asked an 8192-pixel device for 8600 × 3423, #577) or stretched across monitors closed PdfCraft, before its window appeared when it happened at start-up ([emilk/egui#8361](https://github.com/emilk/egui/issues/8361)). The published crate leaves out egui's licence files; `LICENSE-APACHE` and `LICENSE-MIT` are from the 0.36.2 tag; (2) `winit::catch_errors`: a new window's surface is configured inside wgpu error scopes (validation, out of memory, internal), and a failure is returned as `WgpuError::ConfigureSurface` (for the root window at start-up eframe returns it as `Error::Wgpu`, which PdfCraft retries with OpenGL); nothing is kept for the window. wgpu's default handler panicked instead, and eframe configures the root surface before the app is created, so a driver that can't configure one closed PdfCraft at launch (a 2015 Intel GPU through wgpu's GL backend timed out waiting for the GPU to go idle, `GpuWaitTimeout`, #519). Unchanged, so still a panic: an error in a later reconfigure (resize, a lost surface), a device lost during the first configure (wgpu reports it only to a callback), and a window shown at zero size (first configured on resize) | (1) `pdfcraft` `tests::a_window_larger_than_the_gpu_limit_gets_a_surface_within_it`, `tests::surface_fit_never_empties_or_overflows_a_surface`, and `tests::egui_wgpu_carries_the_surface_size_fit`, which keeps the patch in the build and in the paths that size a surface; (2) `pdfcraft` `tests::a_gpu_error_while_a_window_is_set_up_is_returned_not_a_panic` (a real device: an error inside `catch_errors` is returned, not a panic) and `tests::egui_wgpu_returns_a_surface_it_cannot_configure_as_an_error`, which keeps it around `add_surface`'s configure | diff --git a/vendor/lopdf/src/object_stream.rs b/vendor/lopdf/src/object_stream.rs index 8b4c423a9..5a921b4ba 100644 --- a/vendor/lopdf/src/object_stream.rs +++ b/vendor/lopdf/src/object_stream.rs @@ -86,31 +86,61 @@ impl ObjectStream { warn!("object stream: the object stream dictionary specifies a wrong number of objects") } - let chunks_filter_map = |chunk: &[_]| { + // PdfCraft patch: bound members by the next distinct offset and parse each offset + // only once, so overlapping or duplicate entries cannot amplify decoded content. + let mut extents = BTreeMap::new(); + for (index, chunk) in numbers[..len].chunks(2).enumerate() { + if let &[Some(_), Some(offset)] = chunk { + let offset = first_offset.saturating_add(offset as usize); + if offset < content.len() { + extents.entry(offset).or_insert((index, content.len())); + } + } + } + let mut end = content.len(); + for (&offset, (_, extent_end)) in extents.iter_mut().rev() { + *extent_end = end; + end = offset; + } + + let chunks_filter_map = |(index, chunk): (usize, &[_])| { let id = chunk[0]?; - let offset = first_offset + chunk[1]? as usize; + let offset = first_offset.saturating_add(chunk[1]? as usize); if offset >= content.len() { warn!("out-of-bounds offset in object stream"); return None; } + let &(first_index, extent_end) = extents.get(&offset)?; + if index != first_index { + warn!("duplicate offset in object stream"); + return None; + } // Skip leading whitespace — some PDFs emit newlines before objects in ObjStm let mut start = offset; - while start < content.len() && content[start].is_ascii_whitespace() { + while start < extent_end && content[start].is_ascii_whitespace() { start += 1; } - if start >= content.len() { + if start >= extent_end { warn!("only whitespace after offset in object stream"); return None; } - let object = parser::direct_object(&content[start..])?; + let object = parser::direct_object(&content[start..extent_end])?; Some(((id, 0), object)) }; #[cfg(feature = "rayon")] - let objects = numbers[..len].par_chunks(2).filter_map(chunks_filter_map).collect(); + let objects = numbers[..len] + .par_chunks(2) + .enumerate() + .filter_map(chunks_filter_map) + .collect(); #[cfg(not(feature = "rayon"))] - let objects = numbers[..len].chunks(2).filter_map(chunks_filter_map).collect(); + let objects = numbers[..len] + .chunks(2) + .enumerate() + .filter_map(chunks_filter_map) + .collect(); Ok(ObjectStream { objects, diff --git a/vendor/lopdf/src/reader.rs b/vendor/lopdf/src/reader.rs index e048e3acf..df471371d 100644 --- a/vendor/lopdf/src/reader.rs +++ b/vendor/lopdf/src/reader.rs @@ -493,6 +493,10 @@ pub const MAX_BRACKET: usize = 100; pub const MAX_NESTING_DEPTH: usize = 100; +/// PdfCraft patch: a valid stream /Length resolves in at most three lookups (the length, the object stream holding +/// it, and that stream's own /Length); longer lookup chains overflowed the stack. +const MAX_LOOKUP_DEPTH: usize = 16; + /// Cap on reconstructed cross-reference entries, bounding memory on hostile inputs. const MAX_RECONSTRUCTED_OBJECTS: usize = 1_000_000; @@ -1149,7 +1153,7 @@ impl Reader<'_> { } /// Load a compressed object from an object stream (for lightweight metadata extraction) - fn get_compressed_object(&self, id: ObjectId) -> Result { + fn get_compressed_object(&self, id: ObjectId, already_seen: &mut HashSet) -> Result { let entry = self.document.reference_table.get(id.0).ok_or(Error::MissingXrefEntry)?; let container_id = match entry { @@ -1157,15 +1161,30 @@ impl Reader<'_> { _ => return Err(Error::MissingXrefEntry), }; + // PdfCraft patch: require an in-file container (PDF 7.5.7) and retain the caller's cycle history through + // indirect stream /Length lookups; `get_object` caps the depth of that history. + if !matches!( + self.document.reference_table.get(container_id), + Some(XrefEntry::Normal { .. }) + ) { + return Err(Error::InvalidObjectStream( + "Object stream container must be an ordinary in-file object".into(), + )); + } let container_id = (container_id, 0); - let mut already_seen = HashSet::new(); - let container_obj = self.get_object(container_id, &mut already_seen)?; + let container_obj = self.get_object(container_id, already_seen)?; let container_stream = container_obj.as_stream()?; let object_stream = ObjectStream::new_with_limit(container_stream, self.max_decompressed_size)?; object_stream.objects.get(&id).cloned().ok_or(Error::MissingXrefEntry) } pub fn get_object(&self, id: ObjectId, already_seen: &mut HashSet) -> Result { + // PdfCraft patch: each top-level lookup starts with an empty set and every nested lookup adds one object, so + // the set's size is the depth of the lookup chain. + if already_seen.len() >= MAX_LOOKUP_DEPTH { + warn!("lookup chain deeper than {MAX_LOOKUP_DEPTH} at object {} {}", id.0, id.1); + return Err(Error::ReferenceLimit); + } if already_seen.contains(&id) { warn!("reference cycle detected resolving object {} {}", id.0, id.1); return Err(Error::ReferenceCycle(id)); @@ -1175,7 +1194,7 @@ impl Reader<'_> { if let Some(entry) = self.document.reference_table.get(id.0) && matches!(entry, XrefEntry::Compressed { .. }) { - return self.get_compressed_object(id); + return self.get_compressed_object(id, already_seen); } let offset = self.get_offset(id)?; From 74be522c181fc1d0154020a35451ee25fc5049b8 Mon Sep 17 00:00:00 2001 From: Ikki Nishihara Date: Sun, 11 Oct 2026 04:54:26 +0900 Subject: [PATCH 43/69] hayro-syntax: cap the nesting of direct arrays and dictionaries (#772) Skipping a direct array or dictionary, and the lenient repair of a dictionary that has an object where a key belongs, recursed once per level with no bound, so deep nesting in a trailer entry or a content stream overflowed the stack. Count the depth on the Reader and fail beyond 128 levels like any other malformed object. Co-authored-by: Claude Sonnet 5.5 Co-authored-by: Brandon Thomas --- crates/render/src/raster.rs | 259 ++++++++++++++++++- vendor/README.md | 2 + vendor/hayro-interpret/src/function/type3.rs | 6 +- vendor/hayro-syntax/src/byte_reader.rs | 23 +- vendor/hayro-syntax/src/object/array.rs | 8 +- vendor/hayro-syntax/src/object/dict.rs | 27 +- 6 files changed, 316 insertions(+), 9 deletions(-) diff --git a/crates/render/src/raster.rs b/crates/render/src/raster.rs index 012879b46..18d19e151 100644 --- a/crates/render/src/raster.rs +++ b/crates/render/src/raster.rs @@ -1661,7 +1661,11 @@ mod tests { let object = Object::from_bytes(data.as_bytes()).unwrap(); assert_eq!(Function::new(&object).unwrap().eval([0.5].into_iter().collect()).unwrap().as_slice(), &[0.5]); let over = format!("<< /FunctionType 3 /Domain [0 1] /Functions [{data}] /Bounds [] /Encode [0 1] >>"); - assert!(Function::new(&Object::from_bytes(over.as_bytes()).unwrap()).is_none()); + // The 64 functions above nest exactly 128 levels (each dictionary and its /Functions + // array, then the leaf's /Domain array), the parser's nesting cap. 65 nest 130, so the + // parser refuses them before the function-depth guard sees them (a refusal either way). + // `function_limits_depth_guard_refuses_a_chain_of_references` tests the guard itself. + assert!(Object::from_bytes(over.as_bytes()).is_none(), "the parser refuses direct nesting past its cap"); // One root plus 10,000 leaves is one node past the common budget. This input remains // below a megabyte and does not attempt excessive recursion or an allocation failure. @@ -1677,6 +1681,43 @@ mod tests { assert_eq!(Function::new(&object).is_some(), children == 9999); } } + /// Stitching functions that each name the next through `N 0 R` nest no direct objects, so + /// the parser's nesting cap doesn't stop them and the function-depth guard does. + #[test] + fn function_limits_depth_guard_refuses_a_chain_of_references() { + use hayro::hayro_interpret::Function; + use hayro_syntax::object::{Object, ObjectIdentifier}; + for functions in [64, 65] { + // Objects 4 onwards: each stitching function names the next one; the last is a leaf. + let objects: String = (4..4 + functions) + .map(|number| { + let body = if number < 3 + functions { + format!("<< /FunctionType 3 /Domain [0 1] /Functions [{} 0 R] /Bounds [] /Encode [0 1] >>", number + 1) + } else { + "<< /FunctionType 2 /Domain [0 1] /C0 [0] /C1 [1] /N 1 >>".to_owned() + }; + format!("{number} 0 obj {body} endobj\n") + }) + .collect(); + let parsed = hayro_syntax::Pdf::new( + format!( + "%PDF-1.7\n1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj\n\ + 2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 >> endobj\n\ + 3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 20 20] >> endobj\n\ + {objects}trailer << /Root 1 0 R >>\n%%EOF" + ) + .into_bytes(), + ) + .unwrap(); + let object = parsed.xref().get::>(ObjectIdentifier::new(4, 0)).unwrap(); + let function = Function::new(&object); + if functions == 64 { + assert_eq!(function.unwrap().eval([0.5].into_iter().collect()).unwrap().as_slice(), &[0.5]); + } else { + assert!(function.is_none(), "65 functions are one past the function-depth guard"); + } + } + } #[test] fn function_limits_stitching_cycles_are_refused_and_shared_children_work() { use hayro::hayro_interpret::Function; @@ -3353,6 +3394,222 @@ trailer << /Root 1 0 R >> assert_eq!(&page.rgba[((38 * 40 + 1) * 4)..][..4], &[255, 0, 0, 255], "the rest of the page draws"); } + /// `depth` direct arrays (`array`), dictionaries with a key (`dict`) or both in turn (`mixed`) + /// around `null`. + fn nested(depth: usize, kind: &str) -> String { + let is_dict = |level| kind == "dict" || (kind == "mixed" && level % 2 == 0); + let mut value = String::new(); + for level in 0..depth { + value.push_str(if is_dict(level) { "<< /N " } else { "[" }); + } + value.push_str("null"); + for level in (0..depth).rev() { + value.push_str(if is_dict(level) { " >>" } else { "]" }); + } + value + } + + /// `depth` dictionaries nested without keys (`<< << >> >>`), closed or not. + fn keyless(depth: usize, closed: bool) -> String { + let mut value = "<< ".repeat(depth); + if closed { + value.push_str(&">> ".repeat(depth)); + } + value + } + + /// A 40 × 40 page that runs `content`, with a correct xref table and `trailer` at the end + /// of a trailer that also points at an `/Info` title. + fn nesting_pdf(trailer: &str, content: &str) -> Vec { + let mut bytes = b"%PDF-1.7\n".to_vec(); + let mut offsets = vec![0]; + for (index, body) in [ + "<< /Type /Catalog /Pages 2 0 R >>".to_string(), + "<< /Type /Pages /Kids [3 0 R] /Count 1 >>".to_string(), + "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 40 40] /Contents 4 0 R /Resources << >> >>".to_string(), + format!("<< /Length {} >> stream\n{content}\nendstream", content.len()), + "<< /Title (Nested) >>".to_string(), + ] + .iter() + .enumerate() + { + offsets.push(bytes.len()); + bytes.extend_from_slice(format!("{} 0 obj\n{body}\nendobj\n", index + 1).as_bytes()); + } + let xref = bytes.len(); + bytes.extend_from_slice(b"xref\n0 6\n0000000000 65535 f \n"); + for offset in &offsets[1..] { + bytes.extend_from_slice(format!("{offset:010} 00000 n \n").as_bytes()); + } + bytes.extend_from_slice(format!("trailer << /Size 6 /Root 1 0 R /Info 5 0 R {trailer} >>\nstartxref\n{xref}\n%%EOF\n").as_bytes()); + bytes + } + + /// Opens `bytes` and renders its one page, which must render without an error. + fn nesting_render(bytes: Vec) -> RenderedPage { + let parsed = Pdf::new(bytes.clone()).expect("xref repair can recover the catalog when the trailer is malformed"); + assert_eq!(parsed.pages().len(), 1); + let mut renderer = PageRenderer::new(Arc::new(bytes), RenderConfig::default()); + let page = renderer.render(RenderRequest { page: 0, kind: RequestKind::Pixels, scale: 1.0, ..Default::default() }); + assert!(page.error.is_none(), "{:?}", page.error); + assert_eq!((page.width, page.height), (40, 40)); + page + } + + /// Direct arrays and dictionaries used to recurse without a bound while skipping even + /// unused trailer values and content operands. Exercise the vendored patch in workspace CI. + #[test] + fn deeply_nested_direct_objects_do_not_overflow_the_stack() { + use hayro_syntax::object::Object; + use hayro_syntax::reader::{Reader, ReaderExt}; + let pdf = |unused: &str, content: &str| nesting_pdf(&format!("/Unused {unused}"), content); + let paint = "1 0 0 rg 0 0 4 4 re f"; + for kind in ["array", "dict", "mixed"] { + // Run the crash reproducer first, so the unpatched red state is a stack overflow. + let page = nesting_render(pdf(&nested(100_000, kind), paint)); + assert_eq!(&page.rgba[((38 * 40 + 1) * 4)..][..4], &[255, 0, 0, 255], "{kind}: repaired trailer"); + for depth in [20, 128, 129] { + let value = nested(depth, kind); + let siblings = format!("{value} [] []"); + let mut reader = Reader::new(siblings.as_bytes()); + assert_eq!(reader.skip::>(false).is_some(), depth <= 128, "{kind}: depth {depth}"); + if depth > 128 { + assert_eq!(reader.offset(), 0, "failed skips restore the offset"); + reader.jump(value.len()); + } + // A failed skip must unwind its depth; successful siblings must not accumulate it. + for _ in 0..2 { + reader.skip_white_spaces(); + assert!(reader.skip::>(false).is_some()); + } + let page = nesting_render(pdf(&value, paint)); + assert_eq!(&page.rgba[((38 * 40 + 1) * 4)..][..4], &[255, 0, 0, 255], "{kind}: depth {depth}"); + } + // Many siblings exercise the offset constructor and ensure depth is per nesting. + let siblings = "[] ".repeat(256); + let mut reader = Reader::new_with(siblings.as_bytes(), 0); + for _ in 0..256 { + assert!(reader.skip::>(false).is_some()); + reader.skip_white_spaces(); + } + } + // Content-stream arrays take the Object path, rather than MaybeRef. + for depth in [100_000, 20, 128, 129] { + let value = nested(depth, "array"); + let mut reader = Reader::new(value.as_bytes()); + assert_eq!(reader.skip::>(true).is_some(), depth <= 128, "content: depth {depth}"); + let content = format!("{value} discard {paint}"); + let page = nesting_render(pdf("null", &content)); + if depth <= 128 { + assert_eq!(&page.rgba[((38 * 40 + 1) * 4)..][..4], &[255, 0, 0, 255], "content: depth {depth}"); + } + } + } + + /// A dictionary with an object where a key belongs is repaired by reading that object, not + /// skipping it, and that read recursed without a bound when the object was another such + /// dictionary: `<<` 100,000 times aborted the process. Here as a content-stream operand. + #[test] + fn dictionaries_nested_without_keys_in_content_do_not_overflow_the_stack() { + for closed in [true, false] { + let content = format!("1 0 0 rg 0 0 4 4 re f {} discard", keyless(100_000, closed)); + let page = nesting_render(nesting_pdf("", &content)); + assert_eq!(px40(&page, 1, 38), &[255, 0, 0, 255], "closed: {closed}"); + } + } + + /// The same, where a key of the trailer belongs: the xref table can't be used, and repair + /// finds the catalog. + #[test] + fn dictionaries_nested_without_keys_in_the_trailer_do_not_overflow_the_stack() { + for closed in [true, false] { + let page = nesting_render(nesting_pdf(&keyless(100_000, closed), "1 0 0 rg 0 0 4 4 re f")); + assert_eq!(px40(&page, 1, 38), &[255, 0, 0, 255], "closed: {closed}"); + } + } + + /// The same as the properties of marked content (`/Tag <<<<<< … BDC`). + #[test] + fn dictionaries_nested_without_keys_as_marked_content_properties_do_not_overflow_the_stack() { + for closed in [true, false] { + let properties = if closed { "<<".repeat(100_000) + &">>".repeat(100_000) } else { "<<".repeat(100_000) }; + let content = format!("1 0 0 rg 0 0 4 4 re f /Tag {properties} BDC 0 0 1 rg 36 36 4 4 re f EMC"); + let page = nesting_render(nesting_pdf("", &content)); + assert_eq!(px40(&page, 1, 38), &[255, 0, 0, 255], "closed: {closed}"); + } + } + + /// Through the same three paths, dictionaries without keys are read up to the nesting cap + /// (128 dictionaries in all) and refused one past it. + #[test] + fn dictionaries_nested_without_keys_are_read_up_to_the_nesting_cap() { + use hayro_syntax::object::{FromBytes, Object}; + use hayro_syntax::reader::{Reader, ReaderExt}; + let (red, blue, white) = ([255, 0, 0, 255], [0, 0, 255, 255], [255, 255, 255, 255]); + for depth in [128, 129] { + let within = depth <= 128; + let value = keyless(depth, true); + assert_eq!(Object::from_bytes(value.as_bytes()).is_some(), within, "object: {depth}"); + assert_eq!(Reader::new(value.as_bytes()).read_without_context::>().is_some(), within, "operand: {depth}"); + // Content reading stops at an operand it can't read, so the blue square is drawn + // only when the dictionaries were read. + let properties = "<<".repeat(depth) + &">>".repeat(depth); + for (path, operand) in [("operand", format!("{value} discard")), ("BDC", format!("/Tag {properties} BDC"))] { + let content = format!("1 0 0 rg 0 0 4 4 re f {operand} 0 0 1 rg 36 36 4 4 re f"); + let page = nesting_render(nesting_pdf("", &content)); + assert_eq!(px40(&page, 1, 38), &red, "{path}: {depth}"); + assert_eq!(px40(&page, 38, 1), if within { &blue } else { &white }, "{path}: {depth}"); + } + // The trailer is the outermost dictionary. Within the cap it is read (with its + // /Info); past it, repair recovers the catalog but not the trailer. + let bytes = nesting_pdf(&keyless(depth - 1, true), "1 0 0 rg 0 0 4 4 re f"); + let parsed = Pdf::new(bytes.clone()).expect("the trailer or repair finds the catalog"); + assert_eq!(parsed.metadata().title, within.then(|| b"Nested".to_vec()), "trailer: {depth}"); + assert_eq!(px40(&nesting_render(bytes), 1, 38), &red, "trailer: {depth}"); + } + } + + /// The cap must also fit small stacks: skip, read, iterate down with the array iterators and + /// format (`{:?}`) the deepest nesting it admits, of arrays, dictionaries, both and + /// dictionaries without keys, on a 512 KiB thread in a debug build. Measured when this was + /// written (macOS arm64, dev profile): at most about 100 KiB, for `{:?}` of 128 dictionaries. + #[cfg(not(target_arch = "wasm32"))] + #[test] + fn nesting_at_the_cap_fits_a_512_kib_stack() { + use hayro_syntax::object::{FromBytes, Object}; + use hayro_syntax::reader::{Reader, ReaderExt}; + let worker = std::thread::Builder::new().stack_size(512 * 1024).spawn(|| { + // (kind, arrays and dictionaries `{:?}` shows, levels walked down to the end) + for (kind, arrays, dicts, levels) in [("array", 128, 0, 129), ("dict", 0, 128, 129), ("mixed", 64, 64, 129), ("keyless", 0, 1, 1)] { + let value = if kind == "keyless" { keyless(128, true) } else { nested(128, kind) }; + assert!(Reader::new(value.as_bytes()).skip::>(false).is_some(), "{kind}: skip"); + assert!(Reader::new(value.as_bytes()).skip::>(true).is_some(), "{kind}: skip in content"); + assert!(Reader::new(value.as_bytes()).read_without_context::>().is_some(), "{kind}: operand"); + let object = Object::from_bytes(value.as_bytes()).expect("the cap admits 128 levels"); + let shown = format!("{object:?}"); + assert_eq!((shown.matches("Array(").count(), shown.matches("Dict(").count()), (arrays, dicts), "{kind}: {{:?}}"); + let (mut walked, mut next) = (0, Some(object)); + while let Some(object) = next.take() { + walked += 1; + next = match object { + Object::Array(array) => { + assert_eq!(array.raw_iter().count(), 1, "{kind}: raw_iter"); + assert!(array.flex_iter().next::>().is_some(), "{kind}: flex_iter"); + array.iter::>().next() + } + Object::Dict(dict) => dict.get::>("N"), + _ => None, + }; + } + assert_eq!(walked, levels, "{kind}: iterate"); + } + }); + // A stack overflow aborts the process; a failed check is a panic, shown as it is. + if let Err(panic) = worker.expect("spawn a 512 KiB thread").join() { + std::panic::resume_unwind(panic); + } + } + /// From the nightly `cargo xtask fuzz`: many small inline images, each with "EI" (followed /// by a space) inside its data, and no text anywhere. To decide whether such an "EI" ends the /// data, the parser re-read the rest of the stream as content, and that re-read did the same diff --git a/vendor/README.md b/vendor/README.md index cb8e6931a..230b28611 100644 --- a/vendor/README.md +++ b/vendor/README.md @@ -13,6 +13,8 @@ the fix. Vendoring copyleft code is never allowed (plan/adr/0001). | hayro-jbig2 | 0.3.0 | Apache-2.0 OR MIT | (1) at most 2^28 pixels per bitmap, page bitmap, halftone grid and grayscale image (each side could be 65535: 4.3 billion pixels decoded one by one); (2) at most 2^26 symbol pixels per symbol dictionary (a fuzzed dictionary decoded for minutes); (3) a symbol dictionary's height classes are capped at one per new symbol and its export runs at `2 × symbols + 1`, each plus `EXTRA_EMPTY_STEPS` (1024): an empty height class or a zero-length export run doesn't advance, and past the end of its data the arithmetic decoder can decode them for ever (one changed byte hung the renderer) | (1) `raster::tests::jbig2_regions_of_billions_of_pixels_are_refused`; (2) the nightly `cargo xtask fuzz` finding, which now finishes in about a second; (3) `raster::tests::jbig2_symbol_dictionaries_that_never_advance_terminate` | | lopdf | 0.45.0 | MIT | (1) PNG predictor rows longer than the data are refused before their buffers are allocated (a fuzzed `/Columns 4294967295` allocated two 4 GiB rows), and empty frames return without row buffers; (2) predictor row widths use saturating arithmetic or reject overflow; (3) sub-byte TIFF Predictor 2 scratch is bounded by the samples present in the input instead of the declared `/Colors`, aligned with [lopdf#589](https://github.com/J-F-Liu/lopdf/pull/589); (4) the object lookup keeps its cycle history through compressed objects, requires an ordinary in-file container, and caps the chain of indirect lookups at 16 (a chain of new objects, each a `/Length` compressed in a container whose own `/Length` names the next, or a stream whose `/Length` names the next stream, overflowed the stack: a dev build's 2 MiB thread within 800 hops); (5) object-stream members are parsed within their own extent (up to the next distinct offset), an entry whose offset repeats an earlier entry's is skipped (so a repeated object number can resolve to its earlier entry), and offsets are processed in index order | (1) `inspect::tests::png_predictor_rows_longer_than_the_data_are_refused_before_allocating`, `inspect::tests::empty_png_predictor_frames_do_not_allocate_rows`; (2) `inspect::tests::tiff_subbyte_predictor_row_width_overflow_is_refused`; (3) `inspect::tests::tiff_subbyte_predictor_scratch_is_bounded_by_available_samples`, `inspect::tests::tiff_subbyte_predictors_keep_components_rows_and_padding`; (4) `inspect::tests::compressed_object_self_container_does_not_recurse`, `inspect::tests::compressed_object_mutual_containers_do_not_recurse`, `inspect::tests::object_stream_length_cycle_does_not_recurse`, `inspect::tests::compressed_object_container_chain_does_not_recurse`, `inspect::tests::compressed_length_chain_does_not_recurse`, `inspect::tests::plain_stream_length_chain_does_not_recurse`, `inspect::tests::compressed_objects_and_indirect_stream_length_still_load`, `inspect::tests::compressed_length_in_a_container_with_an_indirect_length_still_loads`; (5) `inspect::tests::object_stream_member_extents_bound_owned_strings`, `inspect::tests::object_stream_member_extents_preserve_index_order_and_whitespace` | | hayro-syntax | 0.7.2 | Apache-2.0 OR MIT | (1) Page-tree cycle guard in `resolve_pages` (visited set + depth cap): a `/Kids` loop among compressed objects overflowed the stack and aborted the process; (2) `ccitt_size_ok`: CCITT images wider than 2^20 columns or over 2^28 pixels are not decoded (a fuzzed `/Columns 4294967295` allocated 4 GiB); (3) Flate/LZW predictor row widths are saturating `usize`, a zero width is refused and a row longer than the data is not allocated (a fuzzed `/Columns 9223372036854775807` wrapped to a 2^61-byte allocation and aborted); (4) the inline-image end-of-data check (`EI`) doesn't nest inside its own look-ahead and examines at most 256 candidates and steps (50 images with "EI" in their data ran for over ten minutes); (5) a stream whose `/Length` is wrong is recovered only when `stream` is the next token — scanning ahead to a later object's `stream` made an indirect appearance-state dictionary (Quartz checkboxes: `/AP /N` → `<< /Yes … /Off … >>`) render as a non-form, so the widget was skipped; (6) JPX rescaling uses an exact integer sample range for 1–32-bit components (32 shifted past u32, and floating-point maxima at 25–31 bits wrapped to zero), and refuses zero-sized rows; (7) `decode_limit`: Flate (through flate2 and the fallback inflater), LZW and RunLength never write past `MAX_DECODED_STREAM` (256 MiB), or, for the last filter of an image of known size, past twice its pixel bytes (a predictor tag byte per byte), at most 1 GiB; `Stream::decoded_within` for budgeted callers; a page's `/Contents` array shares one `MAX_DECODED_STREAM` (a 2 MB page whose content stream or 8 × 8 image inflated to 2 GB took the renderer past 3 GB in under a second; naming the stream twenty times in `/Contents`, 5 GB); (8) high-precision numbers (jsPDF MediaBox coordinates with over 19 digits) parse without overflowing the u64 mantissa: fractional digits past it are dropped, integer digits past it scale the value (a real, kept finite); (9) large dictionary indexes are built on first use and shared across repeated reads within each PDF (#307); the validated-byte cache is bounded, reader contexts remain separate, repairs and inline/content dictionaries bypass sharing, and small dictionaries stay eager; (10) decoded object-stream slots publish once under contention, cold loading rejects recursive dependencies, and xref repair serializes without holding recursive read guards; this permits one parser per native render pool with private worker caches; (11) `sync.rs`: a poisoned lock is recovered instead of unwrapped (a worker panic no longer cascades into every reader of the same PDF), and xref repair takes a blocking write lock instead of asserting a non-blocking one; (12) `SegmentList` (`util.rs`) returns `None` for an out-of-range index instead of panicking; (13) a page's `/UserUnit` scales `render_dimensions` and `initial_transform`, so the page and everything on it are sized in points, as Acrobat shows them; it is read as Acrobat reads it (the page's own entry, not inherited; a number from 1 up, at most 75,000; anything else counts as 1), and a page without it keeps exactly the transform it had | (1) `cos/tests/writer.rs::page_tree_loops_in_object_streams_do_not_crash_readers`; (2) `raster::tests::absurd_mask_and_fax_sizes_are_refused_before_decoding`; (3) `raster::tests::absurd_predictor_columns_and_line_widths_render`; (4) `raster::tests::inline_images_with_ei_in_their_data_parse_in_linear_time`; (5) `raster::tests::indirect_appearance_state_dict_is_not_another_stream`; (6) `raster::tests::jpx_stream_bit_depth_boundaries_do_not_panic`, `raster::tests::jpx_32_bit_image_does_not_prevent_other_page_content_from_rendering`, `raster::tests::jpx_gray_rgb_and_alpha_keep_eight_bit_samples`, `raster::tests::jpx_streams_reject_out_of_range_bit_depths`; (7) `raster::tests::decompression_bombs_stop_at_the_stream_limit`, `raster::tests::page_contents_and_nested_forms_share_one_budget`; (8) `inspect::tests::jspdf_high_precision_mediabox_does_not_overflow_dimensions`, `object::number::tests::high_precision_float_does_not_overflow_mantissa` (hayro-syntax); (9) `object::dict::lazy_index_tests`, `raster::tests::large_resource_indexes_preserve_pixels_and_text`; (10) `data::tests`, `xref::tests::concurrent_cold_object_stream_reads_all_succeed`, `raster::tests::shared_parser_is_lazy_and_initializes_once_under_contention`; (11) `data::tests::panicking_initializer_wakes_waiters_and_prevents_retries`, `xref::tests::concurrent_cold_object_stream_reads_all_succeed`; (12) `util::tests::segment_list_rejects_out_of_range_indices`; (13) `raster::tests::user_unit_scales_the_page_and_everything_on_it` | +| lopdf | 0.45.0 | MIT | (1) PNG predictor rows longer than the data are refused before their buffers are allocated (a fuzzed `/Columns 4294967295` allocated two 4 GiB rows), and empty frames return without row buffers; (2) predictor row widths use saturating arithmetic or reject overflow; (3) sub-byte TIFF Predictor 2 scratch is bounded by the samples present in the input instead of the declared `/Colors`, aligned with [lopdf#589](https://github.com/J-F-Liu/lopdf/pull/589) | (1) `inspect::tests::png_predictor_rows_longer_than_the_data_are_refused_before_allocating`, `inspect::tests::empty_png_predictor_frames_do_not_allocate_rows`; (2) `inspect::tests::tiff_subbyte_predictor_row_width_overflow_is_refused`; (3) `inspect::tests::tiff_subbyte_predictor_scratch_is_bounded_by_available_samples`, `inspect::tests::tiff_subbyte_predictors_keep_components_rows_and_padding` | +| hayro-syntax | 0.7.2 | Apache-2.0 OR MIT | (1) Page-tree cycle guard in `resolve_pages` (visited set + depth cap): a `/Kids` loop among compressed objects overflowed the stack and aborted the process; (2) `ccitt_size_ok`: CCITT images wider than 2^20 columns or over 2^28 pixels are not decoded (a fuzzed `/Columns 4294967295` allocated 4 GiB); (3) Flate/LZW predictor row widths are saturating `usize`, a zero width is refused and a row longer than the data is not allocated (a fuzzed `/Columns 9223372036854775807` wrapped to a 2^61-byte allocation and aborted); (4) the inline-image end-of-data check (`EI`) doesn't nest inside its own look-ahead and examines at most 256 candidates and steps (50 images with "EI" in their data ran for over ten minutes); (5) a stream whose `/Length` is wrong is recovered only when `stream` is the next token — scanning ahead to a later object's `stream` made an indirect appearance-state dictionary (Quartz checkboxes: `/AP /N` → `<< /Yes … /Off … >>`) render as a non-form, so the widget was skipped; (6) JPX rescaling uses an exact integer sample range for 1–32-bit components (32 shifted past u32, and floating-point maxima at 25–31 bits wrapped to zero), and refuses zero-sized rows; (7) `decode_limit`: Flate (through flate2 and the fallback inflater), LZW and RunLength never write past `MAX_DECODED_STREAM` (256 MiB), or, for the last filter of an image of known size, past twice its pixel bytes (a predictor tag byte per byte), at most 1 GiB; `Stream::decoded_within` for budgeted callers; a page's `/Contents` array shares one `MAX_DECODED_STREAM` (a 2 MB page whose content stream or 8 × 8 image inflated to 2 GB took the renderer past 3 GB in under a second; naming the stream twenty times in `/Contents`, 5 GB); (8) high-precision numbers (jsPDF MediaBox coordinates with over 19 digits) parse without overflowing the u64 mantissa: fractional digits past it are dropped, integer digits past it scale the value (a real, kept finite); (9) large dictionary indexes are built on first use and shared across repeated reads within each PDF (#307); the validated-byte cache is bounded, reader contexts remain separate, repairs and inline/content dictionaries bypass sharing, and small dictionaries stay eager; (10) decoded object-stream slots publish once under contention, cold loading rejects recursive dependencies, and xref repair serializes without holding recursive read guards; this permits one parser per native render pool with private worker caches; (11) `sync.rs`: a poisoned lock is recovered instead of unwrapped (a worker panic no longer cascades into every reader of the same PDF), and xref repair takes a blocking write lock instead of asserting a non-blocking one; (12) `SegmentList` (`util.rs`) returns `None` for an out-of-range index instead of panicking; (13) a page's `/UserUnit` scales `render_dimensions` and `initial_transform`, so the page and everything on it are sized in points, as Acrobat shows them; it is read as Acrobat reads it (the page's own entry, not inherited; a number from 1 up, at most 75,000; anything else counts as 1), and a page without it keeps exactly the transform it had; (14) direct arrays and dictionaries share a reader-local nesting cap of 128, counted while skipping and while parsing a dictionary (its lenient repair reads, rather than skips, an object found where a key belongs, so `<< << …` recursed through that read); deeper objects return `None` instead of overflowing the stack (including unused trailer values, trailer entries, content operands and marked-content properties); a chain of 65 direct stitching functions now stops at this cap, before the 64-function guard | (1) `cos/tests/writer.rs::page_tree_loops_in_object_streams_do_not_crash_readers`; (2) `raster::tests::absurd_mask_and_fax_sizes_are_refused_before_decoding`; (3) `raster::tests::absurd_predictor_columns_and_line_widths_render`; (4) `raster::tests::inline_images_with_ei_in_their_data_parse_in_linear_time`; (5) `raster::tests::indirect_appearance_state_dict_is_not_another_stream`; (6) `raster::tests::jpx_stream_bit_depth_boundaries_do_not_panic`, `raster::tests::jpx_32_bit_image_does_not_prevent_other_page_content_from_rendering`, `raster::tests::jpx_gray_rgb_and_alpha_keep_eight_bit_samples`, `raster::tests::jpx_streams_reject_out_of_range_bit_depths`; (7) `raster::tests::decompression_bombs_stop_at_the_stream_limit`, `raster::tests::page_contents_and_nested_forms_share_one_budget`; (8) `inspect::tests::jspdf_high_precision_mediabox_does_not_overflow_dimensions`, `object::number::tests::high_precision_float_does_not_overflow_mantissa` (hayro-syntax); (9) `object::dict::lazy_index_tests`, `raster::tests::large_resource_indexes_preserve_pixels_and_text`; (10) `data::tests`, `xref::tests::concurrent_cold_object_stream_reads_all_succeed`, `raster::tests::shared_parser_is_lazy_and_initializes_once_under_contention`; (11) `data::tests::panicking_initializer_wakes_waiters_and_prevents_retries`, `xref::tests::concurrent_cold_object_stream_reads_all_succeed`; (12) `util::tests::segment_list_rejects_out_of_range_indices`; (13) `raster::tests::user_unit_scales_the_page_and_everything_on_it`; (14) `raster::tests::deeply_nested_direct_objects_do_not_overflow_the_stack`, `raster::tests::dictionaries_nested_without_keys_in_content_do_not_overflow_the_stack`, `raster::tests::dictionaries_nested_without_keys_in_the_trailer_do_not_overflow_the_stack`, `raster::tests::dictionaries_nested_without_keys_as_marked_content_properties_do_not_overflow_the_stack`, `raster::tests::dictionaries_nested_without_keys_are_read_up_to_the_nesting_cap`, `raster::tests::nesting_at_the_cap_fits_a_512_kib_stack` (native only), `raster::tests::function_limits_depth_guard_refuses_a_chain_of_references` | | winit | 0.30.13 | Apache-2.0 | (1) on Windows 11 (build 22000 and later), `WM_DPICHANGED` applies the rect Windows suggests. It used to nudge the window onto the monitor `MonitorFromWindow` names, which during a drag is still the monitor being left; the nudge sent another `WM_DPICHANGED`, so a window dragged to a monitor with another scale factor grew and stayed on the wrong monitor at the wrong scale (#324). Backport of winit master 488c036a ([winit#4341](https://github.com/rust-windowing/winit/pull/4341)), which no 0.30 release has; Windows 10 keeps the old adjustment, as on winit master. (2) on Wayland, a seat's text input is destroyed when the seat goes away, not whenever it loses a capability: losing any capability (a touch screen or tablet unplugged, a remote-desktop session ending) destroyed it, and only an added capability recreated it, so IME stopped working until restart. Port of [winit#4747](https://github.com/rust-windowing/winit/pull/4747); (3) on Windows, an IME composition reports its cursor from `GCS_CURSORPOS` only when `WM_IME_COMPOSITION` says it is set, and at the end of the composition otherwise: the Microsoft Korean IME never sets it, so the cursor read as 0 (egui then drew no caret while composing; it now draws it after the syllable). Port of [winit#4746](https://github.com/rust-windowing/winit/pull/4746); (4) on Windows, a hidden window is not maximized until it is shown, and a hidden window shown while maximized gets one `ShowWindow(SW_MAXIMIZE)`: `SW_MAXIMIZE` shows a hidden window, so PdfCraft, which eframe keeps hidden until its first frame is drawn, appeared unpainted (white) when it restored a maximized window, and could lose its focus. Port of [winit#4587](https://github.com/rust-windowing/winit/pull/4587) (the later, focus-safe form of [winit#4575](https://github.com/rust-windowing/winit/pull/4575)); (5) on X11, when allowing or disallowing IME replaces the focused window's input context (egui does it each time a text field gains or loses focus), the new context is focused: no focus event follows, so it stayed unfocused until the window lost and regained focus. Port of [winit#4727](https://github.com/rust-windowing/winit/pull/4727); (6) on macOS, `insertText` commits through IME when a composition was started (`pending_commit`, set by `setMarkedText`) rather than when marked text is left: input methods that end a composition with `unmarkText` before `insertText` lost the text, and Apple Korean dropped the key typed after a syllable (`한` then `5` or `;`); other `insertText` calls stay `KeyboardInput`; a commit outside a key press (a candidate clicked with the mouse, dictation) clears the marked text and leaves no key to swallow, as in [winit#4748](https://github.com/rust-windowing/winit/pull/4748) (without it the next key, such as Backspace, was lost). `validAttributesForMarkedText` lists the underline and marked-clause attributes, without which some input methods don't compose. Port of [winit#4650](https://github.com/rust-windowing/winit/pull/4650), the 0.30 backport of [winit#4651](https://github.com/rust-windowing/winit/pull/4651) (merged on winit master), with the underline attribute spelled as AppKit's `NSUnderlineStyleAttributeName` (`NSUnderline`); (7) on macOS, disallowing IME with a composition in progress (focus leaving a text field) tells the input method to discard its marked text (`discardMarkedText`): it kept composing it once IME was allowed again, so the abandoned syllable came back (`하`, then `가` typed in the next field, became `학`, then `하` + `가`; Japanese `か` then `さ` became `傘`). Port of [winit#4745](https://github.com/rust-windowing/winit/pull/4745); (8) on macOS, text inserted outside a key press without a composition (the emoji picker) is reported as `Ime::Commit`; it was dropped. Port of the emoji-picker part of [winit#4749](https://github.com/rust-windowing/winit/pull/4749) (0.30 backport of [winit#4748](https://github.com/rust-windowing/winit/pull/4748)); its `selectedRange` change for dictation is left out because it switches on press-and-hold for every held key (an accent menu instead of key repeat, and the chosen accent added after the repeated letters); (9) on macOS, a key whose text was committed through IME isn't also sent as `KeyboardInput` unless AppKit forwards it as a command (Enter): Apple Korean answers Space with no syllable in progress with `setMarkedText(" ")`, `insertText(" ")`, `setMarkedText("")`, and the last call reset winit's state, so the space arrived twice. Same goal as [winit#4478](https://github.com/rust-windowing/winit/pull/4478), whose changes don't reach this call order (macOS 14); to be proposed upstream there; (10) on macOS, when the first key in a fresh app reaches Apple Korean's `insertText` as a raw compatibility jamo instead of starting a composition, that key is interpreted once more, synchronously, before it is sent as `KeyboardInput` (g k s typed ㅎㅏㄴ instead of 한); only an unmodified, non-repeated first key whose one callback inserts that same jamo, with IME enabled and nothing composed, focus and input source unchanged, is retried (`ime_startup.rs`). Port of [winit#4744](https://github.com/rust-windowing/winit/pull/4744), with its policy tests. The published crate's asset files (`docs/res/*.svg`, `examples/data/*.png`) are left out, so its `window` example doesn't build | (1) `pdfcraft` `tests::winit_carries_the_windows_11_monitor_scale_fix` keeps the patch in the build; the drag itself needs two monitors with different scale factors on Windows 11; (2) `pdfcraft` `tests/winit_wayland_text_input.rs`; the capability change itself needs a Wayland session and an input method; (3) `pdfcraft` `tests/winit_windows_ime_cursor.rs`; composing itself needs the Microsoft Korean IME; (4) `pdfcraft` `tests/winit_windows_hidden_maximized.rs`; the start-up itself needs Windows and a window that was closed maximized; (5) `pdfcraft` `tests/winit_x11_ime_focus.rs`; switching fields itself needs an X11 session and an input method (IBus, fcitx5); (6) `pdfcraft` `tests/winit_macos_ime_commit.rs`; composing itself needs macOS and an input method; (7) `pdfcraft` `tests/winit_macos_ime_discard.rs`; composing itself needs macOS and an input method; (8) `pdfcraft` `tests/winit_macos_emoji_picker.rs`; the picker itself needs macOS; (9) `pdfcraft` `tests/winit_macos_korean_space.rs`; composing itself needs macOS and Apple Korean; (10) `pdfcraft` `tests/winit_macos_korean_first_key.rs` and `ime_startup.rs`'s own tests (run them with `rustc --test`); typing itself needs macOS and Apple Korean | | egui-wgpu | 0.36.2 | MIT OR Apache-2.0 | (1) `winit::surface_fit`: a window's surface is configured within the device's `max_texture_dimension_2d`, both sides shrunk by one factor that egui then draws at (`pixels_per_point × render_scale`), so the whole window is drawn into the surface (on DX12 it is stretched over the window and lines up with the pointer; wgpu's GL backend copies it unscaled into a corner); the longer side becomes exactly the limit; the depth and MSAA textures follow, and a recreated surface starts again from the window's own size. egui-wgpu configured the window's size as is, and `Surface::configure` panics beyond the limit: a window restored at another monitor's scale (opening an Outlook attachment on a mixed-DPI laptop asked an 8192-pixel device for 8600 × 3423, #577) or stretched across monitors closed PdfCraft, before its window appeared when it happened at start-up ([emilk/egui#8361](https://github.com/emilk/egui/issues/8361)). The published crate leaves out egui's licence files; `LICENSE-APACHE` and `LICENSE-MIT` are from the 0.36.2 tag; (2) `winit::catch_errors`: a new window's surface is configured inside wgpu error scopes (validation, out of memory, internal), and a failure is returned as `WgpuError::ConfigureSurface` (for the root window at start-up eframe returns it as `Error::Wgpu`, which PdfCraft retries with OpenGL); nothing is kept for the window. wgpu's default handler panicked instead, and eframe configures the root surface before the app is created, so a driver that can't configure one closed PdfCraft at launch (a 2015 Intel GPU through wgpu's GL backend timed out waiting for the GPU to go idle, `GpuWaitTimeout`, #519). Unchanged, so still a panic: an error in a later reconfigure (resize, a lost surface), a device lost during the first configure (wgpu reports it only to a callback), and a window shown at zero size (first configured on resize) | (1) `pdfcraft` `tests::a_window_larger_than_the_gpu_limit_gets_a_surface_within_it`, `tests::surface_fit_never_empties_or_overflows_a_surface`, and `tests::egui_wgpu_carries_the_surface_size_fit`, which keeps the patch in the build and in the paths that size a surface; (2) `pdfcraft` `tests::a_gpu_error_while_a_window_is_set_up_is_returned_not_a_panic` (a real device: an error inside `catch_errors` is returned, not a panic) and `tests::egui_wgpu_returns_a_surface_it_cannot_configure_as_an_error`, which keeps it around `add_surface`'s configure | diff --git a/vendor/hayro-interpret/src/function/type3.rs b/vendor/hayro-interpret/src/function/type3.rs index 2d3720d48..f63668f17 100644 --- a/vendor/hayro-interpret/src/function/type3.rs +++ b/vendor/hayro-interpret/src/function/type3.rs @@ -174,8 +174,10 @@ mod tests { Some(smallvec![0.5]) ); let data = function_limits_stitching(65); - let object = Object::from_bytes(data.as_bytes()).unwrap(); - assert!(Function::new(&object).is_none()); + // Direct nesting beyond the hayro-syntax cap (PdfCraft patch) is refused while parsing, + // before this guard; the guard is tested with indirect references in pdfcraft-render + // (`function_limits_depth_guard_refuses_a_chain_of_references`). + assert!(Object::from_bytes(data.as_bytes()).is_none()); } fn function_limits_fanout(children: usize) -> String { diff --git a/vendor/hayro-syntax/src/byte_reader.rs b/vendor/hayro-syntax/src/byte_reader.rs index 9ea295728..f3c7a20e0 100644 --- a/vendor/hayro-syntax/src/byte_reader.rs +++ b/vendor/hayro-syntax/src/byte_reader.rs @@ -2,6 +2,10 @@ use core::ops::Range; +/// PdfCraft patch: how deeply direct arrays and dictionaries may nest (the same cap as `pdfcraft-cos`; a +/// chain of 64 direct stitching functions is exactly this deep). +const MAX_CONTAINER_DEPTH: usize = 128; + /// A reader for reading bytes and PDF objects. #[derive(Clone, Debug)] pub struct Reader<'a> { @@ -9,19 +13,34 @@ pub struct Reader<'a> { pub data: &'a [u8], /// The current byte-offset. pub offset: usize, + // PdfCraft patch: bound direct-object nesting while skipping arrays and dictionaries + // and while parsing dictionaries (`Dict::skip`, `Array::skip`, `parse_dict_with`). + skip_depth: usize, } impl<'a> Reader<'a> { /// Create a new reader. #[inline] pub fn new(data: &'a [u8]) -> Self { - Self { data, offset: 0 } + Self { data, offset: 0, skip_depth: 0 } } /// Create a new reader at the given offset. #[inline] pub fn new_with(data: &'a [u8], offset: usize) -> Self { - Self { data, offset } + Self { data, offset, skip_depth: 0 } + } + + pub(crate) fn enter_container(&mut self) -> Option<()> { + if self.skip_depth >= MAX_CONTAINER_DEPTH { + return None; + } + self.skip_depth += 1; + Some(()) + } + + pub(crate) fn leave_container(&mut self) { + self.skip_depth -= 1; } /// Returns `true` if the reader has reached the end of the data. diff --git a/vendor/hayro-syntax/src/object/array.rs b/vendor/hayro-syntax/src/object/array.rs index 332f09ab8..d39230f7d 100644 --- a/vendor/hayro-syntax/src/object/array.rs +++ b/vendor/hayro-syntax/src/object/array.rs @@ -71,8 +71,10 @@ object!(Array<'a>, Array); impl Skippable for Array<'_> { fn skip(r: &mut Reader<'_>, is_content_stream: bool) -> Option<()> { r.forward_tag(b"[")?; + r.enter_container()?; - loop { + // PdfCraft patch: leave even when an element's skip fails through `?`. + let result = (|| loop { r.skip_white_spaces_and_comments(); if let Some(()) = r.forward_tag(b"]") { @@ -82,7 +84,9 @@ impl Skippable for Array<'_> { } else { r.skip::>>(false)?; } - } + })(); + r.leave_container(); + result } } diff --git a/vendor/hayro-syntax/src/object/dict.rs b/vendor/hayro-syntax/src/object/dict.rs index 55ea355ea..1e016b567 100644 --- a/vendor/hayro-syntax/src/object/dict.rs +++ b/vendor/hayro-syntax/src/object/dict.rs @@ -177,8 +177,10 @@ impl Debug for Dict<'_> { impl Skippable for Dict<'_> { fn skip(r: &mut Reader<'_>, is_content_stream: bool) -> Option<()> { r.forward_tag(b"<<")?; + r.enter_container()?; - loop { + // PdfCraft patch: leave even when a value or garbage fails through `?`. + let result = (|| loop { r.skip_white_spaces_and_comments(); if let Some(()) = r.forward_tag(b">>") { @@ -198,7 +200,9 @@ impl Skippable for Dict<'_> { r.skip::>>(is_content_stream)?; } } - } + })(); + r.leave_container(); + result } } @@ -362,7 +366,26 @@ pub(crate) fn probe_dict<'a>( }) } +// PdfCraft patch: an object where a key belongs is read, not skipped, so a dictionary +// holding another one there (`<< << …`) recursed without a bound. Count each parsed +// dictionary like a skipped one, and leave on every exit of the parse below. fn parse_dict_with<'a, F>( + r: &mut Reader<'a>, + ctx: &ReaderContext<'a>, + start_tag: Option<&[u8]>, + end_tag: &[u8], + on_entry: F, +) -> Option<(&'a [u8], bool)> +where + F: FnMut(Name<'a>, usize, &Reader<'a>) -> Option<()>, +{ + r.enter_container()?; + let result = parse_dict_with_inner(r, ctx, start_tag, end_tag, on_entry); + r.leave_container(); + result +} + +fn parse_dict_with_inner<'a, F>( r: &mut Reader<'a>, ctx: &ReaderContext<'a>, start_tag: Option<&[u8]>, From 4fd2c2b3a36741a226055af6250af802a5c0f56e Mon Sep 17 00:00:00 2001 From: taylorandrew07-lab Date: Sat, 10 Oct 2026 16:20:50 -0400 Subject: [PATCH 44/69] M11: Reduce file size shrinks office scans, and never saves a bigger copy (refs #490) (#849) Office scanners write the default decode array (/Decode [0 1 0 1 0 1]) on their page images. Reduce skipped every image with a decode array, so a scan stayed the same size. The default array is now treated as absent; any other array still skips the image. Reduce also saved its copy even when it wasn't smaller, reported as "-N% smaller". It now saves nothing and says the file is already as small as it can be made; doc_reduce returns "written": false. Optimize PDF still saves (what it discards may matter more than the size) but says "not smaller". Co-authored-by: Andrew Taylor Co-authored-by: Claude Opus 5.5 Co-authored-by: Brandon Thomas --- ATTRIBUTION.toml | 30 ++++++++-------- crates/automation/src/lib.rs | 8 +++-- crates/automation/src/tools.rs | 2 +- crates/automation/tests/automation.rs | 7 +++- crates/optimize/src/images.rs | 17 +++++++-- crates/optimize/src/lib.rs | 4 +-- crates/optimize/tests/optimize.rs | 33 +++++++++++++++++ crates/ui-egui/src/create_ui.rs | 38 ++++++++++++-------- crates/ui-egui/src/i18n/ar.tsv | 2 ++ crates/ui-egui/src/i18n/bg.tsv | 2 ++ crates/ui-egui/src/i18n/cs.tsv | 2 ++ crates/ui-egui/src/i18n/de.tsv | 2 ++ crates/ui-egui/src/i18n/es.tsv | 2 ++ crates/ui-egui/src/i18n/fr.tsv | 2 ++ crates/ui-egui/src/i18n/hu.tsv | 2 ++ crates/ui-egui/src/i18n/it.tsv | 2 ++ crates/ui-egui/src/i18n/ja.tsv | 2 ++ crates/ui-egui/src/i18n/pt-br.tsv | 2 ++ crates/ui-egui/src/i18n/ru.tsv | 2 ++ crates/ui-egui/src/i18n/te.tsv | 2 ++ crates/ui-egui/src/i18n/uk.tsv | 2 ++ crates/ui-egui/src/i18n/zh-hans.tsv | 2 ++ crates/ui-egui/src/i18n/zh-hant.tsv | 2 ++ crates/ui-egui/src/optimize_ui.rs | 6 ++-- crates/ui-egui/tests/create.rs | 52 +++++++++++++++++++++------ parity/acrobat-features.toml | 4 +-- 26 files changed, 178 insertions(+), 53 deletions(-) diff --git a/ATTRIBUTION.toml b/ATTRIBUTION.toml index 0aeb9adb3..fdb2c4aa9 100644 --- a/ATTRIBUTION.toml +++ b/ATTRIBUTION.toml @@ -19,7 +19,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 = "2692f1dfc21b9d7284ba69316085fb9a2f30dcb6d6f336401d7b9aa4b3fcf67a" +sha256 = "d79b409519f49bc8d257eda7abb3381a0fc62fec31c88712c95c53c95d756162" [[asset]] path = "crates/ui-egui/src/i18n/zh-hant.tsv" @@ -30,7 +30,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 = "242e4c748a66ee6ff18d657ee3e1ef0808be0d5adc005e45723f93c835d142f8" +sha256 = "cefe9cffb13e9babbbfbcc881bf3f24ee65b7ed013d94405b04bcf57349b3371" [[asset]] path = "crates/ui-egui/src/i18n/cs.tsv" @@ -41,7 +41,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 = "c6c0dec458b50a15fd5a7b38ce9ec5772e38231c5db584ff9aecceb4955ce132" +sha256 = "2d3a92f0f131fa5ece645fa0a6f3299e4d2912e5776b3951caad996254b9549b" [[asset]] path = "crates/ui-egui/src/i18n/pt-br.tsv" @@ -52,7 +52,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 = "ef28211bc332cc811a786952e4b60ba7dafb8e9a8553d75e11c4f800ad71832e" +sha256 = "3de8647fc74dc4a11979836ec0bcaeb2299965ce6779c118d0df3fbbb9e1fb05" [[asset]] path = "crates/ui-egui/src/i18n/es.tsv" @@ -63,7 +63,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 = "ca6d842ff9568adc5f8d8223f7791f075faf94845369bc2dfc8e52fe727f8b23" +sha256 = "83d2a772ac1d08d506b4e6e19c2ebc07860614710a058d69923d5bff7e1355c5" [[asset]] path = "crates/ui-egui/src/i18n/fr.tsv" @@ -74,7 +74,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 = "7c791c69d517bcb16c487217861428b7f6a53b96e3c496e2cc6a8710e7c18365" +sha256 = "32a092a72ebc03f4bc9f849091421ba306a0ee06531249c104471f51420450e9" [[asset]] path = "crates/ui-egui/src/i18n/de.tsv" @@ -85,7 +85,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 = "9030aef3c2790bb4dd1dda022c0d527fdce73a29bd4d1875edcbbaa95a3bf49e" +sha256 = "b598b6564cb9851cd97ccadc064071f02c3061d4f5a297baacdf3c7ef41dfd89" [[asset]] path = "crates/ui-egui/src/i18n/te.tsv" @@ -96,7 +96,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 = "bd15f74a2630e97216f56ff329e8e8d7cea2eae341f875dd7804908ad13e20f8" +sha256 = "531223a0da0367941ed6fde83cf4dbce173a978793218af372add0484d3f0428" [[asset]] path = "crates/ui-egui/src/i18n/it.tsv" @@ -107,7 +107,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 = "da28f8082b39ee9cff48217f69b920edde7107d140d18d212d1d1f110c5178df" +sha256 = "48ecc081b27cfa6be06af619785123cba7a786a1290d6aeb794f693c902d8246" [[asset]] path = "crates/ui-egui/src/i18n/ru.tsv" @@ -118,7 +118,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 = "cbb0332f3e85a5267fc0af033aa77430a35e83171c3e2e5dfe02bf103e4fc0ce" +sha256 = "1b7bc6dc28b20c10840af092f5a52e45f8fbb3740ddd907185b89a91b5d8a5ab" [[asset]] path = "crates/ui-egui/src/i18n/hu.tsv" @@ -129,7 +129,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 = "b4b1cef29030b23e8a3b10dc743a4a6394c0353aaddfc783bde79f6cfe8d81c5" +sha256 = "b6800b09844ec71ad26cc38670a8fb94873690f3dc9585a47352f334f7993906" [[asset]] path = "crates/ui-egui/src/i18n/bg.tsv" @@ -140,7 +140,7 @@ 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 = "efba2c1d7652d730c716eded3972982b5f6c1f797be48f4232a0d2efb7b19451" +sha256 = "e15e166aafc0c0e0a939b578c25a7a4f3808932aeed8eddbfe9bcc06e0362f3b" [[asset]] path = "crates/ui-egui/src/i18n/ja.tsv" @@ -151,7 +151,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 = "d6043524e622f7e00f272566f779530ff3dc5f4e5be93f442be79172df7d2f5f" +sha256 = "519d505722abc4e778a7ca3ff0aacc808321771d4e0b5629c7bf0b39e2dd8759" [[asset]] path = "crates/ui-egui/src/i18n/uk.tsv" @@ -162,7 +162,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 = "86512da6fab7b0860336437398b7ff57e03cee9db78e13dfd28b8aa8fee957a2" +sha256 = "5a7a4592c205347621e002d6eaffbd8b021db54252c9cba2759d79763901af5c" [[asset]] path = "crates/ui-egui/src/i18n/ar.tsv" @@ -173,7 +173,7 @@ 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 = "0dbeebeebfa940260e4355aa2741ba68a3aacba7a3d24d721981035fa0bef254" +sha256 = "e593813504da5443bd89d764c383872c997910ba63581b8bca7dc3879fe7b0fc" [[asset]] path = "assets/icons/accessibility.svg" diff --git a/crates/automation/src/lib.rs b/crates/automation/src/lib.rs index 511904b3e..d15652236 100644 --- a/crates/automation/src/lib.rs +++ b/crates/automation/src/lib.rs @@ -303,8 +303,12 @@ impl Automation { let before = self.doc(&a)?.bytes.len(); let path = self.resolve(a.str("path")?, true)?; let (bytes, merged) = self.session.reduced_bytes(id).map_err(failed)?; - write_atomic(&path, &bytes)?; - json!({ "path": path.to_string_lossy(), "bytes_before": before, "bytes_after": bytes.len(), "merged_objects": merged }) + // As in the app, a copy that isn't smaller is not written (#490). + let written = bytes.len() < before; + if written { + write_atomic(&path, &bytes)?; + } + json!({ "path": path.to_string_lossy(), "written": written, "bytes_before": before, "bytes_after": bytes.len(), "merged_objects": merged }) } "doc_optimize" => self.doc_optimize(&a)?, "doc_initial_view" => self.doc_initial_view(&a)?, diff --git a/crates/automation/src/tools.rs b/crates/automation/src/tools.rs index 6a89e742f..f7a4d5bca 100644 --- a/crates/automation/src/tools.rs +++ b/crates/automation/src/tools.rs @@ -971,7 +971,7 @@ pub fn tools() -> Vec { }), &["from"], )), - t("doc_reduce", "Reduce file size", "Write a smaller copy of the document to `path` with Acrobat's Reduce File Size choices: images above 225 ppi downsampled to 150 ppi and JPEG-compressed (medium quality), thumbnails dropped, identical fonts and images merged, unused objects dropped, compressed object streams. The open document is unchanged.") + t("doc_reduce", "Reduce file size", "Write a smaller copy of the document to `path` with Acrobat's Reduce File Size choices: images above 225 ppi downsampled to 150 ppi and JPEG-compressed (medium quality), thumbnails dropped, identical fonts and images merged, unused objects dropped, compressed object streams. If the copy wouldn't be smaller, nothing is written and `written` is false. The open document is unchanged.") .cmd("optimize.reduce") .with(schema(json!({ "doc": doc(), "path": { "type": "string" } }), &["doc", "path"])), t("doc_initial_view", "Initial view", "Read or change how the document opens (Document Properties ▸ Initial View) and its reading options: navigation (page, bookmarks, pages, attachments, layers), layout (default, single, continuous, two_up, two_up_continuous, two_up_cover, two_up_continuous_cover), magnification (default, actual, fit_page, fit_width, fit_height, fit_visible, or a percentage), page, window options (fit_window, center_window, full_screen, display_title), interface options (hide_menubar, hide_toolbar, hide_window_ui), language and binding (left, right). Only the given ones change; returns the result. Undoable.").with(schema( diff --git a/crates/automation/tests/automation.rs b/crates/automation/tests/automation.rs index 40e894ff0..7a201c940 100644 --- a/crates/automation/tests/automation.rs +++ b/crates/automation/tests/automation.rs @@ -1791,8 +1791,11 @@ fn creating_and_reducing_through_tools() { let t = ok(&mut a, "doc_create", json!({ "from": "text", "path": "notes.txt" }))["doc"].as_u64().unwrap(); assert_eq!(page_text(&mut a, t), ["Meeting notes\nAction items"]); ok(&mut a, "doc_save", json!({ "doc": t, "path": "notes.pdf" })); + // A new text PDF is already compact: Reduce writes nothing rather than a copy no smaller. let r = ok(&mut a, "doc_reduce", json!({ "doc": t, "path": "notes-small.pdf" })); - assert!(r["bytes_after"].as_u64().unwrap() > 0 && dir.join("notes-small.pdf").exists()); + assert!(r["bytes_after"].as_u64().unwrap() >= r["bytes_before"].as_u64().unwrap(), "{r}"); + assert_eq!(r["written"], false); + assert!(!dir.join("notes-small.pdf").exists()); assert!(matches!(a.call("doc_create", &json!({ "from": "images", "paths": ["notes.txt"] })), Err(ToolError::Failed(_)))); } @@ -2824,6 +2827,8 @@ fn optimizing_through_tools() { assert_eq!(small["pages"], 1); let reduced = ok(&mut a, "doc_reduce", json!({ "doc": doc, "path": "reduced.pdf" })); assert!(reduced["bytes_after"].as_u64().unwrap() < reduced["bytes_before"].as_u64().unwrap()); + assert_eq!(reduced["written"], true); + assert_eq!(std::fs::metadata(dir.join("reduced.pdf")).unwrap().len(), reduced["bytes_after"].as_u64().unwrap()); assert!(matches!( a.call("doc_optimize", &json!({ "doc": doc, "path": "x.pdf", "color": { "compression": "gif" } })), Err(ToolError::InvalidArgs(_)) diff --git a/crates/optimize/src/images.rs b/crates/optimize/src/images.rs index 4f079e14b..8645c82bf 100644 --- a/crates/optimize/src/images.rs +++ b/crates/optimize/src/images.rs @@ -208,10 +208,19 @@ fn jpeg(px: &[u8], n: usize, w: u32, h: u32, quality: u8) -> Option> { Some(out) } +/// Whether `decode` is the default decode array for `n` components ([0 1] each), which changes +/// nothing. Scanners write it on page images (#490). +fn is_identity_decode(doc: &Document, decode: &Object, n: usize) -> bool { + let decode = doc.resolve(decode); + let Some(a) = decode.as_array() else { return false }; + a.len() == 2 * n && a.chunks(2).all(|p| matches!(p, [lo, hi] if doc.resolve(lo).as_f64() == Some(0.0) && doc.resolve(hi).as_f64() == Some(1.0))) +} + /// A new image stream from 8-bit samples: the original dictionary with the new size and filter. +/// A decode array is dropped: only images without one, or with the default one, get here. fn image_stream(old: &Dict, px: &[u8], n: usize, w: u32, h: u32, compression: Compression, was_dct: bool) -> Option { let mut d = old.clone(); - for k in [&b"Filter"[..], b"DecodeParms", b"Length", b"DL"] { + for k in [&b"Filter"[..], b"DecodeParms", b"Decode", b"Length", b"DL"] { d.remove(k); } d.set(b"Width".to_vec(), Object::Int(w as i64)); @@ -239,7 +248,11 @@ fn image_stream(old: &Dict, px: &[u8], n: usize, w: u32, h: u32, compression: Co /// Returns the new image and its new soft mask, if the result is smaller. fn process(doc: &Document, s: &Stream, ppi: f64, settings: &ImageSettings, n: usize) -> Option<(Stream, Option<(ObjRef, Stream)>)> { let d = &s.dict; - if d.contains(b"Decode") || d.contains(b"Mask") || d.name(b"ImageMask").is_some() || matches!(d.get(b"ImageMask"), Some(Object::Bool(true))) { + if d.get(b"Decode").is_some_and(|a| !is_identity_decode(doc, a, n)) + || d.contains(b"Mask") + || d.name(b"ImageMask").is_some() + || matches!(d.get(b"ImageMask"), Some(Object::Bool(true))) + { return None; } if d.int(b"BitsPerComponent") != Some(8) { diff --git a/crates/optimize/src/lib.rs b/crates/optimize/src/lib.rs index 7f241b680..1d384ff51 100644 --- a/crates/optimize/src/lib.rs +++ b/crates/optimize/src/lib.rs @@ -5,8 +5,8 @@ //! smallest over its uses, through form XObjects); colour and grayscale images above a //! threshold are resampled (bicubic) to a target resolution and recompressed as JPEG or Flate. //! A new image replaces the old one only if it is smaller. Images PdfCraft can't decode -//! faithfully (CMYK and other colour spaces, masks, decode arrays, JPEG 2000, JBIG2, CCITT, -//! more than 8 bits) are left alone. +//! faithfully (CMYK and other colour spaces, masks, decode arrays other than the default, +//! JPEG 2000, JBIG2, CCITT, other than 8 bits) are left alone. //! - **Discard objects:** page thumbnails, alternate images, document tags (structure tree), //! print settings. //! - **Clean up:** Flate-compress streams that have no filter. diff --git a/crates/optimize/tests/optimize.rs b/crates/optimize/tests/optimize.rs index 9e698756a..007e859f5 100644 --- a/crates/optimize/tests/optimize.rs +++ b/crates/optimize/tests/optimize.rs @@ -131,6 +131,39 @@ fn images_are_measured_where_drawn_and_downsampled() { assert_ne!(&out.rgba[i..i + 3], &[255, 255, 255]); } +#[test] +fn an_identity_decode_array_does_not_stop_reduce() { + // Office scanners write /Decode [0 1 0 1 0 1] (no change) on their page images (#490). + let with_decode = |doc: &mut Document, r: ObjRef, decode: Vec| { + let mut s = stream(doc, r); + s.dict.set(b"Decode".to_vec(), Object::Array(decode)); + doc.set(r, Object::Stream(s)); + }; + let mut doc = Document::new_empty(); + // 900 px drawn 216 pt (3 in) wide → 300 ppi, like a scanned page. + let scan = image(&mut doc, 900, 900, 3, true, None); + with_decode(&mut doc, scan, [0, 1, 0, 1, 0, 1].map(Object::Int).to_vec()); + let gray = image(&mut doc, 900, 900, 1, false, None); + with_decode(&mut doc, gray, vec![Object::Real(0.0), Object::Real(1.0)]); + // A decode array that inverts the colours, and one of the wrong length: left alone. + let inverted = image(&mut doc, 900, 900, 3, true, None); + with_decode(&mut doc, inverted, [1, 0, 1, 0, 1, 0].map(Object::Int).to_vec()); + let odd = image(&mut doc, 900, 900, 3, true, None); + with_decode(&mut doc, odd, [0, 1].map(Object::Int).to_vec()); + page( + &mut doc, + &[("S", scan), ("G", gray), ("I", inverted), ("O", odd)], + "q 216 0 0 216 0 0 cm /S Do Q q 216 0 0 216 216 0 cm /G Do Q q 216 0 0 216 0 300 cm /I Do Q q 216 0 0 216 216 300 cm /O Do Q", + ); + let report = optimize(&mut doc, &Settings::default()).unwrap(); + assert_eq!(report.images_resampled, 2, "{report:?}"); + assert_eq!(stream(&doc, scan).dict.int(b"Width"), Some(450)); + assert_eq!(stream(&doc, gray).dict.int(b"Width"), Some(450)); + assert!(!stream(&doc, scan).dict.contains(b"Decode"), "the default decode array isn't carried over"); + assert_eq!(stream(&doc, inverted).dict.int(b"Width"), Some(900)); + assert_eq!(stream(&doc, odd).dict.int(b"Width"), Some(900)); +} + #[test] fn settings_choose_what_happens() { let mut doc = Document::new_empty(); diff --git a/crates/ui-egui/src/create_ui.rs b/crates/ui-egui/src/create_ui.rs index 56e82d885..fedd2d64c 100644 --- a/crates/ui-egui/src/create_ui.rs +++ b/crates/ui-egui/src/create_ui.rs @@ -227,15 +227,15 @@ impl PdfCraftApp { } /// Save an optimized copy (Reduce File Size, Optimize PDF) next to the original, reporting - /// the saving. + /// the saving. A Reduce File Size copy that isn't smaller is not saved (#490). pub(crate) fn save_optimized( &mut self, id: pdfcraft_engine::DocId, - suffix: &str, + kind: crate::optimize_ui::OptimizeKind, result: Result<(Arc>, String), pdfcraft_engine::EditError>, ) { let Some(doc) = self.session.get(id) else { return }; - let (before, name) = (doc.bytes.len(), format!("{} ({suffix}).pdf", stem(&doc.name))); + let (before, name) = (doc.bytes.len(), format!("{} ({}).pdf", stem(&doc.name), kind.suffix())); let (bytes, detail) = match result { Ok(r) => r, Err(e) => { @@ -244,18 +244,28 @@ impl PdfCraftApp { } }; let after = bytes.len(); - let saved = move |app: &mut PdfCraftApp, place: String| { - let pct = 100.0 * (1.0 - after as f64 / before.max(1) as f64); - app.notify_fmt( - "Saved {place}: {before} → {after} ({pct}% smaller){detail}", - &[ - ("place", &place), - ("before", &crate::panels::human_size(before)), - ("after", &crate::panels::human_size(after)), - ("pct", &format!("{pct:.0}")), - ("detail", &detail), - ], + if kind == crate::optimize_ui::OptimizeKind::Reduce && after >= before { + self.notify_fmt( + "This file is already as small as it can be made ({size}). No copy was saved.", + &[("size", &crate::panels::human_size(before))], ); + return; + } + let saved = move |app: &mut PdfCraftApp, place: String| { + let (b, a) = (crate::panels::human_size(before), crate::panels::human_size(after)); + if after < before { + let pct = 100.0 * (1.0 - after as f64 / before.max(1) as f64); + app.notify_fmt( + "Saved {place}: {before} → {after} ({pct}% smaller){detail}", + &[("place", &place), ("before", &b), ("after", &a), ("pct", &format!("{pct:.0}")), ("detail", &detail)], + ); + } else { + // Optimize PDF still saves: what it discards may matter more than the size. + app.notify_fmt( + "Saved {place}: {before} → {after} (not smaller){detail}", + &[("place", &place), ("before", &b), ("after", &a), ("detail", &detail)], + ); + } }; #[cfg(not(target_arch = "wasm32"))] { diff --git a/crates/ui-egui/src/i18n/ar.tsv b/crates/ui-egui/src/i18n/ar.tsv index 2866522ef..acb60a847 100644 --- a/crates/ui-egui/src/i18n/ar.tsv +++ b/crates/ui-egui/src/i18n/ar.tsv @@ -1496,6 +1496,8 @@ Right Edge الحافة اليمنى Language اللغة Saved {place}: {before} → {after} ({pct}% smaller){detail} تم الحفظ {place}: {before} → {after} (أصغر بنسبة {pct}%){detail} + This file is already as small as it can be made ({size}). No copy was saved. هذا الملف صغير بالفعل قدر الإمكان ({size}). لم تُحفظ أي نسخة. + Saved {place}: {before} → {after} (not smaller){detail} تم الحفظ {place}: {before} → {after} (ليس أصغر){detail} Couldn't save the image: {e} تعذّر حفظ الصورة: {e} Adding images arrives on the web with file pickers for images إضافة الصور ستتوفر على الويب مع منتقيات ملفات الصور Replacing images on page {p} arrives on the web with image pickers ({i}) استبدال الصور في الصفحة {p} سيتوفر على الويب مع منتقيات الصور ({i}) diff --git a/crates/ui-egui/src/i18n/bg.tsv b/crates/ui-egui/src/i18n/bg.tsv index 138b112c0..5e98978d3 100644 --- a/crates/ui-egui/src/i18n/bg.tsv +++ b/crates/ui-egui/src/i18n/bg.tsv @@ -1448,6 +1448,8 @@ Right Edge Десен ръб Language Език Saved {place}: {before} → {after} ({pct}% smaller){detail} Записано {place}: {before} → {after} (с {pct}% по-малко){detail} + This file is already as small as it can be made ({size}). No copy was saved. Файлът вече е възможно най-малък ({size}). Не е записано копие. + Saved {place}: {before} → {after} (not smaller){detail} Записано {place}: {before} → {after} (не е по-малко){detail} Couldn't save the image: {e} Изображението не можа да бъде записано: {e} Adding images arrives on the web with file pickers for images Добавянето на изображения в уеб версията ще стане достъпно с инструментите за избор на файлове с изображения Replacing images on page {p} arrives on the web with image pickers ({i}) Замяната на изображения на страница {p} в уеб версията ще стане достъпна с инструментите за избор на изображения ({i}) diff --git a/crates/ui-egui/src/i18n/cs.tsv b/crates/ui-egui/src/i18n/cs.tsv index 11188391a..a53a439be 100644 --- a/crates/ui-egui/src/i18n/cs.tsv +++ b/crates/ui-egui/src/i18n/cs.tsv @@ -51,6 +51,8 @@ Document properties… Vlastnosti dokumentu… Export to image… Exportovat jako obrázek… Reduce file size… Zmenšit velikost souboru… + This file is already as small as it can be made ({size}). No copy was saved. Soubor je už tak malý, jak to jde ({size}). Žádná kopie nebyla uložena. + Saved {place}: {before} → {after} (not smaller){detail} Uloženo {place}: {before} → {after} (není menší){detail} Optimize PDF… Optimalizovat PDF… Export to text… Exportovat jako text… Export to Word… Exportovat do formátu Word… diff --git a/crates/ui-egui/src/i18n/de.tsv b/crates/ui-egui/src/i18n/de.tsv index 9baf7af67..2830d3f41 100644 --- a/crates/ui-egui/src/i18n/de.tsv +++ b/crates/ui-egui/src/i18n/de.tsv @@ -1476,6 +1476,8 @@ Right Edge Rechter Rand Language Sprache Saved {place}: {before} → {after} ({pct}% smaller){detail} {place} gespeichert: {before} → {after} ({pct} % kleiner){detail} + This file is already as small as it can be made ({size}). No copy was saved. Diese Datei ist bereits so klein wie möglich ({size}). Es wurde keine Kopie gespeichert. + Saved {place}: {before} → {after} (not smaller){detail} {place} gespeichert: {before} → {after} (nicht kleiner){detail} Couldn't save the image: {e} Das Bild konnte nicht gespeichert werden: {e} Adding images arrives on the web with file pickers for images Bilder hinzufügen kommt im Web zusammen mit der Bildauswahl Replacing images on page {p} arrives on the web with image pickers ({i}) Bilder auf Seite {p} ersetzen kommt im Web zusammen mit der Bildauswahl ({i}) diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index 1c8702369..1d8ffcd26 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -1490,6 +1490,8 @@ Right Edge Borde derecho Language Idioma Saved {place}: {before} → {after} ({pct}% smaller){detail} Guardado {place}: {before} → {after} ({pct}% más pequeño){detail} + This file is already as small as it can be made ({size}). No copy was saved. Este archivo ya es lo más pequeño posible ({size}). No se guardó ninguna copia. + Saved {place}: {before} → {after} (not smaller){detail} Guardado {place}: {before} → {after} (no es más pequeño){detail} Couldn't save the image: {e} No se pudo guardar la imagen: {e} Adding images arrives on the web with file pickers for images Añadir imágenes llegará a la versión web con los selectores de archivos de imagen Replacing images on page {p} arrives on the web with image pickers ({i}) Reemplazar imágenes en la página {p} llegará a la versión web con los selectores de imagen ({i}) diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index 88a6117d9..ab1241076 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -1475,6 +1475,8 @@ Right Edge Bord droit Language Langue Saved {place}: {before} → {after} ({pct}% smaller){detail} Enregistré {place} : {before} → {after} ({pct} % plus petit){detail} + This file is already as small as it can be made ({size}). No copy was saved. Ce fichier est déjà aussi petit que possible ({size}). Aucune copie n'a été enregistrée. + Saved {place}: {before} → {after} (not smaller){detail} Enregistré {place} : {before} → {after} (pas plus petit){detail} Couldn't save the image: {e} Impossible d'enregistrer l'image : {e} Adding images arrives on the web with file pickers for images L'ajout d'images arrivera sur le web avec les sélecteurs de fichiers d'images Replacing images on page {p} arrives on the web with image pickers ({i}) Le remplacement d'images à la page {p} arrivera sur le web avec les sélecteurs d'images ({i}) diff --git a/crates/ui-egui/src/i18n/hu.tsv b/crates/ui-egui/src/i18n/hu.tsv index 3ac27fdea..813aa27d5 100644 --- a/crates/ui-egui/src/i18n/hu.tsv +++ b/crates/ui-egui/src/i18n/hu.tsv @@ -1465,6 +1465,8 @@ Right Edge Jobb szél Language Nyelv Saved {place}: {before} → {after} ({pct}% smaller){detail} Mentve ide: {place}: {before} → {after} ({pct}%-kal kisebb){detail} + This file is already as small as it can be made ({size}). No copy was saved. Ez a fájl már a lehető legkisebb ({size}). Nem készült másolat. + Saved {place}: {before} → {after} (not smaller){detail} Mentve ide: {place}: {before} → {after} (nem kisebb){detail} Couldn't save the image: {e} A képet nem sikerült menteni: {e} Adding images arrives on the web with file pickers for images A képek hozzáadása a webes változatban a képválasztóval együtt érkezik Replacing images on page {p} arrives on the web with image pickers ({i}) A {p}. oldal képeinek cseréje a webes változatban a képválasztóval együtt érkezik ({i}) diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv index fff7d958e..38c929395 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -1491,6 +1491,8 @@ Right Edge Bordo destro Language Lingua Saved {place}: {before} → {after} ({pct}% smaller){detail} Salvato {place}: {before} → {after} ({pct}% in meno){detail} + This file is already as small as it can be made ({size}). No copy was saved. Questo file è già il più piccolo possibile ({size}). Nessuna copia salvata. + Saved {place}: {before} → {after} (not smaller){detail} Salvato {place}: {before} → {after} (non più piccolo){detail} Couldn't save the image: {e} Impossibile salvare l'immagine: {e} Adding images arrives on the web with file pickers for images Sul web, l'aggiunta di immagini arriverà con i selettori di file per immagini Replacing images on page {p} arrives on the web with image pickers ({i}) Sul web, la sostituzione di immagini a pagina {p} arriverà con i selettori di immagini ({i}) diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index 0d7525cec..b44cbc77b 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -1496,6 +1496,8 @@ Right Edge 右端 Language 言語 Saved {place}: {before} → {after} ({pct}% smaller){detail} {place} に保存: {before} → {after}({pct}% 削減){detail} + This file is already as small as it can be made ({size}). No copy was saved. このファイルはすでに最小のサイズです({size})。コピーは保存されませんでした。 + Saved {place}: {before} → {after} (not smaller){detail} {place} に保存: {before} → {after}(縮小なし){detail} Couldn't save the image: {e} 画像を保存できませんでした: {e} Adding images arrives on the web with file pickers for images Web 版の画像追加は画像ファイル選択に対応した際に利用可能になります Replacing images on page {p} arrives on the web with image pickers ({i}) ページ {p} の画像置換は Web 版の画像選択に対応した際に利用可能になります({i}) diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index f0912dbda..62527bb11 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -1389,6 +1389,8 @@ Right Edge Borda direita Language Idioma Saved {place}: {before} → {after} ({pct}% smaller){detail} Salvo {place}: {before} → {after} ({pct}% menor){detail} + This file is already as small as it can be made ({size}). No copy was saved. Este arquivo já está o menor possível ({size}). Nenhuma cópia foi salva. + Saved {place}: {before} → {after} (not smaller){detail} Salvo {place}: {before} → {after} (não ficou menor){detail} Couldn't save the image: {e} Não foi possível salvar a imagem: {e} Adding images arrives on the web with file pickers for images A adição de imagens chegará à Web com seletores de arquivo para imagens Replacing images on page {p} arrives on the web with image pickers ({i}) A substituição de imagens na página {p} chegará à Web com seletores de imagem ({i}) diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index c4cf0a429..631be2ce0 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -1497,6 +1497,8 @@ Right Edge Правый край Language Язык Saved {place}: {before} → {after} ({pct}% smaller){detail} Сохранено {place}: {before} → {after} (на {pct}% меньше){detail} + This file is already as small as it can be made ({size}). No copy was saved. Этот файл уже минимального размера ({size}). Копия не сохранена. + Saved {place}: {before} → {after} (not smaller){detail} Сохранено {place}: {before} → {after} (не меньше){detail} Couldn't save the image: {e} Не удалось сохранить изображение: {e} Adding images arrives on the web with file pickers for images Добавление изображений появится в веб-версии с выбором файлов изображений Replacing images on page {p} arrives on the web with image pickers ({i}) Замена изображений на стр. {p} появится в веб-версии с выбором изображений ({i}) diff --git a/crates/ui-egui/src/i18n/te.tsv b/crates/ui-egui/src/i18n/te.tsv index 69ec3fbdb..3a2066aaa 100644 --- a/crates/ui-egui/src/i18n/te.tsv +++ b/crates/ui-egui/src/i18n/te.tsv @@ -1471,6 +1471,8 @@ action wizard Start ప్రారంభించు Right Edge కుడి అంచు Language భాష Saved {place}: {before} → {after} ({pct}% smaller){detail} {place} సేవ్ చేయబడింది: {before} → {after} ({pct}% తగ్గింది){detail} + This file is already as small as it can be made ({size}). No copy was saved. ఈ ఫైల్ ఇప్పటికే సాధ్యమైనంత చిన్నదిగా ఉంది ({size}). కాపీ ఏదీ సేవ్ చేయబడలేదు. + Saved {place}: {before} → {after} (not smaller){detail} {place} సేవ్ చేయబడింది: {before} → {after} (తగ్గలేదు){detail} Couldn't save the image: {e} చిత్రాన్ని సేవ్ చేయడం సాధ్యం కాలేదు: {e} Adding images arrives on the web with file pickers for images చిత్రాలను జోడించడం, చిత్రాల కోసం ఫైల్ పికర్‌లతో పాటు వెబ్‌లో వస్తుంది Replacing images on page {p} arrives on the web with image pickers ({i}) {p}వ పేజీలోని చిత్రాలను భర్తీ చేయడం, చిత్రాల పికర్‌లతో పాటు వెబ్‌లో వస్తుంది ({i}) diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index 82d78fdb4..ae1349cd3 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -1494,6 +1494,8 @@ Right Edge Правий край Language Мова Saved {place}: {before} → {after} ({pct}% smaller){detail} Збережено {place}: {before} → {after} (менше на {pct}%){detail} + This file is already as small as it can be made ({size}). No copy was saved. Цей файл уже мінімального розміру ({size}). Копію не збережено. + Saved {place}: {before} → {after} (not smaller){detail} Збережено {place}: {before} → {after} (не менше){detail} Couldn't save the image: {e} Не вдалося зберегти зображення: {e} Adding images arrives on the web with file pickers for images Додавання зображень з’явиться у вебверсії разом із вибором файлів зображень Replacing images on page {p} arrives on the web with image pickers ({i}) Заміна зображень на сторінці {p} з’явиться у вебверсії разом із вибором зображень ({i}) diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index 98bf4dd74..0aca6798e 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -1455,6 +1455,8 @@ Right Edge 右边缘 Language 语言 Saved {place}: {before} → {after} ({pct}% smaller){detail} 已保存{place}:{before} → {after}(缩小了{pct}%){detail} + This file is already as small as it can be made ({size}). No copy was saved. 此文件已经是最小的了({size})。未保存副本。 + Saved {place}: {before} → {after} (not smaller){detail} 已保存{place}:{before} → {after}(未缩小){detail} Couldn't save the image: {e} 无法保存图像:{e} Adding images arrives on the web with file pickers for images Web 端添加图像将使用图像文件选择器 Replacing images on page {p} arrives on the web with image pickers ({i}) 在 Web 端替换第{p}页的图像将使用图像选择器({i}) diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index 9cefb1c4c..ae7132dd4 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -1404,6 +1404,8 @@ Right Edge 右邊緣 Language 語言 Saved {place}: {before} → {after} ({pct}% smaller){detail} 已儲存{place}:{before} → {after}(縮小了{pct}%){detail} + This file is already as small as it can be made ({size}). No copy was saved. 此檔案已經是最小的了({size})。未儲存副本。 + Saved {place}: {before} → {after} (not smaller){detail} 已儲存{place}:{before} → {after}(未縮小){detail} Couldn't save the image: {e} 無法儲存圖片:{e} Adding images arrives on the web with file pickers for images Web 端新增圖片將使用圖片檔案選擇器 Replacing images on page {p} arrives on the web with image pickers ({i}) 在 Web 端替換第{p}頁的圖片將使用圖片選擇器({i}) diff --git a/crates/ui-egui/src/optimize_ui.rs b/crates/ui-egui/src/optimize_ui.rs index ba65f465f..3452eeb52 100644 --- a/crates/ui-egui/src/optimize_ui.rs +++ b/crates/ui-egui/src/optimize_ui.rs @@ -60,7 +60,7 @@ pub enum OptimizeKind { impl OptimizeKind { /// The suffix of the saved copy's name ("notes (reduced).pdf"). - fn suffix(self) -> &'static str { + pub(crate) fn suffix(self) -> &'static str { match self { OptimizeKind::Reduce => "reduced", OptimizeKind::Advanced => "optimized", @@ -282,7 +282,7 @@ impl PdfCraftApp { } let job = match self.session.optimize_job(id, settings, discard) { Ok(job) => job, - Err(e) => return self.save_optimized(id, kind.suffix(), Err(e)), + Err(e) => return self.save_optimized(id, kind, Err(e)), }; let progress = Arc::new(Mutex::new(OptimizeProgress::default())); let p = progress.clone(); @@ -352,7 +352,7 @@ impl PdfCraftApp { let detail = if kind == OptimizeKind::Advanced { summary(&r) } else { String::new() }; (b, detail) }); - self.save_optimized(doc, kind.suffix(), result); + self.save_optimized(doc, kind, result); } } } diff --git a/crates/ui-egui/tests/create.rs b/crates/ui-egui/tests/create.rs index f8f0e3392..ee81616b5 100644 --- a/crates/ui-egui/tests/create.rs +++ b/crates/ui-egui/tests/create.rs @@ -69,17 +69,13 @@ fn opening_images_and_text_converts_them_to_new_pdfs() { assert!(app.open_bytes("junk.png", None, b"\x89PNG\r\n\x1a\nnot really".to_vec()).is_err()); } -#[test] -fn reduce_file_size_writes_a_compact_copy() { - use egui_kittest::Harness; - let dir = std::env::temp_dir().join(format!("pdfcraft-reduce-{}", std::process::id())); - std::fs::create_dir_all(&dir).unwrap(); - let out = dir.join("reduced.pdf"); - let out2 = out.clone(); - let mut h = Harness::builder().with_size(egui::vec2(1200.0, 800.0)).build_eframe(move |_cc| { +/// Open `name` and run Reduce File Size on it, saving to `out` without a dialog. +fn reduce(name: &'static str, bytes: Vec, out: &std::path::Path) -> egui_kittest::Harness<'static, PdfCraftApp> { + let out2 = out.to_path_buf(); + let mut h = egui_kittest::Harness::builder().with_size(egui::vec2(1200.0, 800.0)).build_eframe(move |_cc| { let mut app = PdfCraftApp::new(); app.set_option("language", "en").unwrap(); - app.open_bytes("notes.txt", None, "lorem ipsum ".repeat(500).into_bytes()).unwrap(); + app.open_bytes(name, None, bytes).unwrap(); app.save_override = Some(out2.to_string_lossy().into_owned()); app }); @@ -87,15 +83,49 @@ fn reduce_file_size_writes_a_compact_copy() { assert!(h.state_mut().execute("optimize.reduce")); // It runs on a worker thread like the PDF Optimizer, with the progress card meanwhile. let start = std::time::Instant::now(); - while (h.state().optimize_run.is_some() || !out.exists()) && start.elapsed() < std::time::Duration::from_secs(30) { + while h.state().optimize_run.is_some() && start.elapsed() < std::time::Duration::from_secs(30) { h.run_steps(1); std::thread::sleep(std::time::Duration::from_millis(10)); } - h.run_steps(1); + h.run_steps(2); assert!(h.state().optimize_run.is_none() && h.state().progress_notice.is_none(), "the run finished"); + h +} + +#[test] +fn reduce_file_size_writes_a_compact_copy() { + use egui_kittest::kittest::Queryable; + // A photo-like picture, stored losslessly: Reduce recompresses it as JPEG. + let (w, h) = (600u32, 400u32); + let px: Vec = (0..w * h).flat_map(|i| [(i % w * 255 / w) as u8 ^ (i & 7) as u8, (i / w * 255 / h) as u8, 128]).collect(); + let mut photo = Vec::new(); + { + let mut enc = png::Encoder::new(&mut photo, w, h); + enc.set_color(png::ColorType::Rgb); + enc.set_depth(png::BitDepth::Eight); + enc.write_header().unwrap().write_image_data(&px).unwrap(); + } + let dir = std::env::temp_dir().join(format!("pdfcraft-reduce-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let out = dir.join("reduced.pdf"); + let h = reduce("photo.png", photo, &out); let bytes = std::fs::read(&out).unwrap(); assert!(bytes.starts_with(b"%PDF-")); + assert!(bytes.len() < h.state().session.docs()[0].bytes.len(), "the copy is smaller"); assert!(h.state().session.docs()[0].dirty, "the open document is unchanged"); + h.get_by_label_contains("% smaller"); +} + +#[test] +fn reduce_file_size_saves_nothing_when_the_file_is_already_small() { + use egui_kittest::kittest::Queryable; + let dir = std::env::temp_dir().join(format!("pdfcraft-reduce-small-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let out = dir.join("reduced.pdf"); + // A new text PDF is already compact: a copy would be no smaller (#490). + let h = reduce("notes.txt", "lorem ipsum ".repeat(500).into_bytes(), &out); + assert!(!out.exists(), "no copy was written"); + h.get_by_label_contains("This file is already as small as it can be made"); } #[test] diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index f95e15a57..4b36bea82 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -7130,8 +7130,8 @@ milestone = "M11" status = "shipped" commands = ["optimize.reduce"] tools = ["doc_reduce"] -evidence = ["crates/ui-egui/tests/create.rs::reduce_file_size_writes_a_compact_copy", "crates/engine/src/tests.rs::create_and_reduce", "crates/automation/tests/automation.rs::creating_and_reducing_through_tools", "crates/automation/tests/automation.rs::optimizing_through_tools"] -notes = "images above 225 ppi to 150 ppi JPEG medium, thumbnails dropped, resources merged, object streams; runs in the background with a progress bar and Cancel" +evidence = ["crates/ui-egui/tests/create.rs::reduce_file_size_writes_a_compact_copy", "crates/ui-egui/tests/create.rs::reduce_file_size_saves_nothing_when_the_file_is_already_small", "crates/engine/src/tests.rs::create_and_reduce", "crates/automation/tests/automation.rs::creating_and_reducing_through_tools", "crates/automation/tests/automation.rs::optimizing_through_tools", "crates/optimize/tests/optimize.rs::an_identity_decode_array_does_not_stop_reduce"] +notes = "images above 225 ppi to 150 ppi JPEG medium, thumbnails dropped, resources merged, object streams; scanner images with the default decode array are included; a copy that isn't smaller is not saved (\"already as small as it can be made\"); runs in the background with a progress bar and Cancel" [[feature]] id = "optimize.compatibility-level" From 4dafbfc32574b2b1218dc80d88224d8bf44090cb Mon Sep 17 00:00:00 2001 From: taylorandrew07-lab Date: Sat, 10 Oct 2026 16:21:07 -0400 Subject: [PATCH 45/69] M11: Reduce file size and Optimize PDF drop images nothing draws, such as a deleted image (#894) Deleting an image with Edit a PDF removes its Do operation, but the image stays in the page's /Resources /XObject, so every full rewrite (Reduce file size, Optimize PDF) carried its data along: a page left blank still weighed the whole picture. Clean Up now drops image and form XObjects from a page's own resources when their name is written in no stream the document still uses. Shared and inherited resources are left alone, and if any stream can't be read the way a viewer would read it, nothing is removed: viewers look names up through parent resources, so only a name written nowhere is safe to drop. doc_optimize reports unused_xobjects. Co-authored-by: Andrew Taylor Co-authored-by: Claude Opus 5.5 --- crates/automation/src/lib.rs | 1 + crates/automation/src/tools.rs | 2 +- crates/automation/tests/automation.rs | 27 +++ crates/optimize/src/lib.rs | 12 +- crates/optimize/src/unused.rs | 304 ++++++++++++++++++++++++++ crates/optimize/tests/optimize.rs | 246 +++++++++++++++++++++ parity/acrobat-features.toml | 4 +- 7 files changed, 590 insertions(+), 6 deletions(-) create mode 100644 crates/optimize/src/unused.rs diff --git a/crates/automation/src/lib.rs b/crates/automation/src/lib.rs index d15652236..f19215c13 100644 --- a/crates/automation/src/lib.rs +++ b/crates/automation/src/lib.rs @@ -855,6 +855,7 @@ impl Automation { "invalid_links": o.invalid_links, "invalid_bookmarks": o.invalid_bookmarks, "unreferenced_dests": o.unreferenced_dests, + "unused_xobjects": o.unused_xobjects, "merged_objects": r.merged, "discarded": r.discarded.iter().map(|(h, n)| json!({ "category": h.id(), "count": n })).collect::>(), })) diff --git a/crates/automation/src/tools.rs b/crates/automation/src/tools.rs index f7a4d5bca..4b29b37ae 100644 --- a/crates/automation/src/tools.rs +++ b/crates/automation/src/tools.rs @@ -1052,7 +1052,7 @@ pub fn tools() -> Vec { .with(schema(json!({ "doc": doc() }), &["doc"])), t("doc_open_revision", "Open a revision", "Open saved revision `revision` (1 = the oldest) of a document as a new, unsaved document, to see the file as it was then.") .with(schema(json!({ "doc": doc(), "revision": { "type": "integer", "minimum": 1 } }), &["doc", "revision"])), - t("doc_optimize", "Optimize PDF", "Write an optimized copy to `path` (Acrobat's PDF Optimizer). color / gray: { downsample, ppi, above_ppi, compression: jpeg|zip|retain, quality 1–100 } (defaults: downsample to 150 ppi above 225, JPEG 60). Images are measured where pages draw them and replaced only when smaller. discard_*: thumbnails (default true), alternate_images (true), tags, print_settings; flate_unencoded (true); remove_invalid_links and remove_unreferenced_dests (true). discard: Remove Hidden Information categories (metadata, attachments, comments, form-fields, hidden-text, hidden-layers, bookmarks, links-actions-scripts, private-data). Signed documents are refused. The open document is unchanged.") + t("doc_optimize", "Optimize PDF", "Write an optimized copy to `path` (Acrobat's PDF Optimizer). color / gray: { downsample, ppi, above_ppi, compression: jpeg|zip|retain, quality 1–100 } (defaults: downsample to 150 ppi above 225, JPEG 60). Images are measured where pages draw them and replaced only when smaller. discard_*: thumbnails (default true), alternate_images (true), tags, print_settings; flate_unencoded (true); remove_invalid_links and remove_unreferenced_dests (true); images and forms the pages list but never draw are always dropped (unused_xobjects). discard: Remove Hidden Information categories (metadata, attachments, comments, form-fields, hidden-text, hidden-layers, bookmarks, links-actions-scripts, private-data). Signed documents are refused. The open document is unchanged.") .cmd("optimize.advanced") .with(schema( json!({ diff --git a/crates/automation/tests/automation.rs b/crates/automation/tests/automation.rs index 7a201c940..79dd29bd3 100644 --- a/crates/automation/tests/automation.rs +++ b/crates/automation/tests/automation.rs @@ -3684,3 +3684,30 @@ fn command_batch_refuses_more_than_a_thousand_steps() { let err = a.call("command_batch", &json!({"steps": steps})).unwrap_err(); assert!(err.to_string().contains("at most 1000 steps"), "{err}"); } + +#[test] +fn a_deleted_image_is_not_kept_by_reduce_or_optimize() { + let dir = workdir("deleted-image"); + let (w, h) = (600u32, 400u32); + let px: Vec = (0..w * h).flat_map(|i| [(i % w * 255 / w) as u8 ^ (i & 7) as u8, (i / w * 255 / h) as u8, 128]).collect(); + let mut photo = Vec::new(); + { + let mut enc = png::Encoder::new(&mut photo, w, h); + enc.set_color(png::ColorType::Rgb); + enc.set_depth(png::BitDepth::Eight); + enc.write_header().unwrap().write_image_data(&px).unwrap(); + } + std::fs::write(dir.join("photo.png"), photo).unwrap(); + let mut a = auto(&dir); + let doc = ok(&mut a, "doc_create", json!({ "from": "images", "paths": ["photo.png"] }))["doc"].as_u64().unwrap(); + ok(&mut a, "image_edit", json!({ "doc": doc, "page": 1, "image": 1, "action": "delete" })); + assert_eq!(ok(&mut a, "page_images", json!({ "doc": doc, "page": 1 }))["count"], 0); + // The page is blank, so the copies are tiny: the picture's data is not carried along. + let r = ok(&mut a, "doc_optimize", json!({ "doc": doc, "path": "optimized.pdf" })); + assert_eq!(r["unused_xobjects"], 1, "{r}"); + assert!(r["bytes_after"].as_u64().unwrap() < 4_000, "{r}"); + let r = ok(&mut a, "doc_reduce", json!({ "doc": doc, "path": "reduced.pdf" })); + assert!(r["bytes_after"].as_u64().unwrap() < 4_000, "{r}"); + let reopened = ok(&mut a, "doc_open", json!({ "path": "reduced.pdf" })); + assert_eq!(reopened["pages"], 1); +} diff --git a/crates/optimize/src/lib.rs b/crates/optimize/src/lib.rs index 1d384ff51..5ebd006cb 100644 --- a/crates/optimize/src/lib.rs +++ b/crates/optimize/src/lib.rs @@ -9,7 +9,8 @@ //! JPEG 2000, JBIG2, CCITT, other than 8 bits) are left alone. //! - **Discard objects:** page thumbnails, alternate images, document tags (structure tree), //! print settings. -//! - **Clean up:** Flate-compress streams that have no filter. +//! - **Clean up:** drop images and forms that pages list but never draw (such as a deleted +//! image); Flate-compress streams that have no filter. //! //! Metadata, attachments, comments, scripts, private data, hidden layers, bookmarks and form //! fields are discarded by `pdfcraft-redact`'s Remove Hidden Information, which the engine @@ -20,6 +21,7 @@ mod audit; mod images; mod links; +mod unused; pub use audit::{SpaceCategory, SpaceUse, audit_space}; @@ -125,6 +127,8 @@ pub struct Report { pub invalid_links: usize, pub invalid_bookmarks: usize, pub unreferenced_dests: usize, + /// Images and forms the pages listed but never drew, dropped (Clean Up). + pub unused_xobjects: usize, } /// Optimize `doc` in place. @@ -132,8 +136,9 @@ pub fn optimize(doc: &mut Document, settings: &Settings) -> Result bool) -> Result { let mut report = Report::default(); let pages = pdfcraft_annot::page_refs(doc).map_err(|_| OptimizeError::NoPages)?; @@ -141,6 +146,7 @@ pub fn optimize_with_progress(doc: &mut Document, settings: &Settings, progress: if !progress(Stage::CleanUp) { return Err(OptimizeError::Cancelled); } + report.unused_xobjects = unused::remove_unused(doc, &pages, progress)?; if settings.discard_thumbnails { for p in &pages { if doc.get(*p).as_dict().is_some_and(|d| d.contains(b"Thumb")) { diff --git a/crates/optimize/src/unused.rs b/crates/optimize/src/unused.rs new file mode 100644 index 000000000..b8c8a62b2 --- /dev/null +++ b/crates/optimize/src/unused.rs @@ -0,0 +1,304 @@ +//! Clean Up: drop image and form XObjects that a page lists in its resources but nothing draws, +//! such as an image deleted with Edit a PDF (its `Do` goes, its resource entry stays). A full +//! rewrite keeps every object that is still referenced, so without this the image would stay in +//! the file. +//! +//! Removing a name something still draws would make content disappear, so the rule is simple +//! and errs on the side of keeping: +//! - only a page's own resources are cleaned: dictionaries nothing else refers to (resources +//! shared with other pages, forms or appearances, and inherited ones, are left alone); +//! - a name goes only if it is written nowhere in any stream the document still uses, found by +//! scanning the bytes wherever the name appears. Nothing can draw a resource by name without +//! writing the name in a stream, whichever resources a viewer then looks it up in; +//! - if any such stream can't be read exactly the way a viewer would read it, nothing is +//! removed. Only pictures (images in an image codec, used only as images) are skipped. +//! +//! So a name another page writes for its own image keeps this page's image of that name too. + +use std::collections::{HashMap, HashSet}; + +use pdfcraft_cos::{Dict, Document, ObjRef, Object, Stream}; + +use crate::{OptimizeError, Stage}; + +/// Image codecs: a stream encoded with one holds picture data. +const IMAGE_CODECS: [&[u8]; 6] = [b"DCTDecode", b"DCT", b"JPXDecode", b"JBIG2Decode", b"CCITTFaxDecode", b"CCF"]; + +/// Most data decoded while looking for names, in all; past it nothing is removed. +const DECODE_BUDGET: usize = 1 << 31; + +/// How much data is decoded between two checks for a cancel. +const CANCEL_CHECK: usize = 64 << 20; + +/// Where a page's own `/XObject` dictionary is. +#[derive(Clone, Copy, PartialEq, Eq, Hash)] +enum Place { + /// An object of its own (`/XObject n 0 R`). + Own(ObjRef), + /// Directly in the page's resources object (`/Resources n 0 R`). + InResources(ObjRef), + /// Directly in resources written directly in the page. + InPage(ObjRef), +} + +/// How an object is used where a reference to it appears. +#[derive(Clone, Copy, PartialEq)] +enum Use { + /// As a picture: an `/XObject` entry, `/SMask`, `/Mask`, `/Thumb` or an alternate `/Image`. + Picture, + /// As an `/XObject` dictionary (its entries are pictures or forms). + Entries, + Other, +} + +/// The objects the document uses (everything a full rewrite keeps): how many references point +/// at each, and which are referenced other than as a picture. +struct Graph { + refs: HashMap, + not_picture: HashSet, +} + +fn graph(doc: &Document) -> Graph { + let child = |key: &[u8], parent: Use| match (parent, key) { + (Use::Entries, _) => Use::Picture, + (_, b"XObject") => Use::Entries, + (_, b"SMask" | b"Mask" | b"Thumb" | b"Image") => Use::Picture, + _ => Use::Other, + }; + let mut g = Graph { refs: HashMap::new(), not_picture: HashSet::new() }; + let mut stack: Vec<(Object, Use)> = doc.trailer().iter().map(|(_, v)| (v.clone(), Use::Other)).collect(); + while let Some((obj, how)) = stack.pop() { + let expand = |d: &Dict, stack: &mut Vec<(Object, Use)>| stack.extend(d.iter().map(|(k, v)| (v.clone(), child(k, how)))); + match obj { + Object::Ref(r) => { + if how != Use::Picture { + g.not_picture.insert(r.num); + } + let n = g.refs.entry(r.num).or_default(); + *n += 1; + if *n > 1 { + continue; + } + match &*doc.get(r) { + Object::Dict(d) => expand(d, &mut stack), + Object::Stream(s) => expand(&s.dict, &mut stack), + Object::Array(a) => stack.extend(a.iter().map(|v| (v.clone(), how))), + _ => {} + } + } + Object::Dict(d) => expand(&d, &mut stack), + Object::Array(a) => stack.extend(a.into_iter().map(|v| (v, how))), + _ => {} + } + } + g +} + +/// `page`'s own `/XObject` dictionary, if nothing else refers to it. +fn own_xobjects(doc: &Document, page: ObjRef, refs: &HashMap) -> Option<(Place, Dict)> { + let only_once = |r: &ObjRef| refs.get(&r.num) == Some(&1); + let p = doc.get(page); + let p = p.as_dict()?; + // A node with kids passes its resources on. + if p.name(b"Type") != Some(b"Page") || p.contains(b"Kids") { + return None; + } + let (res, place) = match p.get(b"Resources")? { + Object::Ref(r) if only_once(r) => (doc.get(*r).as_dict()?.clone(), Place::InResources(*r)), + Object::Dict(d) => (d.clone(), Place::InPage(page)), + _ => return None, + }; + match res.get(b"XObject")? { + Object::Ref(x) if only_once(x) => Some((Place::Own(*x), doc.get(*x).as_dict()?.clone())), + Object::Dict(x) => Some((place, x.clone())), + _ => None, + } +} + +fn hex(b: &u8) -> Option { + (*b as char).to_digit(16).map(|d| d as u8) +} + +/// Record which of `wanted` (none longer than `longest`) are written in `data` (`/Name`, `#xx` +/// escapes decoded), wherever they appear: operands, strings, comments or inline image data. +fn find_names(data: &[u8], wanted: &HashSet>, longest: usize, found: &mut HashSet>) { + let regular = + |b: u8| !matches!(b, b'\0' | b'\t' | b'\n' | b'\x0C' | b'\r' | b' ' | b'(' | b')' | b'<' | b'>' | b'[' | b']' | b'{' | b'}' | b'/' | b'%'); + let mut i = 0; + while let Some(slash) = data.get(i..).and_then(|rest| rest.iter().position(|&b| b == b'/')) { + let mut j = i + slash + 1; + let mut name = Vec::new(); + let mut too_long = false; + while let Some(&b) = data.get(j) { + if !regular(b) { + break; + } + let (byte, step) = match (b, data.get(j + 1).and_then(hex), data.get(j + 2).and_then(hex)) { + (b'#', Some(h), Some(l)) => (h << 4 | l, 3), + _ => (b, 1), + }; + if name.len() < longest { + name.push(byte); + } else { + too_long = true; + } + j += step; + } + if !too_long && wanted.contains(&name) { + found.insert(name); + } + i = j; + } +} + +/// What a stream's data is, for finding names in it. +enum Data { + /// A picture: image data in an image codec, used only as an image. + Picture, + Decoded(Vec), + /// Not readable exactly the way a viewer would read it. + Unknown, +} + +/// Whether the document's security handler leaves metadata streams unencrypted. +fn plain_metadata(doc: &Document) -> bool { + doc.trailer() + .get(b"Encrypt") + .map(|e| doc.resolve(e)) + .is_some_and(|e| matches!(e.as_dict().and_then(|e| e.get(b"EncryptMetadata")), Some(Object::Bool(false)))) +} + +fn data(s: &Stream, num: u32, g: &Graph, encrypted: bool, plain_metadata: bool) -> Data { + let d = &s.dict; + // Streams the security handler leaves as stored, which a viewer may still decrypt. + let ty = d.name(b"Type"); + if encrypted && (ty == Some(b"XRef") || (ty == Some(b"Metadata") && plain_metadata)) { + return Data::Unknown; + } + // External data, or a filter description that is abbreviated, indirect or doesn't line up: + // a viewer might decode it differently from this check. + if d.contains(b"F") + || d.contains(b"DP") + || matches!(d.get(b"Filter"), Some(Object::Ref(_))) + || matches!(d.get(b"DecodeParms"), Some(Object::Ref(_))) + { + return Data::Unknown; + } + let filters: Vec<&[u8]> = match d.get(b"Filter") { + None => Vec::new(), + Some(Object::Name(n)) => vec![n.as_slice()], + Some(Object::Array(a)) => { + let names: Vec<&[u8]> = a.iter().filter_map(Object::as_name).collect(); + if names.len() != a.len() { + return Data::Unknown; + } + names + } + Some(_) => return Data::Unknown, + }; + if filters.contains(&&b"Crypt"[..]) { + return Data::Unknown; + } + if filters.iter().any(|f| IMAGE_CODECS.contains(f)) { + // Viewers decode any stream's codec, so picture data skipped here must be a picture. + let picture = d.name(b"Subtype") == Some(b"Image") && !g.not_picture.contains(&num); + return if picture { Data::Picture } else { Data::Unknown }; + } + // Parameters only as direct integers, which this check and viewers read alike. + let direct = |p: &Object| match p { + Object::Dict(p) => p.iter().all(|(_, v)| matches!(v, Object::Int(_))), + Object::Null => true, + _ => false, + }; + let parms_line_up = match d.get(b"DecodeParms") { + None => true, + Some(p @ (Object::Dict(_) | Object::Null)) => filters.len() == 1 && direct(p), + Some(Object::Array(a)) => a.len() == filters.len() && a.iter().all(direct), + Some(_) => false, + }; + if !parms_line_up { + return Data::Unknown; + } + s.decoded_strict().map_or(Data::Unknown, Data::Decoded) +} + +/// Drop the image and form XObjects that `pages` list in their own resources but no stream +/// names. Returns how many went. `progress` is asked again every [`CANCEL_CHECK`] bytes +/// decoded; `false` cancels. +pub(crate) fn remove_unused(doc: &mut Document, pages: &[ObjRef], progress: &mut dyn FnMut(Stage) -> bool) -> Result { + let g = graph(doc); + let mut seen = HashSet::new(); + let mut candidates = Vec::new(); + for page in pages { + let Some((place, xobjects)) = own_xobjects(doc, *page, &g.refs) else { continue }; + if !seen.insert(place) { + continue; + } + let names: Vec> = xobjects + .iter() + .filter(|(_, v)| matches!(&*doc.resolve(v), Object::Stream(s) if matches!(s.dict.name(b"Subtype"), Some(b"Image" | b"Form")))) + .map(|(name, _)| name.clone()) + .collect(); + if !names.is_empty() { + candidates.push((place, names)); + } + } + let wanted: HashSet> = candidates.iter().flat_map(|(_, names)| names.iter().cloned()).collect(); + let longest = wanted.iter().map(Vec::len).max().unwrap_or(0); + let (encrypted, plain_metadata) = (doc.trailer().contains(b"Encrypt"), plain_metadata(doc)); + let mut written = HashSet::new(); + let (mut decoded, mut next_check) = (0usize, CANCEL_CHECK); + for num in g.refs.keys() { + if written.len() == wanted.len() { + return Ok(0); + } + let obj = doc.get(ObjRef::new(*num, doc.generation(*num))); + let Object::Stream(s) = &*obj else { continue }; + match data(s, *num, &g, encrypted, plain_metadata) { + Data::Picture => {} + Data::Decoded(bytes) => { + decoded = decoded.saturating_add(bytes.len()); + if decoded > DECODE_BUDGET { + return Ok(0); + } + if decoded >= next_check { + next_check = decoded.saturating_add(CANCEL_CHECK); + if !progress(Stage::CleanUp) { + return Err(OptimizeError::Cancelled); + } + } + find_names(&bytes, &wanted, longest, &mut written); + } + Data::Unknown => return Ok(0), + } + } + let mut removed = 0; + for (place, names) in candidates { + let gone: Vec> = names.into_iter().filter(|n| !written.contains(n)).collect(); + if gone.is_empty() { + continue; + } + let strip = |x: &mut Dict| { + for n in &gone { + x.remove(n); + } + }; + match place { + Place::Own(x) => doc.update_dict(x, strip)?, + Place::InResources(r) => doc.update_dict(r, |res| { + if let Some(Object::Dict(x)) = res.get_mut(b"XObject") { + strip(x); + } + })?, + Place::InPage(p) => doc.update_dict(p, |d| { + if let Some(Object::Dict(res)) = d.get_mut(b"Resources") + && let Some(Object::Dict(x)) = res.get_mut(b"XObject") + { + strip(x); + } + })?, + } + removed += gone.len(); + } + Ok(removed) +} diff --git a/crates/optimize/tests/optimize.rs b/crates/optimize/tests/optimize.rs index 007e859f5..ff4dc801a 100644 --- a/crates/optimize/tests/optimize.rs +++ b/crates/optimize/tests/optimize.rs @@ -337,3 +337,249 @@ fn progress_is_reported_per_image_and_a_refusal_cancels() { assert!(matches!(r, Err(OptimizeError::Cancelled)), "{r:?}"); assert_eq!(calls, 2, "nothing runs after the refusal"); } + +/// A page with `resources` and, if given, `contents`. +fn raw_page(doc: &mut Document, resources: Object, contents: Option) -> ObjRef { + let pages = doc.root().and_then(|r| doc.get(r).as_dict().and_then(|d| d.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(), 612.into(), 792.into()])); + p.set(b"Resources".to_vec(), resources); + if let Some(c) = contents { + p.set(b"Contents".to_vec(), Object::Ref(c)); + } + let r = doc.add(Object::Dict(p)); + doc.update_dict(pages, |d| { + let mut kids = d.get(b"Kids").and_then(|k| k.as_array().cloned()).unwrap_or_default(); + kids.push(Object::Ref(r)); + d.set(b"Count".to_vec(), Object::Int(kids.len() as i64)); + d.set(b"Kids".to_vec(), Object::Array(kids)); + }) + .unwrap(); + r +} + +/// Builders for the tests below. +fn xobjects(pairs: &[(&str, ObjRef)]) -> Dict { + let mut x = Dict::new(); + for (n, r) in pairs { + x.set(n.as_bytes().to_vec(), Object::Ref(*r)); + } + x +} + +fn resources(x: Dict) -> Dict { + let mut res = Dict::new(); + res.set(b"XObject".to_vec(), Object::Dict(x)); + res +} + +fn content(doc: &mut Document, c: &str) -> ObjRef { + doc.add(Object::Stream(Stream::flate(Dict::new(), c.as_bytes()))) +} + +fn form(doc: &mut Document, resources: Option, c: &str) -> ObjRef { + let mut d = Dict::new(); + d.set(b"Subtype".to_vec(), Object::name("Form")); + d.set(b"BBox".to_vec(), Object::Array(vec![0.into(), 0.into(), 1.into(), 1.into()])); + if let Some(r) = resources { + d.set(b"Resources".to_vec(), r); + } + doc.add(Object::Stream(Stream::flate(d, c.as_bytes()))) +} + +fn xobject_names(doc: &Document, resources: &Object) -> Vec { + let res = doc.resolve(resources); + let mut n: Vec = + res.as_dict().unwrap().get(b"XObject").unwrap().as_dict().unwrap().iter().map(|(k, _)| String::from_utf8_lossy(k).into_owned()).collect(); + n.sort(); + n +} + +fn page_xobjects(doc: &Document, page: ObjRef) -> Vec { + let res = doc.get(page).as_dict().unwrap().get(b"Resources").unwrap().clone(); + xobject_names(doc, &res) +} + +fn count_images(bytes: &[u8]) -> usize { + let doc = Document::open(Arc::new(bytes.to_vec())).unwrap(); + doc.object_numbers() + .into_iter() + .filter(|n| matches!(&*doc.get(ObjRef::new(*n, doc.generation(*n))), Object::Stream(s) if s.dict.name(b"Subtype") == Some(b"Image"))) + .count() +} + +#[test] +fn images_and_forms_nothing_draws_are_dropped() { + let mut doc = Document::new_empty(); + let img = |doc: &mut Document| image(doc, 64, 64, 3, false, None); + + // Edit a PDF deleting an image: the page gets a new content stream without the `Do`; the + // old one, which still names the image, is no longer used. + let t1 = img(&mut doc); + let old = content(&mut doc, "q 72 0 0 72 0 0 cm /ImT Do Q"); + let t = raw_page(&mut doc, Object::Dict(resources(xobjects(&[("ImT", t1)]))), Some(old)); + let new = content(&mut doc, ""); + doc.update_dict(t, |p| p.set(b"Contents".to_vec(), Object::Ref(new))).unwrap(); + // Dropped: a JPEG a page no longer draws, beside one it does. + let a1 = img(&mut doc); + let a2 = image(&mut doc, 64, 64, 3, true, None); + let c = content(&mut doc, "q 72 0 0 72 0 0 cm /Im1 Do Q"); + let a = raw_page(&mut doc, Object::Dict(resources(xobjects(&[("Im1", a1), ("Im2", a2)]))), Some(c)); + // Dropped from resources the page alone refers to, written as an object of their own. + let s1 = img(&mut doc); + let own = doc.add(Object::Dict(resources(xobjects(&[("Unused", s1)])))); + let c = content(&mut doc, ""); + let s = raw_page(&mut doc, Object::Ref(own), Some(c)); + // An image drawn only inside a form stays; one beside it goes. + let (d3, d4) = (img(&mut doc), img(&mut doc)); + let f = form(&mut doc, None, "/Im3 Do"); + let c = content(&mut doc, "/F Do"); + let d = raw_page(&mut doc, Object::Dict(resources(xobjects(&[("F", f), ("Im3", d3), ("Im4", d4)]))), Some(c)); + + // Kept, though the page's own content doesn't name them: + // - drawn by a form with resources of its own that lack the name (renderers fall back to + // the page's); + let k1 = img(&mut doc); + let mut procset = Dict::new(); + procset.set(b"ProcSet".to_vec(), Object::Array(vec![Object::name("PDF")])); + let fk = form(&mut doc, Some(Object::Dict(procset)), "/ImK Do"); + let c = content(&mut doc, "/Fk Do"); + let k = raw_page(&mut doc, Object::Dict(resources(xobjects(&[("Fk", fk), ("ImK", k1)]))), Some(c)); + // - drawn by a Type 3 glyph named like an inert key; + let g6 = img(&mut doc); + let glyph = content(&mut doc, "/Im6 Do"); + let mut procs = Dict::new(); + procs.set(b"P".to_vec(), Object::Ref(glyph)); + let mut t3 = Dict::new(); + t3.set(b"Type".to_vec(), Object::name("Font")); + t3.set(b"Subtype".to_vec(), Object::name("Type3")); + t3.set(b"CharProcs".to_vec(), Object::Dict(procs)); + let mut fonts = Dict::new(); + fonts.set(b"T3".to_vec(), Object::Dict(t3)); + let mut res = resources(xobjects(&[("Im6", g6)])); + res.set(b"Font".to_vec(), Object::Dict(fonts)); + let c = content(&mut doc, "BT /T3 1 Tf (P) Tj ET"); + let g = raw_page(&mut doc, Object::Dict(res), Some(c)); + // - after an inline image whose data runs into the operators (no space before `EI`), or + // given as the second of two operands; + let (l1, l2) = (img(&mut doc), img(&mut doc)); + let c = content(&mut doc, "BI /W 1 /H 1 /BPC 8 /CS /G /F /AHx ID 00>EI Q q /ImL Do Q /Other /ImL2 Do"); + let l = raw_page(&mut doc, Object::Dict(resources(xobjects(&[("ImL", l1), ("ImL2", l2)]))), Some(c)); + // - drawn by a form field that is on no page; + let z1 = img(&mut doc); + let c = content(&mut doc, ""); + let z = raw_page(&mut doc, Object::Dict(resources(xobjects(&[("Z1", z1)]))), Some(c)); + let wap = form(&mut doc, None, "/Z1 Do"); + let mut appearance = Dict::new(); + appearance.set(b"N".to_vec(), Object::Ref(wap)); + let mut widget = Dict::new(); + widget.set(b"Subtype".to_vec(), Object::name("Widget")); + widget.set(b"AP".to_vec(), Object::Dict(appearance)); + let widget = doc.add(Object::Dict(widget)); + let mut acroform = Dict::new(); + acroform.set(b"Fields".to_vec(), Object::Array(vec![Object::Ref(widget)])); + let root = doc.root().unwrap(); + doc.update_dict(root, |c| c.set(b"AcroForm".to_vec(), Object::Dict(acroform))).unwrap(); + // - in resources shared by pages (or with an annotation's appearance), or inherited from the + // page tree: left alone; + let (b1, b2) = (img(&mut doc), img(&mut doc)); + let shared = doc.add(Object::Dict(resources(xobjects(&[("X1", b1), ("X2", b2)])))); + let c = content(&mut doc, "0 0 m"); + raw_page(&mut doc, Object::Ref(shared), Some(c)); + let c = content(&mut doc, "/X1 Do"); + raw_page(&mut doc, Object::Ref(shared), Some(c)); + let (inh, unused_inh) = (img(&mut doc), img(&mut doc)); + let tree = doc.root().and_then(|r| doc.get(r).as_dict().and_then(|d| d.reference(b"Pages"))).unwrap(); + doc.update_dict(tree, |d| d.set(b"Resources".to_vec(), Object::Dict(resources(xobjects(&[("Inh", inh), ("Old", unused_inh)]))))).unwrap(); + // - named by another page for its own image. + let (r1, r2) = (img(&mut doc), img(&mut doc)); + let c = content(&mut doc, ""); + let r = raw_page(&mut doc, Object::Dict(resources(xobjects(&[("Im9", r1)]))), Some(c)); + let c = content(&mut doc, "/Im9 Do"); + raw_page(&mut doc, Object::Dict(resources(xobjects(&[("Im9", r2)]))), Some(c)); + + let before = write_full(&doc, &SaveOptions::default()).unwrap(); + let report = optimize(&mut doc, &Settings::default()).unwrap(); + assert_eq!(report.unused_xobjects, 4, "{report:?}"); + assert!(page_xobjects(&doc, t).is_empty(), "the deleted image goes"); + assert_eq!(page_xobjects(&doc, a), ["Im1"]); + assert!(xobject_names(&doc, &Object::Ref(own)).is_empty()); + assert!(page_xobjects(&doc, s).is_empty()); + assert_eq!(page_xobjects(&doc, d), ["F", "Im3"]); + assert_eq!(page_xobjects(&doc, k), ["Fk", "ImK"]); + assert_eq!(page_xobjects(&doc, g), ["Im6"]); + assert_eq!(page_xobjects(&doc, l), ["ImL", "ImL2"]); + assert_eq!(page_xobjects(&doc, z), ["Z1"]); + assert_eq!(xobject_names(&doc, &Object::Ref(shared)), ["X1", "X2"]); + assert_eq!(page_xobjects(&doc, tree), ["Inh", "Old"]); + assert_eq!(page_xobjects(&doc, r), ["Im9"]); + let after = write_full(&doc, &SaveOptions::default()).unwrap(); + assert_eq!((count_images(&before), count_images(&after)), (17, 13), "the dropped images are gone from the file"); +} + +#[test] +fn nothing_is_dropped_when_a_stream_cannot_be_checked() { + // A page with an image nothing draws, and an appearance stream on it. + let build = |stream: Stream| { + let mut doc = Document::new_empty(); + let unused = image(&mut doc, 64, 64, 3, false, None); + let c = content(&mut doc, ""); + let page = raw_page(&mut doc, Object::Dict(resources(xobjects(&[("Im1", unused)]))), Some(c)); + let other = doc.add(Object::Stream(stream)); + let mut appearance = Dict::new(); + appearance.set(b"N".to_vec(), Object::Ref(other)); + let mut annot = Dict::new(); + annot.set(b"AP".to_vec(), Object::Dict(appearance)); + doc.update_dict(page, |p| p.set(b"Annots".to_vec(), Object::Array(vec![Object::Dict(annot)]))).unwrap(); + let report = optimize(&mut doc, &Settings::default()).unwrap(); + (report.unused_xobjects, page_xobjects(&doc, page)) + }; + let (gone, kept) = ((1, Vec::::new()), (0, vec!["Im1".to_string()])); + // Valid compressed data with extra keys. + let flate = |extra: &[(&str, Object)]| { + let mut s = Stream::flate(Dict::new(), b"0 0 m"); + s.dict.set(b"Filter".to_vec(), Object::name("FlateDecode")); + for (k, v) in extra { + s.dict.set(k.as_bytes().to_vec(), v.clone()); + } + s + }; + let parms = |k: &str, v: Object| { + let mut p = Dict::new(); + p.set(k.as_bytes().to_vec(), v); + Object::Dict(p) + }; + // Readable: the image goes. + assert_eq!(build(flate(&[])), gone); + assert_eq!(build(flate(&[("DecodeParms", parms("Predictor", Object::Int(1)))])), gone); + // The name written with an escape still counts. + assert_eq!(build(Stream::flate(Dict::new(), b"/Im#31 Do")), kept); + // Read differently by viewers, or not at all: nothing goes. + let dangling = Object::Ref(ObjRef::new(999, 0)); + let cases = [ + Stream::from_raw(flate(&[]).dict, b"not zlib data".to_vec()), + flate(&[("Filter", dangling.clone())]), + flate(&[("Filter", Object::Array(vec![Object::name("FlateDecode"), dangling.clone()]))]), + flate(&[("Filter", Object::Array(vec![Object::name("Crypt"), Object::name("FlateDecode")]))]), + flate(&[("DP", parms("Predictor", Object::Int(1)))]), + flate(&[("DecodeParms", Object::Array(vec![Object::Dict(Dict::new()), Object::Dict(Dict::new())]))]), + flate(&[("DecodeParms", parms("Predictor", dangling))]), + flate(&[("DecodeParms", parms("Predictor", Object::Real(12.5)))]), + flate(&[("F", Object::name("external.dat"))]), + // An image codec on a stream drawn as an appearance: viewers decode it as content. + Stream::from_raw( + { + let mut d = Dict::new(); + d.set(b"Filter".to_vec(), Object::name("CCITTFaxDecode")); + d + }, + vec![0; 8], + ), + ]; + for s in cases { + let dict = s.dict.clone(); + assert_eq!(build(s), kept, "{dict:?}"); + } +} diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index 4b36bea82..b7690cb8b 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -7154,8 +7154,8 @@ milestone = "M11" status = "partial" commands = ["optimize.advanced"] tools = ["doc_optimize"] -evidence = ["crates/ui-egui/tests/create.rs::the_pdf_optimizer_dialog_saves_an_optimized_copy", "crates/automation/tests/automation.rs::optimizing_through_tools", "crates/ui-egui/src/optimize_ui.rs::the_progress_card_shows_the_stage_and_cancels", "crates/engine/src/tests.rs::an_optimize_job_reports_its_stages_and_can_be_cancelled"] -notes = "Images, Discard Objects, Discard User Data, Clean Up; runs in the background with a progress bar (per image) and Cancel; no Fonts or Transparency panels, no space audit" +evidence = ["crates/ui-egui/tests/create.rs::the_pdf_optimizer_dialog_saves_an_optimized_copy", "crates/automation/tests/automation.rs::optimizing_through_tools", "crates/ui-egui/src/optimize_ui.rs::the_progress_card_shows_the_stage_and_cancels", "crates/engine/src/tests.rs::an_optimize_job_reports_its_stages_and_can_be_cancelled", "crates/optimize/tests/optimize.rs::images_and_forms_nothing_draws_are_dropped", "crates/automation/tests/automation.rs::a_deleted_image_is_not_kept_by_reduce_or_optimize"] +notes = "Images, Discard Objects, Discard User Data, Clean Up (also drops images and forms the pages list but never draw, such as deleted images); runs in the background with a progress bar (per image) and Cancel; no Fonts or Transparency panels, no space audit" [[feature]] id = "optimize.downsample-images" From 6116b0034cdbc045da6c059fdb1ee989127d07c1 Mon Sep 17 00:00:00 2001 From: Epsilon-LLC <64039063+EpsilonLLC@users.noreply.github.com> Date: Sat, 10 Oct 2026 16:25:24 -0400 Subject: [PATCH 46/69] M9: name the Windows store certificates that can't sign (#179) (#761) A store certificate PdfCraft can't use (no private key, a P-521 / Brainpool / Ed25519 key, a key CNG can't open) was dropped from the Sign dialog silently, so a user whose CA-issued identity shows in Acrobat but not here had nothing to go on. `windows::list` now returns those under `unusable` with the subject, fingerprint and a reason that names the Windows error code; the Sign dialog shows them greyed and unselectable with the reason, and `sign_windows_ids` returns them. A key that needs a prompt to open (NTE_SILENT_CONTEXT, NTE_UI_REQUIRED while listing) is listed and prompts at signing instead of being skipped. Certificates without a private key stay out of the dialog but are reported by the tool. Tests: the explained-error table, the store listing's invariants, a key-less certificate in the ignored store test, the automation output, and a kittest dialog test for a greyed entry. Verified by hand that a .p12 imported into the CNG and legacy Microsoft providers lists, and that a P-521 certificate is explained. Co-authored-by: Claude Fable 5.1 Co-authored-by: Brandon Thomas --- ROADMAP.md | 1 + crates/automation/src/lib.rs | 23 ++- crates/automation/src/tools.rs | 2 +- crates/automation/tests/automation.rs | 5 +- crates/sign/README.md | 6 +- crates/sign/src/windows.rs | 204 ++++++++++++++++++++++---- crates/sign/src/x509.rs | 20 ++- crates/sign/tests/pdf.rs | 26 +++- crates/ui-egui/src/sign_ui.rs | 92 ++++++++---- crates/ui-egui/tests/signing.rs | 42 ++++++ parity/acrobat-features.toml | 4 +- 11 files changed, 351 insertions(+), 74 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 5702705af..01d162460 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -216,6 +216,7 @@ M0 → M1 → M2 → M3 → M4 must happen in order. After M4, M5–M12 can run ## Log Newest first: the date, what moved, and the new overall estimate. +- **2026-10-10 (M9, #179 follow-up):** A Windows store certificate that can't sign is no longer left out of the Sign dialog silently: `windows::list` names it with the reason and Windows error code (no private key, a P-521/Brainpool/Ed25519 key, a key CNG can't open), the picker shows it greyed and unselectable, and `sign_windows_ids` returns it under `unusable`. A key that needs a permission prompt to open is listed instead of skipped. Reported by a user whose CA-issued identity showed in Acrobat but not here; a plain .p12 import into the CNG and legacy Microsoft providers was verified to list, so the next report will carry the reason. Store test adds a key-less certificate; overall estimate unchanged (about 30–35%). - **2026-10-10 (M10.2, JPEG scanning follow-up to #729):** Skip marker fill bytes and keep reading JFIF, APP14 and ICC metadata after the frame header, stopping before scan data or at EOI. Invalid segment lengths return errors; damaged or mismatched ICC profiles retain #729's device-space fallback. Synthetic regression cases added but not run at the contributor's request. Estimates unchanged. - **2026-10-10 (M13, desktop action outputs):** Desktop Action Wizard keeps every result when input basenames repeat, or when the destination already has a result from an earlier run. It preserves the original filename and extension for a free name and adds numbered suffixes for occupied names; the bounded search returns an error rather than reusing an occupied path. A real-shell regression drives Start twice and reopens the three-page and one-page results, checking that the original inputs and earlier output remain intact. This covers the desktop writer separately from the CLI/MCP action_run repair in #877. Milestone and effort estimates unchanged. diff --git a/crates/automation/src/lib.rs b/crates/automation/src/lib.rs index f19215c13..9aba0c7d7 100644 --- a/crates/automation/src/lib.rs +++ b/crates/automation/src/lib.rs @@ -600,14 +600,23 @@ impl Automation { } "sign_windows_ids" => { #[cfg(target_os = "windows")] - let ids: Vec = pdfcraft_engine::sign::windows::identities() - .map_err(failed)? - .iter() - .map(|id| json!({ "id": pdfcraft_engine::sign::windows::reference(&id.certificate), "certificate": signing::cert_json(&id.certificate) })) - .collect(); + let (ids, unusable): (Vec, Vec) = { + let listing = pdfcraft_engine::sign::windows::list().map_err(failed)?; + let ids = listing + .ids + .iter() + .map(|id| json!({ "id": pdfcraft_engine::sign::windows::reference(&id.certificate), "certificate": signing::cert_json(&id.certificate) })) + .collect(); + let unusable = listing + .unusable + .iter() + .map(|u| json!({ "subject": u.subject, "sha256": u.fingerprint, "reason": u.reason, "no_private_key": u.no_private_key })) + .collect(); + (ids, unusable) + }; #[cfg(not(target_os = "windows"))] - let ids: Vec = Vec::new(); - json!({ "count": ids.len(), "ids": ids }) + let (ids, unusable): (Vec, Vec) = (Vec::new(), Vec::new()); + json!({ "count": ids.len(), "ids": ids, "unusable": unusable }) } "sign_trust" => self.sign_trust(&a)?, "comment_mark" => self.comment_mark(&a)?, diff --git a/crates/automation/src/tools.rs b/crates/automation/src/tools.rs index 4b29b37ae..9c0489a8e 100644 --- a/crates/automation/src/tools.rs +++ b/crates/automation/src/tools.rs @@ -609,7 +609,7 @@ pub fn tools() -> Vec { }), &["name", "password", "path"], )), - t("sign_windows_ids", "List Windows store digital IDs", "Windows: signing identities in the Current User Personal certificate store (certificate details and the windows: reference sign_document takes). Private keys remain in CNG; Windows may ask permission to use them.") + t("sign_windows_ids", "List Windows store digital IDs", "Windows: signing identities in the Current User Personal certificate store (certificate details and the windows: reference sign_document takes). Private keys remain in CNG; Windows may ask permission to use them. `unusable` lists the store's other certificates with why they can't sign (no private key, an unsupported key type, a key CNG can't open).") .ro() .cmd("sign.digital") .with(schema(json!({}), &[])), diff --git a/crates/automation/tests/automation.rs b/crates/automation/tests/automation.rs index 79dd29bd3..2e89cc19b 100644 --- a/crates/automation/tests/automation.rs +++ b/crates/automation/tests/automation.rs @@ -2755,8 +2755,11 @@ fn digital_ids_signing_and_validation_through_tools() { let ids = store["ids"].as_array().unwrap(); assert_eq!(store["count"].as_u64().unwrap(), ids.len() as u64); assert!(ids.iter().all(|id| id["id"].as_str().unwrap().starts_with("windows:"))); + // Store certificates that can't sign are explained, not dropped (issue #179). + let unusable = store["unusable"].as_array().unwrap(); + assert!(unusable.iter().all(|u| u["subject"].is_string() && !u["reason"].as_str().unwrap().is_empty() && u["no_private_key"].is_boolean())); #[cfg(not(windows))] - assert!(ids.is_empty()); + assert!(ids.is_empty() && unusable.is_empty()); assert!(matches!( a.call("sign_document", &json!({ "doc": doc, "id": "ada.p12", "password": "wrong!", "out": "signed.pdf" })), Err(ToolError::InvalidArgs(_)) diff --git a/crates/sign/README.md b/crates/sign/README.md index 2b0b94424..78fb8d721 100644 --- a/crates/sign/README.md +++ b/crates/sign/README.md @@ -54,7 +54,11 @@ ECDSA P-256/P-384; the store integration is tested with software-backed keys. A or token whose driver registers a CNG key storage provider is such an identity too: the key is opened for signing without the silent flag, so the provider shows its own PIN dialog (checked by hand with a SafeNet token; CI has no hardware). A store identity signs with the -issuers Windows chains its certificate to, without the root. +issuers Windows chains its certificate to, without the root. Store certificates that can't +sign (no private key, another key type, a key CNG can't open) are returned by +`windows::list` as `unusable` with the reason and the Windows error code, shown greyed in +the Sign dialog and by `sign_windows_ids`, instead of being left out silently; a key that +needs a prompt to open is listed and asks at signing (issue #179). - **Validation:** `/ByteRange` and the CMS are read from the file's own bytes; the digest, the signature value and the signer's chain (against a `TrustStore`) are checked. Later revisions are diffed against the signed one, and the changes are classified (signing, form diff --git a/crates/sign/src/windows.rs b/crates/sign/src/windows.rs index 23b56a478..3974aff8c 100644 --- a/crates/sign/src/windows.rs +++ b/crates/sign/src/windows.rs @@ -1,14 +1,19 @@ //! Signing identities in the Windows Current User Personal ("My") certificate store. //! Private keys remain in CNG; Windows may ask permission to use a key. +//! +//! Certificates that can't sign through PdfCraft (no usable private key, a key type it doesn't +//! sign with) are reported in [`Listing::unusable`] with the reason rather than left out +//! silently, so a missing identity can be explained (issue #179). use std::sync::Arc; use rustls_cng::cert::CertContext; -use rustls_cng::key::{AlgorithmGroup, SignaturePadding}; +use rustls_cng::error::CngError; +use rustls_cng::key::{AlgorithmGroup, NCryptKey, SignaturePadding}; use rustls_cng::store::{CertStore, CertStoreType}; use crate::keys::{DigestAlg, ExternalKey, PrivateKey, PublicKey, StoreKey}; -use crate::{Certificate, DigitalId, SignError}; +use crate::{Certificate, DigitalId, SignError, x509}; struct WindowsKey { context: CertContext, @@ -25,16 +30,39 @@ impl ExternalKey for WindowsKey { // signing handle without the silent flag so the provider can show its own dialog. let key = self.context.acquire_key(false).map_err(|e| { SignError::Crypto(format!( - "the Windows certificate store key couldn't be opened for signing (is the smart card or token connected?): {e}" + "the Windows certificate store key couldn't be opened for signing (is the smart card or token connected?): {}", + describe(&e) )) })?; - let raw = key - .sign(&digest, padding) - .map_err(|e| SignError::Crypto(format!("the Windows certificate store didn't sign (key use may have been cancelled): {e}")))?; + let raw = key.sign(&digest, padding).map_err(|e| { + SignError::Crypto(format!("the Windows certificate store didn't sign (key use may have been cancelled): {}", describe(&e))) + })?; self.public.store_signature_der(&raw) } } +/// A Personal store certificate PdfCraft can't sign with, and why. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct Unusable { + /// The subject's common name (else organization, else the DN), or a placeholder when + /// the certificate couldn't be read at all. + pub subject: String, + /// SHA-256 of the certificate as upper-case hex pairs (`Certificate::fingerprint`). + pub fingerprint: String, + /// Why it can't sign, as a clause that follows the name ("its private key is …"). + pub reason: String, + /// The store holds no private key for it: a certificate someone else issued, filed under + /// Personal. Not a problem with an identity, so the Sign dialog doesn't show these. + pub no_private_key: bool, +} + +/// The Personal store's signing identities and the certificates that can't sign. +#[derive(Default)] +pub struct Listing { + pub ids: Vec, + pub unusable: Vec, +} + /// The `windows:` reference kept for an identity. pub fn reference(certificate: &Certificate) -> String { let digest = DigestAlg::Sha256.digest(&[&certificate.raw]); @@ -45,6 +73,7 @@ pub fn reference(certificate: &Certificate) -> String { /// Windows finds for the certificate, so a signature embeds them as it does for a file's. pub fn find(reference_or_name: &str) -> Result { let (context, mut id) = entries()? + .ids .into_iter() .find(|(_, id)| { reference(&id.certificate) == reference_or_name @@ -62,39 +91,156 @@ fn issuers(context: &CertContext, certificate: &Certificate) -> Vec chain.iter().filter(|der| **der != certificate.raw).filter_map(|der| Certificate::parse(der).ok()).collect() } -/// List usable RSA, P-256 and P-384 CNG identities in Current User > Personal. -/// Certificates with unsupported keys or without an accessible private key are skipped. +/// List usable RSA, P-256 and P-384 CNG identities in Current User > Personal +/// (the `ids` of [`list`]). pub fn identities() -> Result, SignError> { - Ok(entries()?.into_iter().map(|(_, id)| id).collect()) + Ok(entries()?.ids.into_iter().map(|(_, id)| id).collect()) } -/// Each usable identity with the store's certificate context it came from. -fn entries() -> Result, SignError> { +/// List Current User > Personal: the RSA, P-256 and P-384 identities whose private key CNG can +/// open, and every other certificate with the reason it can't sign. A key that needs a prompt +/// to open counts as usable; the prompt appears at signing. +pub fn list() -> Result { + let Entries { ids, unusable } = entries()?; + Ok(Listing { ids: ids.into_iter().map(|(_, id)| id).collect(), unusable }) +} + +/// Each usable identity with the store's certificate context it came from, and the rest. +struct Entries { + ids: Vec<(CertContext, DigitalId)>, + unusable: Vec, +} + +fn entries() -> Result { let store = CertStore::open(CertStoreType::CurrentUser, "My") - .map_err(|e| SignError::Crypto(format!("the Windows Current User Personal store couldn't be opened: {e}")))?; - let contexts = store.find_all().map_err(|e| SignError::Crypto(format!("the Windows certificate store couldn't be searched: {e}")))?; - let mut out = Vec::new(); + .map_err(|e| SignError::Crypto(format!("the Windows Current User Personal store couldn't be opened: {}", describe(&e))))?; + let contexts = + store.find_all().map_err(|e| SignError::Crypto(format!("the Windows certificate store couldn't be searched: {}", describe(&e))))?; + let mut ids = Vec::new(); + let mut unusable = Vec::new(); for context in contexts { - let Ok(certificate) = Certificate::parse(context.as_der()) else { continue }; - // Enumeration must not open permission or PIN dialogs. Signing may prompt later. - let Ok(key) = context.acquire_key(true) else { continue }; - // P-521, brainpool and Ed25519 certificates parse (to verify with) but aren't signed with. - let supported = match certificate.public_key.store_signing_key() { - Some(StoreKey::Rsa) => key.algorithm_group().is_ok_and(|a| a == AlgorithmGroup::Rsa), - Some(StoreKey::Ecdsa(want)) => { - key.algorithm_group().is_ok_and(|a| a == AlgorithmGroup::Ecdsa) && key.bits().is_ok_and(|bits| bits == want) - } - None => false, + let der = context.as_der(); + let fingerprint = DigestAlg::Sha256.digest(&[der]).iter().map(|b| format!("{b:02X}")).collect::>().join(" "); + let mut skip = |subject: String, reason: String, no_private_key: bool| { + unusable.push(Unusable { subject, fingerprint: fingerprint.clone(), reason, no_private_key }); }; - if !supported { + let certificate = match Certificate::parse(der) { + Ok(c) => c, + Err(e) => { + let subject = x509::subject_of(der).map(|n| n.display_name()).unwrap_or_else(|| "(unreadable certificate)".to_string()); + skip(subject, format!("the certificate can't be read: {e}"), false); + continue; + } + }; + // P-521, brainpool and Ed25519 certificates parse (to verify with) but aren't signed with. + let Some(want) = certificate.public_key.store_signing_key() else { + let what = certificate.public_key.describe(); + skip(certificate.display_name(), format!("its {what} key can't sign through PdfCraft yet (RSA, P-256 and P-384 only)"), false); continue; + }; + // Enumeration must not open permission or PIN dialogs. Signing may prompt later. + match context.acquire_key(true) { + Ok(key) => { + if let Err(reason) = matches_key(&key, want) { + skip(certificate.display_name(), reason, false); + continue; + } + // The silent handle was only for the checks above; `sign` acquires its own. + drop(key); + } + // The key opens only with a prompt (strong private key protection, a PIN): usable. + Err(CngError::WindowsError(NTE_SILENT_CONTEXT | NTE_UI_REQUIRED)) => {} + Err(e) => { + let no_private_key = e == CngError::WindowsError(CRYPT_E_NO_KEY_PROPERTY); + let reason = if no_private_key { + "it has no private key in the store".to_string() + } else { + format!("its private key can't be opened: {}", describe(&e)) + }; + skip(certificate.display_name(), reason, no_private_key); + continue; + } } - // The silent handle was only for the checks above; `sign` acquires its own. - drop(key); let public = certificate.public_key.clone(); let key = PrivateKey::external(public.clone(), Arc::new(WindowsKey { context: context.clone(), public })); let friendly_name = Some(certificate.display_name()); - out.push((context, DigitalId { key, certificate, chain: Vec::new(), friendly_name })); + ids.push((context, DigitalId { key, certificate, chain: Vec::new(), friendly_name })); + } + Ok(Entries { ids, unusable }) +} + +/// Whether the store's key is what the certificate needs (RSA; ECDSA on its curve). +fn matches_key(key: &NCryptKey, want: StoreKey) -> Result<(), String> { + let group = key.algorithm_group().map_err(|e| format!("its private key's algorithm couldn't be read: {}", describe(&e)))?; + let name = |g: &AlgorithmGroup| match g { + AlgorithmGroup::Rsa => "an RSA".to_string(), + AlgorithmGroup::Ecdsa => "an ECDSA".to_string(), + other => format!("a {other:?}"), + }; + let expected = match want { + StoreKey::Rsa => AlgorithmGroup::Rsa, + StoreKey::Ecdsa(_) => AlgorithmGroup::Ecdsa, + }; + if group != expected { + return Err(format!("its certificate holds {} key but the store's private key is {} key", name(&expected), name(&group))); + } + if let StoreKey::Ecdsa(bits) = want { + let actual = key.bits().map_err(|e| format!("its private key's size couldn't be read: {}", describe(&e)))?; + if actual != bits { + return Err(format!("its certificate holds a P-{bits} key but the store's private key has {actual} bits")); + } + } + Ok(()) +} + +// Error codes (winerror.h) that explain why a key can't be opened. +const NTE_NO_KEY: u32 = 0x8009_000D; +const NTE_PERM: u32 = 0x8009_0010; +const NTE_NOT_FOUND: u32 = 0x8009_0011; +const NTE_BAD_PROVIDER: u32 = 0x8009_0013; +const NTE_BAD_PROV_TYPE: u32 = 0x8009_0014; +const NTE_BAD_KEYSET: u32 = 0x8009_0016; +const NTE_PROV_DLL_NOT_FOUND: u32 = 0x8009_001E; +const NTE_SILENT_CONTEXT: u32 = 0x8009_0022; +const NTE_NOT_SUPPORTED: u32 = 0x8009_0029; +const NTE_UI_REQUIRED: u32 = 0x8009_002E; +const NTE_DEVICE_NOT_READY: u32 = 0x8009_0030; +const NTE_DEVICE_NOT_FOUND: u32 = 0x8009_0035; +const NTE_USER_CANCELLED: u32 = 0x8009_0036; +const CRYPT_E_NO_KEY_PROPERTY: u32 = 0x8009_200B; + +/// A CNG error with the Windows code and, for the usual ones, what it means. +fn describe(e: &CngError) -> String { + let CngError::WindowsError(code) = e else { return e.to_string() }; + let meaning = match *code { + NTE_NO_KEY | NTE_BAD_KEYSET | NTE_NOT_FOUND => "the key is missing from its key store (deleted, or imported under another Windows account)", + NTE_PERM => "access to the key was denied", + NTE_BAD_PROVIDER | NTE_BAD_PROV_TYPE | NTE_PROV_DLL_NOT_FOUND => { + "its key provider isn't available to CNG (a legacy CryptoAPI-only provider, or its driver isn't installed)" + } + NTE_NOT_SUPPORTED => "its key provider doesn't support the operation", + NTE_DEVICE_NOT_READY | NTE_DEVICE_NOT_FOUND => "the smart card or token isn't available", + NTE_USER_CANCELLED => "the request was cancelled", + NTE_SILENT_CONTEXT | NTE_UI_REQUIRED => "the key needs a prompt to open", + CRYPT_E_NO_KEY_PROPERTY => "the certificate has no private key", + 0x8010_0000..=0x8010_FFFF => "the smart card isn't available", + _ => return format!("Windows error 0x{code:08X}"), + }; + format!("{meaning} (Windows error 0x{code:08X})") +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn errors_are_explained_with_their_code() { + assert_eq!( + describe(&CngError::WindowsError(NTE_BAD_KEYSET)), + "the key is missing from its key store (deleted, or imported under another Windows account) (Windows error 0x80090016)" + ); + assert_eq!(describe(&CngError::WindowsError(0x8010_000C)), "the smart card isn't available (Windows error 0x8010000C)"); + assert_eq!(describe(&CngError::WindowsError(0x57)), "Windows error 0x00000057"); + assert_eq!(describe(&CngError::InvalidHashLength), "Invalid hash length"); } - Ok(out) } diff --git a/crates/sign/src/x509.rs b/crates/sign/src/x509.rs index 1873d012f..1c4537391 100644 --- a/crates/sign/src/x509.rs +++ b/crates/sign/src/x509.rs @@ -57,6 +57,11 @@ impl Name { self.get(C) } + /// The name Acrobat shows: the common name, else the organization, else the DN. + pub fn display_name(&self) -> String { + self.common_name().or(self.organization()).map(str::to_string).unwrap_or_else(|| self.display()) + } + /// "CN=Ada Lovelace, O=Example, E=ada@example.com" pub fn display(&self) -> String { self.attrs @@ -400,7 +405,7 @@ impl Certificate { /// The display name Acrobat uses: the common name, else the organization, else the DN. pub fn display_name(&self) -> String { - self.subject.common_name().or(self.subject.organization()).map(str::to_string).unwrap_or_else(|| self.subject.display()) + self.subject.display_name() } /// SHA-256 fingerprint as upper-case hex pairs. @@ -452,6 +457,19 @@ impl Certificate { } } +/// The subject alone, to name a certificate `Certificate::parse` rejects (one with a key type +/// PdfCraft doesn't support, say). `None` when even that can't be read. +pub fn subject_of(raw: &[u8]) -> Option { + let cert = Tlv::parse_all(raw).ok()?.expect(tag::SEQUENCE, "Certificate").ok()?; + let tbs = cert.children().ok()?.into_iter().next()?; + let mut f = tbs.children().ok()?.into_iter().peekable(); + if f.peek().is_some_and(|t| t.tag == tag::ctx(0)) { + f.next(); + } + // serial, signature algorithm, issuer, validity, then the subject. + Name::parse(&f.nth(4)?).ok() +} + /// The chain from `leaf` up through `pool`, as far as issuers can be found and may issue: each /// issuer's key verifies the certificate below it, it is a CA allowed to sign certificates, its /// `pathLenConstraint` allows the CA certificates below it, and (when `at` is given) it was valid diff --git a/crates/sign/tests/pdf.rs b/crates/sign/tests/pdf.rs index 0542190a2..20b7f0883 100644 --- a/crates/sign/tests/pdf.rs +++ b/crates/sign/tests/pdf.rs @@ -1086,7 +1086,12 @@ fn a_timestamp_token_may_sign_with_a_different_digest_than_its_imprint() { #[cfg(windows)] #[test] fn windows_store_enumeration_and_missing_identity() { - assert!(pdfcraft_sign::windows::identities().is_ok()); + let listing = pdfcraft_sign::windows::list().unwrap(); + assert_eq!(listing.ids.len(), pdfcraft_sign::windows::identities().unwrap().len()); + // Whatever this machine's store holds, every certificate left out is named and explained. + for u in &listing.unusable { + assert!(!u.subject.is_empty() && !u.reason.is_empty() && u.fingerprint.len() == 95, "{u:?}"); + } assert!(pdfcraft_sign::windows::find("windows:no such signer").is_err()); } @@ -1108,9 +1113,10 @@ impl Drop for Certificates { fn drop(&mut self) { for thumbprint in &self.0 { // New-SelfSignedCertificate also leaves a public copy of a self-signed or CA - // certificate in Intermediate Certification Authorities (CA). + // certificate in Intermediate Certification Authorities (CA). -DeleteKey fails on + // a certificate without a key; the plain removal then runs. let out = powershell(&format!( - "Remove-Item -LiteralPath 'Cert:\\CurrentUser\\My\\{thumbprint}' -DeleteKey; $copy='Cert:\\CurrentUser\\CA\\{thumbprint}'; if (Test-Path -LiteralPath $copy) {{ Remove-Item -LiteralPath $copy }}" + "$p='Cert:\\CurrentUser\\My\\{thumbprint}'; try {{ Remove-Item -LiteralPath $p -DeleteKey }} catch {{ Remove-Item -LiteralPath $p }}; $copy='Cert:\\CurrentUser\\CA\\{thumbprint}'; if (Test-Path -LiteralPath $copy) {{ Remove-Item -LiteralPath $copy }}" )); if !out.status.success() { eprintln!("test certificate cleanup failed: {}", String::from_utf8_lossy(&out.stderr)); @@ -1156,6 +1162,20 @@ fn signing_with_windows_store_identities() { assert_eq!(signature.modification, Modification::None); assert_eq!(signature.signer.as_deref(), Some(name.as_str())); } + // A certificate without a private key (issued to someone else, filed under Personal) is + // reported as such rather than listed or dropped silently (issue #179). + let ada = pkcs12::open(&data("rsa-aes.p12"), "test").unwrap().certificate; + let cer = std::env::temp_dir().join(format!("pdfcraft-windows-store-{unique}.cer")); + std::fs::write(&cer, &ada.raw).unwrap(); + let out = powershell(&format!("(Import-Certificate -FilePath '{}' -CertStoreLocation 'Cert:\\CurrentUser\\My').Thumbprint", cer.display())); + let _ = std::fs::remove_file(&cer); + assert!(out.status.success(), "{}", String::from_utf8_lossy(&out.stderr)); + created.0.push(String::from_utf8(out.stdout).unwrap().trim().to_string()); + let listing = pdfcraft_sign::windows::list().unwrap(); + let reported = listing.unusable.iter().find(|u| u.fingerprint == ada.fingerprint()).expect("key-less certificate reported"); + assert!(reported.no_private_key, "{reported:?}"); + assert_eq!(reported.subject, ada.display_name()); + assert!(listing.ids.iter().all(|id| id.certificate.raw != ada.raw)); let thumbprints = created.0.clone(); drop(created); for thumbprint in thumbprints { diff --git a/crates/ui-egui/src/sign_ui.rs b/crates/ui-egui/src/sign_ui.rs index 77f588f53..6dda63489 100644 --- a/crates/ui-egui/src/sign_ui.rs +++ b/crates/ui-egui/src/sign_ui.rs @@ -21,6 +21,10 @@ pub struct DigitalIdEntry { pub issuer: String, pub email: String, pub expires: String, + /// Why the ID can't sign (a Windows store certificate whose key PdfCraft can't use): shown + /// greyed with the reason, and never selectable. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub unusable: Option, } #[derive(Clone, Copy, Debug, PartialEq, Eq)] @@ -93,14 +97,16 @@ pub struct SignDraft { } impl SignDraft { - fn new(page: usize, rect: Option<[f64; 4]>, field: Option, certify: Option, ids: usize) -> Self { + /// Starts at Choose when there are IDs to show (even only unusable ones, so their reasons + /// are seen), with the first usable one selected. + fn new(page: usize, rect: Option<[f64; 4]>, field: Option, certify: Option, ids: &[DigitalIdEntry]) -> Self { Self { page, rect, field, certify, - step: if ids == 0 { SignStep::Configure } else { SignStep::Choose }, - selected: (ids > 0).then_some(0), + step: if ids.is_empty() { SignStep::Configure } else { SignStep::Choose }, + selected: ids.iter().position(|e| e.unusable.is_none()), password: String::new(), reason: String::new(), location: String::new(), @@ -220,6 +226,20 @@ pub fn entry_for(path: &str, c: &Certificate) -> DigitalIdEntry { issuer: c.issuer.common_name().map(str::to_string).unwrap_or_else(|| c.issuer.display()), email: c.subject.email().unwrap_or("").to_string(), expires: format!("{:04}.{:02}.{:02}", c.not_after.year, c.not_after.month, c.not_after.day), + unusable: None, + } +} + +/// A greyed entry for a Windows store certificate that can't sign, with the reason. +#[cfg(target_os = "windows")] +fn entry_unusable(u: &sign::windows::Unusable) -> DigitalIdEntry { + DigitalIdEntry { + path: format!("windows:{}", u.fingerprint.split(' ').collect::().to_ascii_lowercase()), + name: u.subject.clone(), + issuer: String::new(), + email: String::new(), + expires: String::new(), + unusable: Some(u.reason.clone()), } } @@ -227,7 +247,7 @@ impl PdfCraftApp { /// Start signing: the rectangle (or field) is known; show Sign with a Digital ID. pub fn start_signing(&mut self, page: usize, rect: Option<[f64; 4]>, field: Option, certify: Option) { self.refresh_os_key_store_ids(); - self.sign_draft = Some(SignDraft::new(page, rect, field, certify, self.digital_ids.len())); + self.sign_draft = Some(SignDraft::new(page, rect, field, certify, &self.digital_ids)); self.dialog = Some(crate::Dialog::Sign); } @@ -247,11 +267,16 @@ impl PdfCraftApp { } #[cfg(target_os = "windows")] if self.os_key_store_ids { - match sign::windows::identities() { - Ok(ids) => { - for id in ids { + match sign::windows::list() { + Ok(listing) => { + for id in &listing.ids { self.digital_ids.push(entry_for(&sign::windows::reference(&id.certificate), &id.certificate)); } + // Certificates without a private key aren't identities; the rest are shown + // greyed with why they can't sign (issue #179). + for u in listing.unusable.iter().filter(|u| !u.no_private_key) { + self.digital_ids.push(entry_unusable(u)); + } } Err(e) => self.notify_fmt("The Windows store's digital IDs couldn't be listed: {e}", &[("e", &e.to_string())]), } @@ -325,7 +350,7 @@ impl PdfCraftApp { } let Some((_, doc_id)) = self.active_ids() else { return Err("no document".into()) }; let d = self.sign_draft.clone().ok_or("nothing to sign")?; - let entry = d.selected.and_then(|i| self.digital_ids.get(i)).cloned().ok_or("Choose a digital ID.")?; + let entry = d.selected.and_then(|i| self.digital_ids.get(i)).filter(|e| e.unusable.is_none()).cloned().ok_or("Choose a digital ID.")?; let id = if entry.path.starts_with("keychain:") { keychain_id(&entry.path)? } else if entry.path.starts_with("windows:") { @@ -467,17 +492,20 @@ fn choose(ui: &mut egui::Ui, app: &mut PdfCraftApp, t: &Tokens) -> bool { egui::ScrollArea::vertical().max_height(220.0).show(ui, |ui| { for (i, e) in ids.iter().enumerate() { let selected = d.selected == Some(i); + let usable = e.unusable.is_none(); let (rect, resp) = ui.allocate_exact_size(vec2(ui.available_width(), 52.0), egui::Sense::click()); - resp.widget_info(|| egui::WidgetInfo::selected(egui::WidgetType::RadioButton, true, selected, &e.name)); + resp.widget_info(|| egui::WidgetInfo::selected(egui::WidgetType::RadioButton, usable, selected, &e.name)); let fill = if selected { t.accent_soft - } else if resp.hovered() { + } else if resp.hovered() && usable { t.hover } else { Color32::TRANSPARENT }; ui.painter().rect_filled(rect, CornerRadius::same(6), fill); - icons::paint(ui, Rect::from_min_size(rect.min + vec2(10.0, 15.0), vec2(20.0, 20.0)), "badge-check", 18.0, t.accent); + // An unusable ID is greyed, with the reason where the issuer would be. + let (icon, name_color) = if usable { (t.accent, t.text) } else { (t.text_muted, t.text_muted) }; + icons::paint(ui, Rect::from_min_size(rect.min + vec2(10.0, 15.0), vec2(20.0, 20.0)), "badge-check", 18.0, icon); // Certificate names can be any length: cut both lines with "…" at the row's edge // and show them whole on hover. let line = |text: &str, font: egui::FontId, color: Color32| { @@ -485,27 +513,33 @@ fn choose(ui: &mut egui::Ui, app: &mut PdfCraftApp, t: &Tokens) -> bool { job.wrap = egui::text::TextWrapping::truncate_at_width((rect.width() - 50.0).max(0.0)); ui.painter().layout_job(job) }; - let name = line(&e.name, theme::semibold(13.0), t.text); - let sub = format!( - "{keychain}{email}{issued}{issuer}{expires}{date}", - keychain = if e.path.starts_with("keychain:") { - tl!("Keychain · ").to_string() - } else if e.path.starts_with("windows:") { - tl!("Windows store · ").to_string() - } else { - String::new() - }, - email = if e.email.is_empty() { String::new() } else { format!("{} · ", e.email) }, - issued = tl!("Issued by: "), - issuer = e.issuer, - expires = tl!(", Expires: "), - date = e.expires, - ); + let name = line(&e.name, theme::semibold(13.0), name_color); + let store = if e.path.starts_with("keychain:") { + tl!("Keychain · ").to_string() + } else if e.path.starts_with("windows:") { + tl!("Windows store · ").to_string() + } else { + String::new() + }; + let sub = match &e.unusable { + Some(reason) => format!("{store}{reason}"), + None => format!( + "{store}{email}{issued}{issuer}{expires}{date}", + email = if e.email.is_empty() { String::new() } else { format!("{} · ", e.email) }, + issued = tl!("Issued by: "), + issuer = e.issuer, + expires = tl!(", Expires: "), + date = e.expires, + ), + }; let details = line(&sub, theme::regular(11.5), t.text_muted); let elided = name.elided || details.elided; - ui.painter().galley(rect.min + vec2(40.0, 9.0), name, t.text); + ui.painter().galley(rect.min + vec2(40.0, 9.0), name, name_color); ui.painter().galley(rect.min + vec2(40.0, 28.0), details, t.text_muted); let resp = if elided { resp.on_hover_text(format!("{}\n{sub}", e.name)) } else { resp }; + if !usable { + continue; + } if resp.clicked() { d.selected = Some(i); } @@ -1141,7 +1175,7 @@ mod tests { fn create_in(dir: &std::path::Path) -> Result { let mut app = PdfCraftApp::new(); app.export_dir_override = Some(dir.to_string_lossy().into_owned()); - let mut draft = SignDraft::new(0, None, None, None, 0); + let mut draft = SignDraft::new(0, None, None, None, &[]); // P-256 keeps the test fast. let key = KEY_ALGORITHMS.iter().position(|k| k.1 == "p256").unwrap(); draft.new_id = diff --git a/crates/ui-egui/tests/signing.rs b/crates/ui-egui/tests/signing.rs index 09c3246ed..516f84461 100644 --- a/crates/ui-egui/tests/signing.rs +++ b/crates/ui-egui/tests/signing.rs @@ -258,6 +258,7 @@ fn os_store_identities_are_not_persisted() { issuer: "Test issuer".into(), email: String::new(), expires: "2030.01.01".into(), + unusable: None, }); } let saved = app.persist(); @@ -282,6 +283,7 @@ fn windows_store_identity_does_not_ask_for_a_file_password() { issuer: "Test issuer".into(), email: String::new(), expires: "2030.01.01".into(), + unusable: None, }); let draft = h.state_mut().sign_draft.as_mut().unwrap(); draft.step = SignStep::Choose; @@ -311,6 +313,7 @@ fn long_digital_id_names_stay_inside_the_picker() { issuer: long, email: String::new(), expires: "2030.01.01".into(), + unusable: None, }); h.state_mut().sign_draft.as_mut().unwrap().step = SignStep::Choose; h.run_steps(3); @@ -334,3 +337,42 @@ fn long_digital_id_names_stay_inside_the_picker() { ); } } + +/// A store certificate that can't sign is shown with its reason (so the user learns why an +/// identity Acrobat lists is missing, issue #179) but is never selected or chosen. +#[test] +fn an_unusable_id_is_shown_with_its_reason_and_cannot_be_chosen() { + let mut h = harness(dir()); + // Not a `windows:` path: those are replaced by the real store listing when signing starts. + h.state_mut().digital_ids.push(pdfcraft_ui_egui::DigitalIdEntry { + path: "store-test:broken".into(), + name: "Broken signer".into(), + issuer: String::new(), + email: String::new(), + expires: String::new(), + unusable: Some("its private key can't be opened: the key is missing from its key store (Windows error 0x80090016)".into()), + }); + h.state_mut().start_signing(0, None, None, None); + h.run_steps(3); + let draft = h.state().sign_draft.as_ref().unwrap(); + assert_eq!(draft.step, SignStep::Choose, "shown, with its reason, rather than skipped to Configure"); + assert_eq!(draft.selected, None); + h.get_by_label("Broken signer").click(); + h.run_steps(3); + assert_eq!(h.state().sign_draft.as_ref().unwrap().selected, None); + h.get_by_label("Continue").click(); + h.run_steps(3); + assert_eq!(h.state().sign_draft.as_ref().unwrap().step, SignStep::Choose); + // A usable ID after it is the one selected to begin with. + h.state_mut().digital_ids.push(pdfcraft_ui_egui::DigitalIdEntry { + path: "store-test:fine".into(), + name: "Working signer".into(), + issuer: "Test issuer".into(), + email: String::new(), + expires: "2030.01.01".into(), + unusable: None, + }); + h.state_mut().start_signing(0, None, None, None); + h.run_steps(3); + assert_eq!(h.state().sign_draft.as_ref().unwrap().selected, Some(1)); +} diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index b7690cb8b..d5fbac55b 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -5822,8 +5822,8 @@ milestone = "M9" status = "shipped" commands = ["sign.digital", "sign.certify"] tools = ["sign_windows_ids", "sign_document"] -evidence = ["crates/sign/tests/pdf.rs::signing_with_windows_store_identities", "crates/sign/tests/pdf.rs::a_windows_store_identity_signs_with_its_issuers", "crates/sign/tests/pdf.rs::windows_store_enumeration_and_missing_identity", "crates/automation/tests/automation.rs::digital_ids_signing_and_validation_through_tools", "crates/ui-egui/tests/signing.rs::windows_store_identity_does_not_ask_for_a_file_password"] -notes = "Current User Personal (My) CNG identities: RSA PKCS #1 v1.5 and DER-encoded ECDSA P-256/P-384. Keys never leave Windows; windows: for agents and Windows store labels in the desktop ID picker. The signature embeds the issuers Windows chains the certificate to. Tested with throwaway software-backed RSA-2048, P-256 and P-384 certificates (run with --ignored). Smart cards and tokens whose driver registers a CNG key storage provider sign with the provider's own PIN dialog (checked by hand with a SafeNet token only; no hardware in CI); direct PKCS #11 still planned." +evidence = ["crates/sign/tests/pdf.rs::signing_with_windows_store_identities", "crates/sign/tests/pdf.rs::a_windows_store_identity_signs_with_its_issuers", "crates/sign/tests/pdf.rs::windows_store_enumeration_and_missing_identity", "crates/automation/tests/automation.rs::digital_ids_signing_and_validation_through_tools", "crates/ui-egui/tests/signing.rs::windows_store_identity_does_not_ask_for_a_file_password", "crates/ui-egui/tests/signing.rs::an_unusable_id_is_shown_with_its_reason_and_cannot_be_chosen"] +notes = "Current User Personal (My) CNG identities: RSA PKCS #1 v1.5 and DER-encoded ECDSA P-256/P-384. Keys never leave Windows; windows: for agents and Windows store labels in the desktop ID picker. The signature embeds the issuers Windows chains the certificate to. Store certificates that can't sign (no private key, another key type, a key CNG can't open) are reported with the reason and Windows error code (greyed in the picker, `unusable` in sign_windows_ids) rather than left out; keys that need a prompt to open are listed and prompt at signing. Tested with throwaway software-backed RSA-2048, P-256 and P-384 certificates and a key-less certificate (run with --ignored). Smart cards and tokens whose driver registers a CNG key storage provider sign with the provider's own PIN dialog (checked by hand with a SafeNet token only; no hardware in CI); still missing: the Local Machine store, legacy CryptoAPI-only providers, P-521/Brainpool/Ed25519 keys, direct PKCS #11." [[feature]] id = "sign.encrypted-documents" From 8309c84a59a2862d01111c17d67d224b3b6a9697 Mon Sep 17 00:00:00 2001 From: Silver Date: Sat, 10 Oct 2026 22:28:29 +0200 Subject: [PATCH 47/69] M12: XFA rows in areas get their instances, and areas stay out of data paths (#853) A Designer form whose table (a top-to-bottom subform inside an area of a positioned page) asks for several rows with `` was laid out with one row, and a FormCalc total looping over the rows stayed empty. - layout: a repeating subform inside a top-to-bottom container in positioned content is drawn and measured once per instance (occur and data), with its own SOM index, as flowed content already was. One `instances` helper now serves flowing, table rows and this case. - data: areas are named in SOM expressions but are not data scopes. `data_path(tpl, som)` leaves them out (unknown segments are kept), and layout, the script tree, reading and writing the datasets and the engine's value and row operations use it. Before, values Adobe's viewers saved were not read, what was filled here went under a new element named after the area (which Adobe's viewers would most likely not read), and scripts counted the rows there: one, so a loop over the table's rows failed on the missing ones. - write_datasets_with / read_values_with take the parsed template when the caller has it (the engine's cached one); the old functions parse the document's. - Tests: layout regression, data_path rules, and an engine round trip shaped like such a form (rows, Adobe-written values, a FormCalc total over every row, the data written back, reopening). Signed-off-by: Hadi Chokr Co-authored-by: Claude Opus 5.5 Co-authored-by: Brandon Thomas --- ROADMAP.md | 2 + crates/engine/src/lib.rs | 15 +++-- crates/engine/src/tests.rs | 40 +++++++++++++ crates/engine/src/xfa.rs | 20 ++++--- crates/xfa/README.md | 6 +- crates/xfa/src/data.rs | 111 +++++++++++++++++++++++++++++++++-- crates/xfa/src/fixtures.rs | 49 ++++++++++++++++ crates/xfa/src/layout.rs | 60 ++++++++++++------- crates/xfa/src/lib.rs | 5 +- crates/xfa/src/script.rs | 13 ++-- crates/xfa/src/tests.rs | 68 +++++++++++++++++++++ parity/acrobat-features.toml | 6 +- 12 files changed, 342 insertions(+), 53 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 01d162460..470fe333d 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -216,6 +216,8 @@ M0 → M1 → M2 → M3 → M4 must happen in order. After M4, M5–M12 can run ## Log 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 (` .to_string() } +/// Shaped like a real Designer leave-request form: a positioned page whose unnamed subform holds +/// an area (`box`) with a top-to-bottom table of positioned rows (``) and a +/// read-only total that a FormCalc calculate works out over a fixed range of rows, as such forms +/// do. `data` is `xfa:data` content as Adobe's viewers write it: areas are not data scopes, so +/// there is no `box` element. +pub fn leave_template(data: &str) -> String { + let datasets = if data.is_empty() { + String::new() + } else { + format!("{data}") + }; + format!( + r##" + + +{datasets} + +"## + ) +} + /// A 2 × 2 RGBA PNG: red, green / blue, transparent. pub fn tiny_png() -> Vec { TINY_PNG.to_vec() diff --git a/crates/xfa/src/layout.rs b/crates/xfa/src/layout.rs index fa5011361..615afbb0e 100644 --- a/crates/xfa/src/layout.rs +++ b/crates/xfa/src/layout.rs @@ -13,7 +13,7 @@ use std::sync::Arc; use base64::Engine; use crate::XfaError; -use crate::data::{DataNode, is_on, iso_to_pattern, som_to_path}; +use crate::data::{DataNode, data_path, is_on, iso_to_pattern}; use crate::model::*; use crate::text::{self, Block, Face, Span}; @@ -508,11 +508,7 @@ impl Layouter<'_> { self.new_page()?; } // Data adds instances of a repeating subform (rows added in another viewer). - let from_data = match (&sf.common.name, self.data) { - (Some(name), Some(d)) if sf.occur.max != Some(1) => d.count(&som_to_path(&ctx.som), name), - _ => 0, - }; - let instances = instance_count(&sf.occur, from_data); + let instances = self.instances(node, &ctx.som); let ctx_parent = ctx; for i in 0..instances { let ctx = ctx_parent.with_som(&sf.common, sib + i); @@ -583,11 +579,7 @@ impl Layouter<'_> { && row.layout == Layout::Row { // Rows added as data (in another viewer) repeat the row. - let from_data = match (&row.common.name, self.data) { - (Some(name), Some(d)) if row.occur.max != Some(1) => d.count(&som_to_path(&ctx.som), name), - _ => 0, - }; - let instances = instance_count(&row.occur, from_data); + let instances = self.instances(child, &ctx.som); for inst in 0..instances { let h = self.row_height(row, &cols, inner_w, ctx, depth + 1); if self.make_room(h)? @@ -626,6 +618,17 @@ impl Layouter<'_> { /// The size a node takes when given `avail_w` of width. Containers are measured once per /// (node, width, depth): measuring a width-less container measures its content twice (for /// its width, then its height), which would otherwise be exponential in the nesting. + /// How many instances of `node` to lay out under the container at `parent_som`: a + /// repeating subform's `occur` and data instances ([`instance_count`]); one for anything else. + fn instances(&self, node: &Node, parent_som: &str) -> usize { + let Node::Subform(sf) = node else { return 1 }; + let from_data = match (&sf.common.name, self.data) { + (Some(name), Some(d)) if sf.occur.max != Some(1) => d.count(&data_path(self.tpl, parent_som), name), + _ => 0, + }; + instance_count(&sf.occur, from_data) + } + fn size_of(&self, node: &Node, avail_w: f64, ctx: &Ctx, depth: usize) -> (f64, f64) { let c = node.common(); let spent = self.measures.get().saturating_add(1); @@ -659,6 +662,9 @@ impl Layouter<'_> { fn measure(&self, node: &Node, avail_w: f64, ctx: &Ctx, depth: usize) -> (f64, f64) { let c = node.common(); + // The container's own SOM, under which its repeating children count their instances. + // Measurements are cached per template node, so instance 0 stands for all of them. + let som = ctx.with_som(c, 0).som; let ctx = ctx.child(c); // A container without a width is as wide as its content (a radio group beside its // question, say); a leaf without one takes what is available. @@ -684,9 +690,13 @@ impl Layouter<'_> { Node::Area(a) => self.positioned_height(&a.children, inner_w, &ctx, depth), Node::Subform(s) => match s.layout { Layout::Positioned => self.positioned_height(&s.children, inner_w, &ctx, depth), - Layout::Tb => { - s.children.iter().filter(|k| k.common().presence.occupies()).map(|k| self.size_of(k, inner_w, &ctx, depth + 1).1).sum() - } + // Each instance of a repeating child takes its height again. + Layout::Tb => s + .children + .iter() + .filter(|k| k.common().presence.occupies()) + .map(|k| self.size_of(k, inner_w, &ctx, depth + 1).1 * self.instances(k, &som) as f64) + .sum(), Layout::LrTb | Layout::RlTb => self.packed_height(&s.children, inner_w, &ctx, depth), Layout::Table => { let cols = self.columns(s, inner_w); @@ -943,8 +953,12 @@ impl Layouter<'_> { continue; } let (w, h) = self.size_of(k, inner.w, &ctx, depth + 1); - self.draw_node(k, Rect::new(inner.x, y, w, h), &ctx, depth + 1, sib_index(&s.children, i))?; - y += h; + // A repeating subform here (a table in an area of a positioned page, + // say) gets its instances, as in a flowed one, one under another. + for instance in 0..self.instances(k, &ctx.som) { + self.draw_node(k, Rect::new(inner.x, y, w, h), &ctx, depth + 1, sib_index(&s.children, i) + instance)?; + y += h; + } } } Layout::LrTb | Layout::RlTb => { @@ -1061,7 +1075,7 @@ impl Layouter<'_> { let ctx = ctx.with_som(&g.common, sib); // The group's data node holds the selected button's on value. let selected: Option = - self.data.and_then(|d| d.text_at(&som_to_path(&ctx.som))).map(|t| t.trim().to_string()).filter(|t| !t.is_empty()); + self.data.and_then(|d| d.text_at(&data_path(self.tpl, &ctx.som))).map(|t| t.trim().to_string()).filter(|t| !t.is_empty()); let inner = rect.inset(&g.common.margin); let group = self.unique_name(g.common.name.as_deref().unwrap_or("group")); let nodes: Vec = g.fields.iter().map(|f| Node::Field(Box::new(f.clone()))).collect(); @@ -1182,7 +1196,7 @@ impl Layouter<'_> { let field_name = f.common.name.clone().unwrap_or_else(|| "an image field".into()); let text: Vec = self .data - .and_then(|d| d.text_at(&som_to_path(&som_path()))) + .and_then(|d| d.text_at(&data_path(self.tpl, &som_path()))) .map(|t| t.bytes().filter(|b| !b.is_ascii_whitespace()).collect()) .unwrap_or_default(); let from_data = if text.is_empty() { @@ -1215,8 +1229,12 @@ impl Layouter<'_> { Ui::Barcode => { // The value as text, where the bars would be. self.warn_once("barcode fields show their value as text, not as bars"); - let value = - self.data.and_then(|d| d.text_at(&som_to_path(&som_path()))).map(str::to_string).or_else(|| f.value.plain()).unwrap_or_default(); + let value = self + .data + .and_then(|d| d.text_at(&data_path(self.tpl, &som_path()))) + .map(str::to_string) + .or_else(|| f.value.plain()) + .unwrap_or_default(); if !value.trim().is_empty() { let mut para = fctx.para.clone(); para.h_align = HAlign::Center; @@ -1244,7 +1262,7 @@ impl Layouter<'_> { let read_only = matches!(f.access, Access::ReadOnly | Access::Protected); // The data's value wins over the template's default. let data_value: Option = - self.data.and_then(|d| d.value_at(&som_to_path(&som))).map(|t| t.trim().to_string()).filter(|t| !t.is_empty()); + self.data.and_then(|d| d.value_at(&data_path(self.tpl, &som))).map(|t| t.trim().to_string()).filter(|t| !t.is_empty()); // A choice list's default is a saved value too. let template_default = f.value.plain().map(|v| f.saved_value(v.trim())).filter(|v| !v.is_empty()); let plain_value = data_value.clone().or_else(|| template_default.clone()); diff --git a/crates/xfa/src/lib.rs b/crates/xfa/src/lib.rs index 6b4e31d4e..a1758ab47 100644 --- a/crates/xfa/src/lib.rs +++ b/crates/xfa/src/lib.rs @@ -21,8 +21,9 @@ pub mod text; use pdfcraft_cos::Document; pub use data::{ - DataNode, DataOp, DataPath, DatasetsWrite, FieldData, FieldDatum, add_data_instances, build_data, iso_to_pattern, parse_datasets, pattern_to_iso, - read_values, remove_data_instance, som_to_path, write_data_ops, write_data_value, write_datasets, write_datasets_reusing, + DataNode, DataOp, DataPath, DatasetsWrite, FieldData, FieldDatum, add_data_instances, build_data, data_path, iso_to_pattern, parse_datasets, + pattern_to_iso, read_values, read_values_with, remove_data_instance, som_to_path, write_data_ops, write_data_value, write_datasets, + write_datasets_reusing, write_datasets_with, }; pub use layout::{Action, BorderShape, Form, Item, MAX_PAGES, Page, Widget, WidgetKind, layout}; pub use packets::{Encoding, Packets, decode as decode_packet, encode as encode_packet, read_packets}; diff --git a/crates/xfa/src/script.rs b/crates/xfa/src/script.rs index c2088deee..255c7f45a 100644 --- a/crates/xfa/src/script.rs +++ b/crates/xfa/src/script.rs @@ -6,7 +6,7 @@ use std::collections::{BTreeMap, HashMap}; use pdfcraft_cos::{Dict, Document, Object, PdfString}; -use crate::data::{DataNode, parse_datasets, som_to_path}; +use crate::data::{DataNode, data_path, parse_datasets}; use crate::model::*; use crate::{Report, XfaError}; @@ -171,6 +171,8 @@ fn child_som(parent: &str, name: Option<&str>, index: usize) -> String { } struct Walker<'a> { + /// For data paths (areas are not data scopes). + tpl: &'a Template, data: Option<&'a DataNode>, ov: &'a Overrides, events: Vec, @@ -241,7 +243,7 @@ impl Walker<'_> { fn field(&mut self, f: &Field, parent_som: &str, index: usize, radio: Option<&str>) -> FormNode { let som = child_som(parent_som, f.common.name.as_deref().or(Some("field")), index); - let data_value = self.data.and_then(|d| d.value_at(&som_to_path(&som))).map(|t| t.trim().to_string()).filter(|t| !t.is_empty()); + let data_value = self.data.and_then(|d| d.value_at(&data_path(self.tpl, &som))).map(|t| t.trim().to_string()).filter(|t| !t.is_empty()); let value = match (radio, data_value) { // A radio button's value is its on value when the group picked it. (Some(selected), _) => { @@ -281,7 +283,7 @@ impl Walker<'_> { match n { Node::Subform(sf) => { let from_data = match (&sf.common.name, self.data) { - (Some(name), Some(d)) if sf.occur.max != Some(1) => d.count(&som_to_path(parent_som), name), + (Some(name), Some(d)) if sf.occur.max != Some(1) => d.count(&data_path(self.tpl, parent_som), name), _ => 0, }; let instances = crate::layout::instance_count(&sf.occur, from_data); @@ -344,7 +346,8 @@ impl Walker<'_> { } Node::ExclGroup(g) => { let som = child_som(parent_som, g.common.name.as_deref().or(Some("group")), sib); - let selected = self.data.and_then(|d| d.text_at(&som_to_path(&som))).map(|t| t.trim().to_string()).filter(|t| !t.is_empty()); + let selected = + self.data.and_then(|d| d.text_at(&data_path(self.tpl, &som))).map(|t| t.trim().to_string()).filter(|t| !t.is_empty()); self.push_events(&som, &g.scripts, None, None, None); let fields: Vec = g.fields.iter().map(|f| Node::Field(Box::new(f.clone()))).collect(); let mut children = Vec::new(); @@ -378,7 +381,7 @@ impl Walker<'_> { /// from the template and `ov`) and every scripted event, in document order, within /// [`MAX_FORM_NODES`] objects. pub fn form_tree(tpl: &Template, data: Option<&DataNode>, ov: &Overrides) -> LiveForm { - let mut w = Walker { data, ov, events: Vec::new(), nodes: 0, truncated: false }; + let mut w = Walker { tpl, data, ov, events: Vec::new(), nodes: 0, truncated: false }; let root = Node::Subform(Box::new(tpl.root.clone())); let mut nodes = w.children(std::slice::from_ref(&root), "", 0); let mut root_node = nodes.pop().unwrap_or_default(); diff --git a/crates/xfa/src/tests.rs b/crates/xfa/src/tests.rs index 4a4d8ad5d..a690e7f3c 100644 --- a/crates/xfa/src/tests.rs +++ b/crates/xfa/src/tests.rs @@ -1041,3 +1041,71 @@ fn pictures_are_embedded_once_across_relayouts_and_bad_data_pictures_are_reporte } assert!(find_image(&t.root.children).is_some_and(|n| n > 1_200_000)); } + +/// A repeating subform drawn inside positioned content (a top-to-bottom table in an area of a +/// positioned subform) gets its `occur` instances, as in Adobe's viewers: real Designer forms +/// have tables like this whose calculations read every row. Before, only one row was laid +/// out, and the field after the table was placed as if there were one. +#[test] +fn repeating_subforms_inside_positioned_content_get_their_instances() { + let xdp = r##" + + + +"##; + let form = layout_xml(xdp).unwrap(); + let all: Vec<&Widget> = form.pages.iter().flat_map(widgets).collect(); + let rows: Vec<&Widget> = all.iter().copied().filter(|w| w.som.ends_with(".reason[0]")).collect(); + let soms: Vec<&str> = rows.iter().map(|w| w.som.as_str()).collect(); + assert_eq!( + soms, + [ + "form[0].page1[0].box[0].wrap[0].table[0].row[0].reason[0]", + "form[0].page1[0].box[0].wrap[0].table[0].row[1].reason[0]", + "form[0].page1[0].box[0].wrap[0].table[0].row[2].reason[0]", + ], + "one widget per instance, each with its own SOM index" + ); + // The rows are stacked, not drawn on top of each other… + let step = rows[1].rect.y - rows[0].rect.y; + assert!(step.abs() > 1.0, "rows overlap: {:?}", rows.iter().map(|w| w.rect).collect::>()); + assert!(((rows[2].rect.y - rows[1].rect.y) - step).abs() < 0.01, "rows are evenly spaced"); + // …and what follows the table is placed after all of them (the table was measured with + // its instances, not with one row). + let after = all.iter().find(|w| w.som.ends_with(".after[0]")).expect("the field after the table"); + assert!(((after.rect.y - rows[2].rect.y) - step).abs() < 0.01, "after: {:?}, last row: {:?}", after.rect, rows[2].rect); +} + +/// Areas are named in SOM expressions but are not data scopes; unnamed subforms and areas have +/// no segment at all. What the template doesn't have is kept as it is. +#[test] +fn data_paths_leave_areas_out() { + let (tpl, _) = parse(&crate::fixtures::leave_template("")).unwrap(); + let path = |som: &str| data_path(&tpl, som).into_iter().map(|(n, i)| format!("{n}[{i}]")).collect::>().join("."); + // The area `box` (inside an unnamed subform) is dropped; indexes are kept. + assert_eq!(path("form[0].page[0].box[0].table[0].row[2].days[0]"), "form[0].page[0].table[0].row[2].days[0]"); + assert_eq!(path("form[0].page[0].box[0].rest[0]"), "form[0].page[0].rest[0]"); + // No area on the way: as som_to_path. + assert_eq!(path("form[0].page[0]"), "form[0].page[0]"); + // Unknown segments, past a leaf, or a SOM from another template: kept. + assert_eq!(path("form[0].page[0].box[0].nothing[0].x[1]"), "form[0].page[0].nothing[0].x[1]"); + assert_eq!(path("form[0].page[0].box[0].rest[0].box[0]"), "form[0].page[0].rest[0].box[0]"); + assert_eq!(path("other[0].box[0].rest[0]"), "other[0].box[0].rest[0]"); + assert_eq!(data_path(&tpl, ""), Vec::new()); +} diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index d5fbac55b..6def869ca 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -5142,7 +5142,7 @@ milestone = "M12" status = "partial" commands = ["file.open"] tools = ["doc_open", "doc_info", "form_fields", "form_fill", "js_run"] -evidence = ["crates/xfa/src/tests.rs::layout_places_captions_fields_and_sizes_containers_to_their_content", "crates/xfa/src/tests.rs::tables_break_across_pages_and_repeat_their_header_row", "crates/xfa/src/tests.rs::rendering_into_the_pdf_replaces_the_placeholder_page_and_adds_fields", "crates/engine/src/tests.rs::dynamic_xfa_forms_are_laid_out_on_open_filled_and_saved_incrementally", "crates/automation/tests/automation.rs::dynamic_xfa_forms_open_render_fill_and_save_through_tools", "crates/ui-egui/tests/xfa.rs::a_dynamic_xfa_form_is_laid_out_and_filled", "crates/xfa/src/tests.rs::nested_width_less_subforms_lay_out_in_polynomial_time", "crates/js/src/tests.rs::instance_managers_add_remove_and_count", "crates/xfa/src/tests.rs::the_form_tree_carries_values_presence_instances_and_the_events", "crates/engine/src/tests.rs::xfa_scripts_initialize_calculate_validate_toggle_and_add_rows", "crates/automation/tests/automation.rs::xfa_scripts_run_for_buttons_and_field_changes_through_tools", "crates/ui-egui/tests/xfa.rs::xfa_buttons_run_their_scripts_in_the_app", "crates/engine/src/tests.rs::hostile_xfa_open_scripts_are_merged_capped_kept_from_printing_and_noted", "crates/engine/src/tests.rs::runaway_xfa_calculations_at_open_end_quickly_with_a_report", "crates/xfa/src/tests.rs::the_live_form_of_nested_repeating_subforms_stops_at_its_node_budget", "crates/xfa/src/tests.rs::data_ops_of_one_event_are_one_datasets_rewrite", "crates/xfa/src/tests.rs::choice_lists_signatures_passwords_and_pictures_become_fields_and_images", "crates/xfa/src/tests.rs::choice_values_come_from_the_data_as_saved_values_and_several_for_lists", "crates/xfa/src/tests.rs::pictures_are_sized_from_their_headers_and_hostile_ones_are_refused_unallocated", "crates/engine/src/tests.rs::xfa_choice_lists_signature_and_picture_fields_open_fill_script_and_save"] +evidence = ["crates/xfa/src/tests.rs::layout_places_captions_fields_and_sizes_containers_to_their_content", "crates/xfa/src/tests.rs::tables_break_across_pages_and_repeat_their_header_row", "crates/xfa/src/tests.rs::rendering_into_the_pdf_replaces_the_placeholder_page_and_adds_fields", "crates/engine/src/tests.rs::dynamic_xfa_forms_are_laid_out_on_open_filled_and_saved_incrementally", "crates/automation/tests/automation.rs::dynamic_xfa_forms_open_render_fill_and_save_through_tools", "crates/ui-egui/tests/xfa.rs::a_dynamic_xfa_form_is_laid_out_and_filled", "crates/xfa/src/tests.rs::nested_width_less_subforms_lay_out_in_polynomial_time", "crates/js/src/tests.rs::instance_managers_add_remove_and_count", "crates/xfa/src/tests.rs::the_form_tree_carries_values_presence_instances_and_the_events", "crates/engine/src/tests.rs::xfa_scripts_initialize_calculate_validate_toggle_and_add_rows", "crates/automation/tests/automation.rs::xfa_scripts_run_for_buttons_and_field_changes_through_tools", "crates/ui-egui/tests/xfa.rs::xfa_buttons_run_their_scripts_in_the_app", "crates/engine/src/tests.rs::hostile_xfa_open_scripts_are_merged_capped_kept_from_printing_and_noted", "crates/engine/src/tests.rs::runaway_xfa_calculations_at_open_end_quickly_with_a_report", "crates/xfa/src/tests.rs::the_live_form_of_nested_repeating_subforms_stops_at_its_node_budget", "crates/xfa/src/tests.rs::data_ops_of_one_event_are_one_datasets_rewrite", "crates/xfa/src/tests.rs::choice_lists_signatures_passwords_and_pictures_become_fields_and_images", "crates/xfa/src/tests.rs::choice_values_come_from_the_data_as_saved_values_and_several_for_lists", "crates/xfa/src/tests.rs::pictures_are_sized_from_their_headers_and_hostile_ones_are_refused_unallocated", "crates/engine/src/tests.rs::xfa_choice_lists_signature_and_picture_fields_open_fill_script_and_save", "crates/xfa/src/tests.rs::repeating_subforms_inside_positioned_content_get_their_instances", "crates/engine/src/tests.rs::xfa_rows_in_areas_are_laid_out_read_calculated_and_written_where_adobe_binds_them"] notes = "laid out from the template on open by the xfa crate (positioned, tb, lr-tb, table/row, page masters, breaks, repeated header rows, captions, rich text, JPEG/PNG/GIF images) into real pages and AcroForm fields (text, password, date with AFDate formats, check boxes, radio groups, drop-downs and list boxes with shown/saved item pairs and multi-select, signature fields, image fields showing their picture (pictures decoded once per document, at most 20 million pixels, and reused across re-layouts), buttons with reset/print/save/URL), filled from the datasets packet (including rows another viewer added), saved as an incremental revision with the values merged into the datasets so Adobe's viewers show them; doc_info reports xfa_layout. XFA JavaScript and FormCalc (form.xfa-formcalc) run in the js crate's XFA object model (xfa.form, SOM resolution, rawValue, presence, access, instance managers, xfa.host, xfa.layout, xfa.event): initialize and calculate on open, exit, validate and calculate after a field changes, click for buttons (through js_run and the app), with rows added and removed, subforms shown and hidden, and the form laid out again, all as one undo step and kept in the file. Hostile scripts are bounded per event (merged and capped effects, messages and console lines, lower loop limits and a time budget on open and calculate, one datasets rewrite per event, print/save/links/focus from clicks only, a runaway script abandoned and the form's scripts turned off) and what open-time scripts changed is noted. Missing: loops inside nested calls are only stopped by abandoning their thread, and script memory is not capped; the change event per keystroke; xfa.template, connections and data descriptions; a hidden instance of a repeating subform hides them all; choosing a new picture for an image field; barcodes are drawn as their text, not bars; keep rules; metrics are approximate and Acrobat fidelity is eyeballed on real forms, not measured (#60)" [[feature]] @@ -5166,8 +5166,8 @@ milestone = "M12" status = "partial" commands = ["file.open", "file.save"] tools = ["doc_open", "form_fill", "doc_save"] -evidence = ["crates/xfa/src/tests.rs::the_datasets_packet_is_written_from_the_fields_and_read_back", "crates/xfa/src/tests.rs::data_fills_the_fields_and_adds_rows", "crates/engine/src/tests.rs::dynamic_xfa_forms_are_laid_out_on_open_filled_and_saved_incrementally"] -notes = "the datasets packet inside the PDF: read on open (values, dates in ISO form, check and radio states, extra rows for repeating subforms) and rewritten after every form edit from the fields' SOM paths (/PCSom). Missing: importing or exporting a standalone XML/XDP data file, explicit bind refs, data descriptions" +evidence = ["crates/xfa/src/tests.rs::the_datasets_packet_is_written_from_the_fields_and_read_back", "crates/xfa/src/tests.rs::data_fills_the_fields_and_adds_rows", "crates/engine/src/tests.rs::dynamic_xfa_forms_are_laid_out_on_open_filled_and_saved_incrementally", "crates/xfa/src/tests.rs::data_paths_leave_areas_out", "crates/engine/src/tests.rs::xfa_rows_in_areas_are_laid_out_read_calculated_and_written_where_adobe_binds_them"] +notes = "the datasets packet inside the PDF: read on open (values, dates in ISO form, check and radio states, extra rows for repeating subforms) and rewritten after every form edit from the fields' SOM paths (/PCSom), with areas left out of data paths as Adobe binds them. Missing: importing or exporting a standalone XML/XDP data file, explicit bind refs, data descriptions" [[feature]] id = "form.xfa-flatten" From 3906dcdff535c8e10b9ab9888c1849cc6ab52ffd Mon Sep 17 00:00:00 2001 From: DWAA1660 Date: Sat, 10 Oct 2026 14:31:41 -0600 Subject: [PATCH 48/69] Exported images keep their transparency, JPEG cut-outs included (#645) (#763) * M10: exported images keep their transparency, JPEG cut-outs included (#645) Export all images and Save image as wrote a JPEG with a soft mask as the bare JPEG and dropped soft masks of another size, JPEG-compressed soft masks, stencil /Mask images and colour-key /Mask arrays, so cut-out images came out on a solid white or black background. - An image with transparency (/SMask, stencil /Mask, colour-key /Mask) is written as a PNG with it as alpha, JPEG images included (decoded with the image crate's JPEG decoder, already in the tree). JPEGs without transparency are still written byte for byte. - Soft and stencil masks of another size are scaled to the image's size; /Decode inversion is honoured and /Matte pre-blending is undone. - Image sizes are checked (positive, at most 100 Mpx) before use, so a negative /Width no longer overflows in Save image as. - Regression tests: JPEG + soft mask, scaled soft mask, stencil mask, colour key, matte, and damaged masks/sizes (all fail or panic on main). Fixes #645 Co-Authored-By: Claude Opus 5.5 * M10: use as_chunks for colour-key pairs (clippy 1.99 chunks_exact_to_as_chunks) (#645) Rust 1.99's clippy denies chunks_exact with a constant size, which failed the Clippy step on all CI targets. as_chunks::<2> also drops the indexing. Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: DWAA1660 Co-authored-by: Claude Opus 5.5 Co-authored-by: Brandon Thomas --- ROADMAP.md | 1 + crates/create/Cargo.toml | 4 +- crates/create/src/extract.rs | 215 ++++++++++++++++++++++++++++++----- crates/create/src/tests.rs | 154 +++++++++++++++++++++++++ parity/acrobat-features.toml | 4 +- 5 files changed, 344 insertions(+), 34 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 470fe333d..2c40b42d2 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -216,6 +216,7 @@ M0 → M1 → M2 → M3 → M4 must happen in order. After M4, M5–M12 can run ## Log Newest first: the date, what moved, and the new overall estimate. +- **2026-10-10 (M10, #645):** Export all images and Save image as keep transparency. A JPEG with a soft mask (the usual cut-out photo) was written as the bare JPEG, and soft masks of another size than the image, JPEG-compressed soft masks, stencil `/Mask` images and colour-key `/Mask` arrays were dropped, so cut-outs came out on solid white or black. These images are now PNG with the mask as alpha (soft masks scaled to the image, `/Matte` un-blended); JPEGs without transparency are still written byte for byte. A negative image size no longer overflows in Save image as. JPX/JBIG2/CCITT masks are still left out. Overall estimate unchanged (about 30–35%). - **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 (`