diff --git a/.github/dependabot.yml b/.github/dependabot.yml
new file mode 100644
index 0000000000..1910ea4bd3
--- /dev/null
+++ b/.github/dependabot.yml
@@ -0,0 +1,32 @@
+version: 2
+updates:
+ # Maintain Rust / Cargo dependencies
+ - package-ecosystem: "cargo"
+ directories:
+ - "/"
+ - "/crates/filters/fuzz"
+ schedule:
+ interval: "daily"
+ time: "06:00"
+ timezone: "America/New_York"
+ # Consolidate updates into a single PR rather than one PR per crate
+ groups:
+ cargo-dependencies:
+ patterns:
+ - "*"
+ commit-message:
+ prefix: "chore(deps)"
+
+ # Maintain GitHub Actions workflows
+ - package-ecosystem: "github-actions"
+ directory: "/"
+ schedule:
+ interval: "daily"
+ time: "06:00"
+ timezone: "America/New_York"
+ groups:
+ actions:
+ patterns:
+ - "*"
+ commit-message:
+ prefix: "ci(deps)"
diff --git a/.github/workflows/nix.yml b/.github/workflows/nix.yml
index 18392587a8..ce817da129 100644
--- a/.github/workflows/nix.yml
+++ b/.github/workflows/nix.yml
@@ -23,7 +23,7 @@ jobs:
timeout-minutes: 15
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- - uses: cachix/install-nix-action@13d8dd58da0234aa297dedd986986ccb8e7f3e24 # v31.11.1
+ - uses: cachix/install-nix-action@448cde739abec1eaf0646249e3d25bb8d9bc7702 # v31.11.2
- name: nix flake check --no-build
run: |
if [ -f flake.nix ]; then
diff --git a/Cargo.lock b/Cargo.lock
index c6f4f49d69..68a758c2fb 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -4026,6 +4026,7 @@ dependencies = [
"pdfcraft-cos",
"pdfcraft-engine",
"pdfcraft-platform",
+ "pdfcraft-preflight",
"pdfcraft-render",
"rustix 1.1.5",
"serde_json",
@@ -4135,6 +4136,7 @@ dependencies = [
"pdfcraft-xfdf",
"png 0.18.1",
"thiserror 2.0.21",
+ "tiff",
"unicode-segmentation",
"url",
]
@@ -4261,9 +4263,11 @@ dependencies = [
name = "pdfcraft-preflight"
version = "0.9.0"
dependencies = [
+ "flate2",
"pdfcraft-content",
"pdfcraft-cos",
"pdfcraft-model",
+ "roxmltree",
]
[[package]]
diff --git a/README.md b/README.md
index bd33ad23be..2a65b66457 100644
--- a/README.md
+++ b/README.md
@@ -11,7 +11,7 @@
PdfCraft
- The PDF workbench; an open-source, clean-room reimplementation of Adobe Acrobat, rebuilt in pure Rust.
+ The PDF workbench; an open-source, modern PDF reader and editor, built in pure Rust.
Read, organize, combine, split and secure PDFs in a fast, native app, written in Rust from the ground up.
macOS · Windows · Linux · FreeBSD · the web
@@ -417,16 +417,16 @@ Logs, environment variables and other development notes are in [docs/development
## What's next
-PdfCraft is young and moving fast. The aim is a workbench where you can view, organize, annotate, fill, sign and edit PDFs, at parity with Acrobat Pro.
+PdfCraft is young and moving fast. The aim is a workbench where you can view, organize, annotate, fill, sign and edit PDFs, for professional and everyday workflows.
-**Where it stands (October 2026), honestly:** PdfCraft is **alpha**. Most of Acrobat Pro's offline features exist in some form (57% of all tracked features, 94% of the must-haves). For everyday work (viewing, commenting, filling and signing forms, organizing pages) it is about 55% of the way to replacing Acrobat; against all of Acrobat Pro, about 49%; for someone who only needs the essentials, about 66%. Every everyday workflow can be completed, but converting and sharing is weak: no Office-to-PDF, and Word export that loses layout.
+**Where it stands (October 2026), honestly:** PdfCraft is **alpha**. Most essential offline PDF features exist in some form (57% of the features tracked in [docs/target-app-parity.md](docs/target-app-parity.md), 94% of core essentials). For everyday work (viewing, commenting, filling and signing forms, organizing pages) readiness is about 55%; across all professional workflows, about 49%; for someone who only needs the essentials, about 66%. Every everyday workflow can be completed, but converting and sharing is weak: no office-document-to-PDF, and .docx export that loses layout.
- **Good today:** viewing and search; organizing, combining and splitting; most kinds of comment; filling and authoring forms (with sandboxed JavaScript); passwords, redaction and sanitizing; basic digital signatures; printing; the Accessibility Checker; agent control through the CLI and MCP.
- **Still borrowed:** pages are drawn by the `hayro` crate while our own renderer is built.
-- **Thin or missing:** reliable editing of existing text (especially CJK and right-to-left), OCR beyond Latin script, Office import/export, signature tokens and online long-term validation, Preflight and PDF/X/UA, printing from the web build, physical printer fidelity, and full XFA forms.
-- **Hardening:** fuzzing still turns up crashes and hangs on hostile files; each one is fixed with a regression test. Quality has not yet been compared with Acrobat side by side.
+- **Thin or missing:** reliable editing of existing text (especially CJK and right-to-left), OCR beyond Latin script, office document import/export, signature tokens and online long-term validation, Preflight and PDF/X/UA, printing from the web build, physical printer fidelity, and full XFA forms.
+- **Hardening:** fuzzing still turns up crashes and hangs on hostile files; each one is fixed with a regression test. Quality has not yet been benchmarked side by side.
-**Next, in order:** Office-to-PDF and a Word export that keeps layout, the reported bugs and the Chinese interface font, a fidelity harness against Acrobat and the renderer, editing existing content, then the Pro workflows (OCR, Office, Preflight, signatures, accessibility tagging) and 1.0 polish.
+**Next, in order:** office-document-to-PDF and a .docx export that keeps layout, the reported bugs and the Chinese interface font, a fidelity harness against a reference renderer, editing existing content, then professional workflows (OCR, office formats, Preflight, signatures, accessibility tagging) and 1.0 polish.
The summary is in **[ROADMAP.md](ROADMAP.md)**; the full assessment by dimension and area is in [docs/target-app-parity.md](docs/target-app-parity.md), the ranked gaps in [docs/gaps.md](docs/gaps.md), and the milestones in [docs/roadmap.md](docs/roadmap.md).
@@ -581,8 +581,6 @@ ArtCraft Team and are not covered by this license. They may be used only unmodif
part of this repository and PdfCraft, under [`docs/brand/LICENSE-brand.txt`](docs/brand/LICENSE-brand.txt).
Forks and modified versions must remove them.
-Adobe, Photoshop, Illustrator, Premiere Pro, Lightroom, Acrobat, After Effects and InDesign are trademarks or registered trademarks of Adobe Inc. in the United States and/or other countries. PdfCraft is an independent, open-source project and is not affiliated with, sponsored by or endorsed by Adobe Inc.; these names are used only to describe the workflows it is compatible with.
-
Made by the ArtCraft team and community.
diff --git a/ROADMAP.md b/ROADMAP.md
index e215cb3bd6..4334c58091 100644
--- a/ROADMAP.md
+++ b/ROADMAP.md
@@ -2,11 +2,13 @@
**Stage: alpha** · next: beta, ~25% (ready for real work 50% → 75%) and ~400–700 h away
+> **Last reviewed:** 2026-10-18 · **Last updated:** 2026-10-18 · **Change:** minor (M5, PR #1971: bounded nested FDF export excludes source page/page-tree dictionaries; refreshed against upstream 4db6ac4a; estimates unchanged)
> **Last reviewed:** 2026-10-15 · **Last updated:** 2026-10-15 · **Change:** minor (M7: the Edit text caret is as tall as the row it is on; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS)
> **Last reviewed:** 2026-10-15 · **Last updated:** 2026-10-15 · **Change:** minor (M6: CSV tab-containing headers; estimates unchanged)
> **Last reviewed:** 2026-10-18 · **Last updated:** 2026-10-18 · **Change:** minor (M3, #3149: discard render demand after page geometry changes and check the GPU texture limit before upload; estimates unchanged)
> **Last reviewed:** 2026-10-18 · **Last updated:** 2026-10-18 · **Change:** minor (M4, #1919: repeated page indices accumulate rotations; M3, #3149: discard render demand after page geometry changes and check the GPU texture limit before upload; estimates unchanged)
> **Last reviewed:** 2026-10-18 · **Last updated:** 2026-10-18 · **Change:** minor (M8, #1350: named replacement descriptions retired during Sanitize and across shared resources; estimates unchanged)
+> **Last reviewed:** 2026-10-17 · **Last updated:** 2026-10-17 · **Change:** minor (M4 #2997: Ctrl-drag (Option on macOS) in the Organize grid copies the pages into the drop gap; `page_duplicate` takes `at`; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS)
> **Last reviewed:** 2026-10-15 · **Last updated:** 2026-10-15 · **Change:** minor (M6: AcroForm XML text; estimates unchanged)
> **Last reviewed:** 2026-10-15 · **Last updated:** 2026-10-15 · **Change:** minor (M2/gap 12: the Annex D.2 alias codes read as the ink they draw in both tables; the text oracle scores reading order; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS)
> **Last reviewed:** 2026-10-15 · **Last updated:** 2026-10-15 · **Change:** minor (M14/#2383 GPU-limit candidate; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS)
@@ -38,6 +40,7 @@
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (#594 paragraph drags validated in the Linux test environment; localized shortcut labels, #496; readiness table with hours per audience; full number as weighted sum 49%; earlier: second look: ready 40% → 47%, mainstream 55% added; core-workflow gate applied → alpha) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS)
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (architectural paper controls from #599; localized shortcut labels, #496; readiness table with hours per audience; full number as weighted sum 49%; earlier: second look: ready 40% → 47%, mainstream 55% added; core-workflow gate applied → alpha) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS)
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (closed issues synced: #774, #756 and the #799–#821 batch; earlier: localized shortcut labels, #496; readiness table with hours per audience; full number as weighted sum 49%; earlier: second look: ready 40% → 47%, mainstream 55% added; core-workflow gate applied → alpha) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS)
+> **Last reviewed:** 2026-10-18 - **Last updated:** 2026-10-18 - **Change:** minor (M6 #2892: Push-button normal labels are editable per widget through Options and form_set_props, independently of field names; rendering, undo/redo and incremental/full save/reopen are covered. Estimates unchanged)
PdfCraft's one-page summary: how close it is to Adobe Acrobat Pro's offline feature set, and what
comes next. Adobe's cloud services (Document Cloud, Acrobat Sign requests, shared review, the AI
@@ -168,6 +171,20 @@ Ranked; detail and the full milestone table in [docs/roadmap.md](docs/roadmap.md
| 8 | 1.0 polish: performance, right-to-left, missing languages, keyboard-only (M14) | 55–95 |
## Progress log
+
+- **2026-10-19 (M14, #3373):** The main menu and its long submenus scroll in short windows, keeping their commands reachable. Estimates unchanged.
+
+- **2026-10-19 (M3, #3387):** Save-prompt shortcuts yield to later consent and utility dialogs; a synthetic UI regression checks that an edited document stays open. Estimates unchanged.
+
+- **2026-10-18 (M10, #3120):** Export to Word, PowerPoint, HTML and RTF reads text from form XObjects nested as deep as the renderer draws them (50 levels), and up to 50 000 forms a page (the renderer's budget for nested ones); it stopped at 12 levels and 4 096 forms and left the rest out silently. A page past those limits is listed in `doc_export_office`'s `truncated_pages`, with a warning. Estimates unchanged.
+
+- **2026-10-18 (M5, PR #1971 review):** Nested FDF annotation values that identify a source `/Page` or `/Pages` dictionary become null at every copy depth. A synthetic three-page PDF saved and reopened before export verifies that destination actions cannot pull source page content into the FDF, while unrelated nested metadata and shared references remain intact. FDF import semantics, copied-graph limits and estimates are unchanged.
+- **2026-10-15 (M5, PR #1971 refresh):** Nested FDF annotation values retain shared or cyclic copied graphs. Discovery borrows direct values and records annotation positions with lazy array owners; retained entries are budgeted before cloning. Synthetic stress fixtures include repeated aliases and saved-PDF cache eviction. Limits cover the copied graph; source parsing and page discovery retain their existing limits, with no total source-memory bound. FDF import semantics and estimates are unchanged.
+- **2026-10-13 (M5, nested FDF values):** Comment export copies and renumbers nested indirect values, retaining geometry, colours, borders and shared graphs. Graph/data limits return an error before the destination is written; synthetic saved-PDF and automation exchange regressions. Estimates unchanged.
+
+- **2026-10-18 (M6, #2892):** Push-button normal labels are editable per widget through Options and form_set_props, independently of field names; rendering, undo/redo and incremental/full save/reopen are covered. Estimates unchanged.
+
+- **2026-10-18 (M7, #3212):** Headers, Bates numbers, watermarks and added text stay visible after unfinished marked content, with original content preserved.
- **2026-10-15 (M7, Edit text caret):** In a text region that mixes sizes (a 24 pt title over 12 pt lines), the inline editor's caret on a small row was as tall as the region's first size and reached into the row below: egui draws the caret at least one row of the text field's font tall, and that font had the paragraph's first size. The field's font is now half the region's smallest size, so each row's own height decides. Kittest regression; checked in the app on a real document. Estimates unchanged.
- **2026-10-16 (M3, #2781):** The Pages panel follows the current page, as Acrobat's page thumbnails do: going to a page whose thumbnail is out of sight (scrolling, Page Down, Go to page, a bookmark) scrolls the panel just enough to show it. A thumbnail already in view, or clicked, leaves the panel where it is, and a page change while the user scrolls the panel or drags thumbnails does not move it. Estimates unchanged.
- **2026-10-16 (M12, #1645):** CLI info, text and render use the engine's dynamic XFA layout rather than placeholder pages. Synthetic two-page and encrypted-form regressions cover counts, text, pixels, copy permissions and unchanged input bytes. Estimates unchanged.
@@ -175,14 +192,19 @@ Ranked; detail and the full milestone table in [docs/roadmap.md](docs/roadmap.md
- **2026-10-18 (M4, PR #1919 review):** repeated page indices apply each rotation to the latest page-local value, preserving indirect/inherited angles. Synthetic adjacent/interleaved duplicates retain siblings and page contents through incremental and full saves. Estimates unchanged.
- **2026-10-15 (M4, #1919 refresh):** Integrated upstream `99941daa` while retaining the indirect page rotation correction and all upstream progress. Only independent documentation conflicts required resolution; focused production and test files are unchanged from the previously green CI head. Estimates unchanged.
- **2026-10-18 (M3, #3149):** Page replacement cancels render demand collected for the previous geometry; all viewer raster upload paths reject empty or oversized textures before cache eviction. Synthetic regressions reproduce the reported 1880×2350 upload at a 2048 limit, retain cached images and unsaved edits, and resume bounded backdrops and tiles. Estimates unchanged.
+- **2026-10-19 (M3, #3360):** Switching between Read and the docking workspace retains the current document’s scroll position at fixed zoom, including within-page position; separate tabs retain their own offsets. Estimates unchanged.
- **2026-10-18 (M8, #1350):** Integrated current main while preserving content-stream comment boundaries. Hidden-text cleanup retires unused named replacement descriptions on rewritten pages and forms, including inherited/shared resource holders, without removing descriptions still used by surviving content. Synthetic saved-byte and preservation regressions cover the cleanup. Estimates unchanged.
+- **2026-10-17 (M4, #2997):** Dragging page thumbnails in the Organize grid with Ctrl held (Option on macOS) drops copies at the gap instead of moving the pages: a "+" badge sits by the pointer, the copies land at the gap selected, and one undo step ("Duplicate pages") takes them out again. `page_duplicate` takes `at` (1-based, where the first copy goes; left out, after the last of them) so agents can drive the same thing, and the UI control channel's `ui.drag` modifiers now reach gestures that read the input state. Estimates unchanged.
+- **2026-10-15 (M6, AcroForm XML text):** Plain AcroForm XML imports retain all direct text around comments and processing instructions, including CDATA and character references. Synthetic regressions verify exact values and unknown catalog data after incremental and full save/reopen. Nested-element and XFA interpretation are unchanged. Estimates unchanged.
- **2026-10-15 (M6, #1917 refresh):** Integrated upstream `99941daa` while retaining the XML/XFDF attribute whitespace correction and all upstream progress. Only independent documentation conflicts required resolution; focused production and test files are unchanged from the previously green CI head. Estimates unchanged.
- **2026-10-15 (M1, #2267 refresh):** Integrated upstream `99941daa` while retaining the raw-deflate fallback correction and all upstream progress. Only independent documentation conflicts required resolution; focused production and test files are unchanged from the previously green CI head. Estimates unchanged.
- **2026-10-16 (M10, #1201 partial):** Lossless image export applies each component's Decode pair. Indexed values round and clip before palette lookup. Color-key masks keep raw samples. The existing 16-bit precision limit remains. JPEG and mask Decode, ICC transforms, and calibrated colors remain outside this fix. Estimates unchanged.
- **2026-10-16 (M3, #2739):** Wrapped revision buttons stay above Binding in Document Properties. A synthetic regression checks control bounds and opens a saved revision at two viewport widths. Estimates unchanged.
+- **2026-10-19 (M7, tracked text):** Editable paragraphs and object labels distinguish repeated glyph tracking from word gaps in TJ arrays and separately positioned glyphs. Synthetic regressions cover character spacing, horizontal scaling, rotated baselines, larger word gaps, and rows of one-glyph words or cells (inferred tracking is capped at half an em, so `a b c` and digit or Y/N columns keep their spaces). Estimates unchanged.
- **2026-10-15 (M2, gap 12):** `cargo xtask text-oracle` scores reading order as well as word F1: the longest common subsequence of the two word sequences over the longer one. F1 is a bag of words and cannot see order at all -- measuring #2431, a change that moves text and nothing else, it called 26 of the 29 files whose output changed identical, where the order score separated 18 improvements from 6 regressions. Reports, does not gate; a disagreement with Poppler, not a verdict. Estimates unchanged.
- **2026-10-15 (M2, #2305 follow-up):** WinAnsiEncoding 0xA0 and 0xAD and MacRomanEncoding 0xCA extract as the ordinary space and hyphen Annex D.2 names them, in the vendored hayro tables (patch 30) as well as `crates/fonts`, so search, copy and redaction see characters that were hidden behind U+00A0 and U+00AD. Across the pdf.js corpus the soft hyphens we extract fall 117 to 13 and the non-breaking spaces 30 to 26; the rest come from `/ToUnicode`. An explicit `/Differences` name keeps its glyph list value, as pdftotext does. Estimates unchanged.
-- **2026-10-15 (M12, XFA, #2574):** A `pageArea` with no `` repeats without limit instead of taking a subform's default of one use, so a later page area with `occur max="1"` is no longer drawn (with a copy of its fields) over every body page. A page area's `occur min` is honoured too: one the body never reached gets the pages it still needs after the body (the once-only closing page of the public ISCIII form, with its button, is page 7 of 7). Acrobat Pro shows the form as 7 pages with that page last, as here; when printing, Acrobat keeps that page but leaves it blank (`relevant="-print"` on the page area), which PdfCraft does not do yet. Synthetic regression tests. Estimates unchanged.
+- **2026-10-15 (M12, XFA, #2574):** A `pageArea` with no `` repeats without limit instead of taking a subform's default of one use, so a later page area with `occur max="1"` is no longer drawn (with a copy of its fields) over every body page. A page area's `occur min` is honoured too: one the body never reached gets the pages it still needs after the body (the once-only closing page of a form, with its button, comes last). Acrobat Pro also puts that page last, and when printing keeps it but leaves it blank (`relevant="-print"` on the page area), which PdfCraft does not do yet; both re-observed on a synthetic fixture, see the 2026-10-19 line. Synthetic regression tests. Estimates unchanged.
+- **2026-10-19 (M12, XFA, #2574):** The Acrobat observations behind the line above were first made on a public real-world form, which the Privacy rule does not allow; they are replaced by ones made on a synthetic form (three `breakBefore pageArea` sections, then a page area with `occur min="1" max="1"`, with and without `relevant="-print"`). Acrobat Pro shows the closing page last in both and prints it in both, blank only with `relevant="-print"`: confirmed. The synthetic form also shows a difference not fixed yet: Acrobat opens it as 5 pages, with an empty first body page before the first section, where PdfCraft lays out 4. Two more synthetic forms narrow it down: with `breakBefore targetType="pageArea"` on the first section, with or without `startNew`, Acrobat opens an empty body page first; without a break on the first section it lays out 4, as PdfCraft does (so `startNew` is not the cause). Not fixed yet: `layout.rs` skips a break while the page is still empty. Estimates unchanged.
- **2026-10-15 (M8, #2447):** Removing links or comments (Remove Hidden Information, Sanitize) also drops the tagged-PDF structure tree's object references to them, so the full rewrite no longer keeps a removed link's URL or a comment's text. Regression seen to fail without the fix. Estimates unchanged.
- **2026-10-15 (M6, CSV tab-containing headers):** CSV export and spreadsheet merging quote tabs, and import distinguishes quoted tabs from TSV delimiters without changing field identities. Synthetic export/import and incremental/full save regressions cover header tabs, quotes and line breaks, plus ordinary TSV controls. Formula-cell policy is unchanged. Estimates unchanged.
- **2026-10-15 (M6, AcroForm XML text):** Plain AcroForm XML imports retain all direct text around comments and processing instructions, including CDATA and character references. Synthetic regressions verify exact values and unknown catalog data after incremental and full save/reopen. Nested-element and XFA interpretation are unchanged. Estimates unchanged.
@@ -530,6 +552,8 @@ Newest first: the date, what moved, and the new overall estimate.
| Date | Change | Summary |
|---|---|---|
+| 2026-10-18 | minor | M5, PR #1971: nested source `/Page` and `/Pages` dictionaries export as null without traversing page content or the page tree. Synthetic saved/reopened three-page regression retains unrelated metadata and aliases. FDF import semantics, copied-graph limits and estimates unchanged. |
+| 2026-10-15 | minor | M5: budget retained FDF entries before cloning; discovery keeps lazy array owners and annotation positions, with alias/cache-eviction stress fixtures. Limits cover the copied graph; source parsing/page discovery retain existing COS/parser limits, with no total source-memory bound. FDF import semantics and estimates unchanged. |
| 2026-10-15 | minor | M7: the Edit text caret is as tall as the row it is on in a region with several sizes; estimates unchanged. |
| 2026-10-18 | minor | M4, PR #1919 review: repeated page indices apply each rotation to the latest page-local value, preserving indirect/inherited angles. Synthetic adjacent/interleaved duplicates retain siblings and page contents through incremental and full saves. Estimates unchanged. |
| 2026-10-18 | minor | M3, #3149: page replacement cancels render demand collected for the previous geometry; all viewer raster upload paths reject empty or oversized textures before cache eviction. Synthetic regressions reproduce the reported 1880×2350 upload at a 2048 limit, retain cached images and unsaved edits, and resume bounded backdrops and tiles. Estimates unchanged. |
diff --git a/apps/pdfcraft-cli/Cargo.toml b/apps/pdfcraft-cli/Cargo.toml
index d7b52fbd66..bab8c28a88 100644
--- a/apps/pdfcraft-cli/Cargo.toml
+++ b/apps/pdfcraft-cli/Cargo.toml
@@ -18,6 +18,7 @@ serde_json = { workspace = true }
pdfcraft-engine = { workspace = true }
pdfcraft-audit = { workspace = true }
pdfcraft-cos = { workspace = true }
+pdfcraft-preflight = { workspace = true }
pdfcraft-automation = { workspace = true }
pdfcraft-platform = { workspace = true }
base64 = "0.22"
diff --git a/apps/pdfcraft-cli/src/main.rs b/apps/pdfcraft-cli/src/main.rs
index be2df77355..5888e51114 100644
--- a/apps/pdfcraft-cli/src/main.rs
+++ b/apps/pdfcraft-cli/src/main.rs
@@ -57,6 +57,7 @@ macro_rules! mcp_usage {
const USAGE: &str = concat!(
"\
pdfcraft-cli info [--password PW] document summary as JSON
+ (includes any declared PDF/A or PDF/UA level)
pdfcraft-cli render --page N [--dpi 96] --out x.png (.png, .jpg, .tif or .pam)
pdfcraft-cli text [--page N] extracted text (pages separated by form feeds)
pdfcraft-cli edit --out out.pdf [--rotate 1,3:90] [--delete 2,4] [--move 5:1]
@@ -315,6 +316,10 @@ fn info(args: &[String]) -> Result<(), CliError> {
})
})
.flatten();
+ // What the document declares as a standard, read the way the Standards panel reads it, so
+ // `info` and the app agree. A file that declares nothing leaves these null and empty.
+ let declared =
+ pdfcraft_cos::Document::open_with_password(bytes.clone(), password).map(|doc| pdfcraft_preflight::declared(&doc)).unwrap_or_default();
let json = serde_json::json!({
"file": path,
"pdf_version": info.pdf_version,
@@ -324,6 +329,9 @@ fn info(args: &[String]) -> Result<(), CliError> {
"first_page_pt": info.pages.first().map(|p| [p.width, p.height]),
"title": info.title, "author": info.author, "producer": info.producer, "creator": info.creator,
"encrypted": info.encrypted, "tagged": info.tagged, "javascript": info.has_javascript,
+ "pdfa": declared.pdfa.as_ref().map(|(part, conformance)| format!("PDF/A-{part}{}", conformance.to_lowercase())),
+ "pdfua": declared.pdfua,
+ "output_intents": declared.output_intents,
"xfa": info.xfa.map(|x| match x { pdfcraft_render::Xfa::Static => "static", pdfcraft_render::Xfa::Dynamic => "dynamic" }),
"xfa_layout": xfa_layout,
"bookmarks": info.outline.len(), "annotations": info.annotations.len(), "fields": info.fields.len(),
@@ -614,7 +622,8 @@ fn render(args: &[String]) -> Result<(), CliError> {
return Err(e.into());
}
let bytes = match format {
- Some(f) => pdfcraft_engine::export::encode_image(p.width, p.height, &p.rgba, f)?,
+ // With `reject_oversize` the page is drawn at `--dpi` or not at all, so the file can say so.
+ Some(f) => pdfcraft_engine::export::encode_image(p.width, p.height, &p.rgba, f, f64::from(dpi))?,
None => {
// PAM (netpbm RGB_ALPHA): the raw premultiplied pixels, for tools that read it.
let mut pam = format!("P7\nWIDTH {}\nHEIGHT {}\nDEPTH 4\nMAXVAL 255\nTUPLTYPE RGB_ALPHA\nENDHDR\n", p.width, p.height).into_bytes();
diff --git a/apps/pdfcraft-cli/tests/info_standards.rs b/apps/pdfcraft-cli/tests/info_standards.rs
new file mode 100644
index 0000000000..68be86228c
--- /dev/null
+++ b/apps/pdfcraft-cli/tests/info_standards.rs
@@ -0,0 +1,87 @@
+//! `info` reports the standard a document declares, as the Standards panel does (#3319). A file
+//! carrying `pdfaid:part` and `pdfaid:conformance` in its XMP metadata was summarised with no
+//! mention of PDF/A at all, though the app reads the same declaration and shows it.
+
+use std::process::Command;
+
+const BIN: &str = env!("CARGO_BIN_EXE_pdfcraft-cli");
+
+/// A one-page PDF, with `metadata` as the catalog's XMP stream when given.
+fn fixture(metadata: Option<&str>) -> Vec {
+ let content = b"0 0 0 rg 10 10 40 20 re f";
+ let catalog = match metadata {
+ Some(_) => "<< /Type /Catalog /Pages 2 0 R /Metadata 5 0 R >>".to_string(),
+ None => "<< /Type /Catalog /Pages 2 0 R >>".to_string(),
+ };
+ let mut objs: Vec> = vec![
+ catalog.into_bytes(),
+ b"<< /Type /Pages /Kids [3 0 R] /Count 1 >>".to_vec(),
+ b"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 120 60] /Contents 4 0 R >>".to_vec(),
+ format!("<< /Length {} >> stream\n", content.len()).into_bytes(),
+ ];
+ objs[3].extend_from_slice(content);
+ objs[3].extend_from_slice(b"\nendstream");
+ if let Some(xmp) = metadata {
+ let mut stream = format!("<< /Type /Metadata /Subtype /XML /Length {} >> stream\n", xmp.len()).into_bytes();
+ stream.extend_from_slice(xmp.as_bytes());
+ stream.extend_from_slice(b"\nendstream");
+ objs.push(stream);
+ }
+ 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 off in &offsets {
+ out.extend_from_slice(format!("{off:010} 00000 n \n").as_bytes());
+ }
+ out.extend_from_slice(format!("trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n", objs.len() + 1).as_bytes());
+ out
+}
+
+fn xmp(part: &str, conformance: &str) -> String {
+ format!(
+ "\n\
+\n \n\
+ \n\
+ {part} \n {conformance} \n\
+ \n \n \n"
+ )
+}
+
+fn info_of(bytes: &[u8], name: &str) -> serde_json::Value {
+ let dir = std::env::temp_dir().join(format!("pdfcraft-info-standards-{}-{name}", std::process::id()));
+ std::fs::create_dir_all(&dir).unwrap();
+ let path = dir.join("f.pdf");
+ std::fs::write(&path, bytes).unwrap();
+ let out = Command::new(BIN).args(["info", path.to_str().unwrap()]).output().unwrap();
+ assert!(out.status.success(), "{}", String::from_utf8_lossy(&out.stderr));
+ let json = serde_json::from_slice(&out.stdout).expect("info prints JSON");
+ let _ = std::fs::remove_dir_all(&dir);
+ json
+}
+
+#[test]
+fn info_names_the_declared_pdfa_level() {
+ let declared = info_of(&fixture(Some(&xmp("2", "B"))), "pdfa2b");
+ assert_eq!(declared["pdfa"], serde_json::json!("PDF/A-2b"), "{declared}");
+ assert_eq!(declared["pdfua"], serde_json::Value::Null);
+ assert_eq!(declared["output_intents"], serde_json::json!([]));
+ // Lower-case conformance in the file still reads as the level's own spelling.
+ assert_eq!(info_of(&fixture(Some(&xmp("3", "a"))), "pdfa3a")["pdfa"], serde_json::json!("PDF/A-3a"));
+}
+
+#[test]
+fn info_declares_nothing_for_a_plain_pdf() {
+ let plain = info_of(&fixture(None), "plain");
+ assert_eq!(plain["pdfa"], serde_json::Value::Null, "{plain}");
+ assert_eq!(plain["pdfua"], serde_json::Value::Null);
+ // The rest of the summary is unaffected.
+ assert_eq!(plain["pages"], serde_json::json!(1));
+ assert_eq!(plain["pdf_version"], serde_json::json!("1.7"));
+}
diff --git a/apps/pdfcraft-cli/tests/render.rs b/apps/pdfcraft-cli/tests/render.rs
index b0ab245601..695431b65b 100644
--- a/apps/pdfcraft-cli/tests/render.rs
+++ b/apps/pdfcraft-cli/tests/render.rs
@@ -56,6 +56,17 @@ fn jpeg_and_tiff_names_get_those_formats() {
assert!(bytes.starts_with(b"II*\0") || bytes.starts_with(b"MM\0*"), "not a TIFF: {:?}", &bytes[..4]);
}
+#[test]
+fn the_file_records_the_dpi_it_was_rendered_at() {
+ // The engine's tests check each format; this checks that `--dpi` reaches the file.
+ let pdf = fixture("dpi");
+ let jpg = tmp("dpi", "p1.jpg");
+ assert!(render(&pdf, &jpg).status.success());
+ let bytes = std::fs::read(&jpg).unwrap();
+ assert_eq!(&bytes[6..11], b"JFIF\0");
+ assert_eq!(bytes[13..18], [1, 0, 36, 0, 36], "JFIF density in dots per inch");
+}
+
#[test]
fn a_pam_name_still_gets_the_netpbm_stream() {
let pdf = fixture("pam");
diff --git a/apps/pdfcraft/Cargo.toml b/apps/pdfcraft/Cargo.toml
index a27401c700..3ce1c96ef3 100644
--- a/apps/pdfcraft/Cargo.toml
+++ b/apps/pdfcraft/Cargo.toml
@@ -54,8 +54,9 @@ winit = { version = "0.30", default-features = false, features = ["wayland"] }
[target.'cfg(target_os = "windows")'.dependencies]
# Which GPU drives which display (EnumDisplayDevices), so the window is drawn on the GPU that
-# shows it (issue #378). Safe wrappers over Win32; only the `user` feature (which brings `kernel` with it).
-winsafe = { version = "0.0.29", default-features = false, features = ["user"] }
+# shows it (issue #378). Safe wrappers over Win32; only the `user` feature (which brings `kernel` with it),
+# and `advapi` for the session id that names the single-instance socket (#3338).
+winsafe = { version = "0.0.29", default-features = false, features = ["user", "advapi"] }
# Settings ▸ Accessibility ▸ Visual effects ▸ Animation effects (src/motion.rs), through WinRT's
# `UISettings`, whose bindings are safe to call (winsafe's `SystemParametersInfo` is an unsafe
# fn). Already in the tree through wgpu's DX12 backend; this adds one small namespace to it.
diff --git a/apps/pdfcraft/src/single_instance.rs b/apps/pdfcraft/src/single_instance.rs
index ecfa12f5c3..751785d0c5 100644
--- a/apps/pdfcraft/src/single_instance.rs
+++ b/apps/pdfcraft/src/single_instance.rs
@@ -153,7 +153,7 @@ pub fn claim(files: &[String], may_hand_off: bool) -> Claim {
/// `%LOCALAPPDATA%` is unset.
#[cfg(windows)]
fn socket_path() -> Option {
- let name = socket_name(std::env::var("SESSIONNAME").ok().as_deref());
+ let name = socket_name(session_id(), std::env::var("SESSIONNAME").ok().as_deref());
// %LOCALAPPDATA% is private to the user's sign-in, so other accounts can't receive this
// user's file paths or send it files to open. A portable copy uses it too (#889), under its
// own name so it never hands files to an installed copy (which keeps its settings elsewhere).
@@ -184,13 +184,31 @@ fn display_socket_name(display: Option<&str>) -> String {
if display.is_empty() { "instance.sock".to_string() } else { format!("instance-{display}.sock") }
}
-/// The socket's file name for a Windows session (`SESSIONNAME`: `Console`, `RDP-Tcp#3`…). The
-/// same user signed in twice (at the console and over Remote Desktop) gets one socket per session,
-/// so a file never opens in a window on the other screen. Only letters, digits and `-` are kept.
+/// The Windows session this process runs in, from its access token: the console, each Remote
+/// Desktop sign-in and session 0 (services) each have their own. `None` if Windows won't say.
+#[cfg(windows)]
+fn session_id() -> Option {
+ use winsafe::{HACCESSTOKEN, TokenInfo, co::TOKEN_INFORMATION_CLASS};
+ match HACCESSTOKEN::GetCurrentProcessToken().GetTokenInformation(TOKEN_INFORMATION_CLASS::SessionId) {
+ Ok(TokenInfo::SessionId(id)) => Some(*id),
+ _ => None,
+ }
+}
+
+/// The socket's file name for a Windows session. The same user signed in twice (at the console
+/// and over Remote Desktop) gets one socket per session, so a file never opens in a window on the
+/// other screen. The session is the token's session id (`session`): a launch from Task Scheduler,
+/// a service or a remote shell has no `SESSIONNAME` but still runs in a session, so it finds the
+/// window of the session it runs in (#3338), and never one in another session (review on #3340).
+/// Only if the id can't be read does `SESSIONNAME` (`Console`, `RDP-Tcp#3`…) name it, letters,
+/// digits and `-` kept.
#[cfg_attr(not(windows), allow(dead_code))]
-fn socket_name(session: Option<&str>) -> String {
- let session: String = session.unwrap_or_default().chars().filter(|c| c.is_ascii_alphanumeric() || *c == '-').take(32).collect();
- if session.is_empty() { "instance.sock".to_string() } else { format!("instance-{session}.sock") }
+fn socket_name(session: Option, session_name: Option<&str>) -> String {
+ if let Some(id) = session {
+ return format!("instance-s{id}.sock");
+ }
+ let name: String = session_name.unwrap_or_default().chars().filter(|c| c.is_ascii_alphanumeric() || *c == '-').take(32).collect();
+ if name.is_empty() { "instance.sock".to_string() } else { format!("instance-{name}.sock") }
}
/// Held while a launch finds out whether it is the first, so launches that start together (several
@@ -359,12 +377,20 @@ mod tests {
#[test]
fn each_windows_session_gets_its_own_socket_name() {
- assert_eq!(socket_name(Some("Console")), "instance-Console.sock");
- assert_eq!(socket_name(Some("RDP-Tcp#3")), "instance-RDP-Tcp3.sock");
- assert_ne!(socket_name(Some("RDP-Tcp#3")), socket_name(Some("RDP-Tcp#4")));
- assert_eq!(socket_name(Some(r"..\x/y:z")), "instance-xyz.sock", "never a path");
- assert_eq!(socket_name(None), "instance.sock");
- assert_eq!(socket_name(Some("")), "instance.sock");
+ // The session id decides: #3338, a launch from Task Scheduler (no SESSIONNAME) in the
+ // console's session finds the window opened from Explorer there...
+ assert_eq!(socket_name(Some(1), None), socket_name(Some(1), Some("Console")));
+ // ...and (review on #3340) one in a Remote Desktop session never reaches the console's.
+ assert_ne!(socket_name(Some(2), None), socket_name(Some(1), Some("Console")));
+ assert_ne!(socket_name(Some(2), Some("RDP-Tcp#3")), socket_name(Some(3), Some("RDP-Tcp#3")));
+ assert_eq!(socket_name(Some(4_000_000_000), None), "instance-s4000000000.sock");
+ // Only without an id does SESSIONNAME name it.
+ assert_eq!(socket_name(None, Some("Console")), "instance-Console.sock");
+ assert_eq!(socket_name(None, Some("RDP-Tcp#3")), "instance-RDP-Tcp3.sock");
+ assert_ne!(socket_name(None, Some("RDP-Tcp#3")), socket_name(None, Some("RDP-Tcp#4")));
+ assert_eq!(socket_name(None, Some(r"..\x/y:z")), "instance-xyz.sock", "never a path");
+ assert_eq!(socket_name(None, None), "instance.sock");
+ assert_eq!(socket_name(None, Some("")), "instance.sock");
}
#[test]
@@ -394,11 +420,21 @@ mod tests {
assert_eq!(display_socket_name(Some("")), "instance.sock");
}
+ /// The socket is named by the session the process runs in, which Windows always gives.
+ #[cfg(windows)]
+ #[test]
+ fn this_process_has_a_session_id() {
+ let id = super::session_id();
+ assert!(id.is_some(), "the access token names the session");
+ let name = socket_name(id, None);
+ assert!(socket_path().is_none_or(|p| p.file_name().is_some_and(|f| f.to_string_lossy().ends_with(&name))), "{name}");
+ }
+
#[cfg(any(windows, all(unix, not(target_os = "macos"))))]
fn socket(test: &str) -> std::path::PathBuf {
let dir = std::env::temp_dir().join(format!("pdfcraft-instance-{}-{test}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
- dir.join(socket_name(None))
+ dir.join(socket_name(None, None))
}
#[cfg(any(windows, all(unix, not(target_os = "macos"))))]
diff --git a/contributors/people.toml b/contributors/people.toml
index da3cfc6a77..f7cb392924 100644
--- a/contributors/people.toml
+++ b/contributors/people.toml
@@ -145,3 +145,11 @@ sync_github_name = false
[people.Silvertree2010]
display_name = "Netter"
sync_github_name = false
+
+[people.MortenHusted]
+sync_github_name = true
+
+[people.EricKaden]
+real_name = "Eric Kaden"
+display_name = "Eric Kaden"
+sync_github_name = false
diff --git a/crates/annot/src/appearance.rs b/crates/annot/src/appearance.rs
index 0f0706428f..3e1dd4a6b6 100644
--- a/crates/annot/src/appearance.rs
+++ b/crates/annot/src/appearance.rs
@@ -174,7 +174,7 @@ fn interior_color(d: &Dict, fallback: Option) -> Option {
color(d, b"IC").flatten().or(fallback)
}
-fn border_width(d: &Dict) -> f64 {
+pub(crate) fn border_width(d: &Dict) -> f64 {
if let Some(w) = d.get(b"BS").and_then(|b| b.as_dict()).and_then(|b| b.get(b"W")).and_then(|w| w.as_f64()) {
return w.max(0.0);
}
diff --git a/crates/annot/src/lib.rs b/crates/annot/src/lib.rs
index b7f9d62697..587cd4466f 100644
--- a/crates/annot/src/lib.rs
+++ b/crates/annot/src/lib.rs
@@ -1987,13 +1987,12 @@ pub fn set_font_size(doc: &mut Document, page: usize, index: usize, size: f64, m
result
}
-/// Whether a comment of this subtype has an interior that can be filled (`/IC`: rectangles,
-/// ovals, polygons and clouds, §12.5.6.8–9).
+/// Whether a comment has a fill: `/IC` for shapes, `/C` for FreeText (§12.5.6).
pub fn fillable(subtype: &str) -> bool {
- matches!(subtype, "Square" | "Circle" | "Polygon")
+ matches!(subtype, "Square" | "Circle" | "Polygon" | "FreeText")
}
-/// Fill a rectangle, oval or polygon comment with `fill`, or remove its fill (`None`), and
+/// Fill a shape or FreeText comment with `fill`, or remove its fill (`None`), and
/// redraw its appearance (Acrobat: Properties ▸ Fill Color).
pub fn set_fill(doc: &mut Document, page: usize, index: usize, fill: Option, meta: &Meta) -> Result<(), AnnotError> {
let (_, r) = annot_ref(doc, page, index)?;
@@ -2001,7 +2000,7 @@ pub fn set_fill(doc: &mut Document, page: usize, index: usize, fill: Option
let d = annot_dict(doc, r);
let subtype = String::from_utf8_lossy(d.name(b"Subtype").unwrap_or_default()).into_owned();
if !fillable(&subtype) {
- return Err(AnnotError::Invalid("only rectangles, ovals and polygons can be filled".into()));
+ return Err(AnnotError::Invalid("only rectangles, ovals, polygons and text boxes can be filled".into()));
}
// Check before changing anything: a stale appearance would contradict the new fill.
if appearance::build(&d).is_none() {
@@ -2013,10 +2012,11 @@ pub fn set_fill(doc: &mut Document, page: usize, index: usize, fill: Option
refuse_foreign_text(doc, &d)?;
let before = d;
doc.update_dict(r, |d| {
+ let key: &[u8] = if subtype == "FreeText" { b"C" } else { b"IC" };
match fill {
- Some(c) => d.set(b"IC".to_vec(), rgb(c)),
+ Some(c) => d.set(key.to_vec(), rgb(c)),
None => {
- d.remove(b"IC");
+ d.remove(key);
}
}
touch(d, meta);
@@ -2205,7 +2205,7 @@ pub struct Props {
pub subject: String,
pub color: Option,
pub opacity: f64,
- /// Border / line width, for shapes, lines and drawings.
+ /// Border / line width, for shapes, lines, drawings and text boxes.
pub width: Option,
/// Notes only.
pub icon: Option,
@@ -2217,9 +2217,9 @@ pub struct Props {
pub locked: bool,
/// `/LE`: two names for a line or polyline (`None` when unset), one for a callout.
pub endings: Option>,
- /// Rectangles, ovals and polygons: their interior can be filled (see [`fillable`]).
+ /// Whether the comment has a fill control (see [`fillable`]).
pub fillable: bool,
- /// `/IC`, the interior colour (`None`: no fill).
+ /// The interior colour (`None`: no fill).
pub fill: Option,
/// Text boxes, typewriter text and callouts: the `/DA` font size in points.
pub font_size: Option,
@@ -2234,8 +2234,13 @@ pub fn props(doc: &Document, page: usize, index: usize) -> Option {
let subtype = String::from_utf8_lossy(d.name(b"Subtype")?).into_owned();
let c: Vec = d.get(b"C").and_then(|o| o.as_array().map(|a| a.iter().filter_map(|x| x.as_f64()).collect())).unwrap_or_default();
let color = if subtype == "FreeText" { Some(appearance::parse_da(d).0) } else { (c.len() == 3).then(|| [c[0], c[1], c[2]]) };
- let width = matches!(subtype.as_str(), "Square" | "Circle" | "Line" | "Ink" | "Polygon" | "PolyLine")
- .then(|| d.get(b"BS").and_then(|b| b.as_dict()).and_then(|b| b.get(b"W")).and_then(|w| w.as_f64()).unwrap_or(1.0));
+ let width = matches!(subtype.as_str(), "Square" | "Circle" | "Line" | "Ink" | "Polygon" | "PolyLine" | "FreeText").then(|| {
+ if subtype == "FreeText" {
+ if !d.contains(b"BS") && !d.contains(b"Border") { 0.0 } else { appearance::border_width(d) }
+ } else {
+ d.get(b"BS").and_then(|b| b.as_dict()).and_then(|b| b.get(b"W")).and_then(|w| w.as_f64()).unwrap_or(1.0)
+ }
+ });
Some(Props {
author: text_value(doc, d, b"T").unwrap_or_default(),
subject: text_value(doc, d, b"Subj").unwrap_or_default(),
@@ -2254,9 +2259,10 @@ pub fn props(doc: &Document, page: usize, index: usize) -> Option {
})
}
-/// `/IC` as an RGB colour. An empty array means no fill; grey and CMYK fills are converted.
+/// A shape’s `/IC` or FreeText `/C` as RGB. Grey and CMYK fills are converted.
fn fill_of(d: &Dict) -> Option {
- let c: Vec = d.get(b"IC")?.as_array()?.iter().filter_map(|x| x.as_f64()).collect();
+ let key: &[u8] = if d.name(b"Subtype") == Some(b"FreeText") { b"C" } else { b"IC" };
+ let c: Vec = d.get(key)?.as_array()?.iter().filter_map(|x| x.as_f64()).collect();
let rgb = match c.as_slice() {
[g] => [*g; 3],
[r, g, b] => [*r, *g, *b],
diff --git a/crates/annot/src/tests.rs b/crates/annot/src/tests.rs
index d3d448e520..4a4dfb1064 100644
--- a/crates/annot/src/tests.rs
+++ b/crates/annot/src/tests.rs
@@ -1335,3 +1335,50 @@ fn text_box_font_size_changes_the_da_and_grows_the_box() {
assert_eq!(props(&doc, 0, i).unwrap().font_size, None);
}
}
+
+#[test]
+fn text_boxes_and_callouts_can_change_fill_and_border() {
+ let mut doc = fixture();
+ let shapes = [
+ Shape::TextBox { rect: [300.0, 600.0, 500.0, 660.0], font_size: 12.0 },
+ Shape::Callout {
+ rect: [300.0, 600.0, 500.0, 660.0],
+ knee: [260.0, 560.0],
+ point: [200.0, 520.0],
+ font_size: 12.0,
+ ending: LineEnding::OpenArrow,
+ },
+ ];
+ for shape in shapes {
+ let index = add_annotation(&mut doc, &new(0, shape), &meta("test")).unwrap();
+ assert!(props(&doc, 0, index).unwrap().fillable);
+ set_fill(&mut doc, 0, index, Some([1.0, 1.0, 1.0]), &meta("test")).unwrap();
+ set_style(&mut doc, 0, index, None, None, Some(3.0), None, &meta("test")).unwrap();
+ let saved = reopen(&doc);
+ let properties = props(&saved, 0, index).unwrap();
+ assert_eq!(properties.fill, Some([1.0; 3]));
+ assert_eq!(properties.width, Some(3.0));
+ let ap = ap_content(&saved, &list(&saved, 0)[index]);
+ assert!(ap.contains("1 1 1 rg") && ap.contains(" re f") && ap.contains("3 w"), "{ap}");
+ set_fill(&mut doc, 0, index, None, &meta("test")).unwrap();
+ assert_eq!(props(&doc, 0, index).unwrap().fill, None);
+ }
+}
+
+#[test]
+fn free_text_properties_show_legacy_or_absent_border_width() {
+ let mut doc = fixture();
+ let index = add_annotation(&mut doc, &new(0, Shape::TextBox { rect: [100.0, 100.0, 200.0, 140.0], font_size: 12.0 }), &meta("test")).unwrap();
+ let (_, reference) = annot_ref(&mut doc, 0, index).unwrap();
+ doc.update_dict(reference, |d| {
+ d.remove(b"BS");
+ d.remove(b"Border");
+ })
+ .unwrap();
+ assert_eq!(props(&doc, 0, index).unwrap().width, Some(0.0));
+ doc.update_dict(reference, |d| {
+ d.set(b"Border".to_vec(), Object::Array([0.0, 0.0, 4.0].into_iter().map(Object::Real).collect()));
+ })
+ .unwrap();
+ assert_eq!(props(&doc, 0, index).unwrap().width, Some(4.0));
+}
diff --git a/crates/automation/README.md b/crates/automation/README.md
index 4e27702a32..669ea61463 100644
--- a/crates/automation/README.md
+++ b/crates/automation/README.md
@@ -64,6 +64,20 @@ semantics; they never silently act on partial text.
## Shared field properties
+For an individual push button, `form_set_props` also edits its visible normal label:
+
+```json
+{"doc": 1, "field": "clear_button", "caption": "Clear Form", "widget": 1}
+```
+
+`widget` is a 1-based index (default 1), used only with `caption`; an empty caption clears the
+label. Neither renames the field. `form_fields` returns the first widget's `caption` and all
+`captions` as `[{"widget": 1, "caption": "Clear Form"}]`. Other widgets and icon-only layouts
+keep their settings. CLI and MCP use the same edit, including undo/redo and save/reopen.
+The desktop exposes it as **Button Properties → Options → Label**. Rollover/down labels and
+icon layout are not edited here; the existing caption font and unsupported-character warning
+policy apply.
+
`form_set_props` accepts either `field` (one name) or `fields` (1–1000 unique names):
```json
@@ -75,7 +89,7 @@ The batch is atomic and creates one undo step. A missing or locked field, invali
unknown argument or malformed appearance refuses the whole edit. Set `locked: false` to
unlock and change fields together. Only supplied properties change: each field/widget keeps
its other colours, fonts, values and unknown PDF dictionary keys. `"none"` removes a border
-or fill colour. Names, rectangles and rotations require the single-field form. Results keep
+or fill colour. Names, rectangles, rotations and captions require the single-field form. Results keep
`field` for single edits and return `fields` for batches. CLI and MCP use the same tool.
## MCP server
diff --git a/crates/automation/src/a11y.rs b/crates/automation/src/a11y.rs
index 8130ed084d..2124549731 100644
--- a/crates/automation/src/a11y.rs
+++ b/crates/automation/src/a11y.rs
@@ -710,9 +710,19 @@ impl Automation {
let ext = path.extension().map(|e| e.to_string_lossy().into_owned()).unwrap_or_default();
let format = pdfcraft_engine::compare::OfficeFormat::from_extension(&ext)
.ok_or_else(|| ToolError::InvalidArgs(format!("unsupported extension {ext:?} (docx, pptx, html or rtf)")))?;
- let bytes = self.doc_to_copy(a)?.export_office(format);
- write_atomic(&path, &bytes)?;
- Ok(json!({ "path": path.to_string_lossy(), "bytes": bytes.len(), "format": format.extension() }))
+ let export = self.doc_to_copy(a)?.export_office_checked(format);
+ write_atomic(&path, &export.bytes)?;
+ // Text left out to keep a hostile page bounded is said, not silent (#3120).
+ let truncated: Vec = export.truncated_pages.iter().map(|p| p.saturating_add(1)).collect();
+ let mut out = json!({ "path": path.to_string_lossy(), "bytes": export.bytes.len(), "format": format.extension(),
+ "truncated": !truncated.is_empty(), "truncated_pages": truncated });
+ if !export.truncated_pages.is_empty() {
+ use pdfcraft_engine::compare::{MAX_FORM_DEPTH, MAX_FORM_VISITS};
+ out["warnings"] = json!([format!(
+ "forms nested more than {MAX_FORM_DEPTH} deep, or past the first {MAX_FORM_VISITS} forms of a page (forms the page draws itself count too), were left out on the pages in truncated_pages: their text and images are missing from the export, although the viewer may still show them"
+ )]);
+ }
+ Ok(out)
}
fn pdfa_level(&self, a: &Args) -> Result {
diff --git a/crates/automation/src/forms.rs b/crates/automation/src/forms.rs
index 7c60425a8c..f2eab5ddc0 100644
--- a/crates/automation/src/forms.rs
+++ b/crates/automation/src/forms.rs
@@ -233,6 +233,19 @@ impl Automation {
o.insert("unsupported_scripts".into(), json!(f.actions.unsupported));
}
match f.kind {
+ FormFieldKind::PushButton => {
+ o.insert("caption".into(), json!(w.and_then(|w| w.caption.as_deref()).unwrap_or_default()));
+ o.insert(
+ "captions".into(),
+ json!(
+ f.widgets
+ .iter()
+ .enumerate()
+ .map(|(i, w)| { json!({"widget": i.saturating_add(1), "caption": w.caption.as_deref().unwrap_or_default()}) })
+ .collect::>()
+ ),
+ );
+ }
FormFieldKind::Radio => {
o.insert("options".into(), json!((0..f.widgets.len()).filter_map(|i| f.export_of(i)).collect::>()));
}
@@ -327,6 +340,9 @@ impl Automation {
}
pub(crate) fn form_set_props(&mut self, a: &Args) -> Result {
+ if a.get("widget").is_some() && a.get("caption").is_none() {
+ return Err(bad("widget requires caption"));
+ }
let single = a.opt_str("field")?;
let bulk = a.get("fields").is_some();
if single.is_some() == bulk {
@@ -344,7 +360,7 @@ impl Automation {
return Err(bad(format!("field {name:?} is listed more than once")));
}
}
- for key in ["name", "rect", "rotation"] {
+ for key in ["name", "rect", "rotation", "caption", "widget"] {
if a.get(key).is_some() {
return Err(bad(format!("{key} applies to one field; use field instead of fields")));
}
@@ -398,6 +414,15 @@ impl Automation {
rect,
name: a.opt_str("name")?.map(str::to_owned),
tooltip: a.opt_str("tooltip")?.map(str::to_owned),
+ caption: a
+ .opt_str("caption")?
+ .map(|caption| -> Result<_> {
+ let wi = a.opt_int("widget")?.unwrap_or(1);
+ let wi =
+ usize::try_from(wi).ok().and_then(|wi| wi.checked_sub(1)).ok_or_else(|| bad("widget must be a positive 1-based index"))?;
+ Ok((wi, caption.to_owned()))
+ })
+ .transpose()?,
read_only: a.opt_bool("read_only")?,
required: a.opt_bool("required")?,
multiline: a.opt_bool("multiline")?,
diff --git a/crates/automation/src/lib.rs b/crates/automation/src/lib.rs
index 3a170739e2..84e6f808d2 100644
--- a/crates/automation/src/lib.rs
+++ b/crates/automation/src/lib.rs
@@ -380,7 +380,8 @@ impl Automation {
}
"page_duplicate" => {
let pages = self.pages(&a, "pages")?;
- self.apply(&a, Edit::DuplicatePages { pages })?
+ let at = self.opt_position(&a, "at")?;
+ self.apply(&a, Edit::DuplicatePages { pages, at })?
}
"page_set_box" => self.page_set_box(&a)?,
"page_transition_set" => self.page_transition_set(&a)?,
@@ -1813,12 +1814,17 @@ impl Automation {
/// A 1-based insertion position (1 = before the first page, n+1 = after the last) → 0-based.
fn position(&self, a: &Args, key: &str) -> Result {
+ self.opt_position(a, key)?.ok_or_else(|| Args::missing(key))
+ }
+
+ /// `position`, but `None` when the key is absent.
+ fn opt_position(&self, a: &Args, key: &str) -> Result> {
+ let Some(at) = a.opt_int(key)? else { return Ok(None) };
let n = self.doc(a)?.info.pages.len();
- let at = a.int(key)?;
if at < 1 || at as usize > n + 1 {
return Err(ToolError::InvalidArgs(format!("{key} must be between 1 and {} (the document has {n} pages)", n + 1)));
}
- Ok(at as usize - 1)
+ Ok(Some(at as usize - 1))
}
fn renderer(&mut self, id: DocId) -> Result<&mut PageRenderer> {
diff --git a/crates/automation/src/tools.rs b/crates/automation/src/tools.rs
index 40ff47be2e..06fcd90813 100644
--- a/crates/automation/src/tools.rs
+++ b/crates/automation/src/tools.rs
@@ -321,7 +321,7 @@ pub fn tools() -> Vec {
}),
&["doc", "from", "to"],
)),
- t("form_fields", "List form fields", "Every interactive form field: name, type (text, checkbox, radio, combo, list, button, signature), value, options, page, rect (top-left-origin points) and rotation (0, 90, 180 or 270 degrees counterclockwise), read-only and required flags.")
+ t("form_fields", "List form fields", "Every interactive form field: name, type (text, checkbox, radio, combo, list, button, signature), value, options, page, rect (top-left-origin points) and rotation (0, 90, 180 or 270 degrees counterclockwise), read-only and required flags. Push buttons include caption (first widget's normal label) and captions [{widget, caption}] for all widgets (1-based).")
.ro()
.with(schema(json!({ "doc": doc() }), &["doc"])),
t(
@@ -374,7 +374,7 @@ pub fn tools() -> Vec {
t(
"form_set_props",
"Field properties",
- "Change field properties: give field for one field, or fields for a list of unique field names (1–1000). A bulk change is atomic and one Undo step; an invalid or locked field leaves every field unchanged. Only the given properties change, and partial appearance changes preserve each field's other colours and font. name (rename), rect (position) and rotation require a single field. General and Options: tooltip, read_only, required, multiline, max_length (0 = no limit), options (combo/list items), font_size (0 = auto), format, validate, calculate (Acrobat's native AF rules). Options: align (left, center, right), default (Reset form's value; check boxes/radio buttons use their on state, \"\" for off), flags { scroll, rich_text, password, file_select, spell_check, comb (needs max_length), sort, editable, multi_select, commit_immediately }, check_style (check boxes/radio buttons: check, circle, cross, diamond, square, star). rotation is 0, 90, 180 or 270 degrees counterclockwise and swaps width/height when the axis changes. Undoable.",
+ "Change field properties: give field for one field, or fields for a list of unique field names (1–1000). A bulk change is atomic and one Undo step; an invalid or locked field leaves every field unchanged. Only the given properties change, and partial appearance changes preserve each field's other colours and font. name (rename), rect (position), rotation and caption require a single field. caption changes one push-button normal label (widget: 1-based index, default 1); an empty string clears it. Other widgets and icon-only layouts are preserved. General and Options: tooltip, read_only, required, multiline, max_length (0 = no limit), options (combo/list items), font_size (0 = auto), format, validate, calculate (Acrobat's native AF rules). Options: align (left, center, right), default (Reset form's value; check boxes/radio buttons use their on state, \"\" for off), flags { scroll, rich_text, password, file_select, spell_check, comb (needs max_length), sort, editable, multi_select, commit_immediately }, check_style (check boxes/radio buttons: check, circle, cross, diamond, square, star). rotation is 0, 90, 180 or 270 degrees counterclockwise and swaps width/height when the axis changes. Undoable.",
)
.with(schema(
json!({
@@ -383,6 +383,8 @@ pub fn tools() -> Vec {
"fields": { "type": "array", "items": { "type": "string" }, "minItems": 1, "maxItems": 1000, "uniqueItems": true, "description": "Apply the same changes to these fields in one atomic edit. Use either field or fields." },
"name": { "type": "string" },
"tooltip": { "type": "string" },
+ "caption": { "type": "string", "description": "Normal push-button label; an empty string clears it. Requires a single field." },
+ "widget": { "type": "integer", "minimum": 1, "description": "Widget whose caption changes (1-based, default 1). Requires caption. See form_fields.captions." },
"read_only": { "type": "boolean" },
"required": { "type": "boolean" },
"multiline": { "type": "boolean" },
@@ -782,13 +784,13 @@ pub fn tools() -> Vec {
json!({ "doc": doc(), "sort": { "type": "string", "enum": ["page", "author", "date", "type"] }, "out": save_out, "open": open }),
&["doc"],
)),
- t("comment_edit", "Edit a comment", "Change a comment's text, colour, opacity, line width, font size (text box/typewriter/callout), fill (rectangle/oval/polygon; \"none\" removes it), rectangle (rectangle/oval/text box/stamp) or position (`move` [dx, dy] in points). Stamps keep their original appearance when resized. One undo step.").with(schema(
+ t("comment_edit", "Edit a comment", "Change a comment's text, colour, opacity, line width, font size (text box/typewriter/callout), fill (rectangle/oval/polygon/text box/callout; \"none\" removes it), rectangle (rectangle/oval/text box/stamp) or position (`move` [dx, dy] in points). Stamps keep their original appearance when resized. One undo step.").with(schema(
comment_ref(json!({
"contents": { "type": "string" },
"color": color(),
"opacity": { "type": "number", "minimum": 0, "maximum": 1 },
"width": { "type": "number", "minimum": 0 },
- "fill": { "type": "string", "description": "Interior colour of a rectangle, oval or polygon: #RRGGBB, a colour name, or \"none\" for no fill." },
+ "fill": { "type": "string", "description": "Interior colour of a rectangle, oval, polygon, text box or callout: #RRGGBB, a colour name, or \"none\" for no fill." },
"font_size": { "type": "number", "minimum": 1, "maximum": 400, "description": "Font size in points of a text box, typewriter text or callout; the box grows to hold the text." },
"rect": { "type": "array", "items": { "type": "number" }, "minItems": 4, "maxItems": 4 },
"move": point(),
@@ -825,9 +827,9 @@ pub fn tools() -> Vec {
t("page_replace", "Replace pages", "Replace the content of pages with pages of another PDF (same count). Links, comments, form fields and bookmarks on the original pages stay, as in Acrobat. Undoable.")
.cmd("page.replace")
.with(schema(json!({ "doc": doc(), "pages": pages("to replace"), "path": path_arg(), "from_pages": pages("of the other file, in order (default: its first pages)") }), &["doc", "pages", "path"])),
- t("page_duplicate", "Duplicate pages", "Insert copies of pages after the last of them (fonts and images are shared, not copied). Undoable.")
+ t("page_duplicate", "Duplicate pages", "Insert copies of pages so the first copy becomes page `at`, or after the last of them when `at` is left out (fonts and images are shared, not copied). Undoable.")
.cmd("page.duplicate")
- .with(schema(json!({ "doc": doc(), "pages": pages("to duplicate") }), &["doc", "pages"])),
+ .with(schema(json!({ "doc": doc(), "pages": pages("to duplicate"), "at": { "type": "integer", "minimum": 1 } }), &["doc", "pages"])),
t(
"page_transition_set",
"Set page transitions",
@@ -1063,7 +1065,7 @@ pub fn tools() -> Vec {
json!({ "doc": doc(), "field": { "type": "string" }, "event": { "type": "string", "enum": ["keystroke", "format", "validate", "calculate", "mouse_up"] }, "script": { "type": "string" } }),
&["doc", "field", "event"],
)),
- t("doc_export_office", "Export to Word, PowerPoint, HTML or RTF", "Export a PDF ▸ Word (.docx), PowerPoint (.pptx, one slide per page), HTML (.html, one file with images inline) or RTF (.rtf), chosen by path's extension: text, headings, bold and italic. Word uses borderless inferred tables and positioned editable shapes on pages with artwork, rotated or large text; each source page retains its size. Font matching and vector graphics remain limited.")
+ t("doc_export_office", "Export to Word, PowerPoint, HTML or RTF", "Export a PDF ▸ Word (.docx), PowerPoint (.pptx, one slide per page), HTML (.html, one file with images inline) or RTF (.rtf), chosen by path's extension: text, headings, bold and italic. Word uses borderless inferred tables and positioned editable shapes on pages with artwork, rotated or large text; each source page retains its size. Font matching and vector graphics remain limited. Export reads forms 50 deep and up to 50 000 a page, counting forms the page draws itself. Pages that go past either limit are listed in truncated_pages, with a warning; the text and images of the forms left out are missing.")
.cmd("export.docx")
.with(schema(json!({ "doc": doc(), "path": { "type": "string" } }), &["doc", "path"])),
t("pdfa_verify", "Verify PDF/A", "Standards ▸ Verify PDF/A compliance: the PDF/A-2b or 3b rules the document breaks (ISO 19005 clause, message, page, whether Save as PDF/A can fix it), plus what it declares.")
diff --git a/crates/automation/tests/automation.rs b/crates/automation/tests/automation.rs
index 1ac19f119d..eb6687ab92 100644
--- a/crates/automation/tests/automation.rs
+++ b/crates/automation/tests/automation.rs
@@ -2189,6 +2189,19 @@ fn reversing_pages_through_tools() {
assert!(matches!(a.call("page_reverse", &json!({ "doc": doc, "pages": [2] })), Err(ToolError::Failed(_))), "one page has nothing to reverse");
}
+#[test]
+fn page_duplicate_at_inserts_the_copies_there() {
+ let dir = workdir("duplicate-at");
+ let mut a = auto(&dir);
+ let doc = ok(&mut a, "doc_open", json!({ "path": "a.pdf" }))["doc"].as_u64().unwrap();
+ let r = ok(&mut a, "page_duplicate", json!({ "doc": doc, "pages": [2], "at": 1 }));
+ assert_eq!(r["pages"], 4);
+ assert_eq!(page_text(&mut a, doc), ["Page 2", "Page 1", "Page 2", "Page 3"]);
+ // `at` is 1-based and within 1..=pages+1, as for the other page inserts.
+ assert!(matches!(a.call("page_duplicate", &json!({ "doc": doc, "pages": [1], "at": 0 })), Err(ToolError::InvalidArgs(_))), "1-based");
+ assert!(matches!(a.call("page_duplicate", &json!({ "doc": doc, "pages": [1], "at": 6 })), Err(ToolError::InvalidArgs(_))), "past the end");
+}
+
#[test]
fn duplicating_and_cropping_through_tools() {
let dir = workdir("boxes");
@@ -5291,6 +5304,74 @@ fn exporting_to_word_html_and_rtf() {
assert!(a.call("doc_export_office", &json!({ "doc": doc, "path": "a.xyz" })).is_err());
}
+/// #3120: a PDF whose pages draw "Head", then a chain of `depths[page]` nested forms whose
+/// innermost writes "TAIL".
+fn nested_forms_pdf(depths: &[usize]) -> Vec {
+ let mut objs: Vec =
+ vec!["<< /Type /Catalog /Pages 2 0 R >>".into(), String::new(), "<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>".into()];
+ let mut kids = Vec::new();
+ for &depth in depths {
+ // The object number of D1, D2 and so on.
+ let first = objs.len() + 1;
+ let xobjects: String = (1..=depth).map(|i| format!("/D{i} {} 0 R ", first + i - 1)).collect();
+ let resources = format!("<< /Font << /F1 3 0 R >> /XObject << {xobjects}>> >>");
+ for i in 1..=depth {
+ let body = if i == depth { "BT /F1 24 Tf 20 150 Td (TAIL) Tj ET".to_string() } else { format!("/D{} Do", i + 1) };
+ objs.push(format!(
+ "<< /Type /XObject /Subtype /Form /BBox [0 0 200 300] /Resources {resources} /Length {} >>\nstream\n{body}\nendstream",
+ body.len()
+ ));
+ }
+ let body = "BT /F1 24 Tf 20 250 Td (Head) Tj ET /D1 Do";
+ objs.push(format!("<< /Length {} >>\nstream\n{body}\nendstream", body.len()));
+ objs.push(format!("<< /Type /Page /Parent 2 0 R /Contents {} 0 R /Resources {resources} >>", objs.len()));
+ kids.push(format!("{} 0 R", objs.len()));
+ }
+ objs[1] = format!("<< /Type /Pages /Kids [{}] /Count {} /MediaBox [0 0 200 300] >>", kids.join(" "), kids.len());
+ 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{o}\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 o in offsets {
+ out.extend_from_slice(format!("{o:010} 00000 n \n").as_bytes());
+ }
+ out.extend_from_slice(format!("trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n", objs.len() + 1).as_bytes());
+ out
+}
+
+/// #3120: export reads forms as deeply nested as the renderer draws them (13 used to lose their
+/// text), and its result says which pages draw forms past that, whose text is missing.
+#[test]
+fn exporting_office_files_says_which_pages_left_form_text_out() {
+ use pdfcraft_engine::compare::{MAX_FORM_DEPTH, MAX_FORM_VISITS};
+ let dir = workdir("office-forms");
+ std::fs::write(dir.join("deep.pdf"), nested_forms_pdf(&[13, MAX_FORM_DEPTH + 1, MAX_FORM_DEPTH])).unwrap();
+ std::fs::write(dir.join("fine.pdf"), nested_forms_pdf(&[13])).unwrap();
+ let mut a = auto(&dir);
+ let doc = ok(&mut a, "doc_open", json!({ "path": "deep.pdf" }))["doc"].as_u64().unwrap();
+ for ext in ["html", "docx"] {
+ let r = ok(&mut a, "doc_export_office", json!({ "doc": doc, "path": format!("deep.{ext}") }));
+ assert_eq!((&r["truncated"], &r["truncated_pages"]), (&json!(true), &json!([2])), "{r}");
+ let warning = r["warnings"][0].as_str().unwrap();
+ assert!(warning.contains(&format!("{MAX_FORM_DEPTH} deep")) && warning.contains(&MAX_FORM_VISITS.to_string()), "{warning}");
+ assert!(warning.contains("truncated_pages"), "{warning}");
+ }
+ let html = std::fs::read_to_string(dir.join("deep.html")).unwrap();
+ let tails: Vec = html.split(" ").map(|page| page.matches("TAIL").count()).collect();
+ assert_eq!(tails, [1, 0, 1], "{html}");
+ assert_eq!(html.matches("Head").count(), 3, "{html}");
+
+ let fine = ok(&mut a, "doc_open", json!({ "path": "fine.pdf" }))["doc"].as_u64().unwrap();
+ let r = ok(&mut a, "doc_export_office", json!({ "doc": fine, "path": "fine.html" }));
+ assert_eq!((&r["truncated"], &r["truncated_pages"]), (&json!(false), &json!([])), "{r}");
+ assert!(r.get("warnings").is_none(), "{r}");
+ assert!(std::fs::read_to_string(dir.join("fine.html")).unwrap().contains("TAIL"));
+}
+
/// #773: a diagonal watermark must not enter the body flow or move invoice fields.
#[test]
fn exporting_an_invoice_to_word_keeps_the_watermark_out_of_flow() {
@@ -6706,3 +6787,93 @@ fn a_root_less_write_output_is_atomic_too() {
assert!(matches!(&e, ToolError::Failed(m) if m.contains("is a folder")), "{e:?}");
let _ = std::fs::remove_dir_all(&dir);
}
+
+#[test]
+fn fdf_nested_comment_values_survive_export_import_and_save_through_tools() {
+ use pdfcraft_cos::{Dict, Document, Object, PdfString, SaveOptions, write_full};
+ use std::sync::Arc;
+ let dir = workdir("fdf-nested-values");
+ let mut source = Document::open(Arc::new(fixture(1))).unwrap();
+ let page = pdfcraft_model::pages(&source)[0].obj;
+ let mut note = Dict::new();
+ note.set(b"Type".to_vec(), Object::name("Annot"));
+ note.set(b"Subtype".to_vec(), Object::name("Square"));
+ note.set(b"NM".to_vec(), PdfString::text("nested-values"));
+ note.set(b"Rect".to_vec(), Object::Array([10, 20, 30, 40].map(|value| Object::Ref(source.add(Object::Int(value)))).into()));
+ note.set(b"C".to_vec(), Object::Array([0.25, 0.5, 0.75].map(|value| Object::Ref(source.add(Object::Real(value)))).into()));
+ let note = source.add(note);
+ source.update_dict(page, |dict| dict.set(b"Annots".to_vec(), Object::Array(vec![Object::Ref(note)]))).unwrap();
+ std::fs::write(dir.join("source.pdf"), write_full(&source, &SaveOptions::default()).unwrap()).unwrap();
+ let mut a = auto(&dir);
+ let source = ok(&mut a, "doc_open", json!({ "path": "source.pdf" }))["doc"].as_u64().unwrap();
+
+ ok(&mut a, "doc_export_data", json!({ "doc": source, "path": "notes.fdf", "what": "comments" }));
+ let target = ok(&mut a, "doc_open", json!({ "path": "b.pdf" }))["doc"].as_u64().unwrap();
+ ok(&mut a, "doc_import_data", json!({ "doc": target, "path": "notes.fdf" }));
+ ok(&mut a, "doc_save", json!({ "doc": target, "path": "restored.pdf" }));
+ let reopened = ok(&mut a, "doc_open", json!({ "path": "restored.pdf" }))["doc"].as_u64().unwrap();
+ let after = ok(&mut a, "comment_list", json!({ "doc": reopened }));
+ // The saved input has legal indirect scalar numbers. The engine's source inspection
+ // currently reads those as defaults, so compare the restored data to the fixture's
+ // known values (converted from PDF bottom-left to the tool's top-left coordinates).
+ assert_eq!(after["comments"][0]["rect"], json!([10.0, 260.0, 30.0, 280.0]));
+ assert_eq!(after["comments"][0]["color"], "#4080BF");
+}
+
+#[test]
+fn refused_fdf_export_keeps_the_destination_and_source_through_tools() {
+ use pdfcraft_cos::{Dict, Document, Object, SaveOptions, write_full};
+ use std::sync::Arc;
+ let dir = workdir("fdf-export-limit");
+ let mut source = Document::open(Arc::new(fixture(1))).unwrap();
+ let page = pdfcraft_model::pages(&source)[0].obj;
+ let mut note = Dict::new();
+ note.set(b"Type".to_vec(), Object::name("Annot"));
+ note.set(b"Subtype".to_vec(), Object::name("Square"));
+ note.set(b"Rect".to_vec(), Object::Array(vec![10.into(), 20.into(), 30.into(), 40.into()]));
+ note.set(b"PCData".to_vec(), Object::Array(vec![Object::Null; 100_001]));
+ let note = source.add(note);
+ source.update_dict(page, |dict| dict.set(b"Annots".to_vec(), Object::Array(vec![Object::Ref(note)]))).unwrap();
+ let bytes = write_full(&source, &SaveOptions::default()).unwrap();
+ std::fs::write(dir.join("source.pdf"), &bytes).unwrap();
+ std::fs::write(dir.join("notes.fdf"), b"existing destination").unwrap();
+ let mut a = auto(&dir);
+ let doc = ok(&mut a, "doc_open", json!({ "path": "source.pdf" }))["doc"].as_u64().unwrap();
+ let before = ok(&mut a, "comment_list", json!({ "doc": doc }));
+ let error = a.call("doc_export_data", &json!({ "doc": doc, "path": "notes.fdf", "what": "comments" })).unwrap_err();
+ assert!(error.to_string().contains("100,000 values"), "{error}");
+ assert_eq!(std::fs::read(dir.join("notes.fdf")).unwrap(), b"existing destination");
+ assert_eq!(std::fs::read(dir.join("source.pdf")).unwrap(), bytes);
+ assert_eq!(ok(&mut a, "comment_list", json!({ "doc": doc })), before);
+}
+
+#[test]
+fn text_box_and_callout_fill_and_border_through_tools() {
+ let dir = workdir("textbox-fill");
+ let mut a = auto(&dir);
+ let doc = ok(&mut a, "doc_open", json!({ "path": "a.pdf" }))["doc"].as_u64().unwrap();
+ for kind in ["textbox", "callout"] {
+ let mut args = json!({ "doc": doc, "page": 1, "type": kind, "rect": [80, 40, 180, 100], "contents": "Example" });
+ if kind == "callout" {
+ args["to"] = json!([20, 120]);
+ }
+ let added = ok(&mut a, "comment_add", args);
+ let comment = &added["comment"];
+ ok(&mut a, "comment_edit", json!({ "doc": doc, "page": 1, "index": comment["index"], "fill": "white", "width": 3 }));
+ }
+ ok(&mut a, "doc_save", json!({ "doc": doc, "path": "filled.pdf" }));
+ let mut session = pdfcraft_engine::Session::new();
+ let bytes = std::sync::Arc::new(std::fs::read(dir.join("filled.pdf")).unwrap());
+ let saved = session.open("filled.pdf", None, bytes, None).unwrap();
+ for index in 0..2 {
+ let props = session.get(saved).unwrap().comment_props(0, index).unwrap();
+ assert_eq!((props.fill, props.width), (Some([1.0; 3]), Some(3.0)));
+ ok(&mut a, "comment_edit", json!({ "doc": doc, "page": 1, "index": index + 1, "fill": "none" }));
+ }
+ ok(&mut a, "doc_save", json!({ "doc": doc, "path": "transparent.pdf" }));
+ let bytes = std::sync::Arc::new(std::fs::read(dir.join("transparent.pdf")).unwrap());
+ let saved = session.open("transparent.pdf", None, bytes, None).unwrap();
+ for index in 0..2 {
+ assert_eq!(session.get(saved).unwrap().comment_props(0, index).unwrap().fill, None);
+ }
+}
diff --git a/crates/automation/tests/button_caption.rs b/crates/automation/tests/button_caption.rs
new file mode 100644
index 0000000000..cb1a22a026
--- /dev/null
+++ b/crates/automation/tests/button_caption.rs
@@ -0,0 +1,91 @@
+//! Editing a button's visible label through the CLI/MCP tool table (#2892).
+
+use pdfcraft_automation::{Automation, Content};
+use pdfcraft_engine::{DocId, Edit, NewField, Session};
+use serde_json::{Value, json};
+
+fn tool(a: &mut Automation, name: &str, args: Value) -> Value {
+ match a.call(name, &args).unwrap().remove(0) {
+ Content::Json(value) => value,
+ other => panic!("expected JSON: {other:?}"),
+ }
+}
+
+#[test]
+fn button_caption_can_be_changed_after_creation() {
+ let dir = std::env::temp_dir().join(format!("pdfcraft-button-caption-{}", std::process::id()));
+ std::fs::create_dir_all(&dir).unwrap();
+ let mut session = Session::new();
+ let bytes = session.create_blank(300.0, 400.0, 1).unwrap();
+ let id = session.open("buttons.pdf", None, bytes, None).unwrap();
+ session
+ .apply(
+ id,
+ Edit::AddField {
+ page: 0,
+ rect: [40.0, 300.0, 200.0, 330.0],
+ kind: NewField::Button { caption: String::new() },
+ name: Some("clear_button".into()),
+ },
+ )
+ .unwrap();
+ std::fs::write(dir.join("buttons.pdf"), session.save_bytes(id).unwrap().as_slice()).unwrap();
+ let mut a = Automation::new().with_root(&dir).unwrap().with_clock(|| 1_700_000_000);
+ let id = tool(&mut a, "doc_open", json!({"path": "buttons.pdf"}))["doc"].as_u64().unwrap();
+ let before = a.session().save_bytes(DocId(id)).unwrap();
+ let (_, _, empty_pixels) = a.render_pixels(id, 1, 1.0).unwrap();
+ tool(&mut a, "form_set_props", json!({"doc": id, "field": "clear_button", "caption": "Clear Form"}));
+ let fields = tool(&mut a, "form_fields", json!({"doc": id}));
+ assert_eq!(fields["fields"][0]["caption"], "Clear Form");
+ assert_eq!(fields["fields"][0]["captions"], json!([{"widget": 1, "caption": "Clear Form"}]));
+ assert_eq!(a.session().get(DocId(id)).unwrap().can_undo(), Some("Change field properties"));
+ let (_, _, labelled_pixels) = a.render_pixels(id, 1, 1.0).unwrap();
+ assert_ne!(empty_pixels, labelled_pixels, "the label must reach the actual page appearance");
+ tool(&mut a, "edit_undo", json!({"doc": id}));
+ assert_eq!(a.session().save_bytes(DocId(id)).unwrap(), before);
+ tool(&mut a, "edit_redo", json!({"doc": id}));
+ tool(&mut a, "doc_save", json!({"doc": id, "path": "labelled.pdf"}));
+ let saved = std::sync::Arc::new(std::fs::read(dir.join("labelled.pdf")).unwrap());
+ let doc = pdfcraft_cos::Document::open(saved).unwrap();
+ let root = doc.get(doc.root().unwrap());
+ let form = doc.resolve(root.as_dict().unwrap().get(b"AcroForm").unwrap());
+ let fields = doc.resolve(form.as_dict().unwrap().get(b"Fields").unwrap());
+ let button = doc.resolve(&fields.as_array().unwrap()[0]);
+ let mk = doc.resolve(button.as_dict().unwrap().get(b"MK").unwrap());
+ assert_eq!(doc.resolve(mk.as_dict().unwrap().get(b"CA").unwrap()).as_string().unwrap().to_text(), "Clear Form");
+ assert_eq!(doc.resolve(button.as_dict().unwrap().get(b"T").unwrap()).as_string().unwrap().to_text(), "clear_button");
+ let reopened = tool(&mut a, "doc_open", json!({"path": "labelled.pdf"}))["doc"].as_u64().unwrap();
+ let fields = tool(&mut a, "form_fields", json!({"doc": reopened}));
+ assert_eq!(fields["fields"][0]["caption"], "Clear Form");
+ let (_, _, pixels) = a.render_pixels(reopened, 1, 1.0).unwrap();
+ assert_eq!(pixels, labelled_pixels);
+ let out = tool(&mut a, "form_set_props", json!({"doc": id, "field": "clear_button", "caption": "提交"}));
+ assert!(out["warnings"].as_array().unwrap().iter().filter_map(Value::as_str).any(|w| w.contains("U+63D0")));
+ let fields = tool(&mut a, "form_fields", json!({"doc": id}));
+ assert_eq!(fields["fields"][0]["caption"], "提交", "unsupported characters are retained and reported");
+ tool(&mut a, "form_set_props", json!({"doc": id, "field": "clear_button", "caption": "Clear Form"}));
+ for invalid in [
+ json!({"doc": id, "field": "clear_button", "caption": "bad", "widget": 0}),
+ json!({"doc": id, "field": "clear_button", "caption": "bad", "widget": -1}),
+ json!({"doc": id, "field": "clear_button", "caption": "bad", "widget": 2}),
+ json!({"doc": id, "field": "clear_button", "caption": "bad", "widget": i64::MAX}),
+ json!({"doc": id, "field": "clear_button", "caption": 42}),
+ json!({"doc": id, "field": "clear_button", "widget": 1, "tooltip": "bad"}),
+ json!({"doc": id, "fields": ["clear_button"], "caption": "bad"}),
+ ] {
+ let before = a.session().save_bytes(DocId(id)).unwrap();
+ assert!(a.call("form_set_props", &invalid).is_err(), "{invalid}");
+ assert_eq!(a.session().save_bytes(DocId(id)).unwrap(), before, "{invalid}");
+ }
+ tool(&mut a, "form_set_props", json!({"doc": id, "field": "clear_button", "locked": true}));
+ let before = a.session().save_bytes(DocId(id)).unwrap();
+ assert!(a.call("form_set_props", &json!({"doc": id, "field": "clear_button", "caption": "bad"})).is_err());
+ assert_eq!(a.session().save_bytes(DocId(id)).unwrap(), before);
+ tool(&mut a, "form_set_props", json!({"doc": id, "field": "clear_button", "locked": false}));
+ tool(&mut a, "form_set_props", json!({"doc": id, "field": "clear_button", "caption": ""}));
+ let fields = tool(&mut a, "form_fields", json!({"doc": id}));
+ assert_eq!(fields["fields"][0]["caption"], "");
+ let (_, _, pixels) = a.render_pixels(id, 1, 1.0).unwrap();
+ assert_eq!(pixels, empty_pixels);
+ std::fs::remove_dir_all(dir).unwrap();
+}
diff --git a/crates/cos/src/parser.rs b/crates/cos/src/parser.rs
index 8ac8a195bf..0458055961 100644
--- a/crates/cos/src/parser.rs
+++ b/crates/cos/src/parser.rs
@@ -337,11 +337,14 @@ impl<'a> Lexer<'a> {
}
return Ok(Object::Real(0.0));
}
- match s.trim_start_matches('+').parse::() {
+ // Tolerate a doubled sign ("--280") like the real branch does.
+ let t = s.trim_start_matches('+');
+ let cleaned = t.strip_prefix('-').filter(|r| r.starts_with('-')).unwrap_or(t);
+ match cleaned.parse::() {
Ok(v) => Ok(Object::Int(v)),
Err(_) => {
// Out-of-range integers are treated as reals (§7.3.3 allows implementation limits).
- s.parse::().map(Object::Real).map_err(|_| CosError::Syntax { offset: start, detail: format!("bad number {s:?}") })
+ cleaned.parse::().map(Object::Real).map_err(|_| CosError::Syntax { offset: start, detail: format!("bad number {s:?}") })
}
}
}
@@ -401,7 +404,16 @@ fn parse_indirect_in(
if let Object::Dict(dict) = &obj
&& lx.eat_keyword(b"stream")
{
- // The keyword is followed by CRLF or LF (a lone CR is tolerated).
+ // The keyword is followed by CRLF or LF (a lone CR is tolerated). Microsoft Reporting
+ // Services writes a space first (`stream \r\n`); spaces and tabs count only when a line
+ // break follows, so they are never taken from the data.
+ let mut eol = lx.pos;
+ while matches!(data.get(eol), Some(b' ' | b'\t')) {
+ eol += 1;
+ }
+ if matches!(data.get(eol), Some(b'\r' | b'\n')) {
+ lx.pos = eol;
+ }
match (data.get(lx.pos), data.get(lx.pos + 1)) {
(Some(b'\r'), Some(b'\n')) => lx.pos += 2,
(Some(b'\n'), _) | (Some(b'\r'), _) => lx.pos += 1,
@@ -475,6 +487,20 @@ mod tests {
Lexer::new(s.as_bytes(), 0).object().unwrap_or_else(|e| panic!("{s}: {e}"))
}
+ #[test]
+ fn doubled_sign_integer_reads_like_the_real_form() {
+ // Some writers emit a TJ kerning as "--280"; the real form "--280.0" is already read.
+ assert_eq!(obj("--280.0"), Object::Real(-280.0));
+ assert_eq!(obj("--280"), Object::Int(-280));
+ match obj("[(Hello) --280 (World)]") {
+ Object::Array(items) => {
+ assert_eq!(items.len(), 3);
+ assert_eq!(items[1], Object::Int(-280));
+ }
+ other => panic!("not an array: {other:?}"),
+ }
+ }
+
#[test]
fn scalars() {
assert_eq!(obj("true"), Object::Bool(true));
@@ -537,6 +563,25 @@ mod tests {
assert_eq!(s.dict.int(b"Length"), Some(5));
}
+ #[test]
+ fn indirect_stream_with_a_space_before_the_line_break() {
+ // `stream \r\n` as Microsoft Reporting Services writes it: the data starts after the
+ // line break, so the /Length is still right and the data keeps no leading space.
+ for eol in [&b" \r\n"[..], b" \n", b"\t \r"] {
+ let mut data = b"7 0 obj\n<< /Length 5 >>\nstream".to_vec();
+ data.extend_from_slice(eol);
+ data.extend_from_slice(b"HELLO\r\nendstream\nendobj\n");
+ let (_, o) = parse_indirect(&data, 0, &|_| None).unwrap();
+ let Object::Stream(s) = o else { panic!("stream expected") };
+ assert_eq!(&s.raw[..], b"HELLO", "{eol:?}");
+ }
+ // Spaces with no line break after them are data, not a keyword terminator.
+ let data = b"7 0 obj\n<< /Length 7 >>\nstream HELLO\nendstream\nendobj\n";
+ let (_, o) = parse_indirect(data, 0, &|_| None).unwrap();
+ let Object::Stream(s) = o else { panic!("stream expected") };
+ assert_eq!(&s.raw[..], b" HELLO");
+ }
+
#[test]
fn indirect_length_is_resolved() {
let data = b"7 0 obj\n<< /Length 8 0 R >>\nstream\r\nAB\r\nendstream\nendobj\n";
diff --git a/crates/edit/src/images.rs b/crates/edit/src/images.rs
index 62bef8bc5e..e90d8e4f15 100644
--- a/crates/edit/src/images.rs
+++ b/crates/edit/src/images.rs
@@ -4,8 +4,8 @@
//! or delete. Only the stream that draws it is rewritten, as a new object.
use kurbo::{BezPath, Point, Shape};
-use pdfcraft_content::{Matrix, Op, parse, serialize_ops};
-use pdfcraft_cos::{Dict, Document, ObjRef, Object, Stream};
+use pdfcraft_content::{Matrix, Op, Pieces, parse};
+use pdfcraft_cos::{Dict, Document, ObjRef, Object};
use std::sync::Arc;
use crate::EditError;
@@ -30,6 +30,8 @@ pub struct PageImage {
/// Pixel size; zero for vector/grouped Form artwork.
pub width: u32,
pub height: u32,
+ /// The content stream its `Do` (its first token) is in, and the `Do`'s index among the
+ /// operators of the page's streams joined ([`Pieces`]).
stream: usize,
op: usize,
/// CTM before `Do`, excluding a Form's own Matrix.
@@ -90,6 +92,11 @@ fn streams(doc: &Document, page: &Dict) -> Vec<(Object, Vec)> {
.collect()
}
+/// The page's streams joined into the one stream they are: an operator may span two of them.
+fn joined(streams: &[(Object, Vec)]) -> Pieces {
+ Pieces::join(&streams.iter().map(|(_, data)| data.as_slice()).collect::>())
+}
+
fn page_of(doc: &Document, page: usize) -> Result {
pdfcraft_model::pages(doc).into_iter().nth(page).ok_or(EditError::NoSuchPage(page))
}
@@ -214,72 +221,71 @@ pub fn page_images(doc: &Document, page: usize) -> Result, EditEr
let p = page_of(doc, page)?;
let xo = xobjects(doc, &p.dict);
let mut out = Vec::new();
- // The graphics state carries over from one stream to the next.
+ // The streams are one stream in pieces, so the graphics state and operators carry over.
let mut state = ImageState::new(Matrix::IDENTITY, p.crop(doc), Vec::new());
- for (si, (_, data)) in streams(doc, &p.dict).into_iter().enumerate() {
- for (i, op) in parse(&data).ops.iter().enumerate() {
- state.apply(op)?;
- let ctm = state.placement.matrix;
- if op.is("Do") {
- let Some(name) = op.name(0) else { continue };
- let Some(resource) = xo.get(name) else { continue };
- let obj = doc.resolve(resource);
- let Object::Stream(s) = &*obj else { continue };
- let is_form = s.dict.name(b"Subtype") == Some(b"Form");
- let (matrix, bounds) = if is_form {
- let Some(bounds) = s.dict.get(b"BBox").map(|v| doc.resolve(v)).and_then(|v| {
- v.as_array().and_then(|a| {
- if a.len() != 4 {
- return None;
- }
- let mut b = [0.0; 4];
- for (n, v) in b.iter_mut().zip(a) {
- *n = v.as_f64()?;
- }
- Some(b)
- })
- }) else {
- continue;
- };
- let matrix = match s.dict.get(b"Matrix") {
- Some(v) => {
- let v = doc.resolve(v);
- let Some(m) = v.as_array().and_then(|a| Matrix::from_operands(a)) else { continue };
- m
+ let pieces = joined(&streams(doc, &p.dict));
+ for (i, op) in pieces.parse().ops.iter().enumerate() {
+ state.apply(op)?;
+ let ctm = state.placement.matrix;
+ if op.is("Do") {
+ let Some(name) = op.name(0) else { continue };
+ let Some(resource) = xo.get(name) else { continue };
+ let obj = doc.resolve(resource);
+ let Object::Stream(s) = &*obj else { continue };
+ let is_form = s.dict.name(b"Subtype") == Some(b"Form");
+ let (matrix, bounds) = if is_form {
+ let Some(bounds) = s.dict.get(b"BBox").map(|v| doc.resolve(v)).and_then(|v| {
+ v.as_array().and_then(|a| {
+ if a.len() != 4 {
+ return None;
}
- None => Matrix::IDENTITY,
- };
- (matrix.then(&ctm), bounds)
- } else if s.dict.name(b"Subtype") == Some(b"Image") {
- (ctm, [0.0, 0.0, 1.0, 1.0])
- } else {
+ let mut b = [0.0; 4];
+ for (n, v) in b.iter_mut().zip(a) {
+ *n = v.as_f64()?;
+ }
+ Some(b)
+ })
+ }) else {
continue;
};
- let rect = matrix.bbox(bounds);
- if !bounds.iter().chain(matrix.0.iter()).chain(rect.iter()).all(|v| v.is_finite())
- || bounds[2] <= bounds[0]
- || bounds[3] <= bounds[1]
- || rect[2] <= rect[0]
- || rect[3] <= rect[1]
- {
- continue;
- }
- out.push(PageImage {
- rect,
- visible_rect: intersection(rect, state.placement.clip),
- clip_rect: Some(state.placement.clip),
- matrix: matrix.0,
- name: String::from_utf8_lossy(name).into_owned(),
- object: resource.as_ref(),
- is_form,
- width: if is_form { 0 } else { s.dict.int(b"Width").unwrap_or(0).clamp(0, u32::MAX as i64) as u32 },
- height: if is_form { 0 } else { s.dict.int(b"Height").unwrap_or(0).clamp(0, u32::MAX as i64) as u32 },
- stream: si,
- op: i,
- draw_matrix: ctm.0,
- clips: state.placement.clips.clone(),
- });
+ let matrix = match s.dict.get(b"Matrix") {
+ Some(v) => {
+ let v = doc.resolve(v);
+ let Some(m) = v.as_array().and_then(|a| Matrix::from_operands(a)) else { continue };
+ m
+ }
+ None => Matrix::IDENTITY,
+ };
+ (matrix.then(&ctm), bounds)
+ } else if s.dict.name(b"Subtype") == Some(b"Image") {
+ (ctm, [0.0, 0.0, 1.0, 1.0])
+ } else {
+ continue;
+ };
+ let rect = matrix.bbox(bounds);
+ if !bounds.iter().chain(matrix.0.iter()).chain(rect.iter()).all(|v| v.is_finite())
+ || bounds[2] <= bounds[0]
+ || bounds[3] <= bounds[1]
+ || rect[2] <= rect[0]
+ || rect[3] <= rect[1]
+ {
+ continue;
}
+ out.push(PageImage {
+ rect,
+ visible_rect: intersection(rect, state.placement.clip),
+ clip_rect: Some(state.placement.clip),
+ matrix: matrix.0,
+ name: String::from_utf8_lossy(name).into_owned(),
+ object: resource.as_ref(),
+ is_form,
+ width: if is_form { 0 } else { s.dict.int(b"Width").unwrap_or(0).clamp(0, u32::MAX as i64) as u32 },
+ height: if is_form { 0 } else { s.dict.int(b"Height").unwrap_or(0).clamp(0, u32::MAX as i64) as u32 },
+ stream: pieces.piece_of(op.span.start),
+ op: i,
+ draw_matrix: ctm.0,
+ clips: state.placement.clips.clone(),
+ });
}
}
Ok(out)
@@ -294,24 +300,24 @@ pub fn reading_images(doc: &Document, page: usize) -> Result, Edi
/// The forms being read (cycle guard) and how many were entered (the page's budget).
struct Forms {
path: Vec,
- visits: usize,
+ budget: crate::text::FormBudget,
}
fn walk(
doc: &Document,
- data: &[u8],
+ ops: &[Op],
resources: &Dict,
st: &mut ImageState,
- stream: usize,
+ stream: &dyn Fn(&Op) -> usize,
forms: &mut Forms,
out: &mut Vec,
) -> Result<(), EditError> {
let xo = resources.get(b"XObject").map(|x| doc.resolve(x)).and_then(|x| x.as_dict().cloned()).unwrap_or_default();
- for (i, op) in parse(data).ops.iter().enumerate() {
+ for (i, op) in ops.iter().enumerate() {
st.apply(op)?;
let ctm = st.placement.matrix;
if op.is("Do") {
let Some(name) = op.name(0) else { continue };
- if let Some(form) = crate::text::form_call(doc, resources, name, ctm, &forms.path, &mut forms.visits) {
+ if let Some(form) = crate::text::form_call(doc, resources, name, ctm, &forms.path, &mut forms.budget) {
if let Some(r) = form.obj {
forms.path.push(r);
}
@@ -325,7 +331,8 @@ pub fn reading_images(doc: &Document, page: usize) -> Result, Edi
);
let clip = bounds.map_or(st.placement.clip, |b| intersection(st.placement.clip, b).unwrap_or([0.0; 4]));
let mut inner = ImageState::new(form.ctm, clip, st.placement.clips.clone());
- walk(doc, &form.data, &form.resources, &mut inner, stream, forms, out)?;
+ let piece = stream(op);
+ walk(doc, &parse(&form.data).ops, &form.resources, &mut inner, &|_| piece, forms, out)?;
if form.obj.is_some() {
forms.path.pop();
}
@@ -347,7 +354,7 @@ pub fn reading_images(doc: &Document, page: usize) -> Result, Edi
is_form: false,
width: s.dict.int(b"Width").unwrap_or(0).max(0) as u32,
height: s.dict.int(b"Height").unwrap_or(0).max(0) as u32,
- stream,
+ stream: stream(op),
op: i,
draw_matrix: ctm.0,
clips: st.placement.clips.clone(),
@@ -360,10 +367,9 @@ pub fn reading_images(doc: &Document, page: usize) -> Result, Edi
let res = p.dict.get(b"Resources").map(|r| doc.resolve(r)).and_then(|r| r.as_dict().cloned()).unwrap_or_default();
let mut out = Vec::new();
let mut st = ImageState::new(Matrix::IDENTITY, p.crop(doc), Vec::new());
- let mut forms = Forms { path: Vec::new(), visits: 0 };
- for (si, (_, data)) in streams(doc, &p.dict).into_iter().enumerate() {
- walk(doc, &data, &res, &mut st, si, &mut forms, &mut out)?;
- }
+ let mut forms = Forms { path: Vec::new(), budget: crate::text::FormBudget::default() };
+ let pieces = joined(&streams(doc, &p.dict));
+ walk(doc, &pieces.parse().ops, &res, &mut st, &|op| pieces.piece_of(op.span.start), &mut forms, &mut out)?;
Ok(out)
}
@@ -450,9 +456,9 @@ pub fn change_image(doc: &mut Document, page: usize, index: usize, change: &Imag
let img = images.get(index).cloned().ok_or_else(|| EditError::Invalid(format!("page {} has no image {}", page + 1, index + 1)))?;
let p = page_of(doc, page)?;
let all = streams(doc, &p.dict);
- let (obj, data) = all.get(img.stream).cloned().ok_or_else(|| EditError::Invalid("the image stream is missing".into()))?;
- let mut ops = parse(&data).ops;
- if ops.get(img.op).is_none() {
+ let pieces = joined(&all);
+ let ops = pieces.parse().ops;
+ if !ops.get(img.op).is_some_and(|op| op.is("Do")) {
return Err(EditError::Invalid("the image operation is missing".into()));
}
let mut resources = None;
@@ -479,16 +485,10 @@ pub fn change_image(doc: &mut Document, page: usize, index: usize, change: &Imag
vec![Op::new("Do", vec![Object::name(&name)])]
}
};
- ops.splice(img.op..=img.op, replacement);
- let mut dict = match &*doc.resolve(&obj) {
- Object::Stream(s) => s.dict.clone(),
- _ => Dict::new(),
- };
- dict.remove(b"Length");
- let new = doc.add(Object::Stream(Stream::flate(dict, &serialize_ops(&ops))));
- let contents: Vec = all.iter().enumerate().map(|(i, (o, _))| if i == img.stream { Object::Ref(new) } else { o.clone() }).collect();
+ let changed = crate::text::splice(&pieces, &ops, |index| if index == img.op { (replacement.clone(), false) } else { (Vec::new(), true) });
+ let contents = crate::text::rewritten_contents(doc, &all, changed);
doc.update_dict(p.obj, |d| {
- d.set(b"Contents".to_vec(), if let [one] = contents.as_slice() { one.clone() } else { Object::Array(contents) });
+ d.set(b"Contents".to_vec(), contents);
if let Some(res) = resources {
d.set(b"Resources".to_vec(), Object::Dict(res));
}
@@ -599,22 +599,12 @@ fn transform_image(
if !transform.iter().all(|v| v.is_finite() && v.abs() <= 1e12) {
return Err(EditError::Invalid("the image transform must be finite and no larger than 1e12".into()));
}
- let mut offset = 0_usize;
- for (_, data) in streams.iter().take(image.stream) {
- offset = offset
- .checked_add(data.len())
- .and_then(|n| n.checked_add(1))
- .ok_or_else(|| EditError::Invalid("the image content is too large".into()))?;
- }
- let data = streams.get(image.stream).ok_or_else(|| EditError::Invalid("the image stream is missing".into()))?;
- let op = parse(&data.1).ops.get(image.op).cloned().ok_or_else(|| EditError::Invalid("the image operation is missing".into()))?;
- let span = offset.checked_add(op.span.start).ok_or_else(|| EditError::Invalid("the image content is too large".into()))?;
- let pieces = pdfcraft_content::Pieces::join(&streams.iter().map(|(_, data)| data.as_slice()).collect::>());
+ let pieces = joined(streams);
let ops = pieces.parse().ops;
- let target = ops
- .iter()
- .position(|op| op.span.start == span && op.is("Do"))
- .ok_or_else(|| EditError::Invalid("the image operation could not be located".into()))?;
+ let target = image.op;
+ if !ops.get(target).is_some_and(|op| op.is("Do")) {
+ return Err(EditError::Invalid("the image operation could not be located".into()));
+ }
// A private path crop moves with its image regardless of intervening state operators.
// Shared crops retain the existing fixed-clip behavior, preserving neighbouring paint.
let (start, end, placement) = image_transform_scope(&ops, target)?.unwrap_or((target, target, Matrix(image.draw_matrix)));
@@ -677,22 +667,12 @@ fn crop_image(
if x1 - x0 < 1e-6 || y1 - y0 < 1e-6 {
return Err(EditError::Invalid("the crop box has no area".into()));
}
- let mut offset = 0_usize;
- for (_, data) in streams.iter().take(image.stream) {
- offset = offset
- .checked_add(data.len())
- .and_then(|n| n.checked_add(1))
- .ok_or_else(|| EditError::Invalid("the image content is too large".into()))?;
- }
- let data = streams.get(image.stream).ok_or_else(|| EditError::Invalid("the image stream is missing".into()))?;
- let op = parse(&data.1).ops.get(image.op).cloned().ok_or_else(|| EditError::Invalid("the image operation is missing".into()))?;
- let span = offset.checked_add(op.span.start).ok_or_else(|| EditError::Invalid("the image content is too large".into()))?;
- let pieces = pdfcraft_content::Pieces::join(&streams.iter().map(|(_, data)| data.as_slice()).collect::>());
+ let pieces = joined(streams);
let ops = pieces.parse().ops;
- let target = ops
- .iter()
- .position(|op| op.span.start == span && op.is("Do"))
- .ok_or_else(|| EditError::Invalid("the image operation could not be located".into()))?;
+ let target = image.op;
+ if !ops.get(target).is_some_and(|op| op.is("Do")) {
+ return Err(EditError::Invalid("the image operation could not be located".into()));
+ }
let (start, end, placement) = image_transform_scope(&ops, target)?.unwrap_or((target, target, Matrix(image.draw_matrix)));
let inverse = placement.invert().ok_or_else(|| EditError::Invalid("the image has no area".into()))?;
let corners = [(x0, y0), (x1, y0), (x1, y1), (x0, y1)].map(|(x, y)| inverse.apply(x, y));
@@ -758,76 +738,72 @@ pub(crate) fn translate_images(doc: &mut Document, page: usize, indexes: &[usize
if !movement.0.iter().all(|v| v.is_finite() && v.abs() <= 1e12) {
return Err(EditError::Invalid("the image move is too large".into()));
}
- plans.insert((image.stream, image.op), (movement, placement, offset));
+ plans.insert(image.op, (movement, placement, offset));
}
- let mut data = Vec::with_capacity(all.len());
+ let pieces = joined(&all);
+ let ops = pieces.parse().ops;
let mut inside_text = false;
- for (stream, (_, bytes)) in all.iter().enumerate() {
- let pieces = pdfcraft_content::Pieces::join(&[bytes.as_slice()]);
- let ops = pieces.parse().ops;
- for (index, op) in ops.iter().enumerate() {
- if op.is("BT") {
- inside_text = true;
- } else if op.is("ET") {
- inside_text = false;
- } else if inside_text && plans.contains_key(&(stream, index)) {
- return Err(EditError::Invalid("an image inside a text object cannot be moved in a group".into()));
- }
+ for (index, op) in ops.iter().enumerate() {
+ if op.is("BT") {
+ inside_text = true;
+ } else if op.is("ET") {
+ inside_text = false;
+ } else if inside_text && plans.contains_key(&index) {
+ return Err(EditError::Invalid("an image inside a text object cannot be moved in a group".into()));
}
- // Images alone in their own `q re W n cm Do Q` take the clip with them.
- let mut clips = std::collections::BTreeMap::new();
- for index in 0..ops.len() {
- let Some(&(_, placement, offset)) = plans.get(&(stream, index)) else { continue };
- let Some((at, cm)) = exclusive_clip(&ops, index) else { continue };
- let translation = Matrix([1.0, 0.0, 0.0, 1.0, offset[0], offset[1]]);
- let Some(c) = clip_matrix(placement, cm, translation).filter(|c| c.0.iter().all(|v| v.is_finite() && v.abs() <= 1e12)) else {
- return Err(EditError::Invalid("the image move is too large".into()));
- };
- plans.remove(&(stream, index));
- // A `cm` an earlier move put before the clip takes this move too: `c · prev` is the
- // same CTM as `prev` followed by another `cm c`, without one more operator per move.
- let folded = at
- .checked_sub(1)
- .and_then(|i| Some((i, c.then(&Matrix(ops.get(i).filter(|op| op.is("cm"))?.nums::<6>()?)))))
- .filter(|(_, m)| m.0.iter().all(|v| v.is_finite() && v.abs() <= 1e12));
- match folded {
- Some((i, m)) => clips.insert((stream, i), (m, false)),
- None => clips.insert((stream, at), (c, true)),
- };
- }
- // An image already alone in `q cm Do Q` moves by changing that `cm`, not by nesting another.
- let mut folds = std::collections::BTreeMap::new();
- for index in 0..ops.len() {
- let Some(&(movement, _, _)) = plans.get(&(stream, index)) else { continue };
- let Some((at, cm)) = enclosing_transform(&ops, index, index) else { continue };
- let folded = movement.then(&cm);
- if !folded.0.iter().all(|v| v.is_finite() && v.abs() <= 1e12) {
- return Err(EditError::Invalid("the image move is too large".into()));
- }
- plans.remove(&(stream, index));
- folds.insert(at, folded);
- }
- let changed = crate::text::splice(&pieces, &ops, |index| {
- if let Some((c, keep)) = clips.remove(&(stream, index)) {
- return (vec![Op::new("cm", c.0.map(Object::Real).to_vec())], keep);
- }
- if let Some(m) = folds.remove(&index) {
- return (vec![Op::new("cm", m.0.map(Object::Real).to_vec())], false);
- }
- let Some((matrix, _, _)) = plans.remove(&(stream, index)) else { return (Vec::new(), true) };
- let original = ops.get(index).cloned().into_iter();
- // Local translations can be tiny under a large CTM. Preserve their precision
- // rather than rounding them to zero or amplifying an error on the page.
- let mut out = vec![Op::new("q", vec![]), Op::new("cm", matrix.0.map(Object::Real).to_vec())];
- out.extend(original);
- out.push(Op::new("Q", vec![]));
- (out, false)
- });
- data.push(changed.into_iter().next().unwrap_or_default());
}
- if !plans.is_empty() {
+ // Images alone in their own `q re W n cm Do Q` take the clip with them.
+ let mut clips = std::collections::BTreeMap::new();
+ for index in 0..ops.len() {
+ let Some(&(_, placement, offset)) = plans.get(&index) else { continue };
+ let Some((at, cm)) = exclusive_clip(&ops, index) else { continue };
+ let translation = Matrix([1.0, 0.0, 0.0, 1.0, offset[0], offset[1]]);
+ let Some(c) = clip_matrix(placement, cm, translation).filter(|c| c.0.iter().all(|v| v.is_finite() && v.abs() <= 1e12)) else {
+ return Err(EditError::Invalid("the image move is too large".into()));
+ };
+ plans.remove(&index);
+ // A `cm` an earlier move put before the clip takes this move too: `c · prev` is the
+ // same CTM as `prev` followed by another `cm c`, without one more operator per move.
+ let folded = at
+ .checked_sub(1)
+ .and_then(|i| Some((i, c.then(&Matrix(ops.get(i).filter(|op| op.is("cm"))?.nums::<6>()?)))))
+ .filter(|(_, m)| m.0.iter().all(|v| v.is_finite() && v.abs() <= 1e12));
+ match folded {
+ Some((i, m)) => clips.insert(i, (m, false)),
+ None => clips.insert(at, (c, true)),
+ };
+ }
+ // An image already alone in `q cm Do Q` moves by changing that `cm`, not by nesting another.
+ let mut folds = std::collections::BTreeMap::new();
+ for index in 0..ops.len() {
+ let Some(&(movement, _, _)) = plans.get(&index) else { continue };
+ let Some((at, cm)) = enclosing_transform(&ops, index, index) else { continue };
+ let folded = movement.then(&cm);
+ if !folded.0.iter().all(|v| v.is_finite() && v.abs() <= 1e12) {
+ return Err(EditError::Invalid("the image move is too large".into()));
+ }
+ plans.remove(&index);
+ folds.insert(at, folded);
+ }
+ if plans.keys().any(|index| !ops.get(*index).is_some_and(|op| op.is("Do"))) {
return Err(EditError::Invalid("a selected image operation is missing".into()));
}
+ let data = crate::text::splice(&pieces, &ops, |index| {
+ if let Some((c, keep)) = clips.remove(&index) {
+ return (vec![Op::new("cm", c.0.map(Object::Real).to_vec())], keep);
+ }
+ if let Some(m) = folds.remove(&index) {
+ return (vec![Op::new("cm", m.0.map(Object::Real).to_vec())], false);
+ }
+ let Some((matrix, _, _)) = plans.remove(&index) else { return (Vec::new(), true) };
+ let original = ops.get(index).cloned().into_iter();
+ // Local translations can be tiny under a large CTM. Preserve their precision
+ // rather than rounding them to zero or amplifying an error on the page.
+ let mut out = vec![Op::new("q", vec![]), Op::new("cm", matrix.0.map(Object::Real).to_vec())];
+ out.extend(original);
+ out.push(Op::new("Q", vec![]));
+ (out, false)
+ });
let contents = crate::text::rewritten_contents(doc, &all, data);
doc.update_dict(p.obj, |d| d.set(b"Contents".to_vec(), contents))?;
Ok(())
diff --git a/crates/edit/src/lib.rs b/crates/edit/src/lib.rs
index cef8f3c9d2..c1062b4105 100644
--- a/crates/edit/src/lib.rs
+++ b/crates/edit/src/lib.rs
@@ -8,7 +8,8 @@
//! `/PCMark` entry (also in the stream dictionary) names the kind of mark, which is how Update and
//! Remove find them again without decompressing page content. When
//! anything is drawn on top, the page's original content is wrapped in `q … Q` (two tiny streams)
-//! so its graphics state can't leak into the mark.
+//! so its graphics state cannot reach the mark. The closing stream also ends known unfinished
+//! marked-content sequences before the new mark.
//!
//! Text uses standard Helvetica (WinAnsi) added to the page resources as `/PCHelv`.
@@ -332,12 +333,16 @@ const WRAP_OPEN: &[u8] = b"q %PdfCraft\n";
const WRAP_CLOSE: &[u8] = b"\nQ %PdfCraft\n";
const OLD_WRAP_CLOSE: &[u8] = b"Q %PdfCraft\n";
-/// Whether `bytes` are a wrapper's close: [`WRAP_CLOSE`] after a `Q` for each `q` the wrapped
-/// content left open, or [`OLD_WRAP_CLOSE`].
+/// Whether `bytes` are a wrapper's close: canonical `EMC` closures, then the existing
+/// graphics closures. Legacy closes without the leading newline remain valid on their own.
fn is_wrap_close(bytes: &[u8]) -> bool {
let mut rest = bytes;
+ while let Some(shorter) = rest.strip_prefix(b"\nEMC".as_slice()) {
+ rest = shorter;
+ }
+ let legacy = rest.len() == bytes.len();
loop {
- if rest == WRAP_CLOSE || rest == OLD_WRAP_CLOSE {
+ if rest == WRAP_CLOSE || (legacy && rest == OLD_WRAP_CLOSE) {
return true;
}
match rest.strip_prefix(b"\nQ".as_slice()) {
@@ -387,11 +392,13 @@ enum Isolation {
/// `suffix`: the stream from which the content leaves graphics state set (or a comment open)
/// that a mark on top must not inherit, so it needs a wrapper from there to the end.
/// `wrappers`: PdfCraft wrapper pairs (open, close) as stream indexes.
- Clean { suffix: Option, wrappers: Vec<(usize, usize)> },
+ /// `marked`: unfinished marked-content sequences to close before a new mark.
+ Clean { suffix: Option, wrappers: Vec<(usize, usize)>, marked: usize },
/// The content could not be read that precisely (it doesn't decode, is too large, or has
/// stray bytes, unbalanced `q`/`Q` or an unfinished text object or path). `unclosed` is
- /// the count of `q` left open at the end, when known.
- Unknown { unclosed: usize },
+ /// the count of `q` left open at the end, when known. `marked` counts known unfinished
+ /// marked-content sequences independently of graphics malformation, or zero when unknown.
+ Unknown { unclosed: usize, marked: usize },
}
const ISOLATION_MAX_STREAMS: usize = 100_000;
@@ -400,7 +407,7 @@ const ISOLATION_MAX_OPS: usize = 1_000_000;
const ISOLATION_MAX_DEPTH: usize = 10_000;
fn isolation(doc: &Document, list: &[Object]) -> Isolation {
- let unknown = Isolation::Unknown { unclosed: 0 };
+ let unknown = Isolation::Unknown { unclosed: 0, marked: 0 };
if list.len() > ISOLATION_MAX_STREAMS {
return unknown;
}
@@ -422,14 +429,17 @@ fn isolation(doc: &Document, list: &[Object]) -> Isolation {
let edges = pieces.comment_edges(&parsed);
let (mut text_open, mut path_open, mut dirty, mut malformed) = (false, false, false, false);
let mut stack: Vec = Vec::new();
+ let (mut marked, mut marked_known) = (0usize, true);
let mut split = 0;
let mut previous_last = None;
let mut wrappers = Vec::new();
for op in &parsed.ops {
let (first, last) = pieces.pieces_of(op);
- // A wrapper can only go in before a whole stream, at the top level, between operators,
- // and where no comment runs into the stream (it would swallow the wrapper's `q`).
+ // A wrapper can only go in before a whole stream, outside graphics and marked-content
+ // scopes, between operators, where no comment would swallow the wrapper's `q`.
if stack.is_empty()
+ && marked_known
+ && marked == 0
&& !text_open
&& !path_open
&& !dirty
@@ -439,6 +449,26 @@ fn isolation(doc: &Document, list: &[Object]) -> Isolation {
split = first;
}
previous_last = Some(last);
+ // Match the reader's tolerant operands: BMC uses the last name, BDC the last
+ // name/object pair, and EMC ignores surplus operands. Invalid begins are ignored.
+ let begin_marked = match op.op.as_slice() {
+ b"BMC" => op.operands.last().is_some_and(|o| matches!(o, Object::Name(_))),
+ b"BDC" => op.operands.len().checked_sub(2).and_then(|i| op.operands.get(i)).is_some_and(|o| matches!(o, Object::Name(_))),
+ _ => false,
+ };
+ if marked_known {
+ if begin_marked {
+ if marked < ISOLATION_MAX_DEPTH {
+ marked += 1;
+ } else {
+ // Once the budget is exceeded, later EMCs cannot restore a known depth.
+ marked_known = false;
+ marked = 0;
+ }
+ } else if op.is("EMC") {
+ marked = marked.saturating_sub(1);
+ }
+ }
match op.op.as_slice() {
b"BT" if !text_open => text_open = true,
b"ET" if text_open => text_open = false,
@@ -468,11 +498,11 @@ fn isolation(doc: &Document, list: &[Object]) -> Isolation {
_ => {}
}
}
- if malformed || text_open || path_open || !stack.is_empty() {
- return Isolation::Unknown { unclosed: if malformed { 0 } else { stack.len() } };
+ if malformed || text_open || path_open || !stack.is_empty() || !marked_known {
+ return Isolation::Unknown { unclosed: if malformed { 0 } else { stack.len() }, marked };
}
let comment_open = edges.last().is_some_and(|edge| edge.1);
- Isolation::Clean { suffix: (dirty || comment_open).then_some(split), wrappers }
+ Isolation::Clean { suffix: (dirty || comment_open || marked != 0).then_some(split), wrappers, marked }
}
/// Put a mark's content on a page: behind (prepended) or on top (appended, after wrapping the
@@ -484,21 +514,22 @@ fn place(doc: &mut Document, page: &pdfcraft_model::Page, kind: MarkKind, conten
/// Put content on a page, tagged `tag` (see [`place`]). On top, whatever graphics state the
/// content before leaves set (a `cm`, a clip, text state…) must not reach the mark, including
/// content appended after an earlier PdfCraft wrapper by another tool (#3084): the stretch of
-/// content that leaves state set is wrapped in q/Q, once.
+/// content that leaves state set is wrapped in q/Q, once. Any known unfinished marked-content
+/// sequences are closed at the end too, so hidden optional content cannot hide the new mark.
fn place_tagged(doc: &mut Document, page: &pdfcraft_model::Page, tag: &str, content: Vec, behind: bool) -> Result<(), EditError> {
let mut list = contents(doc, page)?;
let wrap = if behind {
None
} else {
match isolation(doc, &list) {
- Isolation::Clean { suffix, .. } => suffix.map(|split| (split, 0)),
+ Isolation::Clean { suffix, marked, .. } => suffix.map(|split| (split, 0, marked)),
// Content that can't be read precisely is wrapped whole, as before: after the
// behind-marks at the start. A `q` it leaves open is closed at its end, where
// closing it changes nothing already drawn.
- Isolation::Unknown { unclosed } => {
+ Isolation::Unknown { unclosed, marked } => {
let behind_kinds = [MarkKind::Background.tag(), MarkKind::Watermark.tag()];
let split = list.iter().position(|o| !stream_tag(doc, o).is_some_and(|t| behind_kinds.contains(&t.as_str()))).unwrap_or(list.len());
- (split < list.len()).then_some((split, unclosed))
+ (split < list.len()).then_some((split, unclosed, marked))
}
}
};
@@ -506,8 +537,9 @@ fn place_tagged(doc: &mut Document, page: &pdfcraft_model::Page, tag: &str, cont
if behind {
list.insert(0, mark);
} else {
- if let Some((split, unclosed)) = wrap {
- let mut close_bytes = b"\nQ".repeat(unclosed);
+ if let Some((split, unclosed, marked)) = wrap {
+ let mut close_bytes = b"\nEMC".repeat(marked);
+ close_bytes.extend_from_slice(&b"\nQ".repeat(unclosed));
close_bytes.extend_from_slice(WRAP_CLOSE);
let open = Object::Ref(doc.add(Object::Stream(Stream::from_raw(tagged("Wrap"), WRAP_OPEN.to_vec()))));
let close = Object::Ref(doc.add(Object::Stream(Stream::from_raw(tagged("Wrap"), close_bytes))));
@@ -827,8 +859,8 @@ pub mod indic;
mod linebreak;
pub mod text;
pub use text::{
- BlockStyle, LineEdit, RangeStyle, TextBlock, TextLine, clear_block, reading_blocks, replace_block, replace_line, rewrite_block, text_blocks,
- text_lines,
+ BlockStyle, LineEdit, MAX_FORM_DEPTH, MAX_FORM_VISITS, RangeStyle, ReadingBlocks, TextBlock, TextLine, clear_block, reading_blocks,
+ reading_blocks_checked, replace_block, replace_line, rewrite_block, text_blocks, text_lines,
};
pub mod added;
pub use added::{
@@ -836,5 +868,7 @@ pub use added::{
update_content,
};
+#[cfg(test)]
+mod marked_isolation_tests;
#[cfg(test)]
mod tests;
diff --git a/crates/edit/src/marked_isolation_tests.rs b/crates/edit/src/marked_isolation_tests.rs
new file mode 100644
index 0000000000..6da8322296
--- /dev/null
+++ b/crates/edit/src/marked_isolation_tests.rs
@@ -0,0 +1,122 @@
+use super::*;
+
+fn fixture(parts: &[&[u8]]) -> Document {
+ let mut doc = Document::new_empty();
+ let pages = doc.get(doc.root().unwrap()).as_dict().unwrap().reference(b"Pages").unwrap();
+ let streams = parts.iter().map(|data| Object::Ref(doc.add(Object::Stream(Stream::from_raw(Dict::new(), data.to_vec()))))).collect();
+ let mut page = Dict::new();
+ page.set(b"Type".to_vec(), Object::name("Page"));
+ page.set(b"Parent".to_vec(), Object::Ref(pages));
+ page.set(b"MediaBox".to_vec(), Object::Array(vec![0.into(), 0.into(), 600.into(), 800.into()]));
+ page.set(b"Contents".to_vec(), Object::Array(streams));
+ let page = doc.add(page);
+ doc.update_dict(pages, |d| {
+ d.set(b"Kids".to_vec(), Object::Array(vec![Object::Ref(page)]));
+ d.set(b"Count".to_vec(), Object::Int(1));
+ })
+ .unwrap();
+ doc
+}
+
+fn placed(parts: &[&[u8]]) -> (Document, Vec) {
+ let mut doc = fixture(parts);
+ stamp(&mut doc, 0, "Probe", b"q Q\n".to_vec()).unwrap();
+ let page = page_list(&doc).remove(0);
+ let list = contents(&mut doc, &page).unwrap();
+ (doc, list)
+}
+
+fn close(parts: &[&[u8]]) -> Vec {
+ let (doc, list) = placed(parts);
+ let object = &list[list.len() - 2];
+ assert_eq!(stream_tag(&doc, object).as_deref(), Some("Wrap"));
+ let bytes = stream_bytes(&doc, object).unwrap();
+ assert_eq!(wrapper_role(&doc, object), Some(false));
+ bytes
+}
+
+#[test]
+fn closes_only_marked_operators_not_strings_names_comments_or_image_samples() {
+ for tail in
+ [b"/Span << /Text (BMC BDC EMC) >> DP".as_slice(), b"/BMC /EMC DP", b"% BMC BDC EMC", b"BI /W 11 /H 1 /BPC 8 /CS /G ID BMC BDC EMC EI\n"]
+ {
+ let mut input = b"/OC /L1 BDC\n".to_vec();
+ input.extend_from_slice(tail);
+ assert_eq!(close(&[&input]), b"\nEMC\nQ %PdfCraft\n", "{tail:?}");
+ }
+}
+
+#[test]
+fn safe_suffix_stays_outside_marked_content_across_balanced_graphics() {
+ let (doc, list) = placed(&[b"q /OC /L1 BDC Q", b"50 Tz"]);
+ assert_eq!(stream_bytes(&doc, &list[0]).unwrap(), WRAP_OPEN);
+ assert_eq!(stream_bytes(&doc, &list[1]).unwrap(), b"q /OC /L1 BDC Q");
+ assert_eq!(stream_bytes(&doc, &list[list.len() - 2]).unwrap(), b"\nEMC\nQ %PdfCraft\n");
+ assert_eq!(close(&[b"q /OC /L1 BDC Q"]), b"\nEMC\nQ %PdfCraft\n");
+}
+
+#[test]
+fn known_marked_depth_survives_unknown_graphics_and_tolerant_operands() {
+ for (input, expected) in [
+ (b"/OC /L1 BDC q".as_slice(), b"\nEMC\nQ\nQ %PdfCraft\n".as_slice()),
+ (b"Q /OC /L1 BDC", b"\nEMC\nQ %PdfCraft\n"),
+ (b"/OC /L1 BDC BT", b"\nEMC\nQ %PdfCraft\n"),
+ (b"/OC /L1 BDC 0 0 m", b"\nEMC\nQ %PdfCraft\n"),
+ (b"/OC /L1 BDC 123 BMC /Artifact BDC", b"\nEMC\nQ %PdfCraft\n"),
+ (b"EMC 123 /OC /L1 BDC 123 /Artifact BMC", b"\nEMC\nEMC\nQ %PdfCraft\n"),
+ (b"/OC /L1 BDC 123 EMC", b"\nQ %PdfCraft\n"),
+ (b"/Span 123 BDC", b"\nEMC\nQ %PdfCraft\n"),
+ ] {
+ assert_eq!(close(&[input]), expected, "{input:?}");
+ }
+}
+
+#[test]
+fn closes_cross_stream_begins_without_closing_balanced_sequences_twice() {
+ assert_eq!(close(&[b"/OC", b"/L1 BDC"]), b"\nEMC\nQ %PdfCraft\n");
+ assert_eq!(close(&[b"/OC /L1 BDC", b"EMC"]), WRAP_CLOSE);
+ assert_eq!(close(&[b"/OC /L1 BDC % EMC", b"EMC\n"]), b"\nEMC\nQ %PdfCraft\n");
+}
+
+#[test]
+fn recognizes_only_canonical_new_closes_and_retains_legacy_closes() {
+ for bytes in [WRAP_CLOSE, OLD_WRAP_CLOSE, b"\nQ\nQ %PdfCraft\n", b"\nEMC\nQ %PdfCraft\n", b"\nEMC\nEMC\nQ\nQ %PdfCraft\n"] {
+ assert!(is_wrap_close(bytes), "{bytes:?}");
+ }
+ for bytes in [
+ b"\nEMCQ %PdfCraft\n".as_slice(),
+ b"\nEMC\nQQ %PdfCraft\n",
+ b"\nQ\nEMC\nQ %PdfCraft\n",
+ b"EMC\nQ %PdfCraft\n",
+ b"\nEMC 1 w\nQ %PdfCraft\n",
+ b"\nEMC\nQ %PdfCraft\nq Q",
+ ] {
+ assert!(!is_wrap_close(bytes), "{bytes:?}");
+ }
+}
+
+#[test]
+fn depth_budget_bounds_emitted_closures_and_overflow_stays_unknown() {
+ let maximum = b"/Artifact BMC\n".repeat(ISOLATION_MAX_DEPTH);
+ let mut expected = b"\nEMC".repeat(ISOLATION_MAX_DEPTH);
+ expected.extend_from_slice(WRAP_CLOSE);
+ assert_eq!(close(&[&maximum]), expected);
+ let mut combined = maximum.clone();
+ combined.extend_from_slice(&b"q\n".repeat(ISOLATION_MAX_DEPTH));
+ let mut expected = b"\nEMC".repeat(ISOLATION_MAX_DEPTH);
+ expected.extend_from_slice(&b"\nQ".repeat(ISOLATION_MAX_DEPTH));
+ expected.extend_from_slice(WRAP_CLOSE);
+ assert_eq!(close(&[&combined]), expected);
+ assert!(expected.len() < 64 * 1024, "maximum generated close remains recognizable for removal");
+ let mut doc = fixture(&[&combined]);
+ let page = page_list(&doc).remove(0);
+ let original = contents(&mut doc, &page).unwrap();
+ place_tagged(&mut doc, &page, MarkKind::HeaderFooter.tag(), b"q Q\n".to_vec(), false).unwrap();
+ assert_eq!(remove_marks(&mut doc, &[0], MarkKind::HeaderFooter).unwrap(), 1);
+ assert_eq!(contents(&mut doc, &page).unwrap(), original, "maximum combined close can be removed without nesting wrappers");
+ for suffix in [b"/Artifact BMC\n".as_slice(), b"/Artifact BMC\nEMC\n", b"/Artifact BMC\nEMC\nEMC\n"] {
+ let mut over = maximum.clone();
+ over.extend_from_slice(suffix);
+ assert_eq!(close(&[&over]), WRAP_CLOSE, "overflow cannot resume speculative depth tracking after EMC");
+ }
+}
diff --git a/crates/edit/src/tests.rs b/crates/edit/src/tests.rs
index 99fae205eb..879ebc6c66 100644
--- a/crates/edit/src/tests.rs
+++ b/crates/edit/src/tests.rs
@@ -904,6 +904,92 @@ fn text_page(content: &str) -> Document {
Document::open(Arc::new(out)).unwrap()
}
+#[test]
+fn tracked_text_keeps_words_in_paragraphs_and_objects() {
+ let expected = "TRACKED SAMPLE TEXT";
+ for spacing in [1.5, 2.0001, 3.0] {
+ for scale in [50.0, 100.0, 150.0] {
+ for positioned in [false, true] {
+ let mut show = String::new();
+ if !positioned {
+ show.push('[');
+ }
+ for (i, c) in expected.chars().enumerate() {
+ if positioned {
+ show.push_str(&format!("({c}) Tj "));
+ if i + 1 < expected.len() {
+ show.push_str(&format!("[{}] TJ ", -spacing * 100.0));
+ }
+ } else {
+ show.push_str(&format!("({c}) "));
+ if i + 1 < expected.len() {
+ show.push_str(&format!("{} ", -spacing * 100.0));
+ }
+ }
+ }
+ if !positioned {
+ show.push_str("] TJ");
+ }
+ let doc = text_page(&format!("BT /F1 10 Tf {scale} Tz 72 700 Td {show} ET"));
+ assert_eq!(text::text_lines(&doc, 0).unwrap()[0].text, expected, "{spacing}, {scale}, {positioned}");
+ assert_eq!(text::text_blocks(&doc, 0).unwrap()[0].text, expected);
+ assert_eq!(crate::objects::editable_objects(&doc, 0).unwrap()[0].text.as_deref(), Some(expected));
+ }
+ }
+ let doc = text_page(&format!("BT /F1 10 Tf {spacing} Tc 72 700 Td ({expected}) Tj ET"));
+ assert_eq!(text::text_blocks(&doc, 0).unwrap()[0].text, expected);
+ }
+}
+
+#[test]
+fn single_glyph_words_and_cells_are_not_tracking() {
+ for (content, expected) in [
+ ("BT /F1 10 Tf 72 700 Td [(a) -1000 (b) -1000 (c) -1000 (d)] TJ ET", "a b c d"),
+ ("BT /F1 10 Tf 72 700 Td [(1) -2500 (2) -2500 (3) -2500 (4) -2500 (5)] TJ ET", "1 2 3 4 5"),
+ ("BT /F1 10 Tf 72 700 Td [(A) -300 (B) -1200 (C) -1200 (D) -1200 (E) -300 (F) -1200 (G)] TJ ET", "A B C D E F G"),
+ ] {
+ assert_eq!(text::text_blocks(&text_page(content), 0).unwrap()[0].text, expected, "{content}");
+ }
+ let mut s = String::from("BT /F1 10 Tf ");
+ for (i, c) in "12345".chars().enumerate() {
+ s.push_str(&format!("1 0 0 1 {} 700 Tm ({c}) Tj ", 72 + i * 12));
+ }
+ s.push_str("ET");
+ assert_eq!(text::text_blocks(&text_page(&s), 0).unwrap()[0].text, "1 2 3 4 5");
+}
+
+#[test]
+fn tracked_text_preserves_larger_word_gaps() {
+ for show in [
+ "[(T) -300 (E) -300 (S) -300 (T) -600 (W) -300 (O) -300 (R) -300 (D)] TJ",
+ "(T) Tj [-300] TJ (E) Tj [-300] TJ (S) Tj [-300] TJ (T) Tj [-600] TJ (W) Tj [-300] TJ (O) Tj [-300] TJ (R) Tj [-300] TJ (D) Tj",
+ "[(TEST) -300 (WORD)] TJ",
+ "(TEST) Tj [-300] TJ (W) Tj [-300] TJ (O) Tj [-300] TJ (R) Tj [-300] TJ (D) Tj",
+ ] {
+ let doc = text_page(&format!("BT /F1 10 Tf 72 700 Td {show} ET"));
+ assert_eq!(text::text_blocks(&doc, 0).unwrap()[0].text, "TEST WORD", "{show}");
+ }
+}
+
+#[test]
+fn tracked_text_positions_do_not_count_character_spacing_twice() {
+ let expected = "TRACKED SAMPLE TEXT";
+ for char_spacing in [0.0, 2.0] {
+ for transform in ["1 0 0 1 0 0", "0 1 -1 0 800 0"] {
+ let mut content = format!("q {transform} cm BT /F1 10 Tf {char_spacing} Tc ");
+ let mut x = 72.0;
+ for c in expected.chars() {
+ content.push_str(&format!("1 0 0 1 {x:.4} 700 Tm ({c}) Tj "));
+ x += pdfcraft_fonts::helvetica_width(&c.to_string(), 10.0) + 2.0001;
+ }
+ content.push_str("ET Q");
+ let doc = text_page(&content);
+ assert_eq!(text::text_blocks(&doc, 0).unwrap()[0].text, expected);
+ assert_eq!(crate::objects::editable_objects(&doc, 0).unwrap()[0].text.as_deref(), Some(expected));
+ }
+ }
+}
+
fn descriptor_text_page() -> Document {
let content = "BT /F1 12 Tf 72 700 Td (ab) Tj ET";
let objs: Vec = vec![
@@ -1473,6 +1559,65 @@ fn skia_subset_page() -> Document {
doc
}
+/// #2616: a subset whose ToUnicode has no space (as LaTeX and Sphinx output often has, spacing
+/// words with TJ kerning) makes finding the space width read every font on the page. That ran
+/// for every character of a multi-row draft, so each keystroke in a long paragraph took half a
+/// second. It runs once per font now, however long the paragraph.
+#[test]
+fn a_long_draft_in_a_font_without_a_space_works_out_the_space_width_once() {
+ // Two rows of one paragraph (14 pt apart) in the Chrome-style subset of `skia_subset_page`,
+ // whose ToUnicode here has no entry for the space glyph.
+ let mut doc =
+ text_page("BT /F3 12 Tf 72 700 Td <0177031602E001FB03050255> Tj ET BT /F3 12 Tf 72 686 Td <008D0266035402FE01FB030503E6> Tj ET");
+ let program = doc.add(Object::Stream(Stream::flate(Dict::new(), include_bytes!("../../../assets/fonts/Inter-Regular.ttf"))));
+ let unicode = doc.add(Object::Stream(Stream::flate(
+ Dict::new(),
+ b"1 begincodespacerange <0000> endcodespacerange 12 beginbfchar <0177> <0050> <0316> <006F> <02E0> <006C> <01FB> <0061> <0305> <006E> <0255> <0064> <008D> <0047> <0266> <0065> <0354> <0072> <02FE> <006D> <03E6> <0079> <1000> <007A> endbfchar",
+ )));
+ let widths: Vec =
+ [(0x8D, 746), (0x177, 639), (0x1FB, 551), (0x255, 612), (0x266, 583), (0x2E0, 242), (0x2FE, 876), (0x305, 591), (0x316, 600)]
+ .into_iter()
+ .chain([(0x354, 376), (0x3E6, 515)])
+ .flat_map(|(cid, width)| [Object::Int(cid), Object::Array(vec![Object::Int(width)])])
+ .collect();
+ let descriptor = Dict::from_iter([
+ (b"Type".to_vec(), Object::name("FontDescriptor")),
+ (b"FontName".to_vec(), Object::name("BAAAAA+SampleSans")),
+ (b"Flags".to_vec(), Object::Int(32)),
+ (b"FontFile2".to_vec(), Object::Ref(program)),
+ ]);
+ let cid_font = Dict::from_iter([
+ (b"Type".to_vec(), Object::name("Font")),
+ (b"Subtype".to_vec(), Object::name("CIDFontType2")),
+ (b"BaseFont".to_vec(), Object::name("BAAAAA+SampleSans")),
+ (b"CIDToGIDMap".to_vec(), Object::name("Identity")),
+ (b"DW".to_vec(), Object::Int(562)),
+ (b"W".to_vec(), Object::Array(widths)),
+ (b"FontDescriptor".to_vec(), Object::Dict(descriptor)),
+ ]);
+ let font = Dict::from_iter([
+ (b"Type".to_vec(), Object::name("Font")),
+ (b"Subtype".to_vec(), Object::name("Type0")),
+ (b"BaseFont".to_vec(), Object::name("BAAAAA+SampleSans")),
+ (b"Encoding".to_vec(), Object::name("Identity-H")),
+ (b"DescendantFonts".to_vec(), Object::Array(vec![Object::Dict(cid_font)])),
+ (b"ToUnicode".to_vec(), Object::Ref(unicode)),
+ ]);
+ let p = pdfcraft_model::pages(&doc)[0].obj;
+ let fonts = Dict::from_iter([(b"F3".to_vec(), Object::Dict(font))]);
+ doc.update_dict(p, |d| d.set(b"Resources".to_vec(), Object::Dict(Dict::from_iter([(b"Font".to_vec(), Object::Dict(fonts))])))).unwrap();
+ let blocks = text::text_blocks(&doc, 0).unwrap();
+ assert_eq!(blocks.len(), 1, "one paragraph: {:?}", blocks.iter().map(|b| &b.text).collect::>());
+ let draft = "Poland and Germany
+"
+ .repeat(20);
+ text::SOURCE_SPACE_CALLS.with(|c| c.set(0));
+ let layout = text::text_edit_layout(&doc, 0, 0, draft.trim_end(), &text::BlockStyle::default()).unwrap();
+ let calls = text::SOURCE_SPACE_CALLS.with(std::cell::Cell::get);
+ assert!(layout.draft_rect.is_some_and(|r| r[2] > r[0]), "laid out: {:?}", layout.draft_rect);
+ assert!(calls <= 8, "the space width was worked out {calls} times for {} characters", draft.len());
+}
+
#[test]
fn a_subset_keeps_its_font_for_glyphs_left_to_the_default_width() {
let doc = skia_subset_page();
@@ -2837,6 +2982,88 @@ fn form_xobjects_that_fan_out_stop_at_the_visit_budget() {
assert!(start.elapsed() < std::time::Duration::from_secs(30), "took {:?}", start.elapsed());
}
+/// #3120: a one-page document whose page draws "Secret", then `content`. `forms` are (name,
+/// content) form XObjects, which the page and every form can draw, as they can a 1×1 image `/Im`.
+fn page_with_forms(content: &str, forms: &[(String, String)]) -> Document {
+ let xobjects: String = forms.iter().enumerate().map(|(i, (name, _))| format!("/{name} {} 0 R ", 7 + i)).collect();
+ let resources = format!("<< /Font << /F1 5 0 R >> /XObject << /Im 6 0 R {xobjects}>> >>");
+ let mut objs = vec![
+ "<< /Type /Catalog /Pages 2 0 R >>".to_string(),
+ "<< /Type /Pages /Kids [3 0 R] /Count 1 >>".to_string(),
+ format!("<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] /Resources {resources} /Contents 4 0 R >>"),
+ stream("", &format!("BT /F1 24 Tf 72 700 Td (Secret) Tj ET\n{content}")),
+ "<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>".to_string(),
+ stream("/Type /XObject /Subtype /Image /Width 1 /Height 1 /ColorSpace /DeviceGray /BitsPerComponent 8", "A"),
+ ];
+ objs.extend(forms.iter().map(|(_, body)| stream(&format!("/Type /XObject /Subtype /Form /BBox [0 0 612 792] /Resources {resources}"), body)));
+ build(&objs.iter().map(String::as_str).collect::>())
+}
+
+/// #3120: export reads text from forms as deep and as often as the renderer draws them, and says
+/// when a limit left some out: a text form drawn 5 000 times (past the old budget of 4 096) or
+/// forms nested 13 deep (past the old 12) lost the form drawn after them, silently.
+#[test]
+fn reading_follows_forms_as_far_as_the_renderer_and_says_where_it_stops() {
+ // Covers "Secret" with a black box and writes "TAIL": drawn last, so a reader that stops
+ // early misses it.
+ const TAIL: &str = "0 g 70 692 90 30 re f BT /F1 24 Tf 72 600 Td (TAIL) Tj ET";
+ let tail = |doc: &Document| {
+ let read = text::reading_blocks_checked(doc, 0).unwrap();
+ (read.blocks.iter().any(|b| b.text.contains("TAIL")), read.truncated)
+ };
+ let form = |name: &str, body: &str| (name.to_string(), body.to_string());
+
+ let rows =
+ |n: usize| page_with_forms(&format!("{}/C Do", "/R Do ".repeat(n)), &[form("R", "BT /F1 6 Tf 300 100 Td (row) Tj ET"), form("C", TAIL)]);
+ assert_eq!(tail(&rows(5_000)), (true, false));
+
+ // The innermost form also draws an image: an image at the depth limit is read, and leaves
+ // nothing out.
+ let chain = |depth: usize| {
+ let mut forms: Vec<_> = (1..depth).map(|i| form(&format!("D{i}"), &format!("/D{} Do", i + 1))).collect();
+ forms.push(form(&format!("D{depth}"), &format!("{TAIL} q 20 0 0 10 5 5 cm /Im Do Q")));
+ page_with_forms("/D1 Do", &forms)
+ };
+ assert_eq!(tail(&chain(13)), (true, false));
+ // The renderer's limit, written out so the test does not move with the constant.
+ assert_eq!(tail(&chain(50)), (true, false));
+ assert_eq!(tail(&chain(51)), (false, true));
+ let deepest = chain(text::MAX_FORM_DEPTH);
+ assert_eq!(tail(&deepest), (true, false));
+ assert_eq!(images::reading_images(&deepest, 0).unwrap().len(), 1);
+ let deep = chain(text::MAX_FORM_DEPTH + 1);
+ assert_eq!(tail(&deep), (false, true));
+ assert!(images::reading_images(&deep, 0).unwrap().is_empty());
+
+ // A wrapper form A draws a dot form B `n` times, then C. A, the dots and C use the page's
+ // budget up exactly; one dot more leaves C unread.
+ let fanout =
+ |n: usize| page_with_forms("/A Do", &[form("A", &format!("{}/C Do", "/B Do ".repeat(n))), form("B", "0 g 10 10 1 1 re f"), form("C", TAIL)]);
+ assert_eq!(tail(&fanout(text::MAX_FORM_VISITS - 2)), (true, false));
+ let many = fanout(text::MAX_FORM_VISITS - 1);
+ assert_eq!(tail(&many), (false, true));
+ assert!(images::reading_images(&many, 0).unwrap().is_empty());
+ // Without the flag, reading is unchanged.
+ let blocks: Vec = text::reading_blocks(&many, 0).unwrap().into_iter().map(|b| b.text).collect();
+ assert_eq!(blocks, ["Secret"]);
+}
+
+/// #3120: with the renderer's limits, forms that each draw the next a hundred times, 12 levels
+/// deep (100^11 forms) or past the depth cap, still stop at the page's budget quickly, and say
+/// that text was left out.
+#[test]
+fn forms_that_fan_out_a_hundred_ways_stop_quickly_and_say_so() {
+ for levels in [12, text::MAX_FORM_DEPTH + 1] {
+ let mut forms: Vec<(String, String)> = (1..levels).map(|i| (format!("N{i}"), format!("/N{} Do ", i + 1).repeat(100))).collect();
+ forms.push((format!("N{levels}"), "BT /F1 10 Tf 10 10 Td (leaf) Tj ET".to_string()));
+ let doc = page_with_forms("/N1 Do", &forms);
+ let start = std::time::Instant::now();
+ assert!(text::reading_blocks_checked(&doc, 0).unwrap().truncated, "{levels} levels");
+ assert!(images::reading_images(&doc, 0).unwrap().is_empty());
+ assert!(start.elapsed() < std::time::Duration::from_secs(30), "{levels} levels took {:?}", start.elapsed());
+ }
+}
+
/// Forms that draw themselves or each other, have no resources, a malformed matrix, or are
/// missing are read once (or skipped) without panicking or looping.
#[test]
@@ -3377,6 +3604,55 @@ fn latin_ligature_actual_text_stays_on_its_line() {
}
}
+/// #3110: `/ActualText` may come from a named property list (`/Span /MC0 BDC`) looked up in the
+/// page's or the form's `/Properties`, as well as from an inline one.
+#[test]
+fn actual_text_from_named_property_list() {
+ let properties = "/Properties << /MC0 << /ActualText (Hello World) >> /MC1 << /Lang (en) >> >>";
+ let page_with = |content: &str| {
+ build(&[
+ "<< /Type /Catalog /Pages 2 0 R >>",
+ "<< /Type /Pages /Kids [3 0 R] /Count 1 >>",
+ &format!("<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] /Resources << /Font << /F1 5 0 R >> {properties} >> /Contents 4 0 R >>"),
+ &stream("", content),
+ "<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding /WinAnsiEncoding >>",
+ ])
+ };
+ for (content, expected) in [
+ ("/Span /MC0 BDC BT /F1 24 Tf 72 700 Td (Glyphs) Tj ET EMC", "Hello World"),
+ ("/Span <> BDC BT /F1 24 Tf 72 700 Td (Glyphs) Tj ET EMC", "Hello World"),
+ ("/Span /MC1 BDC BT /F1 24 Tf 72 700 Td (Glyphs) Tj ET EMC", "Glyphs"),
+ ("/Span /Missing BDC BT /F1 24 Tf 72 700 Td (Glyphs) Tj ET EMC", "Glyphs"),
+ ] {
+ let doc = page_with(content);
+ for doc in [&doc, &reopen(&doc)] {
+ let lines = text::text_lines(doc, 0).unwrap();
+ assert_eq!(lines.iter().map(|l| l.text.as_str()).collect::>(), [expected], "{content}");
+ assert_eq!(text::text_blocks(doc, 0).unwrap()[0].editor_text, expected, "{content}");
+ let reading: Vec = text::reading_blocks(doc, 0).unwrap().into_iter().map(|b| b.text).collect();
+ assert_eq!(reading, [expected], "{content}");
+ }
+ }
+
+ // A form's named list is looked up in the form's own /Properties, not the page's.
+ let fm = stream(
+ "/Type /XObject /Subtype /Form /BBox [0 0 612 792] /Resources << /Font << /F1 5 0 R >> /Properties << /MC0 << /ActualText (Form text) >> >> >>",
+ "/Span /MC0 BDC BT /F1 24 Tf 72 600 Td (Glyphs) Tj ET EMC",
+ );
+ let doc = build(&[
+ "<< /Type /Catalog /Pages 2 0 R >>",
+ "<< /Type /Pages /Kids [3 0 R] /Count 1 >>",
+ &format!(
+ "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] /Resources << /Font << /F1 5 0 R >> /XObject << /Fm1 6 0 R >> {properties} >> /Contents 4 0 R >>"
+ ),
+ &stream("", "/Fm1 Do"),
+ "<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding /WinAnsiEncoding >>",
+ &fm,
+ ]);
+ let reading: Vec = text::reading_blocks(&doc, 0).unwrap().into_iter().map(|b| b.text).collect();
+ assert_eq!(reading, ["Form text"]);
+}
+
#[test]
fn actual_text_and_visual_rtl_glyphs_keep_their_own_reading_order() {
let hex: String = "שלום".encode_utf16().map(|u| format!("{u:04X}")).collect();
@@ -3820,6 +4096,74 @@ fn private_image_crop_with_intervening_state_resizes_across_streams() {
}
}
+/// [`image_page`] with `pieces` as its `/Contents` array and a Form /Fm0 drawing 72,500–172,600.
+fn split_image_page(pieces: &[&[u8]]) -> Document {
+ let mut doc = image_page();
+ let page = pdfcraft_model::pages(&doc)[0].obj;
+ let mut form = Dict::new();
+ form.set(b"Type".to_vec(), Object::name("XObject"));
+ form.set(b"Subtype".to_vec(), Object::name("Form"));
+ form.set(b"BBox".to_vec(), Object::Array([72, 500, 172, 600].map(Object::Int).to_vec()));
+ let form = doc.add(Object::Stream(Stream::flate(form, b"0.5 g 72 500 100 100 re f")));
+ let image = doc.get(pdfcraft_model::pages(&doc)[0].obj);
+ let mut res = image.as_dict().and_then(|d| d.get(b"Resources")).and_then(|r| r.as_dict().cloned()).unwrap();
+ let mut xo = res.get(b"XObject").and_then(|x| x.as_dict().cloned()).unwrap();
+ xo.set(b"Fm0".to_vec(), Object::Ref(form));
+ res.set(b"XObject".to_vec(), Object::Dict(xo));
+ let refs = pieces.iter().map(|b| Object::Ref(doc.add(Object::Stream(Stream::flate(Dict::new(), b))))).collect();
+ doc.update_dict(page, |d| {
+ d.set(b"Contents".to_vec(), Object::Array(refs));
+ d.set(b"Resources".to_vec(), Object::Dict(res));
+ })
+ .unwrap();
+ doc
+}
+
+#[test]
+fn an_image_whose_operator_starts_the_next_stream_can_be_selected_moved_and_deleted() {
+ // #3113: an operator's operands end one piece and its keyword starts the next.
+ let square = [72.0, 500.0, 172.0, 600.0];
+ let cases: [(&[&[u8]], bool); 4] = [
+ (&[b"q 100 0 0 100 72 500 cm /Im0", b"Do Q"], false),
+ (&[b"q 100 0 0 100 72 500", b"cm /Im0 Do Q"], false),
+ (&[b"q /Fm0", b"Do Q"], true),
+ (&[b"q 100 0 0 100 72 500 cm /Im0 Do Q"], false),
+ ];
+ for (pieces, is_form) in cases {
+ let doc = split_image_page(pieces);
+ let label: Vec<_> = pieces.iter().map(|p| String::from_utf8_lossy(p)).collect();
+ let images = images::page_images(&doc, 0).unwrap();
+ assert_eq!(images.len(), 1, "{label:?}");
+ assert!(close(images[0].rect, square), "{label:?}: {:?}", images[0].rect);
+ assert_eq!(images[0].is_form, is_form);
+ assert!(images[0].contains([122.0, 550.0]), "{label:?}");
+ if !is_form {
+ let read = images::reading_images(&doc, 0).unwrap();
+ assert!(read.len() == 1 && close(read[0].rect, square), "{label:?}: {read:?}");
+ }
+
+ let mut deleted = doc.clone();
+ images::change_image(&mut deleted, 0, 0, &images::ImageChange::Delete).unwrap();
+ let deleted = reopen(&deleted);
+ assert!(images::page_images(&deleted, 0).unwrap().is_empty(), "{label:?}");
+ let (joined, ops) = joined_ops(&deleted);
+ assert!(!ops.iter().any(|o| o.is("Do")), "{joined}");
+
+ let target = [300.0, 500.0, 400.0, 600.0];
+ let mut moved = doc.clone();
+ images::change_image(&mut moved, 0, 0, &images::ImageChange::Transform(images::rect_to_rect(square, target))).unwrap();
+ let moved = reopen(&moved);
+ let after = images::page_images(&moved, 0).unwrap();
+ assert!(after.len() == 1 && close(after[0].rect, target), "{label:?}: {after:?} {:?}", joined_ops(&moved).0);
+
+ let mut shifted = doc.clone();
+ images::translate_images(&mut shifted, 0, &[0], [228.0, 0.0]).unwrap();
+ let shifted = reopen(&shifted);
+ let after = images::page_images(&shifted, 0).unwrap();
+ assert!(after.len() == 1 && close(after[0].rect, target), "{label:?}: {after:?} {:?}", joined_ops(&shifted).0);
+ }
+}
+
#[test]
fn curved_image_clips_have_analytic_bounds_and_holes_do_not_capture_points() {
let mut doc = image_page();
@@ -4799,3 +5143,78 @@ fn flattening_comments_keeps_annotations_that_are_not_comments() {
let without_widget: Vec<&str> = all.iter().copied().filter(|s| *s != "Widget").collect();
assert_eq!(left(&reopen(&doc)), without_widget, "flattening fields touches no annotation that isn't a widget");
}
+
+/// A page whose text is a clipping mask (`mode` Tr) with a fill painted through it.
+fn clipping_text_page(mode: i64) -> Document {
+ text_page(&format!("q\nBT {mode} Tr /F1 48 Tf 40 60 Td (MASK) Tj ET\n1 0 0 rg 0 0 300 150 re f\nQ\n"))
+}
+
+/// Rewriting text that is a clipping path destroyed the artwork it masked: the glyphs went, the
+/// mask with them, and the fill behind flooded its own rectangle, in a file that still opened
+/// without a warning (#3429). Photoshop exports draw every text object this way. The rewrite is
+/// refused instead, as `clear_block` already refuses a preview.
+#[test]
+fn rewriting_clipping_path_text_is_refused_for_every_clipping_mode() {
+ for mode in [4, 5, 6, 7] {
+ let before = text::text_lines(&clipping_text_page(mode), 0).unwrap();
+ assert_eq!(before.len(), 1, "mode {mode}: the mask is one line");
+
+ let mut doc = clipping_text_page(mode);
+ let line = text::replace_line(&mut doc, 0, 0, "EDITED");
+ assert!(matches!(&line, Err(EditError::Invalid(m)) if m.contains("clipping path")), "mode {mode}: {line:?}");
+
+ let mut doc = clipping_text_page(mode);
+ let block = text::rewrite_block(&mut doc, 0, 0, Some("EDITED"), &BlockStyle::default());
+ assert!(matches!(&block, Err(EditError::Invalid(m)) if m.contains("clipping path")), "mode {mode}: {block:?}");
+
+ // Refused means untouched: the mask is still there to shape what is painted through it.
+ let after = text::text_lines(&doc, 0).unwrap();
+ assert_eq!(after.len(), 1, "mode {mode}");
+ assert_eq!(after[0].text, "MASK", "mode {mode}");
+ }
+}
+
+/// The modes that paint ink are still editable: only 4 to 7 add to the clip path.
+#[test]
+fn painting_modes_are_still_rewritten() {
+ for mode in [0, 1, 2] {
+ let mut doc = clipping_text_page(mode);
+ assert!(text::replace_line(&mut doc, 0, 0, "EDITED").is_ok(), "mode {mode}");
+ assert_eq!(text::text_lines(&doc, 0).unwrap()[0].text, "EDITED", "mode {mode}");
+ }
+}
+
+/// A line's runs need not share a rendering mode: nothing in the join requires it, so a line that
+/// starts as ink can continue as a mask. The refusal reads the whole line, not the mode its first
+/// run set -- otherwise the rewrite passed the guard and took the mask away with the ink.
+#[test]
+fn a_line_that_starts_as_ink_and_continues_as_a_mask_is_refused() {
+ let content = "q\nBT /F1 48 Tf 40 60 Td 0 Tr (TITLE) Tj 7 Tr (MASK) Tj ET\n1 0 0 rg 0 0 300 150 re f\nQ\n";
+ let before = text::text_lines(&text_page(content), 0).unwrap();
+ assert_eq!(before.len(), 1, "the two runs are one line: {:?}", before.iter().map(|l| &l.text).collect::>());
+ assert_eq!(before[0].text, "TITLEMASK", "and the mask is the second half of it");
+
+ let mut doc = text_page(content);
+ let line = text::replace_line(&mut doc, 0, 0, "EDITED");
+ assert!(matches!(&line, Err(EditError::Invalid(m)) if m.contains("clipping path")), "{line:?}");
+ let block = text::rewrite_block(&mut doc, 0, 0, Some("EDITED"), &BlockStyle::default());
+ assert!(matches!(&block, Err(EditError::Invalid(m)) if m.contains("clipping path")), "{block:?}");
+ assert_eq!(text::text_lines(&doc, 0).unwrap()[0].text, "TITLEMASK", "refused means untouched");
+}
+
+/// Another drawing of the line on the same baseline goes with it, so that the replacement is all
+/// that shows -- and that copy can be the mask while the line being replaced is ink. Dropping it
+/// destroys the artwork as surely as rewriting the mask itself, so this is refused too.
+#[test]
+fn a_clipping_copy_on_the_same_baseline_is_refused() {
+ let content = "q\nBT 0 Tr /F1 48 Tf 40 60 Td (MASK) Tj ET\nBT 7 Tr /F1 48 Tf 40 60 Td (MASK) Tj ET\n1 0 0 rg 0 0 300 150 re f\nQ\n";
+ let before = text::text_lines(&text_page(content), 0).unwrap();
+ assert_eq!(before.len(), 2, "two drawings of the same line");
+
+ let mut doc = text_page(content);
+ let line = text::replace_line(&mut doc, 0, 0, "EDITED");
+ assert!(matches!(&line, Err(EditError::Invalid(m)) if m.contains("clipping path")), "{line:?}");
+ let block = text::rewrite_block(&mut doc, 0, 0, Some("EDITED"), &BlockStyle::default());
+ assert!(matches!(&block, Err(EditError::Invalid(m)) if m.contains("clipping path")), "{block:?}");
+ assert_eq!(text::text_lines(&doc, 0).unwrap().len(), 2, "refused means untouched");
+}
diff --git a/crates/edit/src/text.rs b/crates/edit/src/text.rs
index e4eaaa641e..2672592178 100644
--- a/crates/edit/src/text.rs
+++ b/crates/edit/src/text.rs
@@ -68,6 +68,10 @@ pub struct TextLine {
/// Some of the line's text is `/ActualText`, which is already in logical order: visual-order
/// Indic recovery would scramble it.
actual_text: bool,
+ /// Whether any of the line's runs draws in a clipping mode (`4 Tr` to `7 Tr`). The join below
+ /// does not require one rendering mode, so a line that starts as ink can continue as a mask;
+ /// [`refuse_clipping_text`] reads this rather than the origin's mode alone.
+ clips: bool,
}
impl TextLine {
@@ -635,6 +639,7 @@ struct Shown {
glyph_count: usize,
/// Advance in text space, including spacing and TJ adjustments.
advance: f64,
+ tracking: f64,
/// The text matrix where it starts (text space), and the state there and at its `BT`.
tm: Matrix,
tlm: Matrix,
@@ -702,6 +707,77 @@ impl Pen {
}
}
+/// Use the lower third of letter gaps, not word gaps. A pair of isolated labels is not
+/// evidence of tracking; unadjusted neighbours inside a string contribute zero too.
+/// The tracking a run of glyphs was set with: the lower third of its gaps, when that is at
+/// most half an em (`limit`). A wider "tracking" is word or cell spacing between one-glyph
+/// words (`a b c`, a column of digits or Y/N ticks), which must keep its spaces.
+fn typical_tracking(gaps: &mut [f64], limit: f64) -> f64 {
+ if gaps.len() < 3 {
+ return 0.0;
+ }
+ let at = gaps.len() / 3;
+ gaps.select_nth_unstable_by(at, f64::total_cmp);
+ gaps.get(at).copied().filter(|n| n.is_finite() && *n <= limit).unwrap_or(0.0).max(0.0)
+}
+
+fn tj_tracking(pieces: &[Object], metrics: &Metrics, size: f64, scale: f64) -> f64 {
+ let mut gaps = Vec::new();
+ let mut previous = false;
+ let mut gap = 0.0;
+ for piece in pieces {
+ if let Object::String(s) = piece {
+ for (code, len) in metrics.codes(&s.bytes) {
+ let blank = metrics.is_space(code, len) || metrics.text_of(code).is_some_and(|t| !t.is_empty() && t.chars().all(char::is_whitespace));
+ if previous && !blank {
+ gaps.push(gap);
+ }
+ previous = !blank;
+ gap = 0.0;
+ }
+ } else if let Some(n) = piece.as_f64() {
+ gap += -n / 1000.0 * size * scale;
+ }
+ }
+ typical_tracking(&mut gaps, size * scale.abs() * 0.5)
+}
+
+fn positioned_tracking(shown: &mut [Shown]) {
+ let mut start = 0;
+ while start < shown.len() {
+ let mut end = start.saturating_add(1);
+ while let (Some(prev), Some(next)) = (shown.get(end.saturating_sub(1)), shown.get(end)) {
+ if prev.glyph_count != 1
+ || next.glyph_count != 1
+ || prev.font != next.font
+ || prev.scope != next.scope
+ || (prev.size - next.size).abs() > 0.01
+ || prev.state.char_spacing != next.state.char_spacing
+ || prev.state.scale != next.state.scale
+ || !Pen::after(prev).continues(next, 1.5).0
+ {
+ break;
+ }
+ end = end.saturating_add(1);
+ }
+ if let Some(run) = shown.get_mut(start..end) {
+ let mut gaps: Vec = run
+ .windows(2)
+ .filter_map(|pair| {
+ let [prev, next] = pair else { return None };
+ (!prev.text.trim().is_empty() && !next.text.trim().is_empty()).then(|| Pen::after(prev).continues(next, 0.0).1)
+ })
+ .collect();
+ let limit = run.first().map_or(0.0, |s| s.size * s.state.scale.abs() * 0.5);
+ let tracking = typical_tracking(&mut gaps, limit);
+ for s in run.iter_mut().skip(1) {
+ s.tracking = tracking;
+ }
+ }
+ start = end;
+ }
+}
+
/// The graphics state carried from one of a page's content streams to the next: the streams
/// are one stream in pieces (§7.8.2), so a `cm` (AutoCAD scales the whole page in the first
/// stream), an unbalanced `q`, the font and the colour still apply in the streams after it.
@@ -740,7 +816,8 @@ fn interpret(
cache: &mut HashMap, (Rc, Option)>,
carry: &mut Carry,
mut invoked: Option<&mut Vec>,
- logical_text: bool,
+ // `Some(properties)`: read `/ActualText`, looking named property lists up in `properties`.
+ logical_text: Option<&Dict>,
) -> Vec {
let mut out: Vec = Vec::new();
let mut ts = carry.ts.clone();
@@ -757,11 +834,16 @@ fn interpret(
// artifacts and inner spans, which inherit their enclosing region.
let region = matches!(op.name(0), Some(b"P" | b"H" | b"H1" | b"H2" | b"H3" | b"H4" | b"H5" | b"H6" | b"LI" | b"TD" | b"TH"));
marked.push(if region { Some(i) } else { marked.last().copied().flatten() });
- let logical = op.operands.get(1).and_then(Object::as_dict).and_then(|d| d.get(b"ActualText")).and_then(|s| match s {
+ // The property list is inline or named in the resources' /Properties (§14.6.2).
+ let list = logical_text.and_then(|properties| match op.operands.get(1) {
+ Some(Object::Name(n)) => properties.get(n).and_then(|o| doc.dict(o)),
+ other => other.and_then(Object::as_dict).cloned(),
+ });
+ let logical = list.as_ref().and_then(|d| d.get(b"ActualText")).and_then(|s| match s {
Object::String(s) if s.bytes.len() <= 4_000_000 => Some(s.to_text()),
_ => None,
});
- actual.push(if logical_text { logical.map(|s| (out.len(), i, s)) } else { None });
+ actual.push(logical.map(|s| (out.len(), i, s)));
}
b"EMC" => {
marked.pop();
@@ -877,6 +959,8 @@ fn interpret(
b"TJ" => op.operands.first().and_then(Object::as_array).cloned().unwrap_or_default(),
_ => op.operands.last().cloned().into_iter().collect(),
};
+ // Tc is already included in the pen advance; TJ may encode tracking instead.
+ let tracking = if op.is("TJ") { tj_tracking(&pieces, &m, ts.size, ts.scale) } else { 0.0 };
let trm0 = tm.then(&ts.ctm);
let start = trm0.apply(0.0, ts.rise);
let mut text = String::new();
@@ -918,12 +1002,12 @@ fn interpret(
if let Some(n) = other.as_f64() {
let dx = -n / 1000.0 * ts.size * ts.scale;
// A large gap inside TJ reads as a space.
- if dx > ts.size * 0.2 && !text.ends_with(' ') {
+ if dx - tracking > (ts.size * ts.scale).abs() * 0.2 && !text.ends_with(' ') {
text.push(' ');
}
- // Kerning keeps a word together; a jump either way of more
- // than 0.2 em ends it (whatever the sign of the size or `Tz`).
- if dx.abs() > (ts.size * ts.scale).abs() * 0.2 {
+ // Tracking and kerning keep a word together; a larger jump
+ // ends it (whatever the sign of the size or `Tz`).
+ if dx - tracking > (ts.size * ts.scale).abs() * 0.2 || dx < -(ts.size * ts.scale).abs() * 0.2 {
in_word = false;
}
x_text += dx;
@@ -970,6 +1054,7 @@ fn interpret(
extra_ops: Vec::new(),
glyph_count,
advance: x_text,
+ tracking: 0.0,
tm: Matrix([tm.0[0], tm.0[1], tm.0[2], tm.0[3], tm.0[4] - x_text * tm.0[0], tm.0[5] - x_text * tm.0[1]]),
state: ts.clone(),
tlm,
@@ -1003,6 +1088,7 @@ fn interpret(
carry.ts = ts;
carry.stack = stack;
carry.marked = marked;
+ positioned_tracking(&mut out);
out
}
@@ -1018,7 +1104,8 @@ pub fn text_lines(doc: &Document, page: usize) -> Result, EditErro
// joined, so an operator whose operands end one piece still has them.
let pieces = Pieces::join(&content_streams(doc, &p.dict).into_iter().map(|(_, d)| d).collect::>());
let ops = pieces.parse().ops;
- let shown = interpret(doc, &ops, &fonts_res, &mut cache, &mut carry, None, true);
+ let properties = res.get(b"Properties").and_then(|o| doc.dict(o)).unwrap_or_default();
+ let shown = interpret(doc, &ops, &fonts_res, &mut cache, &mut carry, None, Some(&properties));
let mut last: Option<(usize, Pen)> = None; // the stream piece and where its last run ended
for mut s in shown {
// A single RTL glyph's ActualText (e.g. lam-alef) is an inline ligature, not a
@@ -1050,7 +1137,7 @@ pub fn text_lines(doc: &Document, page: usize) -> Result, EditErro
});
if joins && let Some(l) = lines.last_mut() {
let gap = last.map_or(0.0, |(_, pen)| pen.continues(&s, 0.0).1);
- if gap > l.size * 0.2 && !l.text.ends_with(' ') && !s.text.starts_with(' ') {
+ if gap - s.tracking > l.size * s.state.scale.abs() * 0.2 && !l.text.ends_with(' ') && !s.text.starts_with(' ') {
l.text.push(' ');
}
l.text.push_str(&s.text);
@@ -1062,6 +1149,7 @@ pub fn text_lines(doc: &Document, page: usize) -> Result, EditErro
l.decodable &= s.decodable;
l.visual_order |= s.visual_order;
l.actual_text |= s.actual_text;
+ l.clips |= s.state.render_mode >= 4;
// Spaces may use a separate subset font; they do not change the visible style.
} else {
lines.push(TextLine {
@@ -1082,6 +1170,7 @@ pub fn text_lines(doc: &Document, page: usize) -> Result, EditErro
scope: s.scope,
logical_order: s.logical_order,
actual_text: s.actual_text,
+ clips: s.state.render_mode >= 4,
ops: std::iter::once(s.op).chain(s.extra_ops.iter().copied()).collect(),
origin: Origin {
tm: s.tm.0,
@@ -1140,7 +1229,7 @@ fn add_shown(lines: &mut Vec, last: &mut Option, s: Shown, stream
});
if joins && let Some(l) = lines.last_mut() {
let gap = last.map_or(0.0, |pen| pen.continues(&s, 0.0).1);
- if gap > l.size * 0.2 && !l.text.ends_with(' ') && !s.text.starts_with(' ') {
+ if gap - s.tracking > l.size * s.state.scale.abs() * 0.2 && !l.text.ends_with(' ') && !s.text.starts_with(' ') {
l.text.push(' ');
}
l.text.push_str(&s.text);
@@ -1152,6 +1241,7 @@ fn add_shown(lines: &mut Vec, last: &mut Option, s: Shown, stream
l.decodable &= s.decodable;
l.visual_order |= s.visual_order;
l.actual_text |= s.actual_text;
+ l.clips |= s.state.render_mode >= 4;
} else {
lines.push(TextLine {
text: s.text.clone(),
@@ -1171,6 +1261,7 @@ fn add_shown(lines: &mut Vec, last: &mut Option, s: Shown, stream
scope: s.scope,
logical_order: s.logical_order,
actual_text: s.actual_text,
+ clips: s.state.render_mode >= 4,
ops: std::iter::once(s.op).chain(s.extra_ops.iter().copied()).collect(),
origin: Origin {
tm: s.tm.0,
@@ -1188,13 +1279,27 @@ fn add_shown(lines: &mut Vec, last: &mut Option, s: Shown, stream
*last = Some(Pen::after(&s));
}
-/// How deep form XObjects nest before reading stops (they may also refer to themselves).
-pub(crate) const MAX_FORM_DEPTH: usize = 12;
+/// How deep form XObjects nest before reading stops (they may also refer to themselves). The
+/// renderer stops at the same depth, so export reads the text the page shows (#3120).
+pub const MAX_FORM_DEPTH: usize = 50;
/// The most form XObjects one page's walk enters, at any depth. The depth cap alone still lets
-/// a form that draws another form many times fan out exponentially (100 `Do`s on each of 12
-/// levels), which would hang export on a hostile PDF.
-pub(crate) const MAX_FORM_VISITS: usize = 4096;
+/// a form that draws another form many times fan out exponentially (100 `Do`s on each of 50
+/// levels), which would hang export on a hostile PDF. The renderer paints as many forms nested
+/// in forms; this budget also counts the forms the page draws itself, so a page that draws one
+/// a million times stays bounded too. A label or table cell drawn as a form a few thousand
+/// times is read in full (#3120).
+pub const MAX_FORM_VISITS: usize = 50_000;
+
+/// One page's walk through the form XObjects it draws: how many it entered (the page's
+/// [`MAX_FORM_VISITS`]), and whether a limit left a form unread.
+#[derive(Default)]
+pub(crate) struct FormBudget {
+ visits: usize,
+ /// A form was left unread because it nests deeper than [`MAX_FORM_DEPTH`] or comes past
+ /// [`MAX_FORM_VISITS`]: what it draws is missing from the walk.
+ pub(crate) truncated: bool,
+}
/// A form XObject a page draws: its object, content, resources and its matrix composed with the
/// CTM at the `Do`.
@@ -1206,17 +1311,19 @@ pub(crate) struct FormCall {
}
/// The form XObject `name` in `resources`, drawn with `ctm`. `None` for images, missing names
-/// and forms already being read (`path`), nested too deep, or past the page's `visits` budget
-/// ([`MAX_FORM_VISITS`]); each form entered counts one visit.
+/// and forms already being read (`path`), and for forms nested too deep or past the page's
+/// budget, which `budget` records as truncated; each form entered counts one visit.
pub(crate) fn form_call(
doc: &Document,
resources: &Dict,
name: &[u8],
ctm: Matrix,
path: &[pdfcraft_cos::ObjRef],
- visits: &mut usize,
+ budget: &mut FormBudget,
) -> Option {
- if path.len() >= MAX_FORM_DEPTH || *visits >= MAX_FORM_VISITS {
+ let limited = path.len() >= MAX_FORM_DEPTH || budget.visits >= MAX_FORM_VISITS;
+ // Once a limit has cut the page short, refusing the rest costs nothing more.
+ if limited && budget.truncated {
return None;
}
let xobjects = resources.get(b"XObject").map(|x| doc.resolve(x)).and_then(|x| x.as_dict().cloned())?;
@@ -1229,6 +1336,11 @@ pub(crate) fn form_call(
if s.dict.name(b"Subtype") != Some(b"Form") {
return None;
}
+ // Only a form left unread loses content; an image at the limit is still read by its caller.
+ if limited {
+ budget.truncated = true;
+ return None;
+ }
let m = s.dict.get(b"Matrix").map(|m| doc.resolve(m)).and_then(|m| {
let a = m.as_array()?;
let v: Vec = a.iter().filter_map(|x| doc.resolve(x).as_f64()).collect();
@@ -1236,7 +1348,7 @@ pub(crate) fn form_call(
});
// A form without its own resources uses the ones it was drawn with (PDF 1.1 files).
let own = s.dict.get(b"Resources").map(|r| doc.resolve(r)).and_then(|r| r.as_dict().cloned());
- *visits = visits.saturating_add(1);
+ budget.visits = budget.visits.saturating_add(1);
Some(FormCall {
obj,
data: s.decoded().unwrap_or_default(),
@@ -1247,21 +1359,23 @@ pub(crate) fn form_call(
/// The lines a page shows as it reads, including text drawn by form XObjects (which
/// [`text_lines`] leaves out because it can only rewrite the page's own streams). Lines from a
-/// form carry a stream number past the page's streams, one per form drawn.
-fn reading_lines(doc: &Document, page: usize) -> Result, EditError> {
+/// form carry a stream number past the page's streams, one per form drawn. The flag is `true`
+/// when forms past [`MAX_FORM_DEPTH`] or [`MAX_FORM_VISITS`] were left unread.
+fn reading_lines(doc: &Document, page: usize) -> Result<(Vec, bool), EditError> {
struct Walk<'a> {
doc: &'a Document,
lines: Vec,
next_stream: usize,
path: Vec,
- visits: usize,
+ budget: FormBudget,
}
/// `stream` gives the stream number of the operator at an index of `ops`.
fn walk(w: &mut Walk, ops: &[Op], resources: &Dict, carry: &mut Carry, stream: &dyn Fn(usize) -> usize) {
let fonts_res = resources.get(b"Font").map(|f| w.doc.resolve(f)).and_then(|f| f.as_dict().cloned()).unwrap_or_default();
let mut cache = HashMap::new();
let mut invoked = Vec::new();
- let shown = interpret(w.doc, ops, &fonts_res, &mut cache, carry, Some(&mut invoked), true);
+ let properties = resources.get(b"Properties").and_then(|o| w.doc.dict(o)).unwrap_or_default();
+ let shown = interpret(w.doc, ops, &fonts_res, &mut cache, carry, Some(&mut invoked), Some(&properties));
let mut last = None;
let mut last_stream = None;
let mut forms = invoked.into_iter().peekable();
@@ -1283,7 +1397,7 @@ fn reading_lines(doc: &Document, page: usize) -> Result, EditError
}
}
fn enter(w: &mut Walk, resources: &Dict, name: &[u8], ts: Ts) {
- let Some(form) = form_call(w.doc, resources, name, ts.ctm, &w.path, &mut w.visits) else { return };
+ let Some(form) = form_call(w.doc, resources, name, ts.ctm, &w.path, &mut w.budget) else { return };
let ops = parse(&form.data).ops;
// The form starts with the graphics state at its `Do`, its own matrix applied.
let mut carry = Carry { ts: Ts { ctm: form.ctm, ..ts }, stack: Vec::new(), marked: Vec::new() };
@@ -1300,7 +1414,7 @@ fn reading_lines(doc: &Document, page: usize) -> Result, EditError
let p = page_dict(doc, page)?;
let res = p.dict.get(b"Resources").map(|r| doc.resolve(r)).and_then(|r| r.as_dict().cloned()).unwrap_or_default();
let streams = content_streams(doc, &p.dict);
- let mut w = Walk { doc, lines: Vec::new(), next_stream: streams.len(), path: Vec::new(), visits: 0 };
+ let mut w = Walk { doc, lines: Vec::new(), next_stream: streams.len(), path: Vec::new(), budget: FormBudget::default() };
let mut carry = Carry::new();
// Read the page's streams as the one stream they are, in one pass, as rendering and editing
// do: a comment, an operator's operands or a marked-content sequence (its /ActualText too)
@@ -1316,7 +1430,7 @@ fn reading_lines(doc: &Document, page: usize) -> Result, EditError
l.text = crate::indic::to_logical(&l.text);
}
lines.retain(|l| !l.text.trim().is_empty());
- Ok(lines)
+ Ok((lines, w.budget.truncated))
}
/// Text → the runs that show it: each run's font resource name and the bytes shown in that font.
@@ -1661,7 +1775,15 @@ fn source_encodes(metrics: &Metrics, text: &str) -> bool {
text.split(' ').all(|word| metrics.encode(word).is_some())
}
+#[cfg(test)]
+thread_local! {
+ /// How often [`source_space`] ran on this thread, for the test that pins it out of per-character loops.
+ pub(crate) static SOURCE_SPACE_CALLS: std::cell::Cell = const { std::cell::Cell::new(0) };
+}
+
fn source_space(doc: &Document, fonts: &Dict, metrics: &Metrics) -> f64 {
+ #[cfg(test)]
+ SOURCE_SPACE_CALLS.with(|c| c.set(c.get().saturating_add(1)));
let width = |m: &Metrics| m.encode(" ").map(|b| m.codes(&b).iter().map(|(c, _)| m.width(*c)).sum::());
if let Some(w) = width(metrics) {
return w;
@@ -1723,11 +1845,33 @@ pub fn replace_line(doc: &mut Document, page: usize, line: usize, text: &str) ->
replace_line_with_limit(doc, page, line, text, MAX_TYPE3_GLYPHS)
}
+/// Refuses a rewrite that would remove text drawn as a clipping path (`4 Tr` to `7 Tr`). Such
+/// glyphs are not ink but a mask, and whatever is painted through them takes their shape.
+/// Removing the glyphs removes the mask, and the fill or image behind floods its own rectangle
+/// instead -- the artwork is destroyed, in a file that still opens without a warning (#3429).
+/// `clear_block` already refuses these for the same reason.
+///
+/// Every line the rewrite drops is checked, not only the one being replaced: a line's runs need
+/// not share a rendering mode (see [`TextLine::clips`]), and [`coincident_ops`] drops a whole
+/// overlapping line without regard to its mode -- so a mask could go with text that is ink.
+fn refuse_clipping_text(lines: &[TextLine], dropped: &std::collections::HashSet) -> Result<(), EditError> {
+ if lines.iter().any(|l| l.clips && l.ops.iter().any(|op| dropped.contains(op))) {
+ return Err(EditError::Invalid("text used as a clipping path cannot be edited: its glyphs mask the artwork behind them".into()));
+ }
+ Ok(())
+}
+
/// [`replace_line`] with the most unique characters one generated font may hold (tests lower it).
pub(crate) fn replace_line_with_limit(doc: &mut Document, page: usize, line: usize, text: &str, max_glyphs: usize) -> Result {
let text = text.replace(['\n', '\r'], " ");
let lines = text_lines(doc, page)?;
let target = lines.get(line).cloned().ok_or_else(|| EditError::Invalid(format!("page {} has no line {}", page + 1, line + 1)))?;
+ // Copies of the line drawn on the same baseline go with it, so the replacement is all that
+ // shows. Which operators go is settled here, before any font is generated, so that a refusal
+ // leaves the document untouched.
+ let mut drops = coincident_ops(&lines, &[&target]);
+ drops.extend(target.ops.iter().copied());
+ refuse_clipping_text(&lines, &drops)?;
let p = page_dict(doc, page)?;
let mut res = p.dict.get(b"Resources").map(|r| doc.resolve(r)).and_then(|r| r.as_dict().cloned()).unwrap_or_default();
let mut fonts_res = res.get(b"Font").map(|f| doc.resolve(f)).and_then(|f| f.as_dict().cloned()).unwrap_or_default();
@@ -1801,10 +1945,7 @@ pub(crate) fn replace_line_with_limit(doc: &mut Document, page: usize, line: usi
}
}
}
- // Rebuild: the line's first operator becomes the replacement, its others go. Copies of the
- // line drawn on the same baseline go too, so the replacement is all that shows.
- let mut drops = coincident_ops(&lines, &[&target]);
- drops.extend(target.ops.iter().copied());
+ // Rebuild: the line's first operator becomes the replacement, its others go.
let new_data = splice(&pieces, &ops, |i| if i == first { (std::mem::take(&mut replacement), false) } else { (Vec::new(), !drops.contains(&i)) });
let contents = rewritten_contents(doc, &streams, new_data);
let page_ref = p.obj;
@@ -1891,7 +2032,7 @@ pub fn clear_block(doc: &mut Document, page: usize, block: usize) -> Result<(),
let streams = content_streams(doc, &p.dict);
let pieces = Pieces::join(&streams.iter().map(|(_, d)| d.as_slice()).collect::>());
let ops = pieces.parse().ops;
- let shown = interpret(doc, &ops, &fonts, &mut HashMap::new(), &mut Carry::new(), None, false);
+ let shown = interpret(doc, &ops, &fonts, &mut HashMap::new(), &mut Carry::new(), None, None);
let mut patches = HashMap::new();
for s in shown.into_iter().filter(|s| drops.contains(&s.op)) {
if s.state.render_mode >= 4 {
@@ -1924,9 +2065,27 @@ pub fn clear_block(doc: &mut Document, page: usize, block: usize) -> Result<(),
/// The paragraphs on a page as a reader sees them, including text drawn by form XObjects
/// (Export to Word, HTML and RTF). Read-only: indexes into this list are not
/// [`text_blocks`] indexes, so they can't be passed to [`replace_block`] or [`rewrite_block`].
+/// [`reading_blocks_checked`] also says whether text was left out.
pub fn reading_blocks(doc: &Document, page: usize) -> Result, EditError> {
- let lines = reading_lines(doc, page)?;
- Ok(regions::group_reading_blocks(&lines))
+ reading_blocks_checked(doc, page).map(|r| r.blocks)
+}
+
+/// A page's paragraphs as a reader sees them ([`reading_blocks_checked`]).
+#[derive(Clone, Debug, Default, PartialEq)]
+pub struct ReadingBlocks {
+ /// The paragraphs, as [`reading_blocks`] returns them.
+ pub blocks: Vec,
+ /// The page draws forms nested deeper than [`MAX_FORM_DEPTH`], or more of them than
+ /// [`MAX_FORM_VISITS`]. Reading leaves those out to stay bounded on hostile files, so their
+ /// text is missing from `blocks`; [`crate::reading_images`] walks the same forms and misses
+ /// their images too.
+ pub truncated: bool,
+}
+
+/// [`reading_blocks`], saying whether the limits that bound reading left text out.
+pub fn reading_blocks_checked(doc: &Document, page: usize) -> Result {
+ let (lines, truncated) = reading_lines(doc, page)?;
+ Ok(ReadingBlocks { blocks: regions::group_reading_blocks(&lines), truncated })
}
// An ideographic space carries intentional Japanese spacing, including paragraph indentation.
@@ -2047,6 +2206,10 @@ fn rewrite_block_from(
let blocks = group_blocks(&lines);
let b = blocks.get(block).cloned().ok_or_else(|| EditError::Invalid(format!("page {} has no paragraph {}", page + 1, block + 1)))?;
let members: Vec<&TextLine> = b.lines.iter().map(|i| &lines[*i]).collect();
+ // One clipping run is enough: rewriting the paragraph would drop its mask with the rest.
+ let mut dropped = coincident_ops(&lines, &members);
+ dropped.extend(members.iter().flat_map(|l| l.ops.iter().copied()));
+ refuse_clipping_text(&lines, &dropped)?;
let first = members.first().copied().ok_or_else(|| EditError::Invalid("the paragraph has no source runs".into()))?;
let p = page_dict(doc, page)?;
let mut res = p.dict.get(b"Resources").map(|r| doc.resolve(r)).and_then(|r| r.as_dict().cloned()).unwrap_or_default();
@@ -2548,7 +2711,7 @@ pub(crate) fn translate_blocks(doc: &mut Document, page: usize, indexes: &[usize
let fonts = res.get(b"Font").map(|r| doc.resolve(r)).and_then(|r| r.as_dict().cloned()).unwrap_or_default();
let pieces = Pieces::join(&streams.iter().map(|(_, d)| d.as_slice()).collect::>());
let ops = pieces.parse().ops;
- let shown = interpret(doc, &ops, &fonts, &mut HashMap::new(), &mut Carry::new(), None, false);
+ let shown = interpret(doc, &ops, &fonts, &mut HashMap::new(), &mut Carry::new(), None, None);
let mut patches = HashMap::new();
// Keep full precision: a tiny rounded text-space change can be magnified by a CTM,
// and restoring rise/spacing must not alter following unselected text.
diff --git a/crates/edit/src/text_regions.rs b/crates/edit/src/text_regions.rs
index 8e34e0bdf4..e08068d9f5 100644
--- a/crates/edit/src/text_regions.rs
+++ b/crates/edit/src/text_regions.rs
@@ -489,6 +489,20 @@ pub(super) fn rewrite_styled_block(
};
// A source run may carry TJ kerning/justification that is absent from its text state.
// Keep its measured advance when rebuilding the run instead of making it wrap on entry.
+ // Each font's space width, worked out once: when a font can't show a space, finding one
+ // reads every font on the page, and doing that for every character of a paragraph made each
+ // keystroke in a long paragraph take half a second (#2616).
+ // Only for the fonts the paragraph uses, when first asked: working out every page font's width
+ // up front would scan the page once per font for a short edit on a page of many fonts.
+ let spaces: std::cell::RefCell> = std::cell::RefCell::new(HashMap::new());
+ let space_of = |name: &str, m: &Metrics| {
+ if let Some(w) = spaces.borrow().get(name) {
+ return *w;
+ }
+ let w = source_space(doc, &fonts, m);
+ spaces.borrow_mut().insert(name.to_owned(), w);
+ w
+ };
let mut calibration = HashMap::new();
for (index, source) in lines.iter().enumerate() {
if let Some(m) = metrics.get(&source.font) {
@@ -496,7 +510,7 @@ pub(super) fn rewrite_styled_block(
m,
&source.text,
source.origin.state.font.as_ref().map_or(1.0, |(_, s)| *s),
- source_space(doc, &fonts, m),
+ space_of(&source.font, m),
source.origin.state.char_spacing,
source.origin.state.word_spacing,
) * source.origin.state.scale
@@ -513,7 +527,7 @@ pub(super) fn rewrite_styled_block(
m,
&item.0.to_string(),
size_of(source, item.1),
- source_space(doc, &fonts, m),
+ space_of(&item.2, m),
overrides
.get(&item.1)
.and_then(|r| r.char_spacing)
@@ -778,7 +792,7 @@ pub(super) fn rewrite_styled_block(
m,
&string,
size_of(source, item.1),
- source_space(doc, &fonts, m),
+ spaces.borrow().get(&item.2).copied().unwrap_or_else(|| source_space(doc, &fonts, m)),
state.char_spacing,
state.word_spacing,
)?);
diff --git a/crates/engine/Cargo.toml b/crates/engine/Cargo.toml
index 74567eb488..ff5e7611d8 100644
--- a/crates/engine/Cargo.toml
+++ b/crates/engine/Cargo.toml
@@ -37,6 +37,9 @@ pdfcraft-model = { workspace = true }
pdfcraft-geom = { workspace = true }
thiserror = { workspace = true }
png = "0.18"
+# Already in the graph (through image and pdfcraft-create): image's TiffEncoder can't set the
+# resolution or the compression of an exported page.
+tiff = "0.11"
# Already in the graph (url and idna through webbrowser, icu_properties through boa): they show
# the host of a document's link the way the browser reads it (links::display_host).
url = "2.5"
diff --git a/crates/engine/src/compare.rs b/crates/engine/src/compare.rs
index 3c03870f34..f0ed2b4b84 100644
--- a/crates/engine/src/compare.rs
+++ b/crates/engine/src/compare.rs
@@ -4,6 +4,9 @@
use std::sync::Arc;
pub use pdfcraft_compare::{Change, Comparison, Kind, Side};
+// How far Office export reads into form XObjects; `OfficeExport::truncated_pages` lists the pages
+// that draw more.
+pub use pdfcraft_edit::{MAX_FORM_DEPTH, MAX_FORM_VISITS};
use crate::{DocId, Edit, EditError, Markup, NewAnnotation, NoteIcon, Session, Shape};
@@ -151,12 +154,31 @@ impl OfficeFormat {
}
}
+/// A Word, PowerPoint, HTML or RTF export ([`crate::Document::export_office_checked`]).
+#[derive(Clone, Debug, Default, PartialEq)]
+pub struct OfficeExport {
+ /// The exported file.
+ pub bytes: Vec,
+ /// Pages (0-based) that draw forms nested deeper than [`MAX_FORM_DEPTH`] or more of them
+ /// than [`MAX_FORM_VISITS`]: export stops reading there to stay bounded on hostile files, so
+ /// the text and images of those forms are missing from `bytes`.
+ pub truncated_pages: Vec,
+}
+
impl crate::Document {
/// The pages as paragraphs and images (for Word, HTML and RTF export), including what
/// form XObjects draw.
pub fn export_pages(&self) -> Vec {
- let Some(cos) = self.editor.as_ref().map(|e| &e.cos) else { return Vec::new() };
- self.info
+ self.export_pages_checked().0
+ }
+
+ /// [`Self::export_pages`], and the pages whose forms were left partly unread
+ /// ([`OfficeExport::truncated_pages`]).
+ fn export_pages_checked(&self) -> (Vec, Vec) {
+ let Some(cos) = self.editor.as_ref().map(|e| &e.cos) else { return (Vec::new(), Vec::new()) };
+ let mut truncated = Vec::new();
+ let pages = self
+ .info
.pages
.iter()
.enumerate()
@@ -171,8 +193,12 @@ impl crate::Document {
let crop_width = f64::from(info.crop[2] - info.crop[0]);
let display_width = f64::from(if info.rotation % 180 == 0 { info.width } else { info.height });
let unit = if crop_width > 0.0 && crop_width.is_finite() && display_width.is_finite() { display_width / crop_width } else { 1.0 };
- let blocks = pdfcraft_edit::reading_blocks(cos, i)
- .unwrap_or_default()
+ let reading = pdfcraft_edit::reading_blocks_checked(cos, i).unwrap_or_default();
+ if reading.truncated {
+ truncated.push(i);
+ }
+ let blocks = reading
+ .blocks
.into_iter()
.filter(|b| !b.text.trim().is_empty())
.map(|b| {
@@ -204,18 +230,26 @@ impl crate::Document {
.collect();
pdfcraft_export::Page { width: info.width as f64, height: info.height as f64, blocks, images }
})
- .collect()
+ .collect();
+ (pages, truncated)
}
/// The document as a Word, PowerPoint, HTML or RTF file.
pub fn export_office(&self, format: OfficeFormat) -> Vec {
- let pages = self.export_pages();
+ self.export_office_checked(format).bytes
+ }
+
+ /// [`Self::export_office`], and the pages whose text it had to leave out
+ /// ([`OfficeExport::truncated_pages`]).
+ pub fn export_office_checked(&self, format: OfficeFormat) -> OfficeExport {
+ let (pages, truncated_pages) = self.export_pages_checked();
let title = self.info.title.clone().unwrap_or_else(|| self.name.trim_end_matches(".pdf").to_string());
- match format {
+ let bytes = match format {
OfficeFormat::Docx => pdfcraft_export::docx(&pages, &title),
OfficeFormat::Pptx => pdfcraft_export::pptx(&pages, &title),
OfficeFormat::Html => pdfcraft_export::html(&pages, &title).into_bytes(),
OfficeFormat::Rtf => pdfcraft_export::rtf(&pages).into_bytes(),
- }
+ };
+ OfficeExport { bytes, truncated_pages }
}
}
diff --git a/crates/engine/src/export.rs b/crates/engine/src/export.rs
index 6b7aad4fad..0bf4e2f79d 100644
--- a/crates/engine/src/export.rs
+++ b/crates/engine/src/export.rs
@@ -93,14 +93,14 @@ impl Exporter {
encode_png(r.width, r.height, &r.rgba)
}
- /// Page `page` as an image file of `format`.
+ /// Page `page` as an image file of `format`, which says the resolution it was drawn at.
pub fn image(&mut self, page: usize, dpi: f64, format: ImageFormat) -> Result, String> {
self.check(page)?;
let r = self.renderer.render(RenderRequest { page, scale: (dpi.clamp(18.0, 1200.0) / 72.0) as f32, ..Default::default() });
if let Some(e) = r.error {
return Err(format!("page {}: {e}", page + 1));
}
- encode_image(r.width, r.height, &r.rgba, format)
+ encode_image(r.width, r.height, &r.rgba, format, self.dpi_used(page, dpi))
}
/// The reading-order text of a page.
@@ -127,12 +127,14 @@ impl Exporter {
}
}
-/// Premultiplied RGBA → an image file of `format`.
-pub fn encode_image(width: u32, height: u32, premultiplied: &[u8], format: ImageFormat) -> Result, String> {
+/// Premultiplied RGBA → an image file of `format`. A JPEG or TIFF says it is `dpi` dots per inch,
+/// so other programs show and print it at the page's size. A PNG says nothing: its `pHYs` counts
+/// pixels per metre, so 72 dpi would read back as 72.009 and a page made from it would shrink.
+pub fn encode_image(width: u32, height: u32, premultiplied: &[u8], format: ImageFormat, dpi: f64) -> Result, String> {
match format {
ImageFormat::Png => encode_png(width, height, premultiplied),
- ImageFormat::Jpeg { quality } => encode_jpeg(width, height, premultiplied, quality),
- ImageFormat::Tiff => encode_tiff(width, height, premultiplied),
+ ImageFormat::Jpeg { quality } => encode_jpeg(width, height, premultiplied, quality, dpi),
+ ImageFormat::Tiff => encode_tiff(width, height, premultiplied, dpi),
}
}
@@ -199,23 +201,37 @@ fn over_white(premultiplied: &[u8]) -> Vec {
.collect()
}
-/// Premultiplied RGBA → baseline JPEG (pages are opaque: transparency becomes white paper).
-pub fn encode_jpeg(width: u32, height: u32, premultiplied: &[u8], quality: u8) -> Result, String> {
+/// Premultiplied RGBA → baseline JPEG at `dpi` (pages are opaque: transparency becomes white paper).
+pub fn encode_jpeg(width: u32, height: u32, premultiplied: &[u8], quality: u8, dpi: f64) -> Result, String> {
use image::ImageEncoder;
+ use image::codecs::jpeg::{JpegEncoder, PixelDensity};
let rgb = over_white(premultiplied);
let mut out = Vec::new();
- image::codecs::jpeg::JpegEncoder::new_with_quality(&mut out, quality.clamp(1, 100))
- .write_image(&rgb, width, height, image::ExtendedColorType::Rgb8)
- .map_err(|e| e.to_string())?;
+ let mut enc = JpegEncoder::new_with_quality(&mut out, quality.clamp(1, 100));
+ // The JFIF density is whole dots per inch.
+ enc.set_pixel_density(PixelDensity::dpi(dpi.round() as u16));
+ enc.write_image(&rgb, width, height, image::ExtendedColorType::Rgb8).map_err(|e| e.to_string())?;
Ok(out)
}
-/// Premultiplied RGBA → TIFF (RGB, LZW-compressed by the encoder's default).
-pub fn encode_tiff(width: u32, height: u32, premultiplied: &[u8]) -> Result, String> {
- use image::ImageEncoder;
+/// Premultiplied RGBA → TIFF (RGB, LZW-compressed) at `dpi`. Written with `tiff` directly:
+/// `image`'s `TiffEncoder` always says 1 × 1 with no unit and leaves the strips uncompressed.
+/// No predictor: LZW alone is the compression TIFF readers support most widely after none.
+pub fn encode_tiff(width: u32, height: u32, premultiplied: &[u8], dpi: f64) -> Result, String> {
+ use tiff::encoder::{Compression, Rational, TiffEncoder, colortype::RGB8};
+ use tiff::tags::{ResolutionUnit, Tag};
+ let err = |e: tiff::TiffError| e.to_string();
let rgb = over_white(premultiplied);
let mut out = std::io::Cursor::new(Vec::new());
- image::codecs::tiff::TiffEncoder::new(&mut out).write_image(&rgb, width, height, image::ExtendedColorType::Rgb8).map_err(|e| e.to_string())?;
+ let mut tiff = TiffEncoder::new(&mut out).map_err(err)?.with_compression(Compression::Lzw);
+ let mut image = tiff.new_image::(width, height).map_err(err)?;
+ // In hundredths: a page drawn at a lowered resolution (`Exporter::dpi_used`) is rarely a whole number.
+ let resolution = Rational { n: (dpi * 100.0).round() as u32, d: 100 };
+ let tags = image.encoder();
+ tags.write_tag(Tag::ResolutionUnit, ResolutionUnit::Inch).map_err(err)?;
+ tags.write_tag(Tag::XResolution, resolution.clone()).map_err(err)?;
+ tags.write_tag(Tag::YResolution, resolution).map_err(err)?;
+ image.write_data(&rgb).map_err(err)?;
Ok(out.into_inner())
}
diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs
index bf689af541..dfe11b25c6 100644
--- a/crates/engine/src/lib.rs
+++ b/crates/engine/src/lib.rs
@@ -1281,9 +1281,11 @@ pub enum Edit {
width: f64,
height: f64,
},
- /// Copies of `pages` inserted after the last of them.
+ /// Copies of `pages` inserted at `at` (an index in the page order as it is now), or after
+ /// the last of them when `at` is `None`.
DuplicatePages {
pages: Vec,
+ at: Option,
},
/// Replace Pages: the content of `pages` is replaced by `src_pages` of another PDF; the
/// pages' links, comments and bookmarks stay.
@@ -1948,7 +1950,7 @@ impl Edit {
Edit::MovePages { pages, .. } => plural("Move page", "Move pages", pages.len()),
Edit::ReversePages { .. } => Label::of("Reverse pages"),
Edit::InsertBlankPage { .. } => Label::of("Insert blank page"),
- Edit::DuplicatePages { pages } => plural("Duplicate page", "Duplicate pages", pages.len()),
+ Edit::DuplicatePages { pages, .. } => plural("Duplicate page", "Duplicate pages", pages.len()),
Edit::ReplacePages { pages, .. } => plural("Replace page", "Replace pages", pages.len()),
Edit::SetPageBox { pages, which: PageBox::Crop, .. } => plural("Crop page", "Crop pages", pages.len()),
Edit::SetPageBox { .. } => Label::of("Set page boxes"),
@@ -2335,7 +2337,7 @@ fn run_edit(doc: &mut pdfcraft_cos::Document, edit: &Edit, cx: &mut EditCtx) ->
pdfcraft_organize::insert_blank_page(doc, *at, *width, *height)?;
}
Edit::SetInfo { key, value } => pdfcraft_organize::set_info(doc, key, value)?,
- Edit::DuplicatePages { pages } => pdfcraft_organize::duplicate_pages(doc, pages)?,
+ Edit::DuplicatePages { pages, at } => pdfcraft_organize::duplicate_pages(doc, pages, *at)?,
Edit::ReplacePages { pages, name, bytes, src_pages } => {
let src = open_source(name, bytes)?;
pdfcraft_organize::replace_pages(doc, pages, &src, src_pages)?;
@@ -4340,7 +4342,7 @@ impl Session {
}
guard(|| match format {
DataFormat::Xfdf => Ok(pdfcraft_xfdf::export_xfdf(&cos, comments, fields, &doc.name).into_bytes()),
- DataFormat::Fdf => Ok(pdfcraft_xfdf::export_fdf(&cos, comments, fields, &doc.name)),
+ DataFormat::Fdf => pdfcraft_xfdf::export_fdf(&cos, comments, fields, &doc.name).map_err(|e| e.to_string()),
DataFormat::Xml | DataFormat::Xdp if xfa => {
pdfcraft_xfa::export_data(&cos, format == DataFormat::Xdp, &doc.name).map(String::into_bytes).map_err(|e| e.to_string())
}
diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs
index d5ffac5001..f125c41dac 100644
--- a/crates/engine/src/tests.rs
+++ b/crates/engine/src/tests.rs
@@ -870,7 +870,7 @@ fn form_with_field(name: &str) -> Vec {
fn duplicating_a_page_with_a_field_keeps_the_field_name() {
let mut s = Session::new().with_clock(|| 1_700_000_000);
let id = s.open("a.pdf", None, Arc::new(form_with_field("Text1")), None).unwrap();
- s.apply(id, Edit::DuplicatePages { pages: vec![0] }).unwrap();
+ s.apply(id, Edit::DuplicatePages { pages: vec![0], at: None }).unwrap();
let doc = s.get(id).unwrap();
assert_eq!(doc.info.pages.len(), 2);
assert!(doc.form.iter().all(|f| f.name == "Text1"), "{:?}", doc.form.iter().map(|f| &f.name).collect::>());
@@ -2183,7 +2183,7 @@ fn crop_and_duplicate_pages_show_in_the_viewer() {
assert_eq!(d.info.pages[0].crop, [10.0, 20.0, 170.0, 260.0]);
assert_eq!((d.info.pages[0].width, d.info.pages[0].height), (160.0, 240.0), "the viewer shows the cropped size");
assert_eq!(d.page_boxes()[1][1], [0.0, 0.0, 200.0, 300.0]);
- s.apply(id, Edit::DuplicatePages { pages: vec![1] }).unwrap();
+ s.apply(id, Edit::DuplicatePages { pages: vec![1], at: None }).unwrap();
assert_eq!(page_texts(&s, id), ["Page 1", "Page 2", "Page 2"]);
assert_eq!(s.get(id).unwrap().can_undo(), Some("Duplicate page"));
}
@@ -2574,6 +2574,48 @@ fn pages_export_as_png_jpeg_and_tiff() {
let tif = ex.image(0, 144.0, ImageFormat::Tiff).unwrap();
let decoded = image::load_from_memory_with_format(&tif, image::ImageFormat::Tiff).unwrap();
assert_eq!((decoded.width(), decoded.height()), (400, 600));
+ let mut tags = tiff::decoder::Decoder::new(std::io::Cursor::new(&tif)).unwrap();
+ let compression = tags.get_tag_u32(tiff::tags::Tag::Compression).unwrap();
+ let predictor = tags.get_tag_u32(tiff::tags::Tag::Predictor).unwrap();
+ assert_eq!((compression, predictor), (5, 1), "LZW, without a predictor");
+}
+
+/// A TIFF's resolution unit (2 = inch) and its horizontal and vertical resolution.
+fn tiff_resolution(bytes: &[u8]) -> (u32, f64, f64) {
+ use tiff::{decoder::ifd::Value, tags::Tag};
+ let mut d = tiff::decoder::Decoder::new(std::io::Cursor::new(bytes)).unwrap();
+ let mut res = |tag| match d.get_tag(tag).unwrap() {
+ Value::Rational(n, d) => f64::from(n) / f64::from(d),
+ v => panic!("{tag:?} is {v:?}"),
+ };
+ let (x, y) = (res(Tag::XResolution), res(Tag::YResolution));
+ (d.get_tag_u32(Tag::ResolutionUnit).unwrap(), x, y)
+}
+
+/// An exported TIFF or JPEG says the resolution it was drawn at, so other programs show and print it
+/// at the page's size. A TIFF said 1 × 1 with no unit, and a JPEG said nothing.
+#[test]
+fn exported_pages_record_the_resolution_they_were_drawn_at() {
+ use crate::export::{Exporter, ImageFormat};
+ let (s, id) = session_with(1);
+ let mut ex = Exporter::new(s.get(id).unwrap());
+ assert_eq!(tiff_resolution(&ex.image(0, 144.0, ImageFormat::Tiff).unwrap()), (2, 144.0, 144.0));
+ let jpg = ex.image(0, 144.0, ImageFormat::Jpeg { quality: 85 }).unwrap();
+ assert_eq!(&jpg[6..11], b"JFIF\0");
+ assert_eq!(jpg[13..18], [1, 0, 144, 0, 144], "JFIF density in dots per inch");
+ // pHYs counts pixels per metre: 72 dpi would read back as 72.009, and a page made from the PNG
+ // (doc_create from images) would come out 0.01% small.
+ let png = ex.image(0, 72.0, ImageFormat::Png).unwrap();
+ assert!(png::Decoder::new(std::io::Cursor::new(png)).read_info().unwrap().info().pixel_dims.is_none(), "a PNG states no resolution");
+
+ // A page too large for the renderer at the asked resolution says the one it was drawn at.
+ let mut s = Session::new().with_clock(|| 1_700_000_000);
+ let bytes = s.create_blank(14_400.0, 792.0, 1).unwrap();
+ let id = s.open("wide.pdf", None, bytes, None).unwrap();
+ let mut ex = Exporter::new(s.get(id).unwrap());
+ let used = ex.dpi_used(0, 600.0);
+ let (unit, x, y) = tiff_resolution(&ex.image(0, 600.0, ImageFormat::Tiff).unwrap());
+ assert!(unit == 2 && (x - used).abs() < 0.01 && (y - used).abs() < 0.01, "{x} × {y}, drawn at {used}");
}
#[test]
diff --git a/crates/engine/tests/marked_content_isolation.rs b/crates/engine/tests/marked_content_isolation.rs
new file mode 100644
index 0000000000..32a42d517a
--- /dev/null
+++ b/crates/engine/tests/marked_content_isolation.rs
@@ -0,0 +1,274 @@
+//! Contributor-original pages. Added marks must not inherit an unfinished hidden layer.
+use std::sync::Arc;
+
+use pdfcraft_cos::{Dict, Document, Object, SaveOptions, Stream, write_incremental};
+use pdfcraft_engine::{AddedText, Edit, HeaderFooter, Session, Watermark};
+use pdfcraft_render::{PageRenderer, RenderRequest, RequestKind};
+
+const VISIBLE: &str = "BT /F1 18 Tf 72 700 Td (Visible text) Tj ET\n";
+const HIDDEN: &str = "BT /F1 18 Tf 72 600 Td (Hidden note) Tj ET\n";
+
+fn fixture(parts: &[String]) -> Arc> {
+ let mut doc = Document::new_empty();
+ let pages = doc.get(doc.root().unwrap()).as_dict().unwrap().reference(b"Pages").unwrap();
+ let mut font = Dict::new();
+ font.set(b"Type".to_vec(), Object::name("Font"));
+ font.set(b"Subtype".to_vec(), Object::name("Type1"));
+ font.set(b"BaseFont".to_vec(), Object::name("Helvetica"));
+ let mut fonts = Dict::new();
+ fonts.set(b"F1".to_vec(), Object::Ref(doc.add(font)));
+ let mut group = Dict::new();
+ group.set(b"Type".to_vec(), Object::name("OCG"));
+ group.set(b"Name".to_vec(), Object::String(pdfcraft_cos::PdfString::text("Hidden layer")));
+ let group = doc.add(group);
+ let mut properties = Dict::new();
+ properties.set(b"L1".to_vec(), Object::Ref(group));
+ let mut resources = Dict::new();
+ resources.set(b"Font".to_vec(), Object::Dict(fonts));
+ resources.set(b"Properties".to_vec(), Object::Dict(properties));
+ let mut config = Dict::new();
+ config.set(b"OFF".to_vec(), Object::Array(vec![Object::Ref(group)]));
+ let mut oc = Dict::new();
+ oc.set(b"OCGs".to_vec(), Object::Array(vec![Object::Ref(group)]));
+ oc.set(b"D".to_vec(), Object::Dict(config));
+ doc.update_dict(doc.root().unwrap(), |d| d.set(b"OCProperties".to_vec(), Object::Dict(oc))).unwrap();
+ let streams = parts.iter().map(|s| Object::Ref(doc.add(Object::Stream(Stream::from_raw(Dict::new(), s.as_bytes().to_vec()))))).collect();
+ let mut page = Dict::new();
+ page.set(b"Type".to_vec(), Object::name("Page"));
+ page.set(b"Parent".to_vec(), Object::Ref(pages));
+ page.set(b"MediaBox".to_vec(), Object::Array(vec![0.into(), 0.into(), 612.into(), 792.into()]));
+ page.set(b"Resources".to_vec(), Object::Dict(resources));
+ page.set(b"Contents".to_vec(), Object::Array(streams));
+ let page = doc.add(page);
+ doc.update_dict(pages, |d| {
+ d.set(b"Kids".to_vec(), Object::Array(vec![Object::Ref(page)]));
+ d.set(b"Count".to_vec(), Object::Int(1));
+ })
+ .unwrap();
+ Arc::new(write_incremental(&doc, &SaveOptions::default()).unwrap())
+}
+
+fn render(bytes: Arc>) -> (Vec, String) {
+ let mut renderer = PageRenderer::new(bytes, Default::default());
+ let pixels = renderer.render(RenderRequest { page: 0, scale: 1.0, ..Default::default() });
+ assert!(pixels.error.is_none(), "{:?}", pixels.error);
+ let text = renderer.render(RenderRequest { page: 0, kind: RequestKind::Text, ..Default::default() });
+ assert!(text.error.is_none(), "{:?}", text.error);
+ let text = text.text.unwrap();
+ (pixels.rgba.to_vec(), text.text_of(0..text.glyphs.len()))
+}
+
+fn edit(kind: &str) -> Edit {
+ match kind {
+ "HEADER" | "000001" => {
+ let mut settings = HeaderFooter::default();
+ if kind == "HEADER" {
+ settings.text[1] = "HEADER".into();
+ } else {
+ settings.text[5] = "<>".into();
+ }
+ Edit::AddHeaderFooter { pages: vec![0], settings, replace: true }
+ }
+ "WATERMARK" => Edit::AddWatermark {
+ pages: vec![0],
+ settings: Watermark { text: kind.into(), opacity: 1.0, rotation: 0.0, font_size: 18.0, ..Default::default() },
+ replace: true,
+ file: None,
+ },
+ "ADDED" => Edit::AddText { page: 0, text: AddedText { text: kind.into(), rect: [72.0, 300.0, 250.0, 330.0], ..Default::default() } },
+ _ => panic!("unknown mark"),
+ }
+}
+
+fn add_and_save(input: Arc>, kind: &str) -> Arc> {
+ let mut session = Session::new();
+ let id = session.open("fixture.pdf", None, input.clone(), None).unwrap();
+ session.apply(id, edit(kind)).unwrap();
+ let saved = session.save_bytes(id).unwrap();
+ assert!(saved.starts_with(&input), "incremental save preserves the original file bytes");
+ assert_original_streams(&input, &saved);
+ let mut reopened = Session::new();
+ let id = reopened.open("saved.pdf", None, saved.clone(), None).unwrap();
+ assert_eq!(reopened.save_bytes(id).unwrap().as_slice(), saved.as_slice());
+ saved
+}
+
+#[test]
+fn all_four_mark_paths_remain_visible_after_saved_unfinished_marked_content() {
+ let mut failures = Vec::new();
+ for (case, opening, hidden) in [("hidden", "/OC /L1 BDC\n", true), ("artifact", "/Artifact BMC\n", false), ("balanced", "/OC /L1 BDC\n", true)] {
+ let closed = fixture(&[format!("{VISIBLE}{opening}{HIDDEN}EMC\n")]);
+ let input = if case == "balanced" { closed.clone() } else { fixture(&[format!("{VISIBLE}{opening}{HIDDEN}")]) };
+ let original = render(input.clone());
+ assert!(original.1.contains("Visible text"));
+ assert_eq!(original.1.contains("Hidden note"), !hidden);
+ for kind in ["HEADER", "000001", "WATERMARK", "ADDED"] {
+ let saved = add_and_save(input.clone(), kind);
+
+ let actual = render(saved);
+ let expected_bytes = add_and_save(closed.clone(), kind);
+
+ let expected = render(expected_bytes);
+ let mut wanted = vec!["Visible text", kind];
+ if !hidden {
+ wanted.push("Hidden note");
+ }
+ wanted.sort_unstable();
+ let pass = actual.0 == expected.0 && actual.1 == expected.1 && lines(&actual.1) == wanted;
+ eprintln!("{case}/{kind}: visible={} closed-control-pixels={}", actual.1.contains(kind), actual.0 == expected.0);
+ if !pass {
+ failures.push(format!("{case}/{kind}: {:?}", actual.1));
+ }
+ }
+ }
+ assert!(failures.is_empty(), "{}", failures.join("\n"));
+}
+
+fn stream_list(doc: &Document) -> Vec {
+ let page = pdfcraft_model::pages(doc).remove(0);
+ let contents = doc.resolve(page.dict.get(b"Contents").unwrap());
+ match &*contents {
+ Object::Array(items) => items.clone(),
+ _ => vec![page.dict.get(b"Contents").unwrap().clone()],
+ }
+}
+
+fn assert_original_streams(before: &Arc>, after: &Arc>) {
+ let original = Document::open(before.clone()).unwrap();
+ let saved = Document::open(after.clone()).unwrap();
+ let saved_list = stream_list(&saved);
+ for reference in stream_list(&original) {
+ assert!(saved_list.contains(&reference), "the original stream stays on the page");
+ assert_eq!(original.resolve(&reference), saved.resolve(&reference), "original encoded stream object remains unchanged");
+ }
+}
+
+fn lines(text: &str) -> Vec<&str> {
+ let mut lines: Vec<_> = text.lines().filter(|line| !line.is_empty()).collect();
+ lines.sort_unstable();
+ lines
+}
+
+#[test]
+fn nested_stream_edges_and_malformed_graphics_keep_hidden_content_hidden() {
+ let cases: Vec<(&str, Vec, usize)> = vec![
+ ("nested", vec![format!("{VISIBLE}/OC /L1 BDC /Artifact BMC {HIDDEN}")], 2),
+ ("split operands", vec![format!("{VISIBLE}/OC"), format!("/L1 BDC {HIDDEN}")], 1),
+ ("split close", vec![format!("{VISIBLE}/OC /L1 BDC {HIDDEN}"), "EMC\n".into()], 0),
+ ("trailing comment", vec![format!("{VISIBLE}/OC /L1 BDC {HIDDEN}% EMC")], 1),
+ ("cross-stream comment", vec![format!("{VISIBLE}/OC /L1 BDC {HIDDEN}% EMC"), "EMC BMC\n".into()], 1),
+ ("unclosed graphics", vec![format!("{VISIBLE}/OC /L1 BDC {HIDDEN}q")], 1),
+ ("balanced graphics open marked", vec![format!("q {VISIBLE}/OC /L1 BDC {HIDDEN}Q")], 1),
+ ("malformed graphics", vec![format!("Q {VISIBLE}/OC /L1 BDC {HIDDEN}")], 1),
+ ("invalid begins", vec![format!("{VISIBLE}/OC /L1 BDC {HIDDEN}123 BMC /Artifact BDC")], 1),
+ ("extra begins", vec![format!("{VISIBLE}123 /OC /L1 BDC {HIDDEN}123 /Artifact BMC")], 2),
+ ("extra close", vec![format!("{VISIBLE}/OC /L1 BDC {HIDDEN}123 EMC")], 0),
+ ("underflow", vec![format!("EMC {VISIBLE}/OC /L1 BDC {HIDDEN}")], 1),
+ ("operator strings", vec![format!("{VISIBLE}/OC /L1 BDC {HIDDEN}/Note <> DP")], 1),
+ ("inline samples", vec![format!("{VISIBLE}/OC /L1 BDC {HIDDEN}BI /W 11 /H 1 /BPC 8 /CS /G ID BMC BDC EMC EI\n")], 1),
+ ];
+ for (case, parts, close_count) in cases {
+ let input = fixture(&parts);
+ let mut closed = parts.clone();
+ closed.push(format!("\n{}", "EMC\n".repeat(close_count)));
+ let actual = render(add_and_save(input, "HEADER"));
+ let expected = render(add_and_save(fixture(&closed), "HEADER"));
+ assert_eq!(lines(&actual.1), lines("Visible text\nHEADER"), "{case}");
+ assert!(actual.0 == expected.0, "{case}: pixels equal the explicitly closed control");
+ }
+}
+
+#[test]
+fn replacing_and_removing_marks_restores_the_original_stream_list() {
+ let input = fixture(&[format!("{VISIBLE}/OC /L1 BDC {HIDDEN}")]);
+ let original = Document::open(input.clone()).unwrap();
+ for kind in ["HEADER", "000001", "WATERMARK"] {
+ let mut session = Session::new();
+ let mut id = session.open("fixture.pdf", None, input.clone(), None).unwrap();
+ for _ in 0..3 {
+ session.apply(id, edit(kind)).unwrap();
+ let saved = session.save_bytes(id).unwrap();
+ let page = Document::open(saved.clone()).unwrap();
+ assert_eq!(stream_list(&page).len(), 4, "{kind}: updating does not add wrappers");
+ assert_original_streams(&input, &saved);
+ assert_eq!(lines(&render(saved.clone()).1), lines(&format!("Visible text\n{kind}")));
+ session = Session::new();
+ id = session.open("saved.pdf", None, saved, None).unwrap();
+ }
+ let mark_kind = if kind == "WATERMARK" { pdfcraft_edit::MarkKind::Watermark } else { pdfcraft_edit::MarkKind::HeaderFooter };
+ session.apply(id, Edit::RemoveMarks { kind: mark_kind }).unwrap();
+ let saved = session.save_bytes(id).unwrap();
+ let page = Document::open(saved.clone()).unwrap();
+ assert_eq!(stream_list(&page), stream_list(&original), "{kind}: removal restores original references");
+ assert_original_streams(&input, &saved);
+ assert!(render(saved) == render(input.clone()), "{kind}: removal restores original pixels and text");
+ session.undo(id).unwrap();
+ assert_eq!(lines(&render(session.save_bytes(id).unwrap()).1), lines(&format!("Visible text\n{kind}")));
+ session.redo(id).unwrap();
+ assert!(render(session.save_bytes(id).unwrap()) == render(input.clone()), "redo removal restores the original rendering");
+ }
+}
+
+#[test]
+fn added_update_delete_and_readd_preserve_existing_wrapper_behavior() {
+ let input = fixture(&[format!("{VISIBLE}/OC /L1 BDC {HIDDEN}")]);
+ let mut session = Session::new();
+ let mut id = session.open("fixture.pdf", None, input.clone(), None).unwrap();
+ for _ in 0..3 {
+ session.apply(id, edit("ADDED")).unwrap();
+ assert_eq!(session.get(id).unwrap().added().len(), 1);
+ let text = AddedText { text: "UPDATED".into(), rect: [72.0, 300.0, 250.0, 330.0], ..Default::default() };
+ session.apply(id, Edit::UpdateContent { page: 0, index: 0, content: pdfcraft_edit::Content::Text(text) }).unwrap();
+ let saved = session.save_bytes(id).unwrap();
+ assert_eq!(lines(&render(saved.clone()).1), lines("Visible text\nUPDATED"));
+ assert_eq!(stream_list(&Document::open(saved.clone()).unwrap()).len(), 4);
+ assert_original_streams(&input, &saved);
+ session = Session::new();
+ id = session.open("saved.pdf", None, saved, None).unwrap();
+ session.apply(id, Edit::DeleteContent { page: 0, index: 0 }).unwrap();
+ let saved = session.save_bytes(id).unwrap();
+ assert_eq!(stream_list(&Document::open(saved.clone()).unwrap()).len(), 3, "Added deletion retains the established wrappers");
+ assert!(render(saved.clone()) == render(input.clone()), "deleting Added restores the original rendering");
+ assert_original_streams(&input, &saved);
+ session = Session::new();
+ id = session.open("saved.pdf", None, saved, None).unwrap();
+ }
+}
+
+#[test]
+fn an_existing_behind_watermark_does_not_inherit_or_reveal_hidden_content() {
+ let input = fixture(&[format!("{VISIBLE}/OC /L1 BDC {HIDDEN}")]);
+ let mut session = Session::new();
+ let id = session.open("fixture.pdf", None, input.clone(), None).unwrap();
+ session
+ .apply(
+ id,
+ Edit::AddWatermark {
+ pages: vec![0],
+ settings: Watermark { text: "BEHIND".into(), behind: true, font_size: 18.0, opacity: 1.0, rotation: 0.0, ..Default::default() },
+ replace: false,
+ file: None,
+ },
+ )
+ .unwrap();
+ session.apply(id, edit("HEADER")).unwrap();
+ let saved = session.save_bytes(id).unwrap();
+ assert_eq!(lines(&render(saved.clone()).1), lines("Visible text\nBEHIND\nHEADER"));
+ assert_original_streams(&input, &saved);
+}
+
+#[test]
+fn new_mark_closes_hidden_content_appended_after_an_old_wrapper() {
+ let bytes = fixture(&["q %PdfCraft\n".into(), VISIBLE.into(), "Q %PdfCraft\n".into(), format!("/OC /L1 BDC {HIDDEN}")]);
+ let mut doc = Document::open(bytes).unwrap();
+ let list = stream_list(&doc);
+ for index in [0, 2] {
+ let reference = list[index].as_ref().unwrap();
+ let Object::Stream(mut stream) = doc.get(reference).as_ref().clone() else { panic!("stream") };
+ stream.dict.set(b"PCMark".to_vec(), Object::name("Wrap"));
+ doc.set(reference, Object::Stream(stream));
+ }
+ let input = Arc::new(write_incremental(&doc, &SaveOptions::default()).unwrap());
+ let saved = add_and_save(input, "HEADER");
+ assert_eq!(lines(&render(saved).1), lines("Visible text\nHEADER"));
+}
diff --git a/crates/forms/README.md b/crates/forms/README.md
index 3280cc2431..66fb919fe7 100644
--- a/crates/forms/README.md
+++ b/crates/forms/README.md
@@ -56,6 +56,13 @@ delete_field(&mut doc, "email")?;
Button1, Date1, Signature1; `/Helv` is added to `/DR`; appearances are drawn at once. Radio
buttons joining a group become kid widgets with their own export state. Renaming changes only
the last part of a dotted name.
+- `Widget::caption` reads a push button's normal label (`/MK /CA`, including indirect values).
+ `FieldProps { caption: Some((widget_index, text)), ..Default::default() }` changes or clears
+ that label, independently of the field name. The index is 0-based. A label-only edit redraws
+ just that widget, copying shared/indirect `/MK` and appearance dictionaries before editing.
+ Other widgets, actions, icons, rotation and unknown entries are preserved. Existing icon-only
+ layouts keep hiding the caption; caption rendering retains the existing Helvetica/WinAnsi
+ limits and reports unavailable characters as above.
## Not yet
diff --git a/crates/forms/src/af.rs b/crates/forms/src/af.rs
index 129f7fa751..c5b81719ac 100644
--- a/crates/forms/src/af.rs
+++ b/crates/forms/src/af.rs
@@ -9,6 +9,7 @@
//! writes them back when a form is authored. Other scripts are kept untouched and reported as
//! unsupported; they need the JavaScript engine (M6.4).
+use std::collections::VecDeque;
use std::fmt::Write as _;
/// The Format tab.
@@ -545,24 +546,55 @@ fn weekday(y: i32, m: u32, d: u32) -> usize {
(y + y / 4 - y / 100 + y / 400 + t + i64::from(d)).rem_euclid(7) as usize
}
-/// The order of the year/month/day fields in a format, as letters. A run of literal digits
-/// (the fixed year in "dd.mm.26") is `'#'` with its value; the others carry 0.
-fn order(fmt: &str) -> Vec<(char, u32)> {
- let mut out: Vec<(char, u32)> = Vec::new();
+/// One field of a date format, in the order the format has them.
+struct Part {
+ /// `'y'`, `'m'`, `'d'`, `'H'`, `'M'` or `'s'`; a run of literal digits (the fixed year in
+ /// "dd.mm.26") is `'#'`.
+ kind: char,
+ /// The value of a `'#'` run; 0 for the others.
+ literal: u32,
+ /// The digits the field takes when typed run together with the next one: 4 for "yyyy", 2 for
+ /// "yy", "mm", "dd", "HH", "MM" and "ss". 0 when the width varies ("m", "d") or the field is
+ /// a name ("mmm").
+ width: usize,
+ /// The next field follows with nothing between them ("yyyymmdd"), so one run of typed digits
+ /// can hold both.
+ joined: bool,
+}
+
+/// The fields of a format (year, month, day, hours, minutes, seconds and literal digits), in
+/// order.
+fn order(fmt: &str) -> Vec {
+ let mut out: Vec = Vec::new();
// Weekday names (ddd, dddd) are shown, not typed: leave them out.
let mut cleaned = fmt.replace("dddd", " ");
cleaned = cleaned.replace("ddd", " ");
let mut prev = ' ';
+ // The letter of the last field's current run and its length so far.
+ let mut run: Option<(char, usize)> = None;
+ let fixed = |k: char, n: usize| match (k, n) {
+ ('y', 2 | 4) | ('m' | 'd' | 'H' | 'M' | 's', 2) => n,
+ _ => 0,
+ };
for c in cleaned.chars() {
+ let continues = run.is_some_and(|(r, _)| r == c);
+ if let Some((r, n)) = run
+ && !continues
+ {
+ // The run ended; it was the last field's width.
+ if let Some(last) = out.last_mut().filter(|p| p.kind != '#') {
+ last.width = fixed(if r == 'h' { 'H' } else { r }, n);
+ }
+ run = None;
+ }
if let Some(digit) = c.to_digit(10) {
match out.last_mut() {
- Some(('#', n)) if prev.is_ascii_digit() => *n = n.saturating_mul(10).saturating_add(digit),
- _ => out.push(('#', digit)),
+ Some(p) if p.kind == '#' && prev.is_ascii_digit() => p.literal = p.literal.saturating_mul(10).saturating_add(digit),
+ _ => out.push(Part { kind: '#', literal: digit, width: 0, joined: false }),
}
prev = c;
continue;
}
- prev = c;
let k = match c {
'y' => 'y',
'm' => 'm',
@@ -570,11 +602,28 @@ fn order(fmt: &str) -> Vec<(char, u32)> {
'H' | 'h' => 'H',
'M' => 'M',
's' => 's',
- _ => continue,
+ _ => {
+ prev = c;
+ continue;
+ }
};
- if out.last().map(|&(l, _)| l) != Some(k) {
- out.push((k, 0));
+ if continues {
+ run = run.map(|(r, n)| (r, n.saturating_add(1)));
+ } else {
+ // A field starting right where the last one's letters stopped is joined to it.
+ let adjacent = matches!(prev, 'y' | 'm' | 'd' | 'H' | 'h' | 'M' | 's');
+ if out.last().map(|p| p.kind) != Some(k) {
+ if let Some(last) = out.last_mut().filter(|p| p.kind != '#') {
+ last.joined = adjacent;
+ }
+ out.push(Part { kind: k, literal: 0, width: 0, joined: false });
+ }
+ run = Some((c, 1));
}
+ prev = c;
+ }
+ if let (Some((r, n)), Some(last)) = (run, out.last_mut().filter(|p| p.kind != '#')) {
+ last.width = fixed(if r == 'h' { 'H' } else { r }, n);
}
out
}
@@ -591,16 +640,17 @@ pub fn parse_date(text: &str, fmt: &str) -> Option {
let bare_p = lower.strip_suffix('p').is_some_and(|rest| rest.trim_end().ends_with(|c: char| c.is_ascii_digit()));
let pm = lower.contains("pm") || bare_p;
let am = lower.contains("am");
- // Tokens: numbers and month names.
- let mut nums: Vec = Vec::new();
+ // Tokens: runs of digits (kept as text, so a run can be split between fields typed without
+ // separators) and month names.
+ let mut nums: VecDeque = VecDeque::new();
let mut month_name: Option = None;
let mut cur = String::new();
- let flush = |cur: &mut String, nums: &mut Vec, month: &mut Option| {
+ let flush = |cur: &mut String, nums: &mut VecDeque, month: &mut Option| {
if cur.is_empty() {
return;
}
- if let Ok(n) = cur.parse::() {
- nums.push(n);
+ if cur.starts_with(|c: char| c.is_ascii_digit()) {
+ nums.push_back(std::mem::take(cur));
} else if cur.len() >= 3
&& let Some(i) = MONTHS.iter().position(|m| m.to_lowercase().starts_with(&cur.to_lowercase()))
{
@@ -622,34 +672,76 @@ pub fn parse_date(text: &str, fmt: &str) -> Option {
}
flush(&mut cur, &mut nums, &mut month_name);
let mut dt = DateTime { y: 0, m: 1, d: 1, hh: 0, mm: 0, ss: 0 };
- let mut it = nums.into_iter().peekable();
+ // The next number for a field. A field joined to the next one in the format ("yyyymmdd")
+ // takes only its own width from a longer run of digits and leaves the rest for the next.
+ // A run typed on its own is split only when fewer runs were typed than the fields of its kind
+ // still to fill (`need`, this one included: date fields for a date field, time fields for a
+ // time field, since times may be left out), and only when its rest can fill the next field
+ // (`next_width`): with separators each run is one field's number, so "2026-10-18" keeps its
+ // four-digit year in a yymmdd or yymmddHHMM field, and "2026 10 018" is the 18th. What is left
+ // of a split run (`tail`: the front of `nums` is one) goes on being split along the fields
+ // written side by side, and must fill its last field exactly, so "202610018" leaves "018" for
+ // dd and is refused. `None` when there is no number left, it doesn't fit a u32, or such a
+ // rest has the wrong width.
+ let next_number = |nums: &mut VecDeque, tail: &mut bool, part: &Part, need: usize, next_width: usize| -> Option {
+ let run = nums.pop_front()?;
+ let was_tail = std::mem::replace(tail, false);
+ let short = nums.len() < need.saturating_sub(1);
+ let digits = match (run.get(..part.width), run.get(part.width..)) {
+ (Some(head), Some(rest)) if part.joined && part.width > 0 && !rest.is_empty() && (was_tail || (short && rest.len() >= next_width)) => {
+ nums.push_front(rest.to_string());
+ *tail = true;
+ head
+ }
+ _ if was_tail && part.width > 0 && run.len() != part.width => return None,
+ _ => run.as_str(),
+ };
+ digits.parse::().ok()
+ };
+ let parts = order(fmt);
+ let takes_number = |p: &Part| matches!(p.kind, 'y' | 'd' | 'H' | 'M' | 's') || (p.kind == 'm' && month_name.is_none());
+ let is_time = |p: &Part| matches!(p.kind, 'H' | 'M' | 's');
+ // The fields of each kind still to fill: [date, time].
+ let mut wanted = [parts.iter().filter(|p| takes_number(p) && !is_time(p)).count(), parts.iter().filter(|p| is_time(p)).count()];
+ let mut tail = false;
let mut have_y = false;
- for (k, literal) in order(fmt) {
- match k {
+ for (i, part) in parts.iter().enumerate() {
+ let kind = usize::from(is_time(part));
+ let need = wanted.get(kind).copied().unwrap_or(0);
+ let next_width = parts.get(i + 1).map_or(0, |p| p.width);
+ if takes_number(part)
+ && let Some(left) = wanted.get_mut(kind)
+ {
+ *left = left.saturating_sub(1);
+ }
+ let take = |nums: &mut VecDeque, tail: &mut bool| next_number(nums, tail, part, need, next_width);
+ match part.kind {
// Literal digits in the format may be typed or left out ("15.05.26" or "15.05"
// for "dd.mm.26"); a different number there is left over and rejected below.
- '#' if it.peek() == Some(&literal) => {
- it.next();
+ '#' if nums.front().and_then(|n| n.parse::().ok()) == Some(part.literal) => {
+ nums.pop_front();
+ tail = false;
}
'm' => {
dt.m = match month_name {
Some(m) => m,
- None => it.next()?,
+ None => take(&mut nums, &mut tail)?,
}
}
- 'd' => dt.d = it.next()?,
+ 'd' => dt.d = take(&mut nums, &mut tail)?,
'y' => {
- let y = i32::try_from(it.next()?).ok()?;
+ let y = i32::try_from(take(&mut nums, &mut tail)?).ok()?;
dt.y = if y < 100 { if y < 50 { 2000 + y } else { 1900 + y } } else { y };
have_y = true;
}
- 'H' => dt.hh = it.next().unwrap_or(0),
- 'M' => dt.mm = it.next().unwrap_or(0),
- 's' => dt.ss = it.next().unwrap_or(0),
+ // Times may be left out; a number that is there must be readable.
+ 'H' => dt.hh = if nums.is_empty() { 0 } else { take(&mut nums, &mut tail)? },
+ 'M' => dt.mm = if nums.is_empty() { 0 } else { take(&mut nums, &mut tail)? },
+ 's' => dt.ss = if nums.is_empty() { 0 } else { take(&mut nums, &mut tail)? },
_ => {}
}
}
- if it.next().is_some() {
+ if !nums.is_empty() {
return None;
}
if !have_y {
@@ -1309,3 +1401,96 @@ mod pm_marker_tests {
assert_eq!(parse_date("05/09/2026 5:30 pm", "mm/dd/yyyy h:MM").map(|d| d.hh), Some(17));
}
}
+
+#[cfg(test)]
+mod undelimited_tests {
+ use super::*;
+
+ fn ymd(dt: Option) -> Option<(i32, u32, u32)> {
+ dt.map(|d| (d.y, d.m, d.d))
+ }
+
+ /// Fields written side by side in the format ("yyyymmdd") take their fixed widths from one
+ /// run of typed digits. Every such date used to be refused, because the run was read as a
+ /// single number.
+ #[test]
+ fn digits_typed_without_separators_fill_adjacent_fields() {
+ assert_eq!(ymd(parse_date("20261018", "yyyymmdd")), Some((2026, 10, 18)));
+ assert_eq!(ymd(parse_date("261018", "yymmdd")), Some((2026, 10, 18)));
+ assert_eq!(ymd(parse_date("10182026", "mmddyyyy")), Some((2026, 10, 18)));
+ assert_eq!(ymd(parse_date("18102026", "ddmmyyyy")), Some((2026, 10, 18)));
+ let f = Format::Date("yyyymmdd".into());
+ assert_eq!(keystroke(&f, "Date", "20261018"), Ok("20261018".into()));
+ assert_eq!(format_value(&f, "20261018"), "20261018");
+ }
+
+ #[test]
+ fn separators_are_still_accepted_in_an_undelimited_format() {
+ assert_eq!(ymd(parse_date("2026-10-18", "yyyymmdd")), Some((2026, 10, 18)));
+ assert_eq!(ymd(parse_date("2026 1018", "yyyymmdd")), Some((2026, 10, 18)));
+ }
+
+ /// Separators mean one number per field, as before: a four-digit year typed with them stays
+ /// whole in a two-digit-year format. Only a run covering several fields is split.
+ #[test]
+ fn a_run_is_split_only_when_it_has_to_fill_several_fields() {
+ assert_eq!(ymd(parse_date("2026-10-18", "yymmdd")), Some((2026, 10, 18)));
+ assert_eq!(ymd(parse_date("2026-10", "yymm")), Some((2026, 10, 1)));
+ assert_eq!(ymd(parse_date("2610", "yymm")), Some((2026, 10, 1)));
+ assert_eq!(ymd(parse_date("202610 18", "yyyymmdd")), Some((2026, 10, 18)));
+ assert_eq!(ymd(parse_date("18Oct2026", "ddmmmyyyy")), Some((2026, 10, 18)));
+ }
+
+ #[test]
+ fn impossible_undelimited_dates_are_refused() {
+ let f = Format::Date("yyyymmdd".into());
+ assert!(keystroke(&f, "Date", "20261340").is_err(), "no month 13");
+ assert!(keystroke(&f, "Date", "20260230").is_err(), "no 30 February");
+ assert!(keystroke(&f, "Date", "2026101899").is_err(), "digits left over make no day");
+ assert!(keystroke(&f, "Date", "2026").is_err(), "a year alone is not a date");
+ }
+
+ /// What is left of a split run fills its field exactly: "202610018" leaves "018" for the
+ /// day, one digit too many, and used to pass as 18 October. Numbers typed on their own
+ /// keep their leeway.
+ #[test]
+ fn the_rest_of_a_split_run_must_fill_its_field_exactly() {
+ assert_eq!(parse_date("202610018", "yyyymmdd"), None, "one digit too many");
+ assert_eq!(parse_date("2026101", "yyyymmdd"), None, "one digit too few");
+ assert_eq!(parse_date("2026101814305", "yyyymmddHHMM"), None, "one digit too many in the time");
+ assert_eq!(ymd(parse_date("2026 10 018", "yyyymmdd")), Some((2026, 10, 18)), "typed on its own, 018 is still a day");
+ assert_eq!(parse_date("202610181430", "yyyymmddHHMM").map(|d| (d.d, d.hh, d.mm)), Some((18, 14, 30)));
+ }
+
+ /// Times may be left out, so a number typed on its own isn't split for them: "018" is the
+ /// day in "2026 10 018" even when the format goes on to HHMM.
+ #[test]
+ fn a_number_typed_on_its_own_is_split_only_when_the_rest_fills_the_next_field() {
+ let dt = parse_date("2026 10 018", "yyyymmddHHMM").expect("a date with the time left out");
+ assert_eq!((dt.y, dt.m, dt.d, dt.hh, dt.mm), (2026, 10, 18, 0, 0));
+ assert_eq!(ymd(parse_date("20261018", "yyyymmddHHMM")), Some((2026, 10, 18)));
+ assert_eq!(parse_date("2026 10 18 1430", "yyyymmdd HHMM").map(|d| (d.hh, d.mm)), Some((14, 30)));
+ }
+
+ /// The date's runs are matched against the date's fields, the time's against the time's:
+ /// optional times left out don't make a separated four-digit year look like it needs
+ /// splitting, and a time typed in one run still fills HHMM.
+ #[test]
+ fn date_and_time_fields_are_counted_apart() {
+ let dt = parse_date("2026-10-18", "yymmddHHMM").expect("a date with the time left out");
+ assert_eq!((dt.y, dt.m, dt.d, dt.hh, dt.mm), (2026, 10, 18, 0, 0));
+ assert_eq!(parse_date("20261018 1430", "yyyymmddHHMM").map(|d| (d.y, d.m, d.d, d.hh, d.mm)), Some((2026, 10, 18, 14, 30)));
+ assert_eq!(parse_date("2026-10-18 1430", "yyyy-mm-dd HHMM").map(|d| (d.hh, d.mm)), Some((14, 30)));
+ assert_eq!(parse_date("20261018143005", "yyyymmddHHMMss").map(|d| (d.d, d.hh, d.mm, d.ss)), Some((18, 14, 30, 5)));
+ }
+
+ /// A run of more digits than fit in a u32 ("yyyymmddHHMMss") is split, not dropped.
+ #[test]
+ fn long_runs_of_digits_split_into_dates_and_times() {
+ let dt = parse_date("20261018143005", "yyyymmddHHMMss").expect("valid date and time");
+ assert_eq!((dt.y, dt.m, dt.d, dt.hh, dt.mm, dt.ss), (2026, 10, 18, 14, 30, 5));
+ assert_eq!(parse_date("1430", "HHMM").map(|d| (d.hh, d.mm)), Some((14, 30)));
+ assert_eq!(parse_date("99999999999999999999", "yyyymmdd"), None);
+ assert_eq!(parse_date("2026 99999999999", "yyyy"), None, "an unreadable number is left over, not ignored");
+ }
+}
diff --git a/crates/forms/src/author.rs b/crates/forms/src/author.rs
index bb45f38d9f..521d1f5278 100644
--- a/crates/forms/src/author.rs
+++ b/crates/forms/src/author.rs
@@ -63,6 +63,9 @@ impl NewField {
pub struct FieldProps {
pub name: Option,
pub tooltip: Option,
+ /// Normal push-button label (`/MK /CA`) of one widget (0-based index).
+ /// An empty string clears the label; other widgets and icon layouts are preserved.
+ pub caption: Option<(usize, String)>,
pub read_only: Option,
pub required: Option,
pub multiline: Option,
@@ -657,6 +660,7 @@ pub fn add_field(doc: &mut Document, page: usize, rect: [f64; 4], kind: &NewFiel
locked: false,
hidden: false,
rotation: 0,
+ caption: None,
list: None,
};
let ap = appearance::check_box_states(doc, &w, FieldKind::Radio, export);
@@ -677,9 +681,16 @@ pub fn add_field(doc: &mut Document, page: usize, rect: [f64; 4], kind: &NewFiel
/// (`pdfcraft_fonts::UNDRAWN_MARK`) and other viewers are asked to redraw the form (see
/// `ask_viewers_to_redraw`), as when a value is set.
pub fn redraw_field(doc: &mut Document, name: &str) -> Result<(), FormError> {
+ redraw_widgets(doc, name, None)
+}
+
+fn redraw_widgets(doc: &mut Document, name: &str, only: Option) -> Result<(), FormError> {
let f = fields(doc).into_iter().find(|f| f.name == name).ok_or_else(|| FormError::NoSuchField(name.into()))?;
let mut undrawn = false;
- for w in &f.widgets {
+ for (index, w) in f.widgets.iter().enumerate() {
+ if only.is_some_and(|only| only != index) {
+ continue;
+ }
let mut missing = None;
let ap = match f.kind {
FieldKind::Text | FieldKind::Combo | FieldKind::List => {
@@ -861,6 +872,15 @@ pub fn set_props(doc: &mut Document, name: &str, props: &FieldProps) -> Result> = None;
@@ -1178,7 +1198,18 @@ pub fn set_props(doc: &mut Document, name: &str, props: &FieldProps) -> Result,
/// List boxes: where the options are drawn, so a click can pick the one under the pointer.
pub list: Option,
}
@@ -802,6 +804,13 @@ fn walk(
locked: annot_flags & 128 != 0,
hidden: annot_flags & (2 | 32) != 0,
rotation: appearance::mk_rotation(doc, wd),
+ caption: (kind == FieldKind::PushButton)
+ .then(|| {
+ let mk = doc.resolve(wd.get(b"MK")?);
+ let value = doc.resolve(mk.as_dict()?.get(b"CA")?);
+ value.as_string().map(|s| s.to_text())
+ })
+ .flatten(),
list: (kind == FieldKind::List).then(|| appearance::list_rows_of(doc, inh.da.as_deref().unwrap_or("/Helv 0 Tf 0 g"), Some(d), wd)),
})
})
diff --git a/crates/forms/src/tests.rs b/crates/forms/src/tests.rs
index 60066e4739..fef5bcfbd8 100644
--- a/crates/forms/src/tests.rs
+++ b/crates/forms/src/tests.rs
@@ -87,6 +87,108 @@ fn ap(doc: &Document, w: &Widget) -> String {
String::from_utf8_lossy(&s.decoded().unwrap()).into_owned()
}
+#[test]
+fn button_caption_preserves_other_widgets_and_shared_dictionaries() {
+ for indirect_caption in [false, true] {
+ let mut doc = one_page();
+ let a = add_field(&mut doc, 0, [40.0, 600.0, 200.0, 630.0], &NewField::Button { caption: "Original".into() }, Some("first")).unwrap();
+ let b = add_field(&mut doc, 0, [40.0, 500.0, 200.0, 530.0], &NewField::Button { caption: "Original".into() }, Some("second")).unwrap();
+ let all = fields(&doc);
+ let (first, second) = (field(&all, &a).obj, field(&all, &b).obj);
+ let mut parent = Dict::new();
+ parent.set(b"FT".to_vec(), Object::name("Btn"));
+ parent.set(b"Ff".to_vec(), Object::Int(flags::PUSH_BUTTON as i64));
+ parent.set(b"T".to_vec(), PdfString::text("clear_button"));
+ parent.set(b"Kids".to_vec(), Object::Array(vec![Object::Ref(first), Object::Ref(second)]));
+ let mut action = Dict::new();
+ action.set(b"S".to_vec(), Object::name("ResetForm"));
+ parent.set(b"A".to_vec(), Object::Dict(action.clone()));
+ let parent = doc.add(parent);
+ let mut mk = Dict::new();
+ let ca = Object::String(PdfString::text("Original"));
+ let ca = if indirect_caption { Object::Ref(doc.add(ca)) } else { ca };
+ mk.set(b"CA".to_vec(), ca);
+ mk.set(b"TP".to_vec(), Object::Int(0));
+ mk.set(b"R".to_vec(), Object::Int(90));
+ mk.set(b"VendorNote".to_vec(), PdfString::text("kept"));
+ let mk = doc.add(mk);
+ let mut shared_ap = doc.get(first).as_dict().unwrap().get(b"AP").unwrap().as_dict().unwrap().clone();
+ let normal = shared_ap.get(b"N").unwrap().clone();
+ shared_ap.set(b"R".to_vec(), normal.clone());
+ shared_ap.set(b"D".to_vec(), normal);
+ shared_ap.set(b"VendorAP".to_vec(), PdfString::text("kept"));
+ let shared_ap = doc.add(shared_ap);
+ for widget in [first, second] {
+ doc.update_dict(widget, |d| {
+ for key in [b"FT".as_slice(), b"Ff", b"T"] {
+ d.remove(key);
+ }
+ d.set(b"Parent".to_vec(), Object::Ref(parent));
+ d.set(b"MK".to_vec(), Object::Ref(mk));
+ d.set(b"AP".to_vec(), Object::Ref(shared_ap));
+ })
+ .unwrap();
+ }
+ let af = doc.get(doc.root().unwrap()).as_dict().unwrap().reference(b"AcroForm").unwrap();
+ doc.update_dict(af, |d| d.set(b"Fields".to_vec(), Object::Array(vec![Object::Ref(parent)]))).unwrap();
+ let original = pdfcraft_cos::write_full(&doc, &SaveOptions::default()).unwrap();
+ let original = Document::open(Arc::new(original)).unwrap();
+ // Full saves can renumber objects: reacquire references from the reopened model.
+ let f = field(&fields(&original), "clear_button").clone();
+ let first = f.widgets[0].obj;
+ let mk = original.get(first).as_dict().unwrap().reference(b"MK").unwrap();
+ for full in [false, true] {
+ let mut doc = original.clone();
+ let untouched = doc.get(first).clone();
+ let shared_mk = doc.get(mk).clone();
+ assert_eq!(field(&fields(&doc), "clear_button").widgets[1].caption.as_deref(), Some("Original"));
+ set_props(&mut doc, "clear_button", &FieldProps { caption: Some((1, "Clear Form".into())), ..Default::default() }).unwrap();
+ assert_eq!(doc.get(first), untouched, "the other widget and all its appearances are untouched");
+ assert_eq!(doc.get(mk), shared_mk, "shared MK is not patched in place");
+ let bytes = if full {
+ pdfcraft_cos::write_full(&doc, &SaveOptions::default()).unwrap()
+ } else {
+ let bytes = write_incremental(&doc, &SaveOptions::default()).unwrap();
+ assert!(bytes.starts_with(original.bytes().as_slice()));
+ assert!(bytes.len() > original.bytes().len());
+ bytes
+ };
+ let doc = Document::open(Arc::new(bytes)).unwrap();
+ let f = field(&fields(&doc), "clear_button").clone();
+ assert_eq!(f.widgets.iter().map(|w| w.caption.as_deref()).collect::>(), [Some("Original"), Some("Clear Form")]);
+ assert_eq!(f.button, Some(af::ButtonAction::Reset { fields: Vec::new(), exclude: false }));
+ assert_eq!(doc.get(f.obj).as_dict().unwrap().get(b"A"), Some(&Object::Dict(action.clone())));
+ let w = doc.get(f.widgets[1].obj);
+ let mk = w.as_dict().unwrap().get(b"MK").unwrap().as_dict().unwrap();
+ assert_eq!(mk.get(b"VendorNote").unwrap().as_string().unwrap().to_text(), "kept");
+ assert_eq!(mk.get(b"R").unwrap().as_int(), Some(90));
+ assert_eq!(mk.get(b"TP").unwrap().as_int(), Some(0));
+ let apd = w.as_dict().unwrap().get(b"AP").unwrap().as_dict().unwrap();
+ assert!(apd.contains(b"VendorAP"));
+ assert!(!apd.contains(b"R") && !apd.contains(b"D"), "stale hover/pressed text must not survive");
+ assert!(ap(&doc, &f.widgets[1]).contains("(Clear Form) Tj"));
+ }
+ }
+}
+
+#[test]
+fn button_caption_rejects_wrong_type_and_index_before_mutation() {
+ let mut doc = fixture();
+ for (name, wi) in [("name", 0), ("agree", 0), ("go", usize::MAX)] {
+ let before = pdfcraft_cos::write_full(&doc, &SaveOptions::default()).unwrap();
+ let props = FieldProps { caption: Some((wi, "Clear Form".into())), name: Some("renamed".into()), ..Default::default() };
+ assert!(set_props(&mut doc, name, &props).is_err());
+ assert_eq!(pdfcraft_cos::write_full(&doc, &SaveOptions::default()).unwrap(), before);
+ }
+ set_props(&mut doc, "go", &FieldProps { caption: Some((0, "提交".into())), ..Default::default() }).unwrap();
+ assert_eq!(field(&fields(&doc), "go").widgets[0].caption.as_deref(), Some("提交"));
+ assert_eq!(undrawn_fields(&doc), [("go".to_string(), '提')]);
+ set_props(&mut doc, "go", &FieldProps { caption: Some((0, String::new())), ..Default::default() }).unwrap();
+ assert_eq!(field(&fields(&doc), "go").widgets[0].caption.as_deref(), Some(""));
+ assert!(undrawn_fields(&doc).is_empty());
+ assert!(!ap(&doc, &field(&fields(&doc), "go").widgets[0]).contains(" Tj"));
+}
+
/// Read the drawn strings together with the appearance's actual font after saving and reopening.
fn appearance_text(doc: &Document, w: &Widget, font_name: &[u8]) -> (Object, Vec>) {
let widget = doc.get(w.obj);
diff --git a/crates/js/src/formcalc.rs b/crates/js/src/formcalc.rs
index cf82eff466..dc32d457bf 100644
--- a/crates/js/src/formcalc.rs
+++ b/crates/js/src/formcalc.rs
@@ -1933,6 +1933,8 @@ fn days_in(y: i64, m: u32) -> u32 {
}
}
+const WEEKDAYS: [&str; 7] = ["Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"];
+
fn format_date(y: i64, m: u32, d: u32, picture: &str) -> String {
let pic: Vec = picture.chars().collect();
let mut out = String::new();
@@ -1955,6 +1957,19 @@ fn format_date(y: i64, m: u32, d: u32, picture: &str) -> String {
('D', 1) => out.push_str(&d.to_string()),
(_, _) => out.push_str(&format!("{d:02}")),
}
+ } else if pic[i] == 'E' {
+ let mut run = 1;
+ while i + run < pic.len() && pic[i + run] == 'E' {
+ run += 1;
+ }
+ i += run;
+ // Day 1 (1900-01-01) was a Monday, so the serial modulo 7 indexes from Sunday.
+ let weekday = days_from_civil(y, m, d).rem_euclid(7) as usize;
+ match run {
+ 1 | 2 => out.push_str(&(weekday + 1).to_string()),
+ 3 => out.push_str(WEEKDAYS.get(weekday).map_or("", |n| &n[..3])),
+ _ => out.push_str(WEEKDAYS.get(weekday).copied().unwrap_or("")),
+ }
} else {
out.push(pic[i]);
i += 1;
diff --git a/crates/js/src/tests.rs b/crates/js/src/tests.rs
index 4fe5fd2906..61d3a71e51 100644
--- a/crates/js/src/tests.rs
+++ b/crates/js/src/tests.rs
@@ -636,6 +636,21 @@ mod formcalc {
assert_eq!(calc("; a comment\n// another\nSum(1,\n 2,\n 3)"), "6");
}
+ #[test]
+ fn date_pictures_render_the_weekday_letters() {
+ assert_eq!(calc(r#"Num2Date(45350, DateFmt(4))"#), "Thursday, February 29, 2024");
+ assert_eq!(calc(r#"Num2Date(45350, "EEE, YYYY-MM-DD")"#), "Thu, 2024-02-29");
+ assert_eq!(calc(r#"Num2Date(45350, "EEEE")"#), "Thursday");
+ assert_eq!(calc(r#"Num2Date(45350, "E")"#), "5");
+ assert_eq!(calc(r#"Num2Date(45351, "EEEE")"#), "Friday");
+ assert_eq!(calc(r#"Num2Date(45352, "EEEE")"#), "Saturday");
+ assert_eq!(calc(r#"Num2Date(45353, "EEEE")"#), "Sunday");
+ assert_eq!(calc(r#"Num2Date(1, "EEEE")"#), "Monday");
+ assert_eq!(calc(r#"Format("EEEE, YYYY-MM-DD", 45350)"#), "Thursday, 2024-02-29");
+ // Other letters are still literal text.
+ assert_eq!(calc(r#"Num2Date(45350, "YYYY-MM-DD x")"#), "2024-02-29 x");
+ }
+
#[test]
fn str_and_format_round_exact_ties_away_from_zero() {
assert_eq!(calc("Str(2.5, 3, 0)"), " 3");
diff --git a/crates/oracle/src/lib.rs b/crates/oracle/src/lib.rs
index e9e7ae372e..16c7192b17 100644
--- a/crates/oracle/src/lib.rs
+++ b/crates/oracle/src/lib.rs
@@ -23,6 +23,8 @@
//! This crate is test scaffolding (`Class::Testkit` in `xtask/src/layers.rs`): a dev-dependency
//! only, never compiled into the product.
+#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
+
pub mod pixels;
use std::fmt;
diff --git a/crates/oracle/src/pixels.rs b/crates/oracle/src/pixels.rs
index 2b11719819..4ff0ebaf13 100644
--- a/crates/oracle/src/pixels.rs
+++ b/crates/oracle/src/pixels.rs
@@ -201,6 +201,10 @@ pub fn decode_pam_gray(bytes: &[u8]) -> Option<(usize, usize, Vec)> {
}
fn find(haystack: &[u8], needle: &[u8]) -> Option {
+ // `windows(0)` panics.
+ if needle.is_empty() {
+ return None;
+ }
haystack.windows(needle.len()).position(|w| w == needle)
}
@@ -404,6 +408,14 @@ mod tests {
assert_eq!((w, h, px.len()), (4, 2, 8));
}
+ /// An empty needle is not found, rather than panicking in `windows(0)`.
+ #[test]
+ fn find_with_an_empty_needle_is_none() {
+ assert_eq!(find(b"abc", b""), None);
+ assert_eq!(find(b"", b""), None);
+ assert_eq!(find(b"abcabc", b"ca"), Some(2));
+ }
+
/// Malformed or hostile headers return `None` rather than panicking or inventing a size.
#[test]
fn hostile_pgm_headers_are_rejected() {
diff --git a/crates/organize/src/lib.rs b/crates/organize/src/lib.rs
index e120d486b1..99c228c60f 100644
--- a/crates/organize/src/lib.rs
+++ b/crates/organize/src/lib.rs
@@ -499,9 +499,10 @@ pub fn reverse_pages(doc: &mut Document, indices: &[usize]) -> Result<(), Organi
rebuild(doc, &all)
}
-/// Duplicate pages: copies of `indices` (in order) are inserted after the last of them, sharing
+/// Duplicate pages: copies of `indices` (in order) are inserted at `at` in the page order
+/// (clamped to the document's end), or after the last of them when `at` is `None`, sharing
/// fonts and images with the originals.
-pub fn duplicate_pages(doc: &mut Document, indices: &[usize]) -> Result<(), OrganizeError> {
+pub fn duplicate_pages(doc: &mut Document, indices: &[usize], at: Option) -> Result<(), OrganizeError> {
let n = page_count(doc)?;
check(indices, n)?;
let Some(&last) = indices.iter().max() else { return Ok(()) };
@@ -509,7 +510,7 @@ pub fn duplicate_pages(doc: &mut Document, indices: &[usize]) -> Result<(), Orga
order.sort_unstable();
order.dedup();
let src = doc.clone();
- import_pages(doc, &src, &order, last + 1).map(|_| ())
+ import_pages(doc, &src, &order, at.unwrap_or(last + 1)).map(|_| ())
}
/// Replace Pages: the content of `targets` (in order) is replaced by that of `src_pages` of
diff --git a/crates/organize/src/tests.rs b/crates/organize/src/tests.rs
index 32a386d221..9bd3eb396a 100644
--- a/crates/organize/src/tests.rs
+++ b/crates/organize/src/tests.rs
@@ -1971,11 +1971,24 @@ fn page_boxes_default_inherit_and_set() {
#[test]
fn duplicate_pages_inserts_copies_after_the_last() {
let mut doc = open(fixture());
- duplicate_pages(&mut doc, &[0, 1]).unwrap();
+ duplicate_pages(&mut doc, &[0, 1], None).unwrap();
let doc = open(write_full(&doc, &SaveOptions::default()).unwrap());
assert_eq!(labels(&doc), ["Page 1", "Page 2", "Page 1", "Page 2", "Page 3"]);
}
+#[test]
+fn duplicate_pages_at_inserts_the_copies_there() {
+ let mut doc = open(fixture());
+ duplicate_pages(&mut doc, &[2], Some(0)).unwrap();
+ let doc = open(write_full(&doc, &SaveOptions::default()).unwrap());
+ assert_eq!(labels(&doc), ["Page 3", "Page 1", "Page 2", "Page 3"]);
+ // An index past the end lands at the end.
+ let mut doc = open(fixture());
+ duplicate_pages(&mut doc, &[0], Some(99)).unwrap();
+ let doc = open(write_full(&doc, &SaveOptions::default()).unwrap());
+ assert_eq!(labels(&doc), ["Page 1", "Page 2", "Page 3", "Page 1"]);
+}
+
#[test]
fn replace_pages_swaps_content_but_keeps_annotations() {
// Page 2 of the target gets an annotation; replacing its content keeps it.
@@ -2582,7 +2595,7 @@ fn import_can_merge_into_same_named_fields_instead() {
#[test]
fn duplicating_a_page_with_a_field_keeps_the_field_name() {
let mut doc = doc_with_field("Text1");
- crate::duplicate_pages(&mut doc, &[0]).unwrap();
+ crate::duplicate_pages(&mut doc, &[0], None).unwrap();
assert_eq!(crate::page_count(&doc).unwrap(), 2);
assert_eq!(form_field_names(&doc), ["Text1", "Text1"], "nothing renamed");
}
diff --git a/crates/platform/src/staging.rs b/crates/platform/src/staging.rs
index 2cee86c5e9..fee0a16336 100644
--- a/crates/platform/src/staging.rs
+++ b/crates/platform/src/staging.rs
@@ -99,7 +99,8 @@ pub fn staging_suffixes() -> impl Iterator- {
/// a folder at the target is refused by the rename. Flushing is the staging file's own
/// (`sync_all`); parent-directory durability after a power loss is not promised. On Unix a file
/// that is replaced keeps its permissions, so a private file stays private; a new file gets the
-/// usual mode for a new file.
+/// usual mode for a new file. On Windows and macOS a replaced file keeps its creation date, where
+/// the volume stores one.
pub fn write_atomically(path: &Path, bytes: &[u8], form: StagingName) -> std::io::Result<()> {
write_atomically_with(path, bytes, form, staging_suffixes(), std::fs::File::sync_all)
}
@@ -128,12 +129,17 @@ pub fn write_atomically_with(
let keep = std::fs::metadata(path).ok().map(|m| m.permissions());
#[cfg(not(unix))]
let keep = None;
+ // A saved file is the same document: it keeps its creation date (#2658).
+ #[cfg(any(windows, target_os = "macos"))]
+ let created = std::fs::metadata(path).ok().filter(|m| m.is_file()).and_then(|m| m.created().ok());
let (tmp, file) = create_staging_as(dir, name, form, suffixes, keep.as_ref())?;
let result = (|| {
// Closed at the end of the block, before the rename (required on Windows).
{
let mut file = file;
file.write_all(bytes)?;
+ #[cfg(any(windows, target_os = "macos"))]
+ keep_created(&file, created);
sync_staged(&file, sync_all)?;
}
std::fs::rename(&tmp, path)
@@ -144,6 +150,20 @@ pub fn write_atomically_with(
result
}
+/// Give the staging file the creation date of the file it replaces, where the system keeps one
+/// that a program can set (Windows, macOS). Best effort: a volume that can't store it leaves the
+/// new file's own date, as before.
+#[cfg(any(windows, target_os = "macos"))]
+fn keep_created(file: &std::fs::File, created: Option
) {
+ if let Some(created) = created {
+ #[cfg(target_os = "macos")]
+ use std::os::macos::fs::FileTimesExt as _;
+ #[cfg(windows)]
+ use std::os::windows::fs::FileTimesExt as _;
+ let _ = file.set_times(std::fs::FileTimes::new().set_created(created));
+ }
+}
+
/// Flush the staging file to the disk before it replaces the target. On macOS `sync_all` asks
/// for a full flush (`F_FULLFSYNC`), which SMB and FUSE volumes, such as a mounted Cryptomator
/// vault, refuse as unsupported: there a plain `fsync` is the strongest flush the volume offers.
@@ -313,4 +333,32 @@ mod tests {
assert_eq!(mode(&fresh), mode(&plain), "a new file is not made private");
let _ = std::fs::remove_dir_all(&dir);
}
+
+ /// #2658: a save replaces the file, but the document is the same one: it keeps its creation
+ /// date where the system lets a program set it.
+ #[cfg(any(windows, target_os = "macos"))]
+ #[test]
+ fn a_replaced_file_keeps_its_creation_date() {
+ #[cfg(target_os = "macos")]
+ use std::os::macos::fs::FileTimesExt as _;
+ #[cfg(windows)]
+ use std::os::windows::fs::FileTimesExt as _;
+ let dir = test_dir("created");
+ let target = dir.join("doc.pdf");
+ std::fs::write(&target, b"OLD").unwrap();
+ // 2020-01-02 03:04:05 UTC.
+ let then = std::time::SystemTime::UNIX_EPOCH + std::time::Duration::from_secs(1_577_934_245);
+ let file = std::fs::OpenOptions::new().write(true).open(&target).unwrap();
+ file.set_times(std::fs::FileTimes::new().set_created(then)).unwrap();
+ drop(file);
+ assert_eq!(std::fs::metadata(&target).unwrap().created().unwrap(), then, "the fixture's date is set");
+ write_atomically(&target, b"NEW", StagingName::SuffixThenTag).unwrap();
+ assert_eq!(std::fs::read(&target).unwrap(), b"NEW");
+ assert_eq!(std::fs::metadata(&target).unwrap().created().unwrap(), then, "the saved file keeps its creation date");
+ // A new file gets a date of its own.
+ let fresh = dir.join("new.pdf");
+ write_atomically(&fresh, b"NEW", StagingName::SuffixThenTag).unwrap();
+ assert!(std::fs::metadata(&fresh).unwrap().created().unwrap() > then);
+ let _ = std::fs::remove_dir_all(&dir);
+ }
}
diff --git a/crates/preflight/Cargo.toml b/crates/preflight/Cargo.toml
index b79575fc0b..082e44fc5c 100644
--- a/crates/preflight/Cargo.toml
+++ b/crates/preflight/Cargo.toml
@@ -14,3 +14,7 @@ workspace = true
pdfcraft-content = { workspace = true }
pdfcraft-cos = { workspace = true }
pdfcraft-model = { workspace = true }
+roxmltree = "0.20"
+
+[dev-dependencies]
+flate2 = { workspace = true }
diff --git a/crates/preflight/src/lib.rs b/crates/preflight/src/lib.rs
index a4d90d874b..2cda29fea1 100644
--- a/crates/preflight/src/lib.rs
+++ b/crates/preflight/src/lib.rs
@@ -165,7 +165,7 @@ fn metadata(doc: &Document) -> Option {
let cat = catalog(doc);
let m = doc.resolve(cat.get(b"Metadata")?);
match &*m {
- Object::Stream(s) => s.decoded().ok().map(|b| String::from_utf8_lossy(&b).into_owned()),
+ Object::Stream(s) => s.decoded_within(xmp::MAX_XMP_BYTES).ok().map(|b| String::from_utf8_lossy(&b).into_owned()),
_ => None,
}
}
@@ -174,10 +174,10 @@ fn metadata(doc: &Document) -> Option {
pub fn declared(doc: &Document) -> Declared {
let mut d = Declared::default();
if let Some(x) = metadata(doc) {
- let part = xmp::value(&x, "pdfaid:part").and_then(|p| p.parse().ok());
- let conf = xmp::value(&x, "pdfaid:conformance").unwrap_or_default();
+ let part = xmp::identifier(&x, xmp::PDFA_ID, "part").and_then(|p| p.parse().ok());
+ let conf = xmp::identifier(&x, xmp::PDFA_ID, "conformance").unwrap_or_default();
d.pdfa = part.map(|p| (p, conf.to_uppercase()));
- d.pdfua = xmp::value(&x, "pdfuaid:part").and_then(|p| p.parse().ok());
+ d.pdfua = xmp::identifier(&x, xmp::PDFUA_ID, "part").and_then(|p| p.parse().ok());
}
let cat = catalog(doc);
if let Some(list) = cat.get(b"OutputIntents").map(|o| doc.resolve(o)).and_then(|o| o.as_array().cloned()) {
@@ -423,8 +423,8 @@ pub fn verify(doc: &Document, level: Level) -> Vec {
match metadata(doc) {
None => issue("6.6.2.1", "The document has no XMP metadata", None, None, true),
Some(x) => {
- let part = xmp::value(&x, "pdfaid:part");
- let conf = xmp::value(&x, "pdfaid:conformance");
+ let part = xmp::identifier(&x, xmp::PDFA_ID, "part");
+ let conf = xmp::identifier(&x, xmp::PDFA_ID, "conformance");
if part.as_deref() != Some(&level.part().to_string()) || conf.as_deref().map(str::to_uppercase).as_deref() != Some("B") {
issue("6.6.4", "The metadata doesn't identify the file as {}", Some(level.label().to_owned()), None, true);
}
diff --git a/crates/preflight/src/tests.rs b/crates/preflight/src/tests.rs
index 25d06669ed..80b1c63c49 100644
--- a/crates/preflight/src/tests.rs
+++ b/crates/preflight/src/tests.rs
@@ -371,3 +371,49 @@ fn device_colour_in_forms_patterns_and_glyphs_is_found() {
assert!(declared(&doc).output_intents.is_empty(), "{what}: an sRGB output intent was added to a CMYK document: {:?}", r.fixed);
}
}
+
+#[test]
+fn standards_identification_uses_namespace_not_prefix() {
+ fn with_xmp(xmp: &str) -> Document {
+ let stream = format!("<< /Type /Metadata /Subtype /XML /Length {} >>\nstream\n{xmp}\nendstream", xmp.len());
+ build(&["<< /Type /Catalog /Pages 2 0 R /Metadata 3 0 R >>", "<< /Type /Pages /Kids [] /Count 0 >>", &stream], "")
+ }
+ let attribute = r#" "#;
+ let element = r#"2 B "#;
+ for xml in [attribute, element] {
+ let doc = with_xmp(xml);
+ assert_eq!(declared(&doc).pdfa, Some((2, "B".into())));
+ assert!(!verify(&doc, Level::A2b).iter().any(|i| i.clause == "6.6.4"));
+ }
+ assert_eq!(declared(&with_xmp(attribute)).pdfua, Some(1));
+ let wrong = r#" "#;
+ let doc = with_xmp(wrong);
+ assert_eq!(declared(&doc).pdfa, None);
+ assert!(verify(&doc, Level::A2b).iter().any(|i| i.clause == "6.6.4"));
+ let nested = format!("{}{}", "".repeat(1100), " ".repeat(1100));
+ assert_eq!(declared(&with_xmp(&nested)).pdfa, None);
+}
+
+#[test]
+fn oversized_xmp_is_refused_while_decoding() {
+ let xmp = " ".repeat(xmp::MAX_XMP_BYTES + 1);
+ let stream = format!("<< /Type /Metadata /Subtype /XML /Length {} >>\nstream\n{xmp}\nendstream", xmp.len());
+ let doc = build(&["<< /Type /Catalog /Pages 2 0 R /Metadata 3 0 R >>", "<< /Type /Pages /Kids [] /Count 0 >>", &stream], "");
+ assert!(metadata(&doc).is_none());
+ assert_eq!(declared(&doc).pdfa, None);
+
+ use std::io::Write as _;
+ let mut z = flate2::write::ZlibEncoder::new(Vec::new(), flate2::Compression::default());
+ z.write_all(xmp.as_bytes()).unwrap();
+ let compressed = z.finish().unwrap();
+ let hex: String = compressed.iter().map(|byte| format!("{byte:02X}")).collect();
+ let data = format!("{hex}>");
+ let stream =
+ format!("<< /Type /Metadata /Subtype /XML /Filter [/ASCIIHexDecode /FlateDecode] /Length {} >>\nstream\n{data}\nendstream", data.len());
+ let doc = build(&["<< /Type /Catalog /Pages 2 0 R /Metadata 3 0 R >>", "<< /Type /Pages /Kids [] /Count 0 >>", &stream], "");
+ let cat = catalog(&doc);
+ let raw = doc.resolve(cat.get(b"Metadata").unwrap());
+ let Object::Stream(stream) = &*raw else { panic!("fixture has no metadata stream") };
+ assert_eq!(stream.decoded().unwrap().len(), xmp::MAX_XMP_BYTES + 1, "fixture must actually decompress");
+ assert!(metadata(&doc).is_none(), "compressed metadata must be limited during decoding");
+}
diff --git a/crates/preflight/src/xmp.rs b/crates/preflight/src/xmp.rs
index 3057213d1a..0da4869cc6 100644
--- a/crates/preflight/src/xmp.rs
+++ b/crates/preflight/src/xmp.rs
@@ -3,6 +3,116 @@
use pdfcraft_cos::Document;
+/// The largest XMP packet read; a larger one is treated as missing.
+pub(crate) const MAX_XMP_BYTES: usize = 8 * 1024 * 1024;
+/// Namespace URIs of the PDF/A and PDF/UA identification schemas.
+pub(crate) const PDFA_ID: &str = "http://www.aiim.org/pdfa/ns/id/";
+pub(crate) const PDFUA_ID: &str = "http://www.aiim.org/pdfua/ns/id/";
+
+/// Read an identification property by its expanded XML name, not its arbitrary prefix. A packet
+/// that is not well-formed XML (an undeclared prefix, an illegal character) or is too deep or wide
+/// to parse safely is read by the conventional prefix (`pdfaid:`, `pdfuaid:`) instead, as before.
+pub(crate) fn identifier(xmp: &str, namespace: &str, local: &str) -> Option {
+ if xmp.len() > MAX_XMP_BYTES {
+ return None;
+ }
+ match parsed_identifier(xmp, namespace, local) {
+ Ok(found) => found,
+ Err(()) => {
+ let prefix = match namespace {
+ PDFA_ID => "pdfaid",
+ PDFUA_ID => "pdfuaid",
+ _ => return None,
+ };
+ value(xmp, &format!("{prefix}:{local}"))
+ }
+ }
+}
+
+/// [`identifier`] through an XML parser; `Err` when the packet can't be parsed within the limits.
+fn parsed_identifier(xmp: &str, namespace: &str, local: &str) -> Result, ()> {
+ if !xml_limits_ok(xmp) {
+ return Err(());
+ }
+ let options = roxmltree::ParsingOptions { allow_dtd: false, nodes_limit: 100_000 };
+ let doc = roxmltree::Document::parse_with_options(xmp, options).map_err(|_| ())?;
+ for node in doc.descendants().filter(roxmltree::Node::is_element) {
+ if let Some(value) = node.attribute((namespace, local)) {
+ return Ok(Some(value.trim().to_owned()));
+ }
+ if node.tag_name().namespace() == Some(namespace) && node.tag_name().name() == local {
+ return Ok(node.text().map(|value| value.trim().to_owned()));
+ }
+ }
+ Ok(None)
+}
+
+/// Reject deep XML and start tags with too many attributes before roxmltree checks duplicates.
+/// Each unquoted `=` in a valid start tag introduces an attribute, including namespace bindings.
+/// Every `b[i]` and `hay[p..]` below is guarded by `i < b.len()` / `p < hay.len()`.
+fn xml_limits_ok(text: &str) -> bool {
+ fn find(hay: &[u8], from: usize, needle: &[u8]) -> Option {
+ (from..hay.len()).find(|&p| hay[p..].starts_with(needle))
+ }
+ let b = text.as_bytes();
+ let (mut i, mut depth, mut total_attributes) = (0usize, 0usize, 0usize);
+ while i < b.len() {
+ if b[i] != b'<' {
+ i += 1;
+ } else if b[i..].starts_with(b"").map_or(b.len(), |p| p + 3);
+ } else if b[i..].starts_with(b"").map_or(b.len(), |p| p + 3);
+ } else if b[i..].starts_with(b"") {
+ i = find(b, i + 2, b"?>").map_or(b.len(), |p| p + 2);
+ } else if b[i..].starts_with(b"").map_or(b.len(), |p| p + 1);
+ } else if b[i..].starts_with(b"") {
+ depth = depth.saturating_sub(1);
+ i = find(b, i + 2, b">").map_or(b.len(), |p| p + 1);
+ } else {
+ depth += 1;
+ if depth > 1024 {
+ return false;
+ }
+ // Skip the rest of the start tag, over any quoted attribute value, and remember its last
+ // significant byte: a tag that ends in `/` closes itself, so it is one level deep while it
+ // is read rather than a level left open.
+ i += 1;
+ let mut quote = 0u8;
+ let mut last = 0u8;
+ let mut attributes = 0usize;
+ while i < b.len() {
+ let c = b[i];
+ if quote != 0 {
+ if c == quote {
+ quote = 0;
+ }
+ } else if c == b'"' || c == b'\'' {
+ quote = c;
+ } else if c == b'>' {
+ i += 1;
+ break;
+ } else if c == b'=' {
+ attributes += 1;
+ total_attributes += 1;
+ if attributes > 128 || total_attributes > 4096 {
+ return false;
+ }
+ last = c;
+ } else if !c.is_ascii_whitespace() {
+ last = c;
+ }
+ i += 1;
+ }
+ if last == b'/' {
+ depth -= 1;
+ }
+ }
+ }
+ true
+}
+
/// The value of a simple XMP property, written either as an attribute (`pdfaid:part="2"`) or
/// as an element (`2 `, or inside an `rdf:Alt`/`rdf:Seq`).
pub fn value(xmp: &str, key: &str) -> Option {
@@ -133,3 +243,28 @@ mod tests {
assert_eq!(iso_date("junk"), None);
}
}
+
+#[cfg(test)]
+mod limit_tests {
+ use super::*;
+
+ #[test]
+ fn attribute_and_namespace_budgets_bound_parser_work() {
+ let prefix = r#"();
+ let at_limit = format!("{prefix}{}/>", attrs(126));
+ assert_eq!(identifier(&at_limit, PDFA_ID, "part").as_deref(), Some("2"));
+ let over_limit = format!("{prefix}{}/>", attrs(127));
+ assert_eq!(identifier(&over_limit, PDFA_ID, "part"), None);
+ let namespaces = (0..129).map(|i| format!("xmlns:n{i}=\"urn:{i}\" ")).collect::();
+ assert_eq!(identifier(&format!(" "), PDFA_ID, "part"), None);
+ let many_elements = format!("{} ", r#" "#.repeat(4097));
+ assert!(!xml_limits_ok(&many_elements));
+ // Not well-formed (the prefix is never declared): read by the conventional prefix, as before.
+ let undeclared = r#" "#;
+ assert_eq!(identifier(undeclared, PDFA_ID, "part").as_deref(), Some("3"));
+ assert_eq!(identifier(undeclared, PDFA_ID, "conformance").as_deref(), Some("u"));
+ let over_bytes = format!("{} ", " ".repeat(8 * 1024 * 1024));
+ assert_eq!(identifier(&over_bytes, PDFA_ID, "part"), None);
+ }
+}
diff --git a/crates/redact/src/image.rs b/crates/redact/src/image.rs
index 23ef2559a5..9a19ba2f0b 100644
--- a/crates/redact/src/image.rs
+++ b/crates/redact/src/image.rs
@@ -1,5 +1,5 @@
//! Clearing the pixels of an image under redaction regions. The image is decoded, every pixel
-//! whose centre maps into a region is set to zero (no ink for image masks), and the result is
+//! whose footprint intersects a region is set to zero (no ink for image masks), and the result is
//! written as a new Flate image; the original object is untouched (other pages may use it).
//! Images whose codec PdfCraft can't decode (DCT, JPX, JBIG2, CCITT) return `None`, and the
//! caller removes the whole image instead; that is fail-closed.
@@ -33,7 +33,7 @@ fn components(doc: &Document, s: &Stream) -> Option {
})
}
-/// `Ok(Some(copy))` with the covered pixels cleared, `Ok(None)` when no pixel centre is covered,
+/// `Ok(Some(copy))` with the covered pixels cleared, `Ok(None)` when no pixel intersects,
/// `Err(())` when the image can't be cleared (the caller removes it).
#[allow(clippy::result_unit_err)]
pub(crate) fn clear(doc: &Document, s: &Stream, ctm: &Matrix, rects: &[[f64; 4]]) -> Result, ()> {
@@ -91,18 +91,20 @@ fn decode_and_clear(doc: &Document, s: &Stream, ctm: &Matrix, rects: &[[f64; 4]]
};
let mut cleared = 0usize;
for j in 0..h {
- // Image space: (0,0) is the bottom-left of the unit square; row 0 is the top.
- let v = 1.0 - (j as f64 + 0.5) / h as f64;
- let (ax, ay) = ctm.apply(0.0, v);
- let (bx, by) = ctm.apply(1.0, v);
- let row_box = [ax.min(bx), ay.min(by), ax.max(bx), ay.max(by)];
- if !rects.iter().any(|r| overlaps(*r, [row_box[0], row_box[1] - 0.01, row_box[2], row_box[3] + 0.01], 0.0)) {
+ // Image space has its origin at the bottom left; row 0 is at the top.
+ let v0 = 1.0 - (j + 1) as f64 / h as f64;
+ let v1 = 1.0 - j as f64 / h as f64;
+ let row_box = ctm.bbox([0.0, v0, 1.0, v1]);
+ if !rects.iter().any(|r| overlaps(*r, row_box, 0.0)) {
continue;
}
for i in 0..w {
- let u = (i as f64 + 0.5) / w as f64;
- let (x, y) = ctm.apply(u, v);
- if !rects.iter().any(|r| x >= r[0] && x <= r[2] && y >= r[1] && y <= r[3]) {
+ let u0 = i as f64 / w as f64;
+ let u1 = (i + 1) as f64 / w as f64;
+ // The bounding box may cover extra pixels for a rotated or skewed image; that
+ // conservative over-clear prevents a touched sample surviving in the file.
+ let pixel_box = ctm.bbox([u0, v0, u1, v1]);
+ if !rects.iter().any(|r| overlaps(*r, pixel_box, 0.0)) {
continue;
}
cleared += 1;
diff --git a/crates/redact/src/lib.rs b/crates/redact/src/lib.rs
index a8dbf64d9b..fdac37dbec 100644
--- a/crates/redact/src/lib.rs
+++ b/crates/redact/src/lib.rs
@@ -51,6 +51,13 @@ pub enum RedactError {
"redaction appearance has invalid geometry or exceeds the 8 MiB budget; reduce the marked area, turn off repeated text or apply fewer pages at once (nothing was redacted)"
)]
OverlayLimit,
+ /// The structure tree couldn't be cleaned completely (#3302): an element written in place that
+ /// would need changing, an element whose inherited page can't be found, or a tree too large to
+ /// walk. Tags that kept the removed text or annotation would undo the redaction.
+ #[error(
+ "the document's tags can't be cleaned completely (an element written in place, or a structure tree too large or too deep to follow); the operation was refused"
+ )]
+ Tags,
#[error(transparent)]
Edit(#[from] pdfcraft_edit::EditError),
#[error(transparent)]
@@ -305,6 +312,12 @@ fn localize_resources(doc: &mut Document, selected: &[usize]) -> Result<(), pdfc
/// Apply every redaction mark (or only those on `pages`, 0-based). Irreversible for the saved
/// file; callers keep the previous document for undo.
+///
+/// # Errors
+///
+/// This operation is not transactional: an error may leave `doc` partially modified, with
+/// sensitive content still present. Call this on a working clone and replace the original only
+/// on success. On any error, discard the working clone; do not save or reuse it.
pub fn apply(doc: &mut Document, pages: Option<&[usize]>) -> Result {
let all_marks = marks(doc);
let chosen: Vec<&Mark> = all_marks.iter().filter(|m| pages.is_none_or(|p| p.contains(&m.page))).collect();
diff --git a/crates/redact/src/sanitize.rs b/crates/redact/src/sanitize.rs
index 0c9228ac02..1940e376a8 100644
--- a/crates/redact/src/sanitize.rs
+++ b/crates/redact/src/sanitize.rs
@@ -261,6 +261,12 @@ pub fn scan(doc: &Document) -> Vec<(Hidden, usize)> {
}
/// Remove the chosen categories. Returns what was removed per category.
+///
+/// # Errors
+///
+/// This operation is not transactional: an error may leave `doc` partially modified, with
+/// sensitive content still present. Call this on a working clone and replace the original only
+/// on success. On any error, discard the working clone; do not save or reuse it.
pub fn remove_hidden(doc: &mut Document, which: &[Hidden]) -> Result, RedactError> {
let counts = scan(doc);
let count = |h: Hidden| counts.iter().find(|c| c.0 == h).map_or(0, |c| c.1);
diff --git a/crates/redact/src/tags.rs b/crates/redact/src/tags.rs
index b70cf23ab9..ab5735e065 100644
--- a/crates/redact/src/tags.rs
+++ b/crates/redact/src/tags.rs
@@ -9,6 +9,8 @@ use std::collections::{HashMap, HashSet};
use pdfcraft_cos::{Dict, Document, ObjRef, Object};
+use crate::RedactError;
+
/// Each MCID's operators (serialized), and whether anything is still drawn inside it.
fn by_mcid(doc: &Document, resources: &Dict, data: &[u8]) -> HashMap, bool)> {
let mut out: HashMap, bool)> = HashMap::new();
@@ -103,7 +105,23 @@ pub(crate) fn clean(
stream: Option<(ObjRef, ObjRef)>,
changed: &HashSet,
empty: &HashSet,
-) -> Result {
+) -> Result {
+ clean_within(doc, page, stream, changed, empty, MAX_STRUCT_REFS)
+}
+
+/// [`clean`], visiting at most `limit` structure-tree references. Fail-closed (#3302): when the
+/// cleanup can't be shown complete, it is [`RedactError::Tags`] and the redaction isn't made. That
+/// is a tree too large to walk in full (it could hide an element that repeats the redacted text),
+/// an element whose inherited page can't be found (its ancestry too deep, or looping), and an
+/// element written in place that would need changing (only the object holding it could be).
+pub(crate) fn clean_within(
+ doc: &mut Document,
+ page: ObjRef,
+ stream: Option<(ObjRef, ObjRef)>,
+ changed: &HashSet,
+ empty: &HashSet,
+ limit: usize,
+) -> Result {
if changed.is_empty() && stream.is_none() {
return Ok(0);
}
@@ -114,17 +132,44 @@ pub(crate) fn clean(
else {
return Ok(0);
};
- // Indirect elements with the page they inherit.
- let mut todo: Vec<(Object, Option)> = root.get(b"K").map(|k| vec![(k.clone(), None)]).unwrap_or_default();
+ // Indirect elements with the page they inherit. Elements are reached from the root's /K, and
+ // also from the parent tree and the ID tree: one listed only there still owns marked content
+ // (#3302). The stack pops /K first, so an element reached through /K gets the page it inherits
+ // from its ancestors before a tree reaches it without one.
+ let mut todo: Vec<(Object, Option)> =
+ [&b"IDTree"[..], b"ParentTree", b"K"].iter().filter_map(|key| root.get(key).cloned()).map(|k| (k, None)).collect();
let mut seen = HashSet::new();
+ let mut pages = HashMap::new();
let mut hits: Vec<(ObjRef, Dict, Option, bool)> = Vec::new();
+ // Whether marked content `x` (a kid on page `pg`) is one this cleanup must change.
+ let touches = |doc: &Document, x: &Object, pg: Option| {
+ mcid_of(doc, x, pg, page, stream).is_some_and(|m| stream.is_some() || changed.contains(&m) || empty.contains(&m))
+ };
while let Some((k, pg)) = todo.pop() {
match k {
Object::Array(a) => todo.extend(a.into_iter().map(|x| (x, pg))),
+ // A number or name tree node written in place.
+ Object::Dict(d) if !d.contains(b"S") => todo.extend(tree_node_entries(&d).into_iter().map(|x| (x, pg))),
+ // An element written in place: its kids are followed, but if it owns marked content
+ // this cleanup changes, only rewriting what holds it could change it.
+ Object::Dict(d) => {
+ let pg = match d.get(b"Pg").and_then(Object::as_ref).or(pg) {
+ Some(p) => Some(p),
+ None => inherited_page(doc, &d, &mut pages)?,
+ };
+ let kids = kids_of(doc, &d);
+ if kids.iter().any(|x| touches(doc, x, pg)) {
+ return Err(RedactError::Tags);
+ }
+ todo.extend(kids.into_iter().map(|x| (x, pg)));
+ }
Object::Ref(r) => {
- if !seen.insert(r) || seen.len() > 2_000_000 {
+ if !seen.insert(r) {
continue;
}
+ if seen.len() > limit {
+ return Err(RedactError::Tags);
+ }
let object = doc.get(r);
if let Object::Array(a) = &*object {
todo.extend(a.iter().cloned().map(|x| (x, pg)));
@@ -132,16 +177,15 @@ pub(crate) fn clean(
}
let Some(d) = object.as_dict().cloned() else { continue };
if !d.contains(b"S") {
+ // A parent tree or ID tree node: its values may be elements.
+ todo.extend(tree_node_entries(&d).into_iter().map(|x| (x, pg)));
continue;
}
- let pg = d.get(b"Pg").and_then(Object::as_ref).or(pg);
- let kids: Vec = match d.get(b"K").map(|k| doc.resolve(k)) {
- Some(o) => match &*o {
- Object::Array(a) => a.clone(),
- _ => d.get(b"K").cloned().into_iter().collect(),
- },
- None => Vec::new(),
+ let pg = match d.get(b"Pg").and_then(Object::as_ref).or(pg) {
+ Some(p) => Some(p),
+ None => inherited_page(doc, &d, &mut pages)?,
};
+ let kids = kids_of(doc, &d);
let sensitive = kids.iter().any(|x| mcid_of(doc, x, pg, page, stream).is_some_and(|m| changed.contains(&m)));
let remap = stream.is_some() && kids.iter().any(|x| mcid_of(doc, x, pg, page, stream).is_some());
if sensitive || remap {
@@ -203,6 +247,49 @@ pub(crate) fn clean(
Ok(n)
}
+/// The page an element inherits (§14.7.4.4): the nearest `/Pg` among its ancestors, following
+/// `/P`. `Ok(None)` when none has one; [`RedactError::Tags`] when the lookup gives up (more than
+/// [`MAX_PARENTS`] ancestors, or a loop), which isn't the same as having no page. `pages` keeps
+/// each ancestor's answer, so siblings don't climb the same chain again.
+fn inherited_page(doc: &Document, d: &Dict, pages: &mut HashMap>) -> Result, RedactError> {
+ let mut chain: Vec = Vec::new();
+ let mut parent = d.get(b"P").and_then(Object::as_ref);
+ let found = loop {
+ let Some(r) = parent else { break None };
+ if let Some(known) = pages.get(&r) {
+ break *known;
+ }
+ if chain.len() >= MAX_PARENTS || chain.contains(&r) {
+ return Err(RedactError::Tags);
+ }
+ chain.push(r);
+ let object = doc.get(r);
+ let Some(p) = object.as_dict() else { break None };
+ if let Some(pg) = p.get(b"Pg").and_then(Object::as_ref) {
+ break Some(pg);
+ }
+ parent = p.get(b"P").and_then(Object::as_ref);
+ };
+ for r in chain {
+ pages.insert(r, found);
+ }
+ Ok(found)
+}
+
+/// An element's kids: its `/K` array, or the one kid it has.
+fn kids_of(doc: &Document, d: &Dict) -> Vec {
+ match d.get(b"K").map(|k| doc.resolve(k)) {
+ Some(o) => match &*o {
+ Object::Array(a) => a.clone(),
+ _ => d.get(b"K").cloned().into_iter().collect(),
+ },
+ None => Vec::new(),
+ }
+}
+
+/// The most ancestors [`inherited_page`] looks through; deeper trees, and cycles, go unanswered.
+const MAX_PARENTS: usize = 256;
+
/// Whether `o` is an object reference (`/Type /OBJR`, direct or indirect) to one of `gone`.
fn refers_to(doc: &Document, o: &Object, gone: &HashSet) -> bool {
doc.resolve(o)
@@ -210,22 +297,34 @@ fn refers_to(doc: &Document, o: &Object, gone: &HashSet) -> bool {
.is_some_and(|d| d.name(b"Type") == Some(b"OBJR") && d.get(b"Obj").and_then(Object::as_ref).is_some_and(|r| gone.contains(&r)))
}
+/// What a number or name tree node leads on to: its `/Nums` or `/Names` array (keys and values
+/// alike; the walk ignores numbers and strings) and its `/Kids`.
+fn tree_node_entries(d: &Dict) -> Vec {
+ [&b"Nums"[..], b"Names", b"Kids"].iter().filter_map(|key| d.get(key).cloned()).collect()
+}
+
/// Drop the structure tree's object references (OBJR) to `gone`, annotations removed from their
/// pages (#2447). A tagged PDF's tree points at each link and comment it tags; left there, the
/// reference keeps the annotation, its URL or text included, in a full rewrite after the page
/// no longer lists it. Returns how many references were dropped.
-pub(crate) fn drop_object_refs(doc: &mut Document, gone: &HashSet) -> Result {
+///
+/// Only elements that are objects of their own are edited: changing a structure element written in
+/// place (a direct dictionary in a `/K` array or a tree) means rewriting the object that holds it,
+/// so when one of those refers to a removed annotation it is [`RedactError::Tags`] (fail-closed).
+/// Producers rarely write elements that way.
+pub(crate) fn drop_object_refs(doc: &mut Document, gone: &HashSet) -> Result {
drop_object_refs_within(doc, gone, MAX_STRUCT_REFS)
}
/// The most structure-tree references [`drop_object_refs`] visits.
const MAX_STRUCT_REFS: usize = 2_000_000;
-/// [`drop_object_refs`], visiting at most `limit` references. A tree too large to walk in full
-/// could keep a reference the walk never reached, so then the whole structure tree goes instead
-/// (with `/MarkInfo`, which would claim tags there no longer are): fail-closed, at the cost of
-/// the document's tags.
-pub(crate) fn drop_object_refs_within(doc: &mut Document, gone: &HashSet, limit: usize) -> Result {
+/// [`drop_object_refs`], visiting at most `limit` references. Fail-closed: a tree too large to
+/// walk in full could keep a reference the walk never reached, and an element written in place
+/// can only be changed by rewriting what holds it, so either is [`RedactError::Tags`] rather than
+/// a cleanup that leaves the annotation reachable (removing only the tree would not: an outline
+/// item's `/SE` or a link's `/SD` still reaches its elements).
+pub(crate) fn drop_object_refs_within(doc: &mut Document, gone: &HashSet, limit: usize) -> Result {
if gone.is_empty() {
return Ok(0);
}
@@ -236,23 +335,31 @@ pub(crate) fn drop_object_refs_within(doc: &mut Document, gone: &HashSet
else {
return Ok(0);
};
- let mut todo: Vec = root.get(b"K").cloned().into_iter().collect();
+ // Elements are reached from the root's /K, and also from the parent tree and the ID tree:
+ // one listed only there (left behind by an editor, say) still points at its annotation.
+ let mut todo: Vec = [&b"K"[..], b"ParentTree", b"IDTree"].iter().filter_map(|key| root.get(key).cloned()).collect();
let mut seen = HashSet::new();
let mut hits: Vec = Vec::new();
while let Some(k) = todo.pop() {
match k {
Object::Array(a) => todo.extend(a),
+ // A structure element written in place: its kids are followed; one that tags a
+ // removed annotation can't be changed here (see above).
+ Object::Dict(d) if d.contains(b"S") => {
+ let kids = kids_of(doc, &d);
+ if kids.iter().any(|x| refers_to(doc, x, gone)) {
+ return Err(RedactError::Tags);
+ }
+ todo.extend(kids);
+ }
+ // A number or name tree node written in place (§7.9.6, §7.9.7): its values and kids.
+ Object::Dict(d) => todo.extend(tree_node_entries(&d)),
Object::Ref(r) => {
if !seen.insert(r) {
continue;
}
if seen.len() > limit {
- let Some(catalog) = doc.root() else { return Ok(0) };
- doc.update_dict(catalog, |c| {
- c.remove(b"StructTreeRoot");
- c.remove(b"MarkInfo");
- })?;
- return Ok(0);
+ return Err(RedactError::Tags);
}
let object = doc.get(r);
if let Object::Array(a) = &*object {
@@ -261,15 +368,11 @@ pub(crate) fn drop_object_refs_within(doc: &mut Document, gone: &HashSet
}
let Some(d) = object.as_dict() else { continue };
if !d.contains(b"S") {
+ // A parent tree or ID tree node: its values may be elements.
+ todo.extend(tree_node_entries(d));
continue;
}
- let kids: Vec = match d.get(b"K").map(|k| doc.resolve(k)) {
- Some(o) => match &*o {
- Object::Array(a) => a.clone(),
- _ => d.get(b"K").cloned().into_iter().collect(),
- },
- None => Vec::new(),
- };
+ let kids = kids_of(doc, d);
if kids.iter().any(|x| refers_to(doc, x, gone)) {
hits.push(r);
}
diff --git a/crates/redact/src/tests.rs b/crates/redact/src/tests.rs
index a7e11bcaf1..da1ecf12ab 100644
--- a/crates/redact/src/tests.rs
+++ b/crates/redact/src/tests.rs
@@ -399,6 +399,25 @@ fn named_image(doc: &Document, page: usize, name: &[u8]) -> Option> {
}
}
+#[test]
+fn redaction_clears_pixels_intersected_without_covering_their_centres() {
+ for (content, rect) in
+ [(&b"q 200 0 0 100 0 0 cm /Im Do Q"[..], [10.0, 10.0, 20.0, 90.0]), (&b"q 0 100 -200 0 200 0 cm /Im Do Q"[..], [10.0, 5.0, 190.0, 10.0])]
+ {
+ let mut doc = one_page(
+ content,
+ "/XObject << /Im 7 0 R >>",
+ vec![stream("/Type /XObject /Subtype /Image /Width 2 /Height 1 /ColorSpace /DeviceGray /BitsPerComponent 8", [123, 234])],
+ );
+ mark(&mut doc, 0, &[rect], "");
+ let report = apply(&mut doc, None).unwrap();
+ assert_eq!(report.images_cleared, 1);
+ let saved = Document::open(Arc::new(write_full(&doc, &SaveOptions::default()).unwrap())).unwrap();
+ assert_eq!(named_image(&saved, 0, b"Im"), None);
+ assert_eq!(named_image(&saved, 0, b"PCRedacted1"), Some(vec![0, 234]));
+ }
+}
+
/// Apply Redactions gives the page its own pruned /Resources, but the dictionary it drew through
/// before (inherited from the /Pages node, or shared with another page) still named the removed
/// image and form, so a full-rewrite save kept them.
@@ -810,11 +829,12 @@ fn flattened_widgets_and_far_pop_ups_leave_the_structure_tree() {
}
/// #2532 review: a structure tree larger than the walk's limit can't be searched in full, so a
-/// reference the walk never reached could keep a removed annotation. Then the whole tree goes
-/// (with `/MarkInfo`), rather than reporting success with references left behind; under the
-/// limit only the reference goes.
+/// reference the walk never reached could keep a removed annotation. Then it is an error, and
+/// nothing is changed, rather than success with references left behind (removing only the tree
+/// would leave an outline's `/SE` reaching them, review on #3285); under the limit only the
+/// reference goes.
#[test]
-fn a_structure_tree_too_large_to_walk_is_removed_whole() {
+fn a_structure_tree_too_large_to_walk_is_refused() {
let objs: Vec> = vec![
b"<< /Type /Catalog /Pages 2 0 R /MarkInfo << /Marked true >> /StructTreeRoot 5 0 R >>".to_vec(),
b"<< /Type /Pages /Kids [3 0 R] /Count 1 >>".to_vec(),
@@ -835,11 +855,219 @@ fn a_structure_tree_too_large_to_walk_is_removed_whole() {
assert!(catalog(&doc).contains(b"StructTreeRoot") && catalog(&doc).contains(b"MarkInfo"), "under the limit the tree stays");
let mut doc = pdf(objs);
- crate::tags::drop_object_refs_within(&mut doc, &gone, 2).unwrap();
- assert!(!catalog(&doc).contains(b"StructTreeRoot"), "a tree the walk couldn't finish goes whole");
- assert!(!catalog(&doc).contains(b"MarkInfo"));
+ assert!(matches!(crate::tags::drop_object_refs_within(&mut doc, &gone, 2), Err(RedactError::Tags)), "a tree the walk couldn't finish is refused");
+ assert!(catalog(&doc).contains(b"StructTreeRoot"), "nothing changed");
+}
+
+/// #3302: an element listed only in the parent tree (not under the root's /K) still owns the
+/// marked content redaction changed, so its /ActualText and /Alt, which repeat the redacted
+/// words, go as they do for an element under /K.
+#[test]
+fn an_element_listed_only_in_the_parent_tree_loses_its_redacted_text() {
+ let objs: Vec> = vec![
+ b"<< /Type /Catalog /Pages 2 0 R /StructTreeRoot 5 0 R /MarkInfo << /Marked true >> >>".to_vec(),
+ b"<< /Type /Pages /Kids [3 0 R] /Count 1 >>".to_vec(),
+ b"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 300 300] /Contents 4 0 R /StructParents 0 /Resources << /Font << /F1 9 0 R >> >> >>".to_vec(),
+ stream("", b"/P <> BDC BT /F1 14 Tf 20 150 Td (Codename Bluefin) Tj ET EMC"),
+ // The root's /K reaches only the empty document element 6; the paragraph 7 is in the
+ // parent tree only.
+ b"<< /Type /StructTreeRoot /K 6 0 R /ParentTree << /Nums [0 [7 0 R]] >> /ParentTreeNextKey 1 >>".to_vec(),
+ b"<< /Type /StructElem /S /Document /P 5 0 R >>".to_vec(),
+ b"<< /Type /StructElem /S /P /P 6 0 R /Pg 3 0 R /K 0 /ActualText (Codename Bluefin) /Alt (Codename Bluefin) >>".to_vec(),
+ b"<< /Type /StructElem /S /Note /P 6 0 R >>".to_vec(),
+ b"<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding /WinAnsiEncoding >>".to_vec(),
+ ];
+ let mut doc = pdf(objs);
+ mark(&mut doc, 0, &[[10.0, 140.0, 290.0, 170.0]], "");
+ let r = apply(&mut doc, None).unwrap();
+ assert!(r.glyphs > 0 && r.tags >= 1, "{r:?}");
+ let element = doc.get(ObjRef::new(7, 0)).as_dict().cloned().unwrap();
+ assert!(!element.contains(b"ActualText") && !element.contains(b"Alt"), "{element:?}");
+}
+
+/// #3302: a structure tree too large to walk in full fails closed: an error, so the redaction
+/// isn't made, instead of the walk stopping and reporting success with elements never checked.
+#[test]
+fn a_tag_walk_cut_short_is_refused() {
+ let objs: Vec> = vec![
+ b"<< /Type /Catalog /Pages 2 0 R /StructTreeRoot 5 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 300 300] /StructParents 0 >>".to_vec(),
+ b"<< >>".to_vec(),
+ b"<< /Type /StructTreeRoot /K 6 0 R >>".to_vec(),
+ b"<< /Type /StructElem /S /Document /P 5 0 R /K [7 0 R 8 0 R] >>".to_vec(),
+ b"<< /Type /StructElem /S /P /P 6 0 R /Pg 3 0 R /K 0 /ActualText (Codename Bluefin) >>".to_vec(),
+ b"<< /Type /StructElem /S /Figure /P 6 0 R /Alt (A chart) /E (chart) >>".to_vec(),
+ ];
+ let page = ObjRef::new(3, 0);
+ let changed: std::collections::HashSet = [0].into();
+ let mut doc = pdf(objs.clone());
+ assert_eq!(crate::tags::clean_within(&mut doc, page, None, &changed, &Default::default(), 100).unwrap(), 1);
+ let figure = doc.get(ObjRef::new(8, 0)).as_dict().cloned().unwrap();
+ assert!(figure.contains(b"Alt"), "a walk that finishes keeps an unrelated element's text");
+
+ let mut doc = pdf(objs);
+ assert!(matches!(crate::tags::clean_within(&mut doc, page, None, &changed, &Default::default(), 1), Err(RedactError::Tags)));
+}
+
+/// #2532 follow-up: an element reached only through the parent tree or the ID tree, not from
+/// the root's /K, still points at a removed annotation, and the root keeps it reachable in a full
+/// rewrite. Both trees are walked too, written in place or as their own objects.
+#[test]
+fn elements_listed_only_in_the_parent_tree_or_the_id_tree_lose_their_references() {
+ let objs: Vec> = vec![
+ b"<< /Type /Catalog /Pages 2 0 R /MarkInfo << /Marked true >> /StructTreeRoot 5 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 300 300] >>".to_vec(),
+ // 4: the removed link; 8: the removed comment
+ b"<< /Type /Annot /Subtype /Link /Rect [0 0 10 10] /A << /S /URI /URI (https://secret.example) >> >>".to_vec(),
+ // 5: the root; its /K reaches only the empty document element 6. The parent tree is
+ // written in place, the ID tree's kid (9) as its own object.
+ b"<< /Type /StructTreeRoot /K 6 0 R /ParentTree << /Nums [0 7 0 R] >> /IDTree << /Kids [9 0 R] >> >>".to_vec(),
+ b"<< /Type /StructElem /S /Document /P 5 0 R >>".to_vec(),
+ // 7: a link element only the parent tree lists
+ b"<< /Type /StructElem /S /Link /P 6 0 R /K << /Type /OBJR /Obj 4 0 R >> /Alt (secret.example link) >>".to_vec(),
+ b"<< /Type /Annot /Subtype /Text /Rect [0 0 10 10] /Contents (hushhush note) >>".to_vec(),
+ b"<< /Names [(note1) 10 0 R] >>".to_vec(),
+ // 10: an annotation element only the ID tree lists
+ b"<< /Type /StructElem /S /Annot /P 6 0 R /ID (note1) /K [<< /Type /OBJR /Obj 8 0 R >>] >>".to_vec(),
+ ];
+ let gone: std::collections::HashSet = [ObjRef::new(4, 0), ObjRef::new(8, 0)].into();
+ let mut doc = pdf(objs);
+ assert_eq!(crate::tags::drop_object_refs(&mut doc, &gone).unwrap(), 2, "both references go");
let bytes = write_full(&doc, &SaveOptions { object_streams: false, ..SaveOptions::default() }).unwrap();
- assert!(!bytes.windows(14).any(|w| w == b"secret.example"), "the removed link can't be reached any more");
+ for secret in [&b"secret.example"[..], b"hushhush"] {
+ assert!(!bytes.windows(secret.len()).any(|w| w == secret), "{} survived the rewrite", String::from_utf8_lossy(secret));
+ }
+}
+
+/// #3285 review: an element without its own /Pg takes its ancestor's page, also when the parent
+/// tree lists it. One under /K must not be reached first through the parent tree without that
+/// page, and one only the parent tree lists finds it through /P.
+#[test]
+fn an_element_inheriting_its_page_loses_its_redacted_text_when_the_parent_tree_lists_it() {
+ let objs: Vec> = vec![
+ b"<< /Type /Catalog /Pages 2 0 R /StructTreeRoot 5 0 R /MarkInfo << /Marked true >> >>".to_vec(),
+ b"<< /Type /Pages /Kids [3 0 R] /Count 1 >>".to_vec(),
+ b"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 300 300] /Contents 4 0 R /StructParents 0 /Resources << /Font << /F1 9 0 R >> >> >>".to_vec(),
+ stream("", b"/P <> BDC BT /F1 14 Tf 20 150 Td (Codename Bluefin) Tj ET EMC /P <> BDC BT /F1 14 Tf 20 100 Td (Codename Bluefin) Tj ET EMC"),
+ // The document element 6 carries the page; 7 sits under it, 8 only points at it.
+ b"<< /Type /StructTreeRoot /K 6 0 R /ParentTree << /Nums [0 [7 0 R 8 0 R]] >> /ParentTreeNextKey 1 >>".to_vec(),
+ b"<< /Type /StructElem /S /Document /P 5 0 R /Pg 3 0 R /K [7 0 R] >>".to_vec(),
+ b"<< /Type /StructElem /S /P /P 6 0 R /K 0 /ActualText (Codename Bluefin) >>".to_vec(),
+ b"<< /Type /StructElem /S /P /P 6 0 R /K 1 /Alt (Codename Bluefin) >>".to_vec(),
+ b"<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding /WinAnsiEncoding >>".to_vec(),
+ ];
+ let mut doc = pdf(objs);
+ mark(&mut doc, 0, &[[10.0, 90.0, 290.0, 170.0]], "");
+ let r = apply(&mut doc, None).unwrap();
+ assert!(r.glyphs > 0, "{r:?}");
+ for n in [7, 8] {
+ let element = doc.get(ObjRef::new(n, 0)).as_dict().cloned().unwrap();
+ assert!(!element.contains(b"ActualText") && !element.contains(b"Alt"), "{n}: {element:?}");
+ }
+}
+
+/// #3285 review: a structure element written in place, here as an ID tree value, can't be
+/// edited without rewriting what holds it; when it tags a removed annotation, removing hidden
+/// information is refused instead of keeping the annotation reachable in a full rewrite.
+/// Direct callers discard the failed working copy to preserve the original document.
+#[test]
+fn an_element_written_in_place_that_tags_a_removed_annotation_is_refused() {
+ let objs: Vec> = vec![
+ b"<< /Type /Catalog /Pages 2 0 R /MarkInfo << /Marked true >> /StructTreeRoot 5 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 300 300] /Annots [4 0 R] >>".to_vec(),
+ b"<< /Type /Annot /Subtype /Text /Rect [0 0 10 10] /Contents (hushhush note) >>".to_vec(),
+ b"<< /Type /StructTreeRoot /K 6 0 R /IDTree << /Names [(note1) << /Type /StructElem /S /Annot /P 6 0 R /K [<< /Type /OBJR /Obj 4 0 R >>] >>] >> >>".to_vec(),
+ b"<< /Type /StructElem /S /Document /P 5 0 R >>".to_vec(),
+ ];
+ let original = pdf(objs);
+ let before = write_full(&original, &SaveOptions::default()).unwrap();
+ let mut working = original.clone();
+ assert!(matches!(sanitize::remove_hidden(&mut working, &[sanitize::Hidden::Comments]), Err(RedactError::Tags)));
+ assert!(!working.get(ObjRef::new(3, 0)).as_dict().unwrap().contains(b"Annots"), "the failed working copy was partially changed");
+ drop(working);
+ assert_eq!(write_full(&original, &SaveOptions::default()).unwrap(), before, "discarding the working copy preserves the original");
+}
+
+/// Whether a full rewrite of `doc` still holds the redacted word.
+fn bluefin_survives(doc: &Document) -> bool {
+ let bytes = write_full(doc, &SaveOptions { object_streams: false, ..SaveOptions::default() }).unwrap();
+ bytes.windows(7).any(|w| w == b"Bluefin")
+}
+
+/// #3285 review (the maintainer's probes): an element written in place in the parent tree that
+/// owns the redacted text can't be changed, so applying is refused; the same element as an object
+/// of its own is cleaned, and nothing of the word is left in a full rewrite.
+/// Refusal does not promise rollback: direct callers discard the failed working copy.
+#[test]
+fn an_element_written_in_place_owning_redacted_text_is_refused() {
+ let objs = |element: &str| -> Vec> {
+ vec![
+ b"<< /Type /Catalog /Pages 2 0 R /StructTreeRoot 5 0 R /MarkInfo << /Marked true >> >>".to_vec(),
+ b"<< /Type /Pages /Kids [3 0 R] /Count 1 >>".to_vec(),
+ b"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 300 300] /Contents 4 0 R /StructParents 0 /Resources << /Font << /F1 9 0 R >> >> >>"
+ .to_vec(),
+ stream("", b"/P <> BDC BT /F1 14 Tf 20 150 Td (Codename Bluefin) Tj ET EMC"),
+ format!("<< /Type /StructTreeRoot /K 6 0 R /ParentTree << /Nums [0 [{element}]] >> /ParentTreeNextKey 1 >>").into_bytes(),
+ b"<< /Type /StructElem /S /Document /P 5 0 R >>".to_vec(),
+ b"<< /Type /StructElem /S /P /P 6 0 R /Pg 3 0 R /K 0 /ActualText (Codename Bluefin) >>".to_vec(),
+ b"<< >>".to_vec(),
+ b"<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding /WinAnsiEncoding >>".to_vec(),
+ ]
+ };
+ let mut doc = pdf(objs("<< /Type /StructElem /S /P /P 6 0 R /Pg 3 0 R /K 0 /ActualText (Codename Bluefin) >>"));
+ mark(&mut doc, 0, &[[10.0, 140.0, 290.0, 170.0]], "");
+ let before = write_full(&doc, &SaveOptions::default()).unwrap();
+ let mut working = doc.clone();
+ let error = apply(&mut working, None).unwrap_err();
+ assert!(matches!(error, RedactError::Tags));
+ assert_eq!(
+ error.to_string(),
+ "the document's tags can't be cleaned completely (an element written in place, or a structure tree too large or too deep to follow); the operation was refused"
+ );
+ assert!(!content(&working, 0).contains("Bluefin"), "the failed working copy was partially changed");
+ assert!(bluefin_survives(&working), "the failed working copy still holds the word in its tags");
+ drop(working);
+ assert_eq!(write_full(&doc, &SaveOptions::default()).unwrap(), before, "discarding the working copy preserves the original");
+ let mut doc = pdf(objs("7 0 R"));
+ mark(&mut doc, 0, &[[10.0, 140.0, 290.0, 170.0]], "");
+ let r = apply(&mut doc, None).unwrap();
+ assert!(r.glyphs > 0 && !bluefin_survives(&doc), "{r:?}");
+}
+
+/// #3285 review: an element whose inherited page can't be found (more ancestors than the lookup
+/// follows) isn't taken for one with no page: applying is refused. Within the limit the page is
+/// found and the text goes.
+#[test]
+fn an_element_whose_inherited_page_is_out_of_reach_is_refused() {
+ let objs = |depth: u32| -> Vec> {
+ let mut objs: Vec> = vec![
+ b"<< /Type /Catalog /Pages 2 0 R /StructTreeRoot 5 0 R /MarkInfo << /Marked true >> >>".to_vec(),
+ b"<< /Type /Pages /Kids [3 0 R] /Count 1 >>".to_vec(),
+ b"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 300 300] /Contents 4 0 R /StructParents 0 /Resources << /Font << /F1 9 0 R >> >> >>"
+ .to_vec(),
+ stream("", b"/P <> BDC BT /F1 14 Tf 20 150 Td (Codename Bluefin) Tj ET EMC"),
+ b"<< /Type /StructTreeRoot /K 6 0 R /ParentTree << /Nums [0 [7 0 R]] >> /ParentTreeNextKey 1 >>".to_vec(),
+ b"<< /Type /StructElem /S /Document /P 5 0 R /Pg 3 0 R >>".to_vec(),
+ format!("<< /Type /StructElem /S /P /P {} 0 R /K 0 /ActualText (Codename Bluefin) >>", 10 + depth - 1).into_bytes(),
+ b"<< >>".to_vec(),
+ b"<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding /WinAnsiEncoding >>".to_vec(),
+ ];
+ for i in 0..depth {
+ let parent = if i == 0 { 6 } else { 10 + i - 1 };
+ objs.push(format!("<< /Type /StructElem /S /Div /P {parent} 0 R >>").into_bytes());
+ }
+ objs
+ };
+ let mut doc = pdf(objs(300));
+ mark(&mut doc, 0, &[[10.0, 140.0, 290.0, 170.0]], "");
+ assert!(matches!(apply(&mut doc, None), Err(RedactError::Tags)));
+ let mut doc = pdf(objs(100));
+ mark(&mut doc, 0, &[[10.0, 140.0, 290.0, 170.0]], "");
+ apply(&mut doc, None).unwrap();
+ assert!(!bluefin_survives(&doc));
}
/// Issue #1325: removing comments keeps the annotations that are not comments: the multimedia
diff --git a/crates/render/src/raster.rs b/crates/render/src/raster.rs
index b2127894fd..45641178de 100644
--- a/crates/render/src/raster.rs
+++ b/crates/render/src/raster.rs
@@ -1334,6 +1334,10 @@ mod tests {
assert!(parser.acquire(|| panic!("must not retry"), || false).unwrap().is_none());
}
+ /// The waits here only guard against a hang; they don't time anything. The initializer is
+ /// held until the end of the test, so a waiter that can't cancel waits forever and fails the
+ /// guard however long it is, and a correct one returns as soon as its thread runs. Two
+ /// seconds was too short for a busy FreeBSD VM in CI to start the threads (#1970).
#[cfg(not(target_arch = "wasm32"))]
#[test]
fn waiting_worker_can_cancel_before_initializer_returns() {
@@ -1341,6 +1345,7 @@ mod tests {
sync::{atomic::AtomicUsize, mpsc},
time::Duration,
};
+ const HANG_GUARD: Duration = Duration::from_secs(600);
let parser = Arc::new(PoolParser::default());
let (started_tx, started_rx) = mpsc::channel();
let (release_tx, release_rx) = mpsc::channel();
@@ -1350,14 +1355,16 @@ mod tests {
parser.acquire(
|| {
started_tx.send(()).unwrap();
- release_rx.recv().unwrap();
+ // A failed test drops the sender: that ends the hold, it is not a
+ // second failure.
+ let _ = release_rx.recv();
Pdf::new(ONE_PAGE.to_vec()).ok()
},
|| false,
)
})
};
- started_rx.recv_timeout(Duration::from_secs(2)).unwrap();
+ started_rx.recv_timeout(HANG_GUARD).expect("the initializer starts");
let cancelled = Arc::new(AtomicBool::new(false));
let checks = Arc::new(AtomicUsize::new(0));
let (done_tx, done_rx) = mpsc::channel();
@@ -1374,13 +1381,13 @@ mod tests {
done_tx.send(result.unwrap().is_none()).unwrap();
})
};
- let deadline = std::time::Instant::now() + Duration::from_secs(2);
+ let deadline = std::time::Instant::now() + HANG_GUARD;
while checks.load(Ordering::Relaxed) == 0 {
- assert!(std::time::Instant::now() < deadline);
- std::thread::yield_now();
+ assert!(std::time::Instant::now() < deadline, "the waiter never checked for cancellation");
+ std::thread::sleep(Duration::from_millis(1));
}
cancelled.store(true, Ordering::Release);
- assert!(done_rx.recv_timeout(Duration::from_secs(2)).unwrap());
+ assert!(done_rx.recv_timeout(HANG_GUARD).expect("the waiter cancels while the initializer is held"));
waiter.join().unwrap();
release_tx.send(()).unwrap();
assert!(initializer.join().unwrap().unwrap().is_some());
@@ -2881,6 +2888,33 @@ trailer << /Root 1 0 R >>
assert_eq!(px(20, 80), vec![255, 0, 0, 255], "checkbox must show the /Yes appearance, not the decoy stream");
}
+ /// Microsoft Reporting Services (bank statements, invoices) writes `stream \r\n`, a space
+ /// before the line break. The content stream was then taken to start at the space and the
+ /// page rendered blank, while Preview, Chrome and Poppler showed it. Vendored hayro-syntax
+ /// patch: spaces and tabs before the line break are skipped, with the right `/Length` and
+ /// with a wrong one. Spaces with no line break after them never start stream data.
+ #[test]
+ fn space_before_the_line_break_after_stream_still_renders() {
+ let pdf = |eol: &str, length: u32| {
+ format!(
+ "%PDF-1.3\r\n1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj\r\n\
+ 2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 >> endobj\r\n\
+ 3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 100 100] /Contents 4 0 R >> endobj\r\n\
+ 4 0 obj << /Length {length} >> stream{eol}0 0 0 rg 20 20 60 60 re f\r\nendstream\r\nendobj\r\n\
+ trailer << /Root 1 0 R >>\r\n%%EOF"
+ )
+ .into_bytes()
+ };
+ for (eol, length) in [(" \r\n", 25), (" \n", 25), ("\t \r", 25), (" \r\n", 999), ("\r\n", 25)] {
+ let mut r = PageRenderer::new(Arc::new(pdf(eol, length)), RenderConfig::default());
+ let p = r.render(RenderRequest { page: 0, kind: RequestKind::Pixels, tile: None, scale: 1.0, tag: 0, ..Default::default() });
+ assert!(p.error.is_none(), "{eol:?}: {:?}", p.error);
+ let px = |x: u32, y: u32| p.rgba[((y * p.width + x) * 4) as usize..][..4].to_vec();
+ assert_eq!(px(50, 50), vec![0, 0, 0, 255], "stream{eol:?} (/Length {length}) must draw the square");
+ assert_eq!(px(5, 5), vec![255, 255, 255, 255], "outside the square stays white");
+ }
+ }
+
/// An appearance whose /BBox is a line or a point, or whose /Matrix collapses it to one, has
/// no mapping onto the annotation's /Rect: the scale (/Rect size over transformed box size)
/// divided by zero, and the infinite or NaN matrix reached the device with everything the
@@ -3392,6 +3426,36 @@ trailer << /Root 1 0 R >>
}
}
+ /// A shaded Type 3 glyph must use bounds in device coordinates at every position.
+ #[test]
+ fn shaded_type3_glyph_draws_at_multiple_positions() {
+ let content = "BT /F 30 Tf 1 0 0 1 50 70 Tm (A) Tj 1 0 0 1 220 70 Tm (A) Tj ET";
+ let glyph = "100 0 d0 0 0 100 100 re W n /S sh";
+ let pdf = format!(
+ "%PDF-1.7
+1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj
+2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 >> endobj
+3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 300 150] /Resources << /Font << /F 5 0 R >> >> /Contents 4 0 R >> endobj
+4 0 obj << /Length {} >> stream
+{content}
+endstream endobj
+5 0 obj << /Type /Font /Subtype /Type3 /FontBBox [0 0 100 100] /FontMatrix [0.01 0 0 0.01 0 0] /CharProcs << /A 6 0 R >> /Encoding << /Differences [65 /A] >> /FirstChar 65 /LastChar 65 /Widths [100] /Resources << /Shading << /S 7 0 R >> >> >> endobj
+6 0 obj << /Length {} >> stream
+{glyph}
+endstream endobj
+7 0 obj << /ShadingType 2 /ColorSpace /DeviceRGB /Coords [0 0 100 0] /Function << /FunctionType 2 /Domain [0 1] /C0 [1 0 0] /C1 [0 0 1] /N 1 >> /Extend [true true] >> endobj
+trailer << /Root 1 0 R >>
+%%EOF", content.len(), glyph.len()
+ );
+ let mut renderer = PageRenderer::new(Arc::new(pdf.into_bytes()), RenderConfig::default());
+ let page = renderer.render(RenderRequest { page: 0, kind: RequestKind::Pixels, tile: None, scale: 1.0, tag: 0, ..Default::default() });
+ assert!(page.error.is_none(), "{:?}", page.error);
+ for x in [65usize, 235] {
+ let pixel = &page.rgba[(65 * 300 + x) * 4..][..4];
+ assert!(pixel[0] < 240 && pixel[2] < 240 && pixel[1] < 10, "glyph at {x}: {pixel:?}");
+ }
+ }
+
/// From `cargo xtask fuzz`: a Type 3 font without /Resources inherits the page's, where its
/// own name is defined, and its glyph shows text in itself (vendored patch:
/// `MAX_PAINT_NESTING`). Must terminate; a normal Type 3 glyph must still paint.
@@ -3462,6 +3526,141 @@ trailer << /Root 1 0 R >>
assert_eq!(&page.rgba[((38 * 40 + 1) * 4)..][..4], &[255, 0, 0, 255]);
}
+ /// One page `w` × `h` points whose content `content` paints with pattern `/P1`: a 10 × 10 cell
+ /// of blue with `/XStep step /YStep step` and pattern matrix `matrix`. Its RGBA pixels.
+ fn tiling_page(w: usize, h: usize, content: &str, step: &str, matrix: &str) -> Vec {
+ let cell = "0 0 1 rg 0 0 10 10 re f";
+ let pdf = format!(
+ "%PDF-1.7
+1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj
+2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 >> endobj
+3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 {w} {h}] /Contents 4 0 R /Resources << /Pattern << /P1 5 0 R >> >> >> endobj
+4 0 obj << /Length {} >> stream
+{content}
+endstream endobj
+5 0 obj << /Type /Pattern /PatternType 1 /PaintType 1 /TilingType 3 /BBox [0 0 10 10] /XStep {step} /YStep {step} /Matrix [{matrix}] /Length {} >> stream
+{cell}
+endstream endobj
+trailer << /Root 1 0 R >>
+%%EOF",
+ content.len(),
+ cell.len()
+ );
+ let mut r = PageRenderer::new(Arc::new(pdf.into_bytes()), RenderConfig::default());
+ let page = r.render(RenderRequest { page: 0, kind: RequestKind::Pixels, tile: None, scale: 1.0, tag: 0, ..Default::default() });
+ assert!(page.error.is_none(), "{:?}", page.error);
+ page.rgba.to_vec()
+ }
+
+ /// #3349: PowerPoint (PDFMaker) writes a picture fill as a tiling pattern drawn once, with
+ /// `/XStep 2147480100 /YStep 2147480100` and a matrix that flips y. Fitting such a step into
+ /// a cell pixmap shrank the picture below a pixel, so it vanished. The cell's `/BBox` is drawn
+ /// at full scale, once, filled or stroked: where it is it shows, and around it (left, right,
+ /// above, below) nothing repeats or smears.
+ #[test]
+ fn a_tiling_pattern_drawn_once_shows_its_cell() {
+ let at = |px: &[u8], w: usize, x: usize, y: usize| px[(y * w + x) * 4..][..3].to_vec();
+ let (blue, white) = (vec![0, 0, 255], vec![255, 255, 255]);
+ // The cell at user x 10..20 and, flipped, y 10..20: device rows 20..30.
+ let once = tiling_page(40, 40, "/Pattern cs /P1 scn 0 0 40 40 re f", "2147480100", "1 0 0 -1 10 20");
+ assert_eq!(at(&once, 40, 15, 25), blue, "the cell");
+ for (x, y, side) in [(5, 25, "left"), (25, 25, "right"), (15, 15, "above"), (15, 35, "below")] {
+ assert_eq!(at(&once, 40, x, y), white, "nothing {side} of it");
+ }
+ // Stroked: a wide line across the cell shows it too.
+ let stroked = tiling_page(40, 40, "/Pattern CS /P1 SCN 20 w 0 15 m 40 15 l S", "2147480100", "1 0 0 -1 10 20");
+ assert_eq!(at(&stroked, 40, 15, 25), blue, "a stroke with the pattern");
+ assert_eq!(at(&stroked, 40, 25, 25), white);
+ // An ordinary step still tiles.
+ let tiled = tiling_page(40, 40, "/Pattern cs /P1 scn 0 0 40 40 re f", "20", "1 0 0 1 0 0");
+ assert_eq!(at(&tiled, 40, 25, 35), blue, "a step of 20 repeats the cell");
+ }
+
+ /// #3356 review: a `/BBox` too thin to divide by (not zero, which is refused, but 1e-40
+ /// wide) makes the cell scale infinite; it keeps the old path, so no infinite or NaN
+ /// geometry reaches the cell renderer, the page renders and what follows still draws.
+ #[test]
+ fn a_tiling_cell_too_thin_to_scale_keeps_the_old_path() {
+ assert!(hayro::usable_cell_scale(2.0, 1.0));
+ for (xs, ys) in [(f32::INFINITY, 1.0), (1.0, f32::NAN), (0.0, 1.0), (1.0, -2.0)] {
+ assert!(!hayro::usable_cell_scale(xs, ys), "{xs} {ys}");
+ }
+ let thin = "0.0000000000000000000000000000000000000001";
+ let content = "/Pattern cs /P1 scn 0 0 40 40 re f 1 0 0 rg 0 0 4 4 re f";
+ let cell = "0 0 1 rg 0 0 10 10 re f";
+ let pdf = format!(
+ "%PDF-1.7
+1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj
+2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 >> endobj
+3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 40 40] /Contents 4 0 R /Resources << /Pattern << /P1 5 0 R >> >> >> endobj
+4 0 obj << /Length {} >> stream
+{content}
+endstream endobj
+5 0 obj << /Type /Pattern /PatternType 1 /PaintType 1 /TilingType 3 /BBox [0 0 {thin} 10] /XStep 2147480100 /YStep 2147480100 /Length {} >> stream
+{cell}
+endstream endobj
+trailer << /Root 1 0 R >>
+%%EOF",
+ content.len(),
+ cell.len()
+ );
+ let mut r = PageRenderer::new(Arc::new(pdf.into_bytes()), RenderConfig::default());
+ let page = r.render(RenderRequest { page: 0, kind: RequestKind::Pixels, tile: None, scale: 1.0, tag: 0, ..Default::default() });
+ assert!(page.error.is_none(), "{:?}", page.error);
+ assert_eq!(&page.rgba[((38 * 40 + 1) * 4)..][..4], &[255, 0, 0, 255], "what follows still draws");
+ assert_eq!(&page.rgba[((20 * 40 + 20) * 4)..][..3], &[255, 255, 255], "the degenerate cell draws nothing");
+ }
+
+ /// #3349: a step too large for a cell pixmap is drawn as one cell only when the painted area
+ /// can't reach another: a page wide (or tall) enough to show the next cell, or the one before,
+ /// still tiles them.
+ #[test]
+ fn a_large_step_still_tiles_where_the_next_cell_shows() {
+ let at = |px: &[u8], w: usize, x: usize, y: usize| px[(y * w + x) * 4..][..3].to_vec();
+ let blue = vec![0, 0, 255];
+ // Steps of 3100 points: over 3000 pixels at this scale, so the cell is shrunk to fit,
+ // by 3000/3100; a pixel 4 points into a cell is blue either way.
+ let wide = "/Pattern cs /P1 scn 0 0 3200 20 re f";
+ let next = tiling_page(3200, 20, wide, "3100", "1 0 0 1 0 0");
+ assert_eq!(at(&next, 3200, 3104, 15), blue, "the cell to the right");
+ let before = tiling_page(3200, 20, wide, "3100", "1 0 0 1 3100 0");
+ assert_eq!(at(&before, 3200, 4, 15), blue, "the cell to the left");
+ let tall = "/Pattern cs /P1 scn 0 0 20 3200 re f";
+ let above = tiling_page(20, 3200, tall, "3100", "1 0 0 1 0 0");
+ assert_eq!(at(&above, 20, 4, 3200 - 3104), blue, "the cell above");
+ let below = tiling_page(20, 3200, tall, "3100", "1 0 0 1 0 3100");
+ assert_eq!(at(&below, 20, 4, 3200 - 4), blue, "the cell below");
+ // A stroke counts with its width: this line ends at x 3080, short of the next cell at
+ // 3100, but its square cap reaches 3110.
+ let stroked = tiling_page(3200, 20, "/Pattern CS /P1 SCN 60 w 2 J 0 10 m 3080 10 l S", "3100", "1 0 0 1 0 0");
+ assert_eq!(at(&stroked, 3200, 3104, 15), blue, "the next cell, under the stroke's cap");
+ // A miter reaches further than the line width (review on #3356): this join's vertex is
+ // at x 3085, 15 short of the next cell with a 10-wide line, but its miter tip, 45 long
+ // under the default limit of 10, reaches 3130.
+ let mitered = tiling_page(3200, 20, "/Pattern CS /P1 SCN 10 w 0 j 3000 0 m 3085 10 l 3000 20 l S", "3100", "1 0 0 1 0 0");
+ // The tip narrows to a point, so this pixel is partly covered: blue, not white.
+ let tip = at(&mitered, 3200, 3104, 10);
+ assert!(tip[0] < 160 && tip[1] < 160 && tip[2] > 200, "the next cell, under the miter's tip: {tip:?}");
+ // Under a skew, a direction between the axes stretches more than either axis (review on
+ // #3356). This join's vertex is at device x 2980 and its miter tip, along that direction,
+ // at 3107: the larger axis stretch (1.118) put the reach at 3092, short of the next cell;
+ // the most any direction stretches (1.461) puts it at 3126.
+ let skewed = tiling_page(
+ 3200,
+ 20,
+ "/Pattern CS /P1 SCN 1 0 1 0.5 0 0 cm 20 w 0 j 2783.936 -366.118 m 3033.6 -53.6 l 2721.082 -303.264 l S",
+ "3100",
+ "1 0 0 1 0 0",
+ );
+ // The tip narrows to a point, so its pixels are partly covered.
+ let under_tip = (3100..3108).flat_map(|x| (10..20).map(move |y| (x, y))).map(|(x, y)| at(&skewed, 3200, x, y));
+ assert!(
+ under_tip.clone().any(|px| px[0] < 200 && px[1] < 200 && px[2] > 240),
+ "the next cell, under the skewed miter's tip: {:?}",
+ under_tip.collect::>()
+ );
+ }
+
/// Contributor-original JPEG 2000: one unsigned 8-bit pixel per component, one tile,
/// no decomposition, reversible wavelet, and one empty packet per component. Empty
/// coefficients decode to the midpoint (128) after the unsigned DC level shift.
@@ -3503,6 +3702,60 @@ trailer << /Root 1 0 R >>
pdf
}
+ /// [`jpx_midpoint_pdf`]'s codestream (every sample 128) wrapped in a JP2 file whose header
+ /// has a colour specification naming enumerated colour space `enumerated`.
+ fn jp2_midpoint_pdf(components: u16, enumerated: u32) -> Vec {
+ let raw = jpx_midpoint_pdf(8, components);
+ let start = raw.windows(4).position(|w| w == [0xff, 0x4f, 0xff, 0x51]).unwrap();
+ let end = raw.windows(2).rposition(|w| w == [0xff, 0xd9]).unwrap() + 2;
+ let codestream = &raw[start..end];
+ let jp2_box = |kind: &[u8], body: &[u8]| [&((8 + body.len()) as u32).to_be_bytes()[..], kind, body].concat();
+ let mut ihdr = Vec::new();
+ ihdr.extend_from_slice(&1_u32.to_be_bytes()); // height
+ ihdr.extend_from_slice(&1_u32.to_be_bytes()); // width
+ ihdr.extend_from_slice(&components.to_be_bytes());
+ ihdr.extend_from_slice(&[7, 7, 0, 0]); // 8 bits, wavelet, colour known, no IPR
+ let colr = [&[1, 0, 0][..], &enumerated.to_be_bytes()].concat();
+ let jp2 = [
+ vec![0, 0, 0, 12, b'j', b'P', b' ', b' ', 0x0d, 0x0a, 0x87, 0x0a],
+ jp2_box(b"ftyp", b"jp2 \0\0\0\0jp2 "),
+ jp2_box(b"jp2h", &[jp2_box(b"ihdr", &ihdr), jp2_box(b"colr", &colr)].concat()),
+ jp2_box(b"jp2c", codestream),
+ ]
+ .concat();
+ let content = "q 20 0 0 20 5 5 cm /Im1 Do Q";
+ let mut pdf = format!(
+ "%PDF-1.7\n\
+1 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 40 40] /Contents 4 0 R /Resources << /XObject << /Im1 5 0 R >> >> >> endobj\n\
+4 0 obj << /Length {} >> stream\n{content}\nendstream endobj\n\
+5 0 obj << /Type /XObject /Subtype /Image /Width 1 /Height 1 /BitsPerComponent 8 /Filter /JPXDecode /Length {} >> stream\n",
+ content.len(),
+ jp2.len()
+ )
+ .into_bytes();
+ pdf.extend_from_slice(&jp2);
+ pdf.extend_from_slice(b"\nendstream endobj\ntrailer << /Root 1 0 R >>\n%%EOF");
+ pdf
+ }
+
+ /// #3306: a JP2 image whose colour specification names an enumerated colour space the
+ /// decoder refuses (CIEJab in a veraPDF fixture, or YPbPr, or bilevel for one component) is
+ /// drawn as if it had none, by its component count, as MuPDF and Poppler draw it: the
+ /// midpoint image shows mid-grey instead of nothing. A space the decoder knows (sRGB) is
+ /// drawn as before.
+ #[test]
+ fn a_jp2_image_in_a_refused_enumerated_colour_space_is_drawn() {
+ for (components, enumerated) in [(3, 19), (3, 22), (1, 15), (3, 16)] {
+ let mut r = PageRenderer::new(Arc::new(jp2_midpoint_pdf(components, enumerated)), RenderConfig::default());
+ let page = r.render(RenderRequest { page: 0, kind: RequestKind::Pixels, tile: None, scale: 1.0, tag: 0, ..Default::default() });
+ assert!(page.error.is_none(), "{enumerated}: {:?}", page.error);
+ let px = &page.rgba[((40 - 15) * 40 + 15) * 4..][..3];
+ assert!(px.iter().all(|&v| (120..=136).contains(&v)), "enum {enumerated}, {components} component(s): {px:?}");
+ }
+ }
+
#[test]
fn jpx_stream_bit_depth_boundaries_do_not_panic() {
use hayro::hayro_syntax::bit_reader::BitReader;
@@ -3631,6 +3884,46 @@ endstream endobj
}
}
+ /// #3308: a fax image whose `/Rows` says fewer rows than its `/Height` (a plotter driver wrote
+ /// `/Rows` equal to `/Columns`) is decoded to its height: only the first `/Rows` rows were
+ /// decoded and the rest drawn black, as MuPDF and Acrobat don't. `/BlackIs1` turns the fixture
+ /// white, so rows left undecoded show as black. Also through an `/Indexed` colour space, the
+ /// filter's other output path.
+ #[test]
+ fn group4_fax_with_rows_below_its_height_decodes_to_the_image_height() {
+ const ALL_BLACK_8X4: [u8; 6] = [0x26, 0xa2, 0xfe, 0x00, 0x20, 0x02];
+ for (space, rows) in [("/DeviceGray", "/Rows 2"), ("[/Indexed /DeviceRGB 1 <000000FFFFFF>]", "/Rows 2"), ("/DeviceGray", "/Rows 4")] {
+ let content = "q 20 0 0 20 5 5 cm /Im1 Do Q";
+ let mut pdf = format!(
+ "%PDF-1.7
+1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj
+2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 >> endobj
+3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 40 40] /Contents 4 0 R /Resources << /XObject << /Im1 5 0 R >> >> >> endobj
+4 0 obj << /Length {} >> stream
+{content}
+endstream endobj
+5 0 obj << /Type /XObject /Subtype /Image /Width 8 /Height 4 /ColorSpace {space} /BitsPerComponent 1 /Filter /CCITTFaxDecode /DecodeParms << /K -1 /Columns 8 {rows} /BlackIs1 true >> /Length {} >> stream
+",
+ content.len(),
+ ALL_BLACK_8X4.len()
+ )
+ .into_bytes();
+ pdf.extend_from_slice(&ALL_BLACK_8X4);
+ pdf.extend_from_slice(
+ b"
+endstream endobj
+trailer << /Root 1 0 R >>
+%%EOF",
+ );
+ let mut renderer = PageRenderer::new(Arc::new(pdf), RenderConfig::default());
+ let page = renderer.render(RenderRequest { page: 0, kind: RequestKind::Pixels, tile: None, scale: 1.0, tag: 0, ..Default::default() });
+ assert!(page.error.is_none(), "{space} {rows}: {:?}", page.error);
+ for y in [17, 22, 27, 32] {
+ assert_eq!(&page.rgba[((y * 40 + 15) * 4)..][..4], &[255, 255, 255, 255], "{space} {rows}: image row at y {y} is decoded");
+ }
+ }
+ }
+
#[test]
fn tiff_predictor_packed_rgb_rows_keep_their_byte_boundaries() {
use hayro_syntax::object::{FromBytes, Object};
diff --git a/crates/sign/src/windows.rs b/crates/sign/src/windows.rs
index 3974aff8ca..275bdb52c8 100644
--- a/crates/sign/src/windows.rs
+++ b/crates/sign/src/windows.rs
@@ -2,7 +2,7 @@
//! 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
+//! sign with, a key usage or extended key usage that doesn't allow signing documents) 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;
@@ -161,6 +161,13 @@ fn entries() -> Result {
continue;
}
}
+ // A smart card holds encryption and logon certificates beside the signing one, all under
+ // the same name; a signature made with them doesn't validate, so they aren't offered.
+ // Checked after the key, so a certificate without one stays out of the list entirely.
+ if let Some(reason) = certificate.signing_problem_clause() {
+ skip(certificate.display_name(), reason, false);
+ continue;
+ }
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());
diff --git a/crates/sign/src/x509.rs b/crates/sign/src/x509.rs
index 1a0ef58ade..6f4a731e7a 100644
--- a/crates/sign/src/x509.rs
+++ b/crates/sign/src/x509.rs
@@ -384,6 +384,15 @@ impl Certificate {
}
}
+ /// [`signing_problem`](Self::signing_problem) as a clause that follows the certificate's
+ /// name ("it is not issued for signing documents (…)"), for lists of digital IDs.
+ pub fn signing_problem_clause(&self) -> Option {
+ self.signing_problem().map(|d| match d.message.strip_prefix("The signer's certificate ") {
+ Some(rest) => format!("it {}", rest.strip_suffix('.').unwrap_or(rest)),
+ None => d.message,
+ })
+ }
+
/// Issued by itself (subject = issuer and its own key verifies it).
pub fn is_self_signed(&self) -> bool {
self.issuer.raw == self.subject.raw && self.signed_by(&self.public_key)
diff --git a/crates/sign/tests/chain.rs b/crates/sign/tests/chain.rs
index ece529765a..59834104ee 100644
--- a/crates/sign/tests/chain.rs
+++ b/crates/sign/tests/chain.rs
@@ -281,6 +281,21 @@ fn a_trusted_signer_whose_certificate_may_not_sign_is_not_valid() {
assert_eq!(status, Status::Valid, "{details:?}");
}
+#[test]
+fn a_smart_cards_certificates_that_may_not_sign_say_why_in_id_lists() {
+ // A DoD CAC's four certificates, all under the same name; only the signing one may sign.
+ let r = root();
+ let clause = |exts: &[Vec]| signer_with("DOE.JANE.1234567890", &r, exts).cert.signing_problem_clause();
+ let encryption = clause(&[key_usage(KEY_ENCIPHERMENT), extended_key_usage(&["1.3.6.1.5.5.7.3.4"])]);
+ assert_eq!(encryption.as_deref(), Some("it does not allow digital signatures (its key usage has neither digital signature nor non-repudiation)"));
+ let logon = clause(&[key_usage(DIGITAL_SIGNATURE), extended_key_usage(&["1.3.6.1.4.1.311.20.2.2", "1.3.6.1.5.5.7.3.2"])]);
+ assert_eq!(logon.as_deref(), Some("it is not issued for signing documents (its extended key usage has no document-signing purpose)"));
+ let card_auth = clause(&[key_usage(DIGITAL_SIGNATURE), extended_key_usage(&["2.16.840.1.101.3.6.8"])]);
+ assert_eq!(card_auth, logon);
+ let signing = clause(&[key_usage(DIGITAL_SIGNATURE | 0x40), extended_key_usage(&["1.3.6.1.5.5.7.3.4", "1.3.6.1.4.1.311.10.3.12"])]);
+ assert_eq!(signing, None);
+}
+
#[test]
fn unreadable_constraint_extensions_fail_closed() {
// keyUsage present but not a BIT STRING: no usage at all, so no keyCertSign.
diff --git a/crates/testkit/src/lib.rs b/crates/testkit/src/lib.rs
index c9f5e55ef2..b4f69a2808 100644
--- a/crates/testkit/src/lib.rs
+++ b/crates/testkit/src/lib.rs
@@ -25,6 +25,8 @@
//! This crate is test scaffolding (`Class::Testkit` in `xtask/src/layers.rs`): a dev-dependency
//! only, never compiled into the product.
+#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
+
/// A small PDF with a classic cross-reference table, built one object at a time.
///
/// Objects are numbered from 1 in the order they are added, all at generation 0, so the first
diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs
index 00ff0eb3a5..eb9244a284 100644
--- a/crates/ui-egui/src/canvas.rs
+++ b/crates/ui-egui/src/canvas.rs
@@ -482,6 +482,9 @@ pub struct DocView {
/// (#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,
+ /// Per-axis line-wheel state; horizontal input cannot extend a vertical burst.
+ wheel_boost_x: crate::wheel_boost::WheelBoost,
+ wheel_boost_y: crate::wheel_boost::WheelBoost,
/// Touchpad momentum after the fingers lift (#531).
fling: crate::fling::Fling,
pub(crate) auto_scroll: crate::autoscroll::AutoScroll,
@@ -756,6 +759,8 @@ impl DocView {
shown: false,
wheel: Default::default(),
precise_scroll_until: 0.0,
+ wheel_boost_x: Default::default(),
+ wheel_boost_y: Default::default(),
fling: Default::default(),
auto_scroll: Default::default(),
selected: BTreeSet::new(),
@@ -835,6 +840,7 @@ impl DocView {
self.flash = None;
// A thumbnail drag holds page indexes from before the change.
self.panel_drag = None;
+ self.reset_coarse_state();
}
/// The document was replaced by a newer version of its file (#431). The reader's place
@@ -1153,6 +1159,7 @@ impl DocView {
.unwrap_or(0.0);
self.goto = Some((page, frac));
self.current = page;
+ self.reset_coarse_state();
}
/// Screen position of the centre of glyph `glyph` on `page`, if that page is on screen and its
@@ -1174,6 +1181,14 @@ impl DocView {
self.thumbs.get(&page).map(|t| &t.tex)
}
+ /// Drops wheel state after a discontinuous jump so the destination cannot
+ /// inherit momentum from the previous viewport.
+ fn reset_coarse_state(&mut self) {
+ self.wheel.clear_motion();
+ self.wheel_boost_x.reset();
+ self.wheel_boost_y.reset();
+ }
+
pub fn go_to_page(&mut self, page: usize) {
let page = page.min(self.page_count.saturating_sub(1));
if page != self.current {
@@ -1187,7 +1202,7 @@ impl DocView {
self.forward.clear();
}
// Partial wheel motion belongs to the old place.
- self.wheel.clear_motion();
+ self.reset_coarse_state();
self.goto = Some((page, 0.0));
self.current = page;
}
@@ -1322,6 +1337,7 @@ impl DocView {
let page = page.min(self.page_count.saturating_sub(1));
self.goto = Some((page, 0.0));
self.current = page;
+ self.reset_coarse_state();
true
}
@@ -1346,12 +1362,14 @@ impl DocView {
pub fn rotate_view(&mut self, clockwise: bool) {
self.rotation = (self.rotation + if clockwise { 90 } else { 270 }) % 360;
self.goto = Some((self.current, 0.0));
+ self.reset_coarse_state();
}
/// Switch the zoom mode, keeping the current page in view.
pub fn set_fit(&mut self, fit: Fit) {
self.fit = fit;
self.goto = Some((self.current, 0.0));
+ self.reset_coarse_state();
}
/// Switch the base page display (Single or Two-page), leaving continuous scrolling as it is.
@@ -1363,6 +1381,7 @@ impl DocView {
}
self.layout = layout;
self.goto = Some((self.current, 0.0));
+ self.reset_coarse_state();
}
/// Whether the view scrolls continuously (as opposed to one page or spread at a time).
@@ -1376,6 +1395,7 @@ impl DocView {
if self.continuous != continuous {
self.continuous = continuous;
self.goto = Some((self.current, 0.0));
+ self.reset_coarse_state();
}
}
@@ -1391,6 +1411,7 @@ impl DocView {
if self.cover != cover {
self.cover = cover;
self.goto = Some((self.current, 0.0));
+ self.reset_coarse_state();
}
}
@@ -2037,6 +2058,14 @@ fn annotation_hover_text(a: &pdfcraft_render::Annotation) -> Option {
Some(if body.is_empty() { who } else { format!("{who}\n{body}") })
}
+/// The scroll offset that puts `point` (content coordinates) at `rel` in the viewport, kept within
+/// the content as egui keeps it. egui clamps only after drawing, so an offset past the end showed
+/// the page misplaced for a frame and then jumped (zooming out beside a page that fits).
+fn anchored_scroll(point: Pos2, rel: Vec2, content: Vec2, viewport: Vec2) -> Vec2 {
+ let max = vec2((content.x - viewport.x).max(0.0), (content.y - viewport.y).max(0.0));
+ vec2((point.x - rel.x).clamp(0.0, max.x), (point.y - rel.y).clamp(0.0, max.y))
+}
+
/// The request tag for a raster at `scale`: equal tags mean the same scale (to 1/65536).
fn scale_tag(scale: f32) -> u64 {
(f64::from(scale) * 65536.0).round() as u64
@@ -2318,6 +2347,23 @@ pub fn shortcuts(view: &mut DocView, ctx: &egui::Context, speed: crate::scroll_s
/// How far ↓ / ↑ scroll at Normal speed (Scroll speed halves or doubles it). Page Down / Page Up
/// keep this much of the previous screen in view.
const KEY_SCROLL_LINE: f32 = 40.0;
+/// Suppression window for egui's eased copy of point input.
+///
+/// egui 0.36.2 drains `WheelState::after_events` tail deltas by 90% per 0.1 s.
+const SUPPRESS_SECS: f64 = 0.25;
+/// Short rolling extension while egui still has an unprocessed wheel tail.
+const SUPPRESS_TAIL_SECS: f64 = 0.05;
+
+// The viewer moves between docking and the central panel as modes change; keep its scroll
+// identity tied to the document instead of either parent UI.
+fn page_scroll(
+ ui: &mut egui::Ui,
+ scroll: egui::ScrollArea,
+ id: egui::Id,
+ draw: impl FnOnce(&mut egui::Ui, egui::Rect) -> R,
+) -> egui::scroll_area::ScrollAreaOutput {
+ ui.scope_builder(egui::UiBuilder::new().id(id), |ui| scroll.show_viewport(ui, draw)).inner
+}
pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) {
let t = Tokens::get(ui.ctx());
@@ -2507,36 +2553,25 @@ 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).
+ // Point input bypasses egui smoothing for 1:1 motion. Line input keeps egui's configured,
+ // smoothed base and adds only the accelerated surplus. While point suppression is active,
+ // reconstruct line and page input here so current coarse events survive without exposing an
+ // older eased tail.
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;
- }
+ let input = if ui.rect_contains_pointer(avail) {
+ // Match the line distance used by egui's other scroll areas.
+ let live = ui.ctx().options(|o| o.input_options.line_scroll_speed);
+ let line_points = if live.is_finite() && live > 0.0 { live } else { crate::scroll_speed::EGUI_LINE_POINTS };
+ ui.input(|i| crate::wheel_boost::accumulate(&i.events, line_points, single_paging))
+ } else {
+ crate::wheel_boost::FrameInput::default()
+ };
+ let mut precise_scroll = input.precise;
+ let mut line_base = input.line;
+ let mut boost_extra = crate::wheel_boost::boost_extra(input, &mut view.wheel_boost_x, &mut view.wheel_boost_y, now);
+ let has_page = input.page != Vec2::ZERO;
+ if now.is_finite() && precise_scroll != Vec2::ZERO && !has_page {
+ view.precise_scroll_until = now + SUPPRESS_SECS;
}
if edge_turned {
// The turn leaves egui's eased copy of the notch to decay for a few frames; it must not
@@ -2549,11 +2584,40 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) {
let paging_owns_vertical = single_paging || (!view.continuous && view.wheel.gesture_turned());
if paging_owns_vertical {
precise_scroll.y = 0.0;
+ line_base.y = 0.0;
+ boost_extra.y = 0.0;
}
if now < view.precise_scroll_until {
+ if ui.input(|i| i.is_scrolling()) {
+ // Keep hiding egui's unprocessed copy until it has drained. The short rolling grace
+ // avoids a delayed duplicate without prolonging suppression after the tail is gone.
+ view.precise_scroll_until = view.precise_scroll_until.max(now + SUPPRESS_TAIL_SECS);
+ }
ui.input_mut(|i| i.smooth_scroll_delta = Vec2::ZERO);
+ // The point path suppresses every eased wheel delta. Restore the line base through the
+ // same un-eased path as its surplus; paging-owned axes were cleared above.
+ boost_extra = crate::wheel_boost::cap_frame(boost_extra + line_base);
+ if has_page {
+ // Page units normally stay on egui's viewport-sized path. During suppression only,
+ // reproduce the current event directly so an older hidden tail stays hidden.
+ let page_scale = ui.input(|i| i.viewport_rect().height());
+ let mut page_delta =
+ if page_scale.is_finite() && page_scale > 0.0 { crate::wheel_boost::cap_frame(input.page * page_scale) } else { Vec2::ZERO };
+ if paging_owns_vertical {
+ page_delta.y = 0.0;
+ }
+ boost_extra = crate::wheel_boost::cap_frame(boost_extra + page_delta);
+ }
} else {
precise_scroll = Vec2::ZERO;
+ if line_base != Vec2::ZERO || has_page {
+ // Selection handling below uses zero to distinguish an inactive window.
+ view.precise_scroll_until = 0.0;
+ }
+ }
+ if has_page && paging_owns_vertical {
+ // Page input belongs to egui on the free axis; the pager still owns vertical motion.
+ ui.input_mut(|i| i.smooth_scroll_delta.y = 0.0);
}
// Touchpad momentum (#531): a swipe over the page lifted at speed glides on and slows to a
// stop, through the same un-eased path. Never while the pager owns the wheel, a gesture or
@@ -2631,7 +2695,7 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) {
if let Some((page, fx, fy, rel)) = view.zoom_anchor.take() {
let r = rects[page.min(rects.len() - 1)];
let point = pos2(r.left() + fx * r.width(), r.top() - y_shift + fy * r.height());
- scroll = scroll.scroll_offset(vec2((point.x - rel.x).max(0.0), (point.y - rel.y).max(0.0)));
+ scroll = scroll.scroll_offset(anchored_scroll(point, rel, vec2(content_w, content_h), avail.size()));
} else if let Some((page, frac)) = view.goto.take() {
let page = page.min(rects.len() - 1);
let r = rects[page];
@@ -2766,7 +2830,8 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) {
scroll = scroll.vertical_scroll_offset((r.top() - y_shift + fy * r.height() - rel.y).clamp(0.0, max_y));
}
}
- let out = scroll.show_viewport(ui, |ui, viewport| {
+ let selection_wheel = view.selection_drag && navigation_enabled && unobstructed && !single_paging && ui.rect_contains_pointer(avail);
+ let out = page_scroll(ui, scroll, egui::Id::new(("page-view-scope", view.id)), |ui, viewport| {
// egui's drag responses accept every pointer button. Keep a wheel gesture from also
// selecting text, drawing a mark, or panning with the Hand tool.
if middle_gesture {
@@ -2776,10 +2841,13 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) {
ui.set_opacity(opacity);
}
// Middle-button auto-scroll, the keyboard, precise touchpad input (#759) and its glide
- // (#531), and a drawing's edge scroll; positive y moves the content down. All scroll
- // without easing, unlike egui's wheel default.
- let delta = auto_delta + edge_delta - vec2(0.0, key_scroll) + precise_scroll + glide;
- if delta != Vec2::ZERO {
+ // (#531), accelerated wheel surplus (#1072), and a drawing's edge scroll; positive y
+ // moves the content down. All scroll without easing, unlike egui's wheel default.
+ // ScrollArea ignores wheel input during a widget drag. Defer the wheel contribution
+ // until after the text widgets when a selection owns that drag.
+ let wheel_delta = if selection_wheel { Vec2::ZERO } else { precise_scroll + boost_extra };
+ let delta = auto_delta + edge_delta - vec2(0.0, key_scroll) + wheel_delta + glide;
+ if delta != Vec2::ZERO && delta.is_finite() {
ui.scroll_with_delta_animation(delta, egui::style::ScrollAnimation::none());
}
let (resp_rect, resp) = ui.allocate_exact_size(vec2(content_w, content_h), Sense::click_and_drag());
@@ -3512,17 +3580,13 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) {
// ScrollArea ignores the wheel while any widget is dragged, so a text-selection drag
// scrolls the page itself (#721). After the page loop, so it keeps scrolling once the
// pointer leaves the selection's page for the gap or the next page; over the whole
- // document area, like #759. A Line/Page event clears precise_scroll_until; a Point
- // gesture's eased tail after its suppression deadline is not picked up.
- if view.selection_drag
- && navigation_enabled
- && unobstructed
- && !single_paging
- && view.precise_scroll_until == 0.0
- && ui.rect_contains_pointer(avail)
- {
- let delta = ui.input_mut(|input| std::mem::take(&mut input.smooth_scroll_delta));
- if delta != Vec2::ZERO {
+ // document area, like #759. Combine direct and smoothed motion in one request so mixed
+ // Point and Line events retain their full contribution.
+ if selection_wheel {
+ let smooth =
+ if view.precise_scroll_until == 0.0 { ui.input_mut(|input| std::mem::take(&mut input.smooth_scroll_delta)) } else { Vec2::ZERO };
+ let delta = precise_scroll + boost_extra + smooth;
+ if delta != Vec2::ZERO && delta.is_finite() {
ui.scroll_with_delta_animation(delta, egui::style::ScrollAnimation::none());
}
}
@@ -4438,11 +4502,8 @@ fn notices(
// A form whose scripts were turned off no longer calculates or answers its buttons: that
// comes before what else there is to say about the document.
let scripts_off = doc.xfa_scripts_off();
- let msg = if scripts_off {
- Some(("triangle-alert", crate::js_ui::scripts_off_notice(), true))
- } else if secured {
- Some(("lock", tl!("This document is secured. Some changes are restricted by its security settings.").to_string(), false))
- } else if let Some(x) = xfa {
+ // What the document is as a form, if it is one: what the bar says first, secured or not.
+ let form = if let Some(x) = xfa {
let lang = crate::i18n::current();
let pages = crate::i18n::trn(lang, x.pages as u64, "{n} page", "{n} pages");
let fields = crate::i18n::trn(lang, x.fields as u64, "{n} field", "{n} fields");
@@ -4473,6 +4534,21 @@ fn notices(
crate::i18n::fmt(tl!("This document contains {n} interactive form fields."), &[("n", &info.fields.len().to_string())]),
true,
))
+ } else {
+ None
+ };
+ let msg = if scripts_off {
+ Some(("triangle-alert", crate::js_ui::scripts_off_notice(), true))
+ } else if secured {
+ // A secured form still says what it is, and keeps Highlight fields: the security
+ // sentence follows the form's.
+ let security = tl!("This document is secured. Some changes are restricted by its security settings.");
+ Some(match form {
+ Some((icon, text, fields)) => (icon, format!("{text} {security}"), fields),
+ None => ("lock", security.to_string(), false),
+ })
+ } else if form.is_some() {
+ form
} else if repaired {
Some(("bandage", tl!("This file was damaged and has been repaired. Saving keeps the repaired version.").to_string(), false))
} else if !info.warnings.is_empty() {
@@ -4862,6 +4938,15 @@ pub(crate) fn drop_gap(cells: &[(usize, Rect)], p: Pos2) -> Option {
Some(if p.x < r.center().x { *i } else { i + 1 })
}
+/// Ctrl (Option on macOS) held when a page drag drops copies the dragged pages into the gap
+/// instead of moving them, as Acrobat's Organize Pages does.
+///
+/// The OS comes from the context, not `cfg!`: the wasm build runs on macOS too, where
+/// `cfg!(target_os = "macos")` is false, so Option has to copy there as well (#3092 review).
+fn copy_modifier(ctx: &egui::Context, m: &egui::Modifiers) -> bool {
+ if crate::commands::mac_shortcuts(ctx) { m.alt } else { m.ctrl }
+}
+
/// A "+" on a gap of the page grid: insert files there. Returns whether it was clicked.
fn gap_button(ui: &mut egui::Ui, gap: usize, at: Pos2, height: f32, label: String, t: &Tokens) -> bool {
let resp = ui.interact(Rect::from_center_size(at, Vec2::splat(22.0)), ui.id().with(("org-gap", gap)), Sense::click());
@@ -5093,6 +5178,8 @@ fn organize_grid(view: &mut DocView, info: &DocInfo, editable: bool, dirty: bool
}
// While dragging: the gap the pages would go to, drawn as a bar.
if let (Some(pages), Some(p)) = (view.org_drag.clone(), ui.input(|i| i.pointer.hover_pos())) {
+ // Ctrl (Option on macOS) held over the drop copies the pages; see #2997.
+ let copying = copy_modifier(ui.ctx(), &ui.input(|i| i.modifiers));
if let Some(gap) = drop_gap(&cells, p) {
let x = match cells.iter().find(|(i, _)| *i == gap) {
Some((_, r)) => r.left() + 3.0,
@@ -5100,8 +5187,18 @@ fn organize_grid(view: &mut DocView, info: &DocInfo, editable: bool, dirty: bool
};
let row = cells.iter().find(|(i, _)| *i == gap).or(cells.last()).map(|(_, r)| r.y_range()).unwrap_or(egui::Rangef::new(0.0, 0.0));
ui.painter().line_segment([pos2(x, row.min + 10.0), pos2(x, row.max - 10.0)], Stroke::new(3.0, t.accent));
+ let mut text_at = p + vec2(14.0, 14.0);
+ if copying {
+ // A "+" by the pointer: this drop copies the pages, it doesn't move them.
+ let badge = p + vec2(12.0, 12.0);
+ ui.painter().circle(badge, 9.0, t.accent, Stroke::new(1.0, t.accent));
+ let ink = Stroke::new(1.5, Color32::WHITE);
+ ui.painter().line_segment([badge - vec2(4.0, 0.0), badge + vec2(4.0, 0.0)], ink);
+ ui.painter().line_segment([badge - vec2(0.0, 4.0), badge + vec2(0.0, 4.0)], ink);
+ text_at.x += 18.0;
+ }
ui.painter().text(
- p + vec2(14.0, 14.0),
+ text_at,
Align2::LEFT_TOP,
if pages.len() == 1 { tl!("1 page").to_string() } else { crate::i18n::fmt(tl!("{n} pages"), &[("n", &pages.len().to_string())]) },
theme::medium(12.0),
@@ -5111,13 +5208,19 @@ fn organize_grid(view: &mut DocView, info: &DocInfo, editable: bool, dirty: bool
if drop {
view.org_drag = None;
if let Some(gap) = drop_gap(&cells, p) {
- // `to` counts positions without the moving pages.
- let to = gap - pages.iter().filter(|x| **x < gap).count();
- let first = pages[0];
- let contiguous = pages.windows(2).all(|w| w[1] == w[0] + 1);
- if !(contiguous && to == first) {
- view.pending_edit = Some(Edit::MovePages { pages: pages.clone(), to });
- view.selected = (to..to + pages.len()).collect();
+ if copying {
+ // The copies land at the gap as it is: nothing leaves its place.
+ view.pending_edit = Some(Edit::DuplicatePages { pages: pages.clone(), at: Some(gap) });
+ view.selected = (gap..gap + pages.len()).collect();
+ } else {
+ // `to` counts positions without the moving pages.
+ let to = gap - pages.iter().filter(|x| **x < gap).count();
+ let first = pages[0];
+ let contiguous = pages.windows(2).all(|w| w[1] == w[0] + 1);
+ if !(contiguous && to == first) {
+ view.pending_edit = Some(Edit::MovePages { pages: pages.clone(), to });
+ view.selected = (to..to + pages.len()).collect();
+ }
}
}
}
@@ -5213,6 +5316,19 @@ fn grayscale(mut img: egui::ColorImage) -> egui::ColorImage {
#[cfg(test)]
mod tests {
+ #[test]
+ fn zoom_anchor_scroll_stays_within_the_content() {
+ let (content, viewport) = (vec2(1000.0, 2000.0), vec2(800.0, 600.0));
+ // Past the end: clamped to the maximum (200, 1400), not left for egui to snap back.
+ assert_eq!(anchored_scroll(pos2(900.0, 1900.0), vec2(0.0, 0.0), content, viewport), vec2(200.0, 1400.0));
+ // Before the start.
+ assert_eq!(anchored_scroll(pos2(10.0, 10.0), vec2(300.0, 300.0), content, viewport), Vec2::ZERO);
+ // Inside: unchanged.
+ assert_eq!(anchored_scroll(pos2(150.0, 800.0), vec2(100.0, 100.0), content, viewport), vec2(50.0, 700.0));
+ // Content smaller than the viewport: no scrolling.
+ assert_eq!(anchored_scroll(pos2(400.0, 400.0), Vec2::ZERO, vec2(500.0, 500.0), viewport), vec2(0.0, 0.0));
+ }
+
#[test]
fn page_size_hint_zone_is_the_bottom_left_quadrant() {
let area = Rect::from_min_max(pos2(100.0, 50.0), pos2(500.0, 450.0));
diff --git a/crates/ui-egui/src/chrome.rs b/crates/ui-egui/src/chrome.rs
index fb9e0a7c0d..e01d752c40 100644
--- a/crates/ui-egui/src/chrome.rs
+++ b/crates/ui-egui/src/chrome.rs
@@ -533,88 +533,100 @@ pub fn mode_bar(app: &mut PdfCraftApp, ui: &mut egui::Ui) {
});
}
+/// Keep menus usable when their contents are taller than the window.
+pub(crate) fn scroll_menu(ui: &mut egui::Ui, add: impl FnOnce(&mut egui::Ui)) {
+ let height = (ui.ctx().content_rect().height() - 24.0).max(40.0);
+ egui::ScrollArea::vertical().max_height(height).auto_shrink([true, true]).show(ui, add);
+}
+
fn main_menu(app: &mut PdfCraftApp, ui: &mut egui::Ui) {
let t = Tokens::get(ui.ctx());
let resp = widgets::ghost_button(ui, "panel-left", tl!("Menu"));
egui::Popup::menu(&resp).show(|ui| {
- ui.set_min_width(230.0);
- ui.menu_button(tl!("File"), |ui| crate::commands::registry_menu(app, ui, "File"));
- ui.menu_button(tl!("Edit"), |ui| crate::commands::registry_menu(app, ui, "Edit"));
- ui.menu_button(tl!("Pages"), |ui| crate::commands::registry_menu(app, ui, "Pages"));
- ui.menu_button(tl!("View"), |ui| {
- if let Some(i) = app.active {
- let v = &mut app.views[i];
- let ctx = ui.ctx().clone();
- let key = |s| crate::commands::shortcut_label(&ctx, s);
- use crate::commands::{ACTUAL_SIZE, FIT_WIDTH, NEXT_VIEW, PAGE_LEVEL, PREV_VIEW, ROTATE_CCW, ROTATE_CW, ZOOM_IN, ZOOM_OUT};
- ui.label(egui::RichText::new(tl!("Zoom")).color(t.text_faint).small());
- if widgets::menu_item(ui, tl!("Actual size"), &key(ACTUAL_SIZE)).clicked() {
- v.set_zoom(1.0);
- }
- if widgets::menu_item(ui, tl!("Zoom to page level"), &key(PAGE_LEVEL)).clicked() {
- v.fit = Fit::Page;
- }
- if widgets::menu_item(ui, tl!("Fit to width"), &key(FIT_WIDTH)).clicked() {
- v.fit = Fit::Width;
- }
- if widgets::menu_item(ui, tl!("Fit to height"), "").clicked() {
- v.fit = Fit::Height;
- v.goto = Some((v.current, 0.0));
- }
- if widgets::menu_item(ui, tl!("Fit visible"), &crate::commands::command_shortcut_label(&ctx, "view.fit_visible")).clicked() {
- ui.close();
- app.execute("view.fit_visible");
- return;
- }
- if widgets::menu_item(ui, tl!("Zoom in"), &key(ZOOM_IN)).clicked() {
- v.zoom_step(true);
- }
- if widgets::menu_item(ui, tl!("Zoom out"), &key(ZOOM_OUT)).clicked() {
- v.zoom_step(false);
- }
- if widgets::menu_item(ui, tl!("Rotate view clockwise"), &key(ROTATE_CW)).clicked() {
- v.rotate_view(true);
- }
- if widgets::menu_item(ui, tl!("Rotate view counterclockwise"), &key(ROTATE_CCW)).clicked() {
- v.rotate_view(false);
- }
- ui.separator();
- ui.label(egui::RichText::new(tl!("Page navigation")).color(t.text_faint).small());
- if ui.add_enabled(!v.back.is_empty(), egui::Button::new(tl!("Previous view")).shortcut_text(key(PREV_VIEW))).clicked() {
- v.view_history(false);
- }
- if ui.add_enabled(!v.forward.is_empty(), egui::Button::new(tl!("Next view")).shortcut_text(key(NEXT_VIEW))).clicked() {
- v.view_history(true);
- }
- ui.separator();
- if let Some(id) = page_display_menu(ui, &app.views[i]) {
- app.execute(id);
- }
- ui.separator();
- }
- crate::commands::registry_menu(app, ui, "View");
- ui.menu_button(tl!("Display theme"), |ui| theme_menu(app, ui));
- ui.menu_button(tl!("Side panels"), |ui| {
- for (p, label) in [
- (RightPanel::Comments, tl!("Comments")),
- (RightPanel::Bookmarks, tl!("Bookmarks")),
- (RightPanel::Pages, tl!("Pages")),
- (RightPanel::Fields, tl!("Fields")),
- (RightPanel::Layers, tl!("Layers")),
- (RightPanel::Attachments, tl!("Attachments")),
- (RightPanel::Signatures, tl!("Signatures")),
- (RightPanel::Accessibility, tl!("Accessibility Checker")),
- (RightPanel::Tags, tl!("Tags")),
- (RightPanel::Search, tl!("Search")),
- (RightPanel::Compare, tl!("Compare")),
- ] {
- if ui.add(widgets::radio(app.right == Some(p), tl!(label))).clicked() {
- app.choose_right_panel(Some(p));
+ scroll_menu(ui, |ui| {
+ ui.set_min_width(230.0);
+ ui.menu_button(tl!("File"), |ui| scroll_menu(ui, |ui| crate::commands::registry_menu(app, ui, "File")));
+ ui.menu_button(tl!("Edit"), |ui| scroll_menu(ui, |ui| crate::commands::registry_menu(app, ui, "Edit")));
+ ui.menu_button(tl!("Pages"), |ui| scroll_menu(ui, |ui| crate::commands::registry_menu(app, ui, "Pages")));
+ ui.menu_button(tl!("View"), |ui| {
+ scroll_menu(ui, |ui| {
+ if let Some(i) = app.active {
+ let v = &mut app.views[i];
+ let ctx = ui.ctx().clone();
+ let key = |s| crate::commands::shortcut_label(&ctx, s);
+ use crate::commands::{ACTUAL_SIZE, FIT_WIDTH, NEXT_VIEW, PAGE_LEVEL, PREV_VIEW, ROTATE_CCW, ROTATE_CW, ZOOM_IN, ZOOM_OUT};
+ ui.label(egui::RichText::new(tl!("Zoom")).color(t.text_faint).small());
+ if widgets::menu_item(ui, tl!("Actual size"), &key(ACTUAL_SIZE)).clicked() {
+ v.set_zoom(1.0);
+ }
+ if widgets::menu_item(ui, tl!("Zoom to page level"), &key(PAGE_LEVEL)).clicked() {
+ v.fit = Fit::Page;
+ }
+ if widgets::menu_item(ui, tl!("Fit to width"), &key(FIT_WIDTH)).clicked() {
+ v.fit = Fit::Width;
+ }
+ if widgets::menu_item(ui, tl!("Fit to height"), "").clicked() {
+ v.fit = Fit::Height;
+ v.goto = Some((v.current, 0.0));
+ }
+ if widgets::menu_item(ui, tl!("Fit visible"), &crate::commands::command_shortcut_label(&ctx, "view.fit_visible")).clicked() {
+ ui.close();
+ app.execute("view.fit_visible");
+ return;
+ }
+ if widgets::menu_item(ui, tl!("Zoom in"), &key(ZOOM_IN)).clicked() {
+ v.zoom_step(true);
+ }
+ if widgets::menu_item(ui, tl!("Zoom out"), &key(ZOOM_OUT)).clicked() {
+ v.zoom_step(false);
+ }
+ if widgets::menu_item(ui, tl!("Rotate view clockwise"), &key(ROTATE_CW)).clicked() {
+ v.rotate_view(true);
+ }
+ if widgets::menu_item(ui, tl!("Rotate view counterclockwise"), &key(ROTATE_CCW)).clicked() {
+ v.rotate_view(false);
+ }
+ ui.separator();
+ ui.label(egui::RichText::new(tl!("Page navigation")).color(t.text_faint).small());
+ if ui.add_enabled(!v.back.is_empty(), egui::Button::new(tl!("Previous view")).shortcut_text(key(PREV_VIEW))).clicked() {
+ v.view_history(false);
+ }
+ if ui.add_enabled(!v.forward.is_empty(), egui::Button::new(tl!("Next view")).shortcut_text(key(NEXT_VIEW))).clicked() {
+ v.view_history(true);
+ }
+ ui.separator();
+ if let Some(id) = page_display_menu(ui, &app.views[i]) {
+ app.execute(id);
+ }
+ ui.separator();
}
- }
+ crate::commands::registry_menu(app, ui, "View");
+ ui.menu_button(tl!("Display theme"), |ui| theme_menu(app, ui));
+ ui.menu_button(tl!("Side panels"), |ui| {
+ scroll_menu(ui, |ui| {
+ for (p, label) in [
+ (RightPanel::Comments, tl!("Comments")),
+ (RightPanel::Bookmarks, tl!("Bookmarks")),
+ (RightPanel::Pages, tl!("Pages")),
+ (RightPanel::Fields, tl!("Fields")),
+ (RightPanel::Layers, tl!("Layers")),
+ (RightPanel::Attachments, tl!("Attachments")),
+ (RightPanel::Signatures, tl!("Signatures")),
+ (RightPanel::Accessibility, tl!("Accessibility Checker")),
+ (RightPanel::Tags, tl!("Tags")),
+ (RightPanel::Search, tl!("Search")),
+ (RightPanel::Compare, tl!("Compare")),
+ ] {
+ if ui.add(widgets::radio(app.right == Some(p), tl!(label))).clicked() {
+ app.choose_right_panel(Some(p));
+ }
+ }
+ })
+ });
+ })
});
- });
- ui.menu_button(tl!("Help"), |ui| crate::commands::registry_menu(app, ui, "Help"));
+ ui.menu_button(tl!("Help"), |ui| scroll_menu(ui, |ui| crate::commands::registry_menu(app, ui, "Help")));
+ })
});
}
diff --git a/crates/ui-egui/src/commands.rs b/crates/ui-egui/src/commands.rs
index 832ce7cfbd..b769c0c00c 100644
--- a/crates/ui-egui/src/commands.rs
+++ b/crates/ui-egui/src/commands.rs
@@ -627,7 +627,7 @@ impl PdfCraftApp {
"create.clipboard" => self.create_from_clipboard(),
"optimize.reduce" => self.reduce_file_size(),
"page.duplicate" => {
- self.apply_edit(Edit::DuplicatePages { pages: targets });
+ self.apply_edit(Edit::DuplicatePages { pages: targets, at: None });
}
// Two or more selected pages reverse among themselves; otherwise the whole document.
"page.reverse" => {
@@ -779,17 +779,19 @@ pub(crate) fn registry_menu(app: &mut PdfCraftApp, ui: &mut egui::Ui, menu: &str
let mut open: Option = None;
let mut clear = false;
ui.menu_button(label, |ui| {
- for r in &app.recent {
- if ui.button(&r.name).on_hover_text(&r.path).clicked() {
- open = Some(r.path.clone());
+ crate::chrome::scroll_menu(ui, |ui| {
+ for r in &app.recent {
+ if ui.button(&r.name).on_hover_text(&r.path).clicked() {
+ open = Some(r.path.clone());
+ ui.close();
+ }
+ }
+ ui.separator();
+ if ui.button(tl!("Clear Recent Files")).clicked() {
+ clear = true;
ui.close();
}
- }
- ui.separator();
- if ui.button(tl!("Clear Recent Files")).clicked() {
- clear = true;
- ui.close();
- }
+ })
});
if let Some(p) = open {
app.open_recent(&p);
diff --git a/crates/ui-egui/src/comment_props.rs b/crates/ui-egui/src/comment_props.rs
index 052fcd55fc..19f7187af6 100644
--- a/crates/ui-egui/src/comment_props.rs
+++ b/crates/ui-egui/src/comment_props.rs
@@ -109,8 +109,7 @@ impl PdfCraftApp {
style.fill = p.fill;
}
self.comment_prefs.set_style(tool, style);
- // Opacity and width go through the clamping setters: the dialog allows 0% opacity, and a
- // default that draws invisible comments can't be found again.
+ // Keep invisible defaults out of tools where a zero stroke would make them disappear.
self.comment_prefs.set_opacity(tool, p.opacity);
if let Some(w) = p.width {
self.comment_prefs.set_width(tool, w);
@@ -240,9 +239,10 @@ pub(crate) fn body(ui: &mut egui::Ui, app: &mut PdfCraftApp, t: &Tokens) -> (boo
e.opacity = pct / 100.0;
}
ui.end_row();
+ let min_width = if e.font_size.is_some() { 0.0 } else { 0.5 };
if let Some(w) = e.width.as_mut() {
ui.label(tl!("Thickness"));
- ui.add(egui::Slider::new(w, 0.5..=12.0).step_by(0.5).suffix(" pt"));
+ ui.add(egui::Slider::new(w, min_width..=12.0).step_by(0.5).suffix(" pt"));
ui.end_row();
}
if let Some(ends) = e.endings.as_mut() {
diff --git a/crates/ui-egui/src/comments.rs b/crates/ui-egui/src/comments.rs
index 68b1a72a0c..7fd9b7a622 100644
--- a/crates/ui-egui/src/comments.rs
+++ b/crates/ui-egui/src/comments.rs
@@ -188,12 +188,17 @@ impl CommentTool {
/// Whether the line-thickness control applies.
pub fn has_width(self) -> bool {
- self.draws() || self.clicks_points()
+ self.draws() || self.clicks_points() || matches!(self, Self::TextBox | Self::Callout)
}
- /// Whether the fill-colour control applies: shapes with an interior (#686).
+ /// Text comments may have no border while retaining visible text or fill.
+ fn min_width(self) -> f64 {
+ if matches!(self, Self::TextBox | Self::Callout) { 0.0 } else { 0.5 }
+ }
+
+ /// Whether the fill-colour control applies to this tool.
pub fn has_fill(self) -> bool {
- matches!(self, Self::Rectangle | Self::Oval | Self::Polygon | Self::Cloud)
+ matches!(self, Self::Rectangle | Self::Oval | Self::Polygon | Self::Cloud | Self::TextBox | Self::Callout)
}
/// A placeholder shape of this kind (for per-tool default styles).
@@ -284,7 +289,7 @@ impl CommentPrefs {
pub fn set_width(&mut self, tool: CommentTool, w: f64) {
if let Some((_, s)) = self.styles.iter_mut().find(|(t, _)| *t == tool) {
- s.width = w.clamp(0.5, 12.0);
+ s.width = w.clamp(tool.min_width(), 12.0);
}
}
@@ -295,7 +300,7 @@ impl CommentPrefs {
}
}
- /// The tool's fill (`None`: no fill). Only shapes with an interior keep one.
+ /// The tool's fill (`None`: no fill).
pub fn set_fill(&mut self, tool: CommentTool, fill: Option