From 2a218f3420d76310c1c6e4e9bbc6792ccb9b34da Mon Sep 17 00:00:00 2001 From: MMexicann Date: Tue, 13 Oct 2026 14:13:38 +0000 Subject: [PATCH 001/105] M4: resolve indirect page rotations before editing Read the effective rotation through its PDF integer object so inherited and page-local indirect values retain their orientation when rotated. Co-Authored-By: Codex --- ROADMAP.md | 4 ++- crates/organize/src/lib.rs | 8 ++++-- crates/organize/src/tests.rs | 47 ++++++++++++++++++++++++++++++++++++ docs/roadmap.md | 5 ++-- 4 files changed, 59 insertions(+), 5 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index a035c9bf35..867b077ce4 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -2,7 +2,7 @@ **Stage: alpha** · next: beta, ~26% (ready for real work 49% → 75%) and ~400–700 h away -> **Last reviewed:** 2026-10-12 · **Last updated:** 2026-10-12 · **Change:** minor (M13: CLI check visits each directory once through aliases and cycles; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS) +> **Last reviewed:** 2026-10-12 · **Last updated:** 2026-10-13 · **Change:** minor (M4: indirect page rotations; M13: CLI check visits each directory once through aliases and cycles; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS) > **Last reviewed:** 2026-10-12 · **Last updated:** 2026-10-12 · **Change:** minor (M4: Extract and Split preserve shared Type 3 glyph resources; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS) > **Last reviewed:** 2026-10-12 · **Last updated:** 2026-10-12 · **Change:** minor (M2: packed TIFF-predictor RGB rows retain their byte boundaries; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS) > **Last reviewed:** 2026-10-12 · **Last updated:** 2026-10-12 · **Change:** minor (M3 #1364 candidate: consent before absolute local GoToR access; estimates unchanged); earlier: minor (M1 #1339: an unchanged incremental save of a classic cross-reference table writes a zero-length subsection; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS) @@ -148,6 +148,7 @@ Ranked; detail and the full milestone table in [docs/roadmap.md](docs/roadmap.md ## Progress log +- **2026-10-13 (M4, indirect page rotation):** Rotation queries and edits resolve indirect integer values on pages and their page-tree ancestors. Turning a page already at 270° by 90° now saves it at 0°, including when its rotation is inherited. Synthetic full/incremental save-and-reopen tests cover nested inheritance, sibling preservation, negative/large angles and invalid-value fallback. Estimates unchanged. - **2026-10-12 (M13, CLI robustness scans):** `pdfcraft-cli check` visits a directory once even when input roots overlap or directory symlinks lead back into the tree. PDFs reached through file symlinks remain accepted. Three real CLI regressions cover overlapping roots, a directory cycle and directory/file aliases. Estimates unchanged. - **2026-10-12 (M4, Type 3 extraction fidelity):** Extract and Split preserve shared image/resource dictionaries reached through Type 3 glyphs, including nested forms, patterns and soft masks. A bounded object-graph walk protects unknown glyph content; unreadable pages/forms retain their glyph resources, and shared explicit fonts are visited once. Separate page dictionaries still prune unused images. Synthetic save/reopen regressions and an opt-in Poppler pixel oracle cover resource ownership, nested forms, cycles, limits and repeated fonts. Estimates unchanged. - **2026-10-12 (M2, TIFF predictor rows):** Packed RGB images using TIFF horizontal differencing keep every row when samples do not fill the last byte. The renderer aligns each decoded row before moving to the next. Synthetic decoding tests cover 1/2/4-bit samples, horizontal differencing and an 8-bit control; a red/green page-render regression verifies visible output. Estimates unchanged. @@ -424,6 +425,7 @@ Newest first: the date, what moved, and the new overall estimate. | Date | Change | Summary | |---|---|---| +| 2026-10-13 | minor | M4: rotation queries and edits resolve indirect page-tree values; inherited, negative and large rotations verified through both saves. Estimates unchanged. | | 2026-10-12 | minor | M3/#1364 candidate: absolute local GoToR links require desktop consent or headless allow_absolute; existing relative navigation and validation retained. Estimates unchanged. | | 2026-10-12 | minor | M8: redaction retires inherited `/Resources` so a parent `/Pages` can't keep a removed XObject reachable; only redacted pages that inherit are pinned (one shared reference), unredacted pages are untouched; estimates unchanged | | 2026-10-12 | minor | M7/#1200 candidate: bounded local GoToR opening, root-confined automation, and action-preserving appearance edits; named destinations and window behavior remain partial; estimates unchanged. | diff --git a/crates/organize/src/lib.rs b/crates/organize/src/lib.rs index 7b366bad9d..58faa78e4a 100644 --- a/crates/organize/src/lib.rs +++ b/crates/organize/src/lib.rs @@ -111,13 +111,17 @@ pub fn pages(doc: &Document) -> Result, OrganizeError> { pub fn page_rotation(doc: &Document, index: usize) -> Result { let pages = walk(doc)?; let (_, attrs) = pages.get(index).ok_or(OrganizeError::NoSuchPage(index))?; - Ok(attrs.int(b"Rotate").unwrap_or(0).rem_euclid(360)) + Ok(resolved_rotation(doc, attrs).rem_euclid(360)) } pub fn page_count(doc: &Document) -> Result { Ok(walk(doc)?.len()) } +fn resolved_rotation(doc: &Document, attrs: &Dict) -> i64 { + attrs.get(b"Rotate").and_then(|value| doc.resolve(value).as_int()).unwrap_or(0) +} + /// Rebuild the page tree as one flat node with `order` as its kids (attributes pushed down). fn rebuild(doc: &mut Document, order: &[(ObjRef, Dict)]) -> Result<(), OrganizeError> { let root = pages_root(doc)?; @@ -163,7 +167,7 @@ pub fn rotate_pages(doc: &mut Document, indices: &[usize], degrees: i64) -> Resu let delta = degrees.rem_euclid(360); for &i in indices { let (page, inherited) = &all[i]; - let current = doc.get(*page).as_dict().and_then(|d| d.int(b"Rotate")).or_else(|| inherited.int(b"Rotate")).unwrap_or(0); + let current = resolved_rotation(doc, inherited); let new = (current.rem_euclid(360) + delta).rem_euclid(360); doc.update_dict(*page, |d| d.set(b"Rotate".to_vec(), Object::Int(new)))?; } diff --git a/crates/organize/src/tests.rs b/crates/organize/src/tests.rs index e07af72a1c..7ca88316d5 100644 --- a/crates/organize/src/tests.rs +++ b/crates/organize/src/tests.rs @@ -16,6 +16,53 @@ fn page_rotation_resolves_inheritance_overrides_and_missing_pages() { assert_eq!(page_rotation(&doc, 3), Err(OrganizeError::NoSuchPage(3))); } +#[test] +fn indirect_page_rotation_is_read_and_updated_from_its_effective_value() { + let large = i64::MAX - i64::MAX % 90; + for (current, degrees, expected) in [(270, 90, 0), (-90, 90, 0), (large, 90, 90), (90, -180, 270)] { + for level in 0..3 { + let mut doc = open(fixture()); + let rotation = doc.add(Object::Int(current)); + let page = pages(&doc).unwrap()[0].obj; + let target = match level { + 0 => page, + 1 => doc.get(page).as_dict().unwrap().reference(b"Parent").unwrap(), + _ => pages_root(&doc).unwrap(), + }; + doc.update_dict(target, |d| d.set(b"Rotate".to_vec(), Object::Ref(rotation))).unwrap(); + let bytes = write_full(&doc, &SaveOptions::default()).unwrap(); + let mut doc = open(bytes); + let rotation = walk(&doc).unwrap()[0].1.reference(b"Rotate").unwrap(); + let initial = page_rotation(&doc, 0).unwrap(); + rotate_pages(&mut doc, &[0], degrees).unwrap(); + assert_eq!(*doc.get(rotation), Object::Int(current), "shared rotation object unchanged"); + for saved in [write_incremental(&doc, &SaveOptions::default()).unwrap(), write_full(&doc, &SaveOptions::default()).unwrap()] { + let reopened = open(saved.clone()); + let pdf = hayro_syntax::Pdf::new(saved).unwrap(); + let dimensions = if expected % 180 == 0 { (300.0, 400.0) } else { (400.0, 300.0) }; + assert_eq!(pdf.pages()[0].render_dimensions(), dimensions, "written PDF has the correct orientation"); + assert_eq!(page_rotation(&reopened, 0).unwrap(), expected); + assert_eq!(page_rotation(&reopened, 1).unwrap(), if level == 0 { 90 } else { current.rem_euclid(360) }, "sibling unchanged"); + assert_eq!(page_rotation(&reopened, 2).unwrap(), if level == 2 { current.rem_euclid(360) } else { 90 }, "other subtree unchanged"); + } + assert_eq!(initial, current.rem_euclid(360), "effective indirect rotation at level {level}"); + } + } +} + +#[test] +fn malformed_indirect_rotations_keep_the_zero_fallback() { + for value in [Object::Null, Object::name("invalid"), Object::Ref(ObjRef::new(99_999, 0))] { + let mut doc = open(fixture()); + let rotation = doc.add(value); + let page = pages(&doc).unwrap()[0].obj; + doc.update_dict(page, |d| d.set(b"Rotate".to_vec(), Object::Ref(rotation))).unwrap(); + assert_eq!(page_rotation(&doc, 0).unwrap(), 0); + rotate_pages(&mut doc, &[0], 90).unwrap(); + assert_eq!(page_rotation(&doc, 0).unwrap(), 90); + } +} + #[test] fn rotate_rejects_angles_that_are_not_multiples_of_90() { // The page tree only turns in quarter steps, so anything else used to be silently truncated diff --git a/docs/roadmap.md b/docs/roadmap.md index 04103fb632..ecc521bd9e 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -1,6 +1,6 @@ # PdfCraft roadmap: milestones and what's next -> **Last reviewed:** 2026-10-12 · **Last updated:** 2026-10-12 · **Change:** minor (M13: CLI check visits each directory once through aliases and cycles; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS) +> **Last reviewed:** 2026-10-12 · **Last updated:** 2026-10-13 · **Change:** minor (M4: indirect page rotations; M13: CLI check visits each directory once through aliases and cycles; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS) > **Last reviewed:** 2026-10-12 · **Last updated:** 2026-10-12 · **Change:** minor (M4: Extract and Split preserve shared Type 3 glyph resources; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS) > **Last reviewed:** 2026-10-12 · **Last updated:** 2026-10-12 · **Change:** minor (M2: packed TIFF-predictor RGB rows retain their byte boundaries; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS) > **Last reviewed:** 2026-10-12 · **Last updated:** 2026-10-12 · **Change:** minor (M3 #1364 candidate: consent before absolute local GoToR access; estimates unchanged); earlier: minor (M1 #1339: unchanged incremental saves of a classic cross-reference table write a zero-length subsection; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS) @@ -94,7 +94,7 @@ total matches the parity estimate. | M2 | Model, render, text | 200–350 | 15% | 130–230 | hayro bootstrap renderer (vendored patches); inspector predictor scratch is bounded by available input, and JPX sample rescaling handles 1–32 bits safely. Text extraction reaches word-F1 0.98 against pdftotext. Short same-line redraws split before placement rather than interleaving. A custom text match that ends on a reconstructed separator stops before the next glyph (#1315). Missing: model crate, fonts, DisplayList, renderer independent of hayro. Inspector page-label traversal and output are resource-bounded, with physical-number fallback warnings | | M2 | Model, render, text | 200–350 | 15% | 130–230 | hayro bootstrap renderer (vendored patches); Lab Bradford adaptation uses LMS white-point ratios, covered by independent neutral/chromatic and direct/Indexed render oracles; inspector predictor scratch is bounded by available input, and JPX sample rescaling handles 1–32 bits safely. Text extraction reaches word-F1 0.98 against pdftotext. Short same-line redraws split before placement rather than interleaving. Missing: model crate, fonts, DisplayList, renderer independent of hayro. Inspector page-label traversal and output are resource-bounded, with physical-number fallback warnings | | M3 | Viewer app (native + web) | 80–150 | 90% | 10–20 | Acrobat-style shell, find, select, panels, tiles, web build, UI control channel for agents (opt-in). Missing: 60 fps test on a 500-page document, snapshot tests of every panel
Done since: fit visible, document title in the window; Linux middle-button scrolling; shared three-state theme preferences in the toolbar, View menu and control channel; numeric Linux portal events and idle wakeups completing #881, with native appearance inheritance retained (#446); late browser startup downloads preserve the current document and Save target (#171). | -| M4 | Engine, history, save, organize | 100–180 | 95% | 8–15 | Done:
- command registry (menus, shortcuts and palette all use it);
- undo/redo; incremental, atomic and encrypted saves;
- autosave and crash recovery;
- organize, combine, extract, split and insert-from-file, with identical fonts and images stored once;
- CLI `edit/combine/extract/split`.
Done since: Organize opens directly into the page overview, with checkbox selection, an editable page-range selection (#1092), and per-page rotate/delete controls; Combine files in its own tab (sortable, resizable columns; folders; early warnings; unlocking protected files), bookmark editing, page labels (Number pages), CLI `run`, Set Page Boxes, Crop tool, Duplicate pages, Ctrl+A / Cmd+A to select all pages in Organize, Replace pages, bookmarks from tagged headings. Extract and Split preserve shared Type 3 glyph resource graphs, retaining conservative pruning limits. Missing: page transitions, recovery on the web | +| M4 | Engine, history, save, organize | 100–180 | 95% | 8–15 | Done:
- command registry (menus, shortcuts and palette all use it);
- undo/redo; incremental, atomic and encrypted saves;
- autosave and crash recovery;
- organize, combine, extract, split and insert-from-file, with identical fonts and images stored once;
- CLI `edit/combine/extract/split`.
Done since: Organize opens directly into the page overview, with checkbox selection, an editable page-range selection (#1092), and per-page rotate/delete controls; Combine files in its own tab (sortable, resizable columns; folders; early warnings; unlocking protected files), bookmark editing, page labels (Number pages), CLI `run`, Set Page Boxes, Crop tool, Duplicate pages, Ctrl+A / Cmd+A to select all pages in Organize, Replace pages, bookmarks from tagged headings. Extract and Split preserve shared Type 3 glyph resource graphs, retaining conservative pruning limits. Rotation queries and edits resolve indirect values on pages and ancestors. Missing: page transitions, recovery on the web | | M3 | Viewer app (native + web) | 80–150 | 90% | 10–20 | Acrobat-style shell, find, select, panels, tiles, web build, UI control channel for agents (opt-in). Missing: 60 fps test on a 500-page document, snapshot tests of every panel
Done since: fit visible, document title in the window; Linux middle-button scrolling; shared three-state theme preferences in the toolbar, View menu and control channel; numeric Linux portal events and idle wakeups completing #881, with native appearance inheritance retained (#446); late browser startup downloads preserve the current document and Save target (#171). Renderer-creation errors return to the existing OpenGL startup fallback; a real-GPU regression covers errors and device reuse. The underlying x86 DX12 shader compiler failure remains unresolved; see `windows-renderer-startup.md`. With Windows' Animation effects turned off, the wheel scrolls and zooms without easing and egui's animations take no time (`motion.rs`; macOS, Linux and the browser aren't asked yet); Preferences ▸ Animation effects (Use system setting, On, Off) decides it on every platform. | | M4 | Engine, history, save, organize | 100–180 | 95% | 8–15 | Done:
- command registry (menus, shortcuts and palette all use it);
- undo/redo; incremental, atomic and encrypted saves;
- autosave and crash recovery;
- organize, combine, extract, split and insert-from-file, with identical fonts and images stored once;
- CLI `edit/combine/extract/split`.
Done since: Combine files in its own tab (sortable, resizable columns; folders; early warnings; unlocking protected files), bookmark editing, page labels (Number pages), CLI `run`, Set Page Boxes, Crop tool, Duplicate pages, Ctrl+A / Cmd+A to select all pages in Organize, Replace pages, bookmarks from tagged headings. Missing: page transitions, recovery on the web | | M5 | Comments (all annotation types, XFDF) | 120–200 | 88% | 12–25 | Done: notes, highlight/underline/strikeout/squiggly, text boxes, ink, lines, arrows, rectangles, ovals, stamps (dynamic, Sign Here, Standard Business), with appearance streams; replies, status, checkmarks, locking, move/resize/restyle/delete; properties dialog; panel filter and sort; hide all; summaries (comments only); XFDF/FDF import and export (partial imports preserve existing reply and review-status links); flatten; Fill & Sign with adjustable text/date size; agent tools.
Done since: stamps placed on a rotated page read upright (#1398); polygons, connected lines, clouds, callouts, inserted text, colour and checkmark filters; image signature pointer previews, selection handles and live image movement/resizing with proportional corners and independent edge handles. Missing: custom stamps, replace-text proposals, summary layouts with the page | @@ -161,6 +161,7 @@ M2 (renderer), M7 (content editing), M10 (OCR and Office), M11 (Preflight and st | Date | Change | Summary | |---|---|---| +| 2026-10-13 | minor | M4: rotation queries and edits resolve indirect page-tree values; inherited, negative and large rotations verified through both saves. Estimates unchanged. | | 2026-10-12 | minor | M13: CLI check directory identity, with overlap and symlink regressions; estimates unchanged | | 2026-10-12 | minor | M4: shared Type 3 glyph resources survive extraction, with save/reopen and independent raster regressions; estimates unchanged | | 2026-10-12 | minor | M2: packed TIFF-predictor RGB row boundaries, with decoding and page-render regressions; estimates unchanged | From d0a3427184dacb5a6303b024b849a8fb8fdb47ca Mon Sep 17 00:00:00 2001 From: oracola91 <118899664+oracola91@users.noreply.github.com> Date: Thu, 15 Oct 2026 16:40:45 +0300 Subject: [PATCH 002/105] fix(platform): a save keeps the permissions of the file it replaces On Unix the staging file got the default mode for a new file (usually 0644) and was renamed over the original, so saving a private 0600 PDF left it readable by other local users. The staging file is now created owner-only and given the original's permissions before anything is written. A new file still gets the usual mode. Desktop save, the CLI and the automation tools all write through this function. Fixes #1790. --- crates/platform/src/staging.rs | 73 ++++++++++++++++++++++++++++++++-- 1 file changed, 69 insertions(+), 4 deletions(-) diff --git a/crates/platform/src/staging.rs b/crates/platform/src/staging.rs index f7200efb9e..5120deae43 100644 --- a/crates/platform/src/staging.rs +++ b/crates/platform/src/staging.rs @@ -27,6 +27,20 @@ pub fn create_staging( name: &str, form: StagingName, suffixes: impl IntoIterator, +) -> std::io::Result<(PathBuf, std::fs::File)> { + create_staging_as(dir, name, form, suffixes, None) +} + +/// [`create_staging`], giving the new file the permissions `keep` on Unix. It is created readable +/// and writable by its owner only and then given `keep`, so it is never open to more people than +/// the file it will replace, not even while it is still empty (a handle opened then would keep +/// reading what is written later). +fn create_staging_as( + dir: &Path, + name: &str, + form: StagingName, + suffixes: impl IntoIterator, + keep: Option<&std::fs::Permissions>, ) -> std::io::Result<(PathBuf, std::fs::File)> { // At most 128 bytes of the target's name, cut between characters, so the staging name fits // the 255-byte (Linux, macOS) and 255-unit (Windows) limits however long that name is. @@ -42,8 +56,22 @@ pub fn create_staging( StagingName::SuffixThenTag => format!(".{stem}.{suffix:016x}.pdfcraft-tmp"), StagingName::TagThenSuffix => format!(".{stem}.pdfcraft-{suffix:016x}.tmp"), }); - match std::fs::OpenOptions::new().write(true).create_new(true).open(&tmp) { - Ok(file) => return Ok((tmp, file)), + let mut options = std::fs::OpenOptions::new(); + options.write(true).create_new(true); + #[cfg(unix)] + if keep.is_some() { + use std::os::unix::fs::OpenOptionsExt as _; + options.mode(0o600); + } + match options.open(&tmp) { + Ok(file) => { + if let Some(keep) = keep { + // A volume that can't change modes (FAT, some network shares) leaves the file + // owner-only: stricter than the file it replaces, never looser. + let _ = file.set_permissions(keep.clone()); + } + return Ok((tmp, file)); + } Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => {} // Windows reports a folder at the name as "access denied"; it is taken all the same. Err(e) if e.kind() == std::io::ErrorKind::PermissionDenied && tmp.symlink_metadata().is_ok() => {} @@ -69,7 +97,9 @@ pub fn staging_suffixes() -> impl Iterator { /// crash, a full disk or a refused flush never leaves a half-written file where a good one was, /// and the target is untouched until the new file is complete. The target's folder must exist; /// 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. +/// (`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. 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) } @@ -87,7 +117,12 @@ pub fn write_atomically_with( use std::io::Write as _; let dir = path.parent().filter(|d| !d.as_os_str().is_empty()).unwrap_or(Path::new(".")); let name = path.file_name().and_then(|n| n.to_str()).unwrap_or("save"); - let (tmp, file) = create_staging(dir, name, form, suffixes)?; + // A file that is replaced keeps its permissions (#1790). Unix modes only: Windows is as before. + #[cfg(unix)] + let keep = std::fs::metadata(path).ok().map(|m| m.permissions()); + #[cfg(not(unix))] + let keep = None; + 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). { @@ -215,4 +250,34 @@ mod tests { assert_eq!(names(&dir), ["out.pdf"]); let _ = std::fs::remove_dir_all(&dir); } + + /// #1790: a replaced file keeps its permissions, and its staging file already has them while + /// it is written; a new file gets the same mode as any other new file. + #[cfg(unix)] + #[test] + fn a_replaced_file_keeps_its_permissions() { + use std::os::unix::fs::PermissionsExt as _; + let dir = test_dir("modes"); + let mode = |p: &Path| std::fs::metadata(p).unwrap().permissions().mode() & 0o777; + let target = dir.join("out.pdf"); + for keep in [0o600, 0o640, 0o664] { + std::fs::write(&target, b"OLD").unwrap(); + std::fs::set_permissions(&target, std::fs::Permissions::from_mode(keep)).unwrap(); + let mut staged = 0; + write_atomically_with(&target, b"NEW", StagingName::SuffixThenTag, [keep as u64], |f| { + staged = f.metadata()?.permissions().mode() & 0o777; + Ok(()) + }) + .unwrap(); + assert_eq!(staged, keep, "the staging file has the target's mode before it is flushed"); + assert_eq!(mode(&target), keep, "the saved file keeps mode {keep:o}"); + assert_eq!(std::fs::read(&target).unwrap(), b"NEW"); + } + let fresh = dir.join("new.pdf"); + write_atomically(&fresh, b"NEW", StagingName::TagThenSuffix).unwrap(); + let plain = dir.join("plain"); + std::fs::write(&plain, b"").unwrap(); + assert_eq!(mode(&fresh), mode(&plain), "a new file is not made private"); + let _ = std::fs::remove_dir_all(&dir); + } } From 398ad770773de836805a8b00ebac57ea98b66551 Mon Sep 17 00:00:00 2001 From: komo1iddin <45395275+komo1iddin@users.noreply.github.com> Date: Thu, 15 Oct 2026 19:51:57 +0500 Subject: [PATCH 003/105] M7: rewrap a paragraph at a slant at the width its rows have MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A turned paragraph's width came from its rows' upright bounding boxes, turned into the baseline's frame. On a slant that box is wider than the row by the row's height times |sin 2θ| (about 12 pt for 12 pt text at 30°), so a rewrite let a word that doesn't fit stay on its row. Each run now also records its box along and across its baseline, from the same corners as its upright box, and a line unions those. Grouping and the rewrite's width read that box. Upright and quarter-turned text are unchanged. Co-Authored-By: Claude Opus 5.5 --- crates/edit/src/tests.rs | 25 +++++++++++++++++++++++++ crates/edit/src/text.rs | 31 +++++++++++++++++++++++++------ crates/edit/src/text_regions.rs | 4 ---- 3 files changed, 50 insertions(+), 10 deletions(-) diff --git a/crates/edit/src/tests.rs b/crates/edit/src/tests.rs index b542142df8..e48116c8ac 100644 --- a/crates/edit/src/tests.rs +++ b/crates/edit/src/tests.rs @@ -3906,6 +3906,31 @@ fn turned_paragraph_keeps_its_line_breaks_when_a_word_is_added() { assert!(text::rewrite_block(&mut doc, 0, 0, Some("Only line"), &style).is_err()); } +/// A paragraph drawn at a slant rewraps at the width its rows have along their baseline, as the +/// same paragraph upright does. Its upright bounding box turned into the baseline's frame is wider +/// by the rows' height times |sin 2θ|, which let a word that doesn't fit stay on its row. +#[test] +fn a_paragraph_at_a_slant_rewraps_at_the_width_it_has_upright() { + let rows = ["Rotated paragraph, first row of text", "that continues on a second row and", "ends on the third row here."]; + let draft = "Rotated paragraph, first row of text that continues on a second row and now carries a few extra words \ + before it ends on the third row here."; + let rewrapped = |degrees: f64| { + let (sin, cos) = degrees.to_radians().sin_cos(); + let shown = rows.iter().map(|r| format!("({r}) Tj")).collect::>().join(" T* "); + let mut doc = text_page(&format!("BT /F1 12 Tf 14.4 TL {cos:.6} {sin:.6} {:.6} {cos:.6} 300 400 Tm {shown} ET", -sin)); + let block = text::text_blocks(&doc, 0).unwrap().remove(0); + assert_eq!(block.editor_text, rows.join("\n"), "{degrees}°"); + text::rewrite_block(&mut doc, 0, 0, Some(draft), &text::BlockStyle::default()).unwrap(); + text::text_lines(&reopen(&doc), 0).unwrap().into_iter().map(|l| l.text).collect::>() + }; + let upright = rewrapped(0.0); + // The widest source row is 189.44 pt; "now carries a few extra words before" is 196.07 pt. + assert_eq!(upright[2], "now carries a few extra words", "{upright:?}"); + for degrees in [30.0, 45.0, 60.0, 90.0, 135.0, 210.0, 330.0] { + assert_eq!(rewrapped(degrees), upright, "{degrees}°"); + } +} + /// A paragraph in a Chrome-style subset font: `/W` leaves out the glyphs of the `/DW` width. /// Adding a word keeps every row as it was and shows the word in the page's own font (#1443). #[test] diff --git a/crates/edit/src/text.rs b/crates/edit/src/text.rs index 9c83cf9a39..bf0fcbff02 100644 --- a/crates/edit/src/text.rs +++ b/crates/edit/src/text.rs @@ -50,6 +50,10 @@ pub struct TextLine { pub visual_order: bool, /// The Japanese or Hebrew fallback face the font subsets, when the line is in one (see [`same_font`]). face: Option, + /// Its box along and across its baseline, `[along0, across0, along1, across1]` (see + /// [`TextLine::frame_rect`]). Upright text has `rect`; turned text has its own box, which is + /// narrower than `rect` turned into the same frame. + frame: [f64; 4], /// The content stream piece the line starts in, and its operators (indices into the page's /// streams parsed joined). stream: usize, @@ -470,12 +474,7 @@ impl TextLine { /// Its box in the same frame, `[along0, across0, along1, across1]` (for upright text, `rect`). fn frame_rect(&self) -> [f64; 4] { - let dir = self.direction(); - let r = self.rect; - [(r[0], r[1]), (r[2], r[1]), (r[0], r[3]), (r[2], r[3])] - .into_iter() - .map(|p| to_frame(dir, p)) - .fold([f64::MAX, f64::MAX, f64::MIN, f64::MIN], |b, p| [b[0].min(p.0), b[1].min(p.1), b[2].max(p.0), b[3].max(p.1)]) + self.frame } } @@ -493,6 +492,11 @@ fn to_frame(dir: (f64, f64), p: (f64, f64)) -> (f64, f64) { (p.0 * dir.0 + p.1 * dir.1, p.1 * dir.0 - p.0 * dir.1) } +/// The box covering both `[x0, y0, x1, y1]` boxes. +fn union(a: [f64; 4], b: [f64; 4]) -> [f64; 4] { + [a[0].min(b[0]), a[1].min(b[1]), a[2].max(b[2]), a[3].max(b[3])] +} + /// What replacing a line did. #[derive(Clone, Debug, PartialEq)] pub struct LineEdit { @@ -639,6 +643,8 @@ struct Shown { bt_state: Ts, text: String, rect: [f64; 4], + /// Its box along and across its baseline (see [`TextLine::frame`]). + frame: [f64; 4], /// The boxes of its words (see [`TextLine::ink`]). ink: Vec<[f64; 4]>, baseline: f64, @@ -773,6 +779,7 @@ fn interpret( first.rect[2].max(shown.rect[2]), first.rect[3].max(shown.rect[3]), ]; + first.frame = union(first.frame, shown.frame); first.ink.extend(shown.ink); } first.extra_ops.extend([open, i]); @@ -936,6 +943,13 @@ fn interpret( let rect = corners .iter() .fold([f64::MAX, f64::MAX, f64::MIN, f64::MIN], |b, p| [b[0].min(p.0), b[1].min(p.1), b[2].max(p.0), b[3].max(p.1)]); + // The same corners along and across the baseline: the run's own box, not its + // upright bounding box turned (which is wider by the box's height on a slant). + let dir = direction(&trm0); + let frame = corners + .iter() + .map(|&p| to_frame(dir, p)) + .fold([f64::MAX, f64::MAX, f64::MIN, f64::MIN], |b, p| [b[0].min(p.0), b[1].min(p.1), b[2].max(p.0), b[3].max(p.1)]); let ink = words .iter() .map(|&(a, b)| { @@ -963,6 +977,7 @@ fn interpret( bt_state: at_bt.1.clone(), text, rect, + frame, ink, baseline: start.1, start_x: start.0, @@ -1040,6 +1055,7 @@ pub fn text_lines(doc: &Document, page: usize) -> Result, EditErro } l.text.push_str(&s.text); l.rect = [l.rect[0].min(s.rect[0]), l.rect[1].min(s.rect[1]), l.rect[2].max(s.rect[2]), l.rect[3].max(s.rect[3])]; + l.frame = union(l.frame, s.frame); l.ink.extend(s.ink.iter().copied()); l.ops.push(s.op); l.ops.extend(s.extra_ops.iter().copied()); @@ -1051,6 +1067,7 @@ pub fn text_lines(doc: &Document, page: usize) -> Result, EditErro lines.push(TextLine { text: s.text.clone(), rect: s.rect, + frame: s.frame, ink: s.ink.clone(), font: String::from_utf8_lossy(&s.font).into_owned(), base_font: s.base_font.clone(), @@ -1128,6 +1145,7 @@ fn add_shown(lines: &mut Vec, last: &mut Option, s: Shown, stream } l.text.push_str(&s.text); l.rect = [l.rect[0].min(s.rect[0]), l.rect[1].min(s.rect[1]), l.rect[2].max(s.rect[2]), l.rect[3].max(s.rect[3])]; + l.frame = union(l.frame, s.frame); l.ink.extend(s.ink.iter().copied()); l.ops.push(s.op); l.ops.extend(s.extra_ops.iter().copied()); @@ -1138,6 +1156,7 @@ fn add_shown(lines: &mut Vec, last: &mut Option, s: Shown, stream lines.push(TextLine { text: s.text.clone(), rect: s.rect, + frame: s.frame, ink: s.ink.clone(), font: String::from_utf8_lossy(&s.font).into_owned(), base_font: s.base_font.clone(), diff --git a/crates/edit/src/text_regions.rs b/crates/edit/src/text_regions.rs index 2d7baf519f..7b512cf883 100644 --- a/crates/edit/src/text_regions.rs +++ b/crates/edit/src/text_regions.rs @@ -17,10 +17,6 @@ fn same_plane(a: &TextLine, b: &TextLine) -> bool { }) } -fn union(a: [f64; 4], b: [f64; 4]) -> [f64; 4] { - [a[0].min(b[0]), a[1].min(b[1]), a[2].max(b[2]), a[3].max(b[3])] -} - fn row_rect(row: &[usize], lines: &[TextLine]) -> Option<[f64; 4]> { row.iter().filter_map(|i| lines.get(*i)).map(|l| l.rect).reduce(union) } From 00fb10cf1eb8158079376fe5018ad7e391b55e0d Mon Sep 17 00:00:00 2001 From: oracola91 <118899664+oracola91@users.noreply.github.com> Date: Thu, 15 Oct 2026 17:56:45 +0300 Subject: [PATCH 004/105] fix(crypt): /Perms limits what an edited /P allows AES-256 files (R5 and R6) store the permissions twice: in clear in /P, and in /Perms, encrypted with the file key (ISO 32000-2, Algorithm 13). We wrote /Perms but never read it, so a document opened with the user password got whatever /P said, even when /P no longer matched /Perms. permissions() now decrypts /Perms and, when it holds a valid block (marked "adb"), counts a permission only if both /P and /Perms grant it. Without a readable /Perms, /P applies as before. R2 to R4 need no check: /P is part of their file key, so an edited /P does not open. --- crates/crypt/src/lib.rs | 16 +++++++++++++++- crates/crypt/src/tests.rs | 22 ++++++++++++++++++++++ parity/acrobat-features.toml | 2 +- 3 files changed, 38 insertions(+), 2 deletions(-) diff --git a/crates/crypt/src/lib.rs b/crates/crypt/src/lib.rs index fd600cb9af..82b598688a 100644 --- a/crates/crypt/src/lib.rs +++ b/crates/crypt/src/lib.rs @@ -203,8 +203,22 @@ impl SecurityHandler { self.recovered_user.clone() } + /// For R5/R6, `/P` is stored in clear and again in `/Perms`, encrypted with the file key + /// (Algorithm 13). Where the two differ, a permission counts only when both grant it, so an + /// edited `/P` grants nothing extra. Without a readable `/Perms`, `/P` applies as before. pub fn permissions(&self) -> Permissions { - Permissions { bits: self.dict.p, owner: self.auth == Auth::Owner } + let bits = self.perms_p().map_or(self.dict.p, |p| self.dict.p & p); + Permissions { bits, owner: self.auth == Auth::Owner } + } + + /// `/P` as `/Perms` holds it: AES-256 ECB of one block (= CBC with a zero IV), marked `adb`. + fn perms_p(&self) -> Option { + if !matches!(self.dict.r, 5 | 6) { + return None; + } + let plain = aes_cbc_decrypt(&self.key, &[0; 16], self.dict.perms.first_chunk::<16>()?, false); + let p = i32::from_le_bytes(*plain.first_chunk::<4>()?); + (plain.get(9..12) == Some(&b"adb"[..])).then_some(p) } pub fn dict(&self) -> &EncryptDict { diff --git a/crates/crypt/src/tests.rs b/crates/crypt/src/tests.rs index 7272af9ea1..5110e79998 100644 --- a/crates/crypt/src/tests.rs +++ b/crates/crypt/src/tests.rs @@ -212,6 +212,28 @@ fn permission_bits_follow_table_22() { assert!(p.modify(), "owners ignore /P"); } +#[test] +fn perms_limits_what_an_edited_p_grants() { + let made = new(Algorithm::Aes256, "user pw", "owner pw", 0b0100); // print only + let open = |d: &EncryptDict, pw| SecurityHandler::open(d.clone(), ID, Some(pw)).unwrap().permissions(); + let mut d = made.dict().clone(); + assert_eq!(open(&d, "user pw").bits, d.p, "/P and /Perms agree"); + d.p = -4; // /P edited to allow everything, /Perms as written + let p = open(&d, "user pw"); + assert!(p.print() && !p.modify() && !p.copy() && !p.annotate() && !p.fill_forms() && !p.assemble()); + assert!(open(&d, "owner pw").modify(), "owners ignore /P"); + d.p = -3904; // a stricter /P still applies + assert!(!open(&d, "user pw").print()); + // No /Perms, or one that does not decrypt to an `adb` block: /P as before. + for perms in [Vec::new(), vec![0; 16], made.dict().perms[..15].to_vec()] { + let d = EncryptDict { p: -4, perms, ..made.dict().clone() }; + assert!(open(&d, "user pw").modify()); + } + // R2 to R4 have no /Perms: /P is part of their file key, so an edited /P does not open. + let d = EncryptDict { p: -4, ..new(Algorithm::Aes128, "user pw", "owner pw", 0b0100).dict().clone() }; + assert_eq!(SecurityHandler::open(d, ID, Some("user pw")).unwrap_err(), CryptError::WrongPassword); +} + proptest::proptest! { #[test] fn malformed_dictionaries_never_panic(o in proptest::collection::vec(proptest::num::u8::ANY, 0..60), diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index e841dec082..0fb6b0bb65 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -214,7 +214,7 @@ milestone = "M1" status = "shipped" commands = [] tools = [] -evidence = ["crates/ui-egui/tests/editing.rs::restricted_documents_show_a_notice_and_block_page_changes", "crates/crypt/src/tests.rs::permission_bits_follow_table_22", "crates/engine/src/tests.rs::combining_protected_files_is_refused_clearly"] +evidence = ["crates/ui-egui/tests/editing.rs::restricted_documents_show_a_notice_and_block_page_changes", "crates/crypt/src/tests.rs::permission_bits_follow_table_22", "crates/crypt/src/tests.rs::perms_limits_what_an_edited_p_grants", "crates/engine/src/tests.rs::combining_protected_files_is_refused_clearly"] notes = "" [[feature]] From ff526a78961204c6853a29b1eb2601f5cdb88a1f Mon Sep 17 00:00:00 2001 From: Verix <77591428+jcnnik@users.noreply.github.com> Date: Thu, 15 Oct 2026 17:14:52 +0200 Subject: [PATCH 005/105] M3: single-page view turns pages without a blank frame At Fit Page in single-page view, every wheel turn forward drew one blank frame before the new page appeared. - Lay out the shown row again after the wheel is read. The jump to the new page was measured from the old row's place and scrolled both out of view; going back it came out negative and was clamped, so only forward turns flickered. The edge-turn deferral that worked around this goes. - Prefetch the rows either side of the shown one, so the next page is rendered before the turn instead of showing blank paper. --- crates/ui-egui/src/canvas.rs | 34 ++++++++++++++++++++++++++++------ crates/ui-egui/tests/view.rs | 32 ++++++++++++++++++++++++++++++++ 2 files changed, 60 insertions(+), 6 deletions(-) diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 989b731b7e..27091ba68c 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -2308,9 +2308,10 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) { // When not scrolling continuously, only one row is shown at a time: the current page, or the // current two-page spread (with the cover rule). These indices drive paging, which pages are // drawn, and the content height (#752). Empty for a document with no pages. - let row: Vec = if rects.is_empty() { - Vec::new() - } else { + let shown_row = |view: &DocView| -> Vec { + if rects.is_empty() { + return Vec::new(); + } let last = rects.len() - 1; let cur = view.current.min(last); match view.layout { @@ -2322,6 +2323,7 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) { } } }; + let row = shown_row(view); let middle_gesture = view.auto_scroll.blocks_input(); // Not scrolling: when the whole page or spread fits, the wheel would do nothing, so it turns // pages instead; zoomed in far enough to pan, it pans, and at the edge it turns the page on @@ -2422,6 +2424,9 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) { if view.fling.gliding() { ui.ctx().request_repaint(); } + // The wheel may have turned the page above: draw the row it turned to. Measured from the old + // row, the jump to the new page scrolled both out of view, a blank frame on every turn forward. + let row = shown_row(view); let visible_pages: Vec = if view.continuous { (0..rects.len()).collect() } else { row.clone() }; let (y_shift, content_h) = if view.continuous { (0.0, rects.iter().map(|r| r.bottom()).reduce(f32::max).map(|bottom| bottom + MARGIN).unwrap_or(0.0)) @@ -2461,9 +2466,6 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) { 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))); - } else if edge_turned { - // The row above was laid out for the page before the turn: place the new one next frame. - ui.ctx().request_repaint(); } else if let Some((page, frac)) = view.goto.take() { let page = page.min(rects.len() - 1); let r = rects[page]; @@ -3481,6 +3483,26 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) { view.last_scroll = scroll_offset; let pan_held = now_settle < view.pan_hold_until; let (mut wanted, visible_now) = out.inner; + // Single-page and two-page views draw only the current row, so the next one was first asked + // for on the turn and showed blank paper until it rendered. Prefetch the rows either side, + // as continuous scrolling does for the pages just off screen. + if !view.continuous + && let (Some(&first), Some(&last)) = (visible_pages.first(), visible_pages.last()) + { + let n = if view.layout == PageLayout::Single { 1 } else { 2 }; + let max_side = ui.ctx().input(|inp| inp.max_texture_side) as f32; + for i in (last.saturating_add(1)..=last.saturating_add(n)).chain(first.saturating_sub(n)..first) { + let Some(p) = info.pages.get(i).filter(|_| !view.errors.contains_key(&i)) else { continue }; + // As drawn: the whole page at this scale, or the backdrop of a tiled page. + let side = p.width.max(p.height).max(1.0); + wanted.push(if side * scale > TILE_THRESHOLD.min(max_side) { + let bs = BASE_SIDE.min(max_side) / side; + (i, bs, scale_tag(bs), None) + } else { + (i, scale, tag, None) + }); + } + } let cur = view.current; wanted.sort_by_key(|w| (!visible_now.contains(&w.0), w.0.abs_diff(cur), w.3.is_some())); // Tiles of far-away pages are useless: free them. Tiles of an earlier zoom stay for a page diff --git a/crates/ui-egui/tests/view.rs b/crates/ui-egui/tests/view.rs index 3fdeff637c..42cc9cde8d 100644 --- a/crates/ui-egui/tests/view.rs +++ b/crates/ui-egui/tests/view.rs @@ -185,6 +185,38 @@ fn single_page_layout_shows_one_page_at_a_time() { assert!(rect(&h, 0).is_none() && rect(&h, 1).is_some(), "only page 2"); } +#[test] +fn single_page_view_prefetches_the_next_page() { + // Only the shown page is drawn, but the next one renders ahead of the turn: otherwise + // turning forward shows blank paper until it arrives. + let mut h = harness(&[("layout", "single"), ("zoom", "50")]); + let deadline = std::time::Instant::now() + std::time::Duration::from_secs(10); + while h.state().render_pending() { + assert!(std::time::Instant::now() < deadline, "renders settle"); + h.run_steps(2); + std::thread::sleep(std::time::Duration::from_millis(5)); + } + assert!(rect(&h, 1).is_none(), "page 2 is not shown"); + assert!(h.state().views[0].raster_memory().page_count >= 2, "page 2 is rendered ahead of the turn"); +} + +#[test] +fn single_page_wheel_turn_draws_the_new_page_at_once() { + // The frame that turns the page draws the new one. Its jump used to be measured from the + // old page's place, which scrolled both out of view: a blank frame on every turn forward. + let mut h = harness_stepping(1.0 / 60.0, &[("layout", "single")]); + h.key_press_modifiers(Modifiers::COMMAND, Key::Num0); // fit page + h.run_steps(4); + let vp = h.state().views[0].viewport_rect(); + h.hover_at(vp.center()); + h.run_steps(2); + h.event(wheel(MouseWheelUnit::Line, -1.0, TouchPhase::Move)); + h.step(); + assert_eq!(h.state().views[0].current, 1, "one notch turns one page"); + let r = rect(&h, 1).expect("page 2 drawn in the frame that turns to it"); + assert!(r.min.y >= vp.min.y && r.max.y <= vp.max.y, "page 2 fits on screen: {r:?} in {vp:?}"); +} + #[test] fn single_page_wheel_pans_instead_when_zoomed_in() { // The zoomed-in guard: with room to pan, the wheel pans and must not turn pages. From 8ebe4f7a761698b5123e76fbd2ab64add14b3f6d Mon Sep 17 00:00:00 2001 From: komo1iddin <45395275+komo1iddin@users.noreply.github.com> Date: Thu, 15 Oct 2026 20:21:02 +0500 Subject: [PATCH 006/105] M7: write a full font program compressed and find it again on any page A text edit that needs glyphs the source font's embedding lacks writes the face's full program once. It was written uncompressed, and the document-wide search that lets another page reuse it compared the stream's raw bytes, so it only matched an uncompressed copy. Write the program with Flate, and compare its decoded bytes (after the /Length1 check) both on the page and across the document. Co-Authored-By: Claude Opus 5.5 --- crates/fonts/src/embedding.rs | 46 +++++++++++++++++++++++++++++------ 1 file changed, 39 insertions(+), 7 deletions(-) diff --git a/crates/fonts/src/embedding.rs b/crates/fonts/src/embedding.rs index 84801d5fd2..a5bb011e00 100644 --- a/crates/fonts/src/embedding.rs +++ b/crates/fonts/src/embedding.rs @@ -195,7 +195,7 @@ pub fn embed_mapping( // page; source subset programs stay untouched. let existing = written(doc, fonts).find(|w| w.has_program(doc, bytes)).map(|w| w.program).or_else(|| { doc.scan_objects().take(8192).find_map(|(r, o)| match o.as_ref() { - Object::Stream(s) if s.dict.get(b"PCFullFont").is_some() && s.raw.as_ref() == bytes => Some(Object::Ref(r)), + Object::Stream(s) if is_program(doc, s, bytes) => Some(Object::Ref(r)), _ => None, }) }); @@ -206,7 +206,7 @@ pub fn embed_mapping( d.set(b"Subtype".to_vec(), Object::name("OpenType")); } d.set(b"PCFullFont".to_vec(), Object::Bool(true)); - Object::Ref(doc.add(Object::Stream(Stream::from_raw(d, bytes.to_vec())))) + Object::Ref(doc.add(Object::Stream(Stream::flate(d, bytes)))) }); descriptor.set(if cff { b"FontFile3".to_vec() } else { b"FontFile2".to_vec() }, program); let desc = Object::Ref(doc.add(Object::Dict(descriptor))); @@ -264,15 +264,20 @@ struct Written { } impl Written { - /// Whether its program is `bytes`. The length it was written with is compared before decoding. + /// Whether its program is `bytes`. fn has_program(&self, doc: &Document, bytes: &[u8]) -> bool { - let Object::Stream(s) = doc.resolve(&self.program).as_ref().clone() else { return false }; - s.dict.get(b"PCFullFont").is_some() - && s.dict.get(b"Length1").and_then(|l| doc.resolve(l).as_int()) == i64::try_from(bytes.len()).ok() - && s.decoded_within(17 << 20).is_ok_and(|data| data == bytes) + matches!(doc.resolve(&self.program).as_ref(), Object::Stream(s) if is_program(doc, s, bytes)) } } +/// Whether `s` is a full program written for `bytes`, compressed or not. The length it was +/// written with is compared before decoding. +fn is_program(doc: &Document, s: &Stream, bytes: &[u8]) -> bool { + s.dict.get(b"PCFullFont").is_some() + && s.dict.get(b"Length1").and_then(|l| doc.resolve(l).as_int()) == i64::try_from(bytes.len()).ok() + && s.decoded_within(17 << 20).is_ok_and(|data| data == bytes) +} + /// The `PCFull` resources in `fonts`, in order. fn written<'a>(doc: &'a Document, fonts: &'a Dict) -> impl Iterator + 'a { fonts.iter().filter(|(key, _)| key.starts_with(b"PCFull")).take(256).filter_map(move |(key, value)| { @@ -379,4 +384,31 @@ mod tests { assert!(!fonts.contains(medium.as_bytes()), "{medium} is new"); assert_eq!(programs(&doc), 2); } + + #[test] + fn the_full_program_is_written_compressed_and_another_page_reuses_it() { + let bytes = include_bytes!("../../../assets/fonts/Inter-Regular.ttf"); + let mut doc = Document::new_empty(); + let mut first = Dict::new(); + embed(&mut doc, &mut first, bytes, "Inter").unwrap(); + let program = || { + doc.scan_objects() + .find_map(|(_, o)| match o.as_ref() { + Object::Stream(s) if s.dict.get(b"PCFullFont").is_some() => Some(s.clone()), + _ => None, + }) + .unwrap() + }; + let stream = program(); + assert_eq!(stream.dict.name(b"Filter"), Some(b"FlateDecode".as_slice())); + assert!(stream.raw.len() < bytes.len(), "{} of {} bytes", stream.raw.len(), bytes.len()); + assert_eq!(stream.decoded().unwrap(), bytes); + // Another page's resources don't have it, so it is found among the document's objects. + let objects = doc.scan_objects().count(); + let mut second = Dict::new(); + embed(&mut doc, &mut second, bytes, "Inter").unwrap(); + let programs = doc.scan_objects().filter(|(_, o)| matches!(o.as_ref(), Object::Stream(s) if s.dict.get(b"PCFullFont").is_some())).count(); + assert_eq!(programs, 1, "one program for both pages"); + assert!(doc.scan_objects().count() > objects, "the second page gets its own resource"); + } } From 279712769dc0b32141771d08dbc7fa4773d62a5d Mon Sep 17 00:00:00 2001 From: jonathanwxh-cell Date: Thu, 15 Oct 2026 22:49:12 +0800 Subject: [PATCH 007/105] fix(edit): keep overlapping watermarks and distinct paragraphs on text edit (#1756) --- crates/edit/src/tests.rs | 66 ++++++++++++++++++++++++++++++++++++++++ crates/edit/src/text.rs | 13 ++++++-- 2 files changed, 76 insertions(+), 3 deletions(-) diff --git a/crates/edit/src/tests.rs b/crates/edit/src/tests.rs index b542142df8..5b787f4239 100644 --- a/crates/edit/src/tests.rs +++ b/crates/edit/src/tests.rs @@ -2220,6 +2220,72 @@ fn replacing_a_line_keeps_a_distinct_nearby_line() { } } +#[test] +fn editing_text_keeps_an_overlapping_watermark_and_covered_paragraphs() { + let listed = |doc: &Document| text::text_lines(doc, 0).unwrap().into_iter().map(|l| l.text).collect::>(); + // #1756: a rotated watermark's bounding box covers the middle of the page, and a body line + // whose baseline happens to sit near the watermark's starting y is still unrelated text. + for rotation in [35.0, 45.0, 0.0] { + let mut base = text_page("BT /F1 14 Tf 60 700 Td (Heading) Tj ET"); + add_watermark( + &mut base, + &[0], + &Watermark { text: "TEST WATERMARK".into(), font_size: 48.0, rotation, opacity: 0.25, ..Watermark::default() }, + false, + ) + .unwrap(); + let wm_y = text::text_lines(&base, 0).unwrap().into_iter().find(|l| l.text == "TEST WATERMARK").unwrap().origin_baseline(); + let mut doc = text_page(&format!("BT /F1 14 Tf 1 0 0 1 60 {wm_y:.2} Tm (Body paragraph one with several words here) Tj ET")); + add_watermark( + &mut doc, + &[0], + &Watermark { text: "TEST WATERMARK".into(), font_size: 48.0, rotation, opacity: 0.25, ..Watermark::default() }, + false, + ) + .unwrap(); + let line_idx = text::text_lines(&doc, 0).unwrap().iter().position(|l| l.text.starts_with("Body")).unwrap(); + let mut line_edited = doc.clone(); + text::replace_line(&mut line_edited, 0, line_idx, "Edited body line").unwrap(); + assert_eq!(listed(&reopen(&line_edited)), ["Edited body line", "TEST WATERMARK"], "rotation {rotation}"); + + let block_idx = text::text_blocks(&doc, 0).unwrap().iter().position(|b| b.text.starts_with("Body")).unwrap(); + let mut preview = doc.clone(); + text::clear_block(&mut preview, 0, block_idx).unwrap(); + assert_eq!(listed(&preview), ["TEST WATERMARK"], "clear_block keeps the watermark at rotation {rotation}"); + + text::rewrite_block(&mut doc, 0, block_idx, Some("Edited body paragraph"), &text::BlockStyle::default()).unwrap(); + assert_eq!(listed(&reopen(&doc)), ["Edited body paragraph", "TEST WATERMARK"], "rotation {rotation}"); + } + + // #1368: dragging one paragraph onto another paragraph's baseline and moving it on again + // leaves the covered paragraph intact. + let mut doc = text_page("BT /F1 12 Tf 72 700 Td (First paragraph) Tj ET BT /F1 12 Tf 72 600 Td (Second paragraph) Tj ET"); + text::rewrite_block(&mut doc, 0, 0, None, &text::BlockStyle { offset: Some([0.0, -100.0]), ..Default::default() }).unwrap(); + let second = text::text_blocks(&doc, 0).unwrap().iter().position(|b| b.text == "Second paragraph"); + assert!(second.is_some(), "the covered paragraph stays on the page: {:?}", listed(&doc)); + let first = text::text_blocks(&doc, 0).unwrap().iter().position(|b| b.text == "First paragraph").unwrap(); + text::rewrite_block(&mut doc, 0, first, None, &text::BlockStyle { offset: Some([0.0, -100.0]), ..Default::default() }).unwrap(); + assert_eq!(listed(&reopen(&doc)), ["First paragraph", "Second paragraph"]); + + // Even when a large horizontal watermark shows the same word as a body line at the same y, + // or a rotated 12 pt line crosses a horizontal 12 pt line of the same word at the same origin, + // editing one keeps the other. + let mut same_word = text_page("BT /F1 12 Tf 1 0 0 1 200 400 Tm (DRAFT) Tj ET BT /F1 48 Tf 1 0 0 1 150 400 Tm (DRAFT) Tj ET"); + text::replace_line(&mut same_word, 0, 0, "FINAL").unwrap(); + let after = text::text_lines(&reopen(&same_word), 0).unwrap(); + assert_eq!(after.iter().map(|l| (&l.text[..], l.size as i32)).collect::>(), [("FINAL", 12), ("DRAFT", 48)]); + + let mut crossed = text_page("BT /F1 12 Tf 1 0 0 1 100 500 Tm (Crossed) Tj ET BT /F1 12 Tf 0.707 0.707 -0.707 0.707 100 500 Tm (Crossed) Tj ET"); + text::replace_line(&mut crossed, 0, 0, "Horizontal").unwrap(); + assert_eq!(listed(&reopen(&crossed)), ["Horizontal", "Crossed"]); + + // An invisible OCR text layer (`3 Tr`) on the same baseline and size as a visible line is + // still removed when the visible line is edited, even if OCR misread a letter. + let mut ocr = text_page("BT /F1 12 Tf 0 Tr 1 0 0 1 72 700 Tm (Hello world) Tj ET BT /F1 12 Tf 3 Tr 1 0 0 1 72 700 Tm (Rello world) Tj ET"); + text::replace_line(&mut ocr, 0, 0, "Goodbye world").unwrap(); + assert_eq!(listed(&reopen(&ocr)), ["Goodbye world"]); +} + #[test] fn paragraphs_move_and_rewrap_to_a_new_width() { let para = "BT /F1 10 Tf 12 TL 100 700 Td (One two three four five six seven) Tj T* (eight nine ten eleven twelve) Tj ET"; diff --git a/crates/edit/src/text.rs b/crates/edit/src/text.rs index 9c83cf9a39..f7b8909bc2 100644 --- a/crates/edit/src/text.rs +++ b/crates/edit/src/text.rs @@ -1772,7 +1772,8 @@ 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 drops = coincident_ops(&lines, &[&target]); + let mut drops = coincident_ops(&lines, &[&target]); + drops.extend(target.ops.iter().copied()); 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; @@ -1804,7 +1805,10 @@ fn coincident_ops(lines: &[TextLine], targets: &[&TextLine]) -> std::collections let area = |r: [f64; 4]| ((r[2] - r[0]) * (r[3] - r[1])).max(0.0); let mut drop = std::collections::HashSet::new(); for line in lines { - let copy = targets.iter().any(|target| overlaps_half(line.rect, target.rect, area) && on_same_baseline(line, target)); + let copy = targets.iter().any(|target| { + let same_text = line.text.trim() == target.text.trim() || (line.origin.state.render_mode == 3) != (target.origin.state.render_mode == 3); + same_text && overlaps_half(line.rect, target.rect, area) && on_same_baseline(line, target) + }); if copy { drop.extend(line.ops.iter().copied()); } @@ -1822,13 +1826,16 @@ fn overlaps_half(a: [f64; 4], b: [f64; 4], area: impl Fn([f64; 4]) -> f64) -> bo /// Perpendicular distance between baselines, in user space. Horizontal text compares height; /// turned text compares the distance across the writing direction. fn on_same_baseline(line: &TextLine, target: &TextLine) -> bool { + let (dir, target_dir) = (line.direction(), target.direction()); + let same_dir = (dir.0 - target_dir.0).abs() < 1e-3 && (dir.1 - target_dir.1).abs() < 1e-3; + let same_size = line.size.min(target.size) >= line.size.max(target.size) * 0.8; let angle = target.rotation().to_radians(); let (sin, cos) = if angle.is_finite() { angle.sin_cos() } else { (0.0, 1.0) }; let dx = line.origin.x - target.origin.x; let dy = line.origin.baseline - target.origin.baseline; let offset = dx.mul_add(-sin, dy * cos).abs(); let size = line.size.min(target.size).max(1.0); - offset.is_finite() && offset < size * 0.3 + same_dir && same_size && offset.is_finite() && offset < size * 0.3 } /// The paragraphs on a page (0-based). From bdb943dc3d4871ba4d980efbb0583ea60d71562e Mon Sep 17 00:00:00 2001 From: Verix <77591428+jcnnik@users.noreply.github.com> Date: Thu, 15 Oct 2026 17:28:46 +0200 Subject: [PATCH 008/105] M3: a page jumped to in single-page view no longer hops into place The jump scrolls to MARGIN - GAP above the page. A page that fits has nothing to scroll, and egui clamps the offset only after drawing the frame, so the page showed 14 pt too high for a frame and then hopped down. Clamp the jump to the scroll range before handing it to egui. --- crates/ui-egui/src/canvas.rs | 6 +++++- crates/ui-egui/tests/view.rs | 15 +++++++++++++++ 2 files changed, 20 insertions(+), 1 deletion(-) diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 989b731b7e..4fb6afb940 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -2467,7 +2467,11 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) { } else if let Some((page, frac)) = view.goto.take() { let page = page.min(rects.len() - 1); let r = rects[page]; - scroll = scroll.vertical_scroll_offset((r.top() - y_shift - GAP + frac * r.height()).max(0.0)); + // Clamped here: egui clamps only after drawing, so a page that fits was drawn MARGIN - GAP + // too high for a frame and then hopped into place. (`avail` is the viewport, as the + // scroll bars float.) + let max_y = (content_h - avail.height()).max(0.0); + scroll = scroll.vertical_scroll_offset((r.top() - y_shift - GAP + frac * r.height()).max(0.0).min(max_y)); } let ppp = ui.ctx().pixels_per_point(); let scale = view.render_scale(ppp); diff --git a/crates/ui-egui/tests/view.rs b/crates/ui-egui/tests/view.rs index 3fdeff637c..b492d09b01 100644 --- a/crates/ui-egui/tests/view.rs +++ b/crates/ui-egui/tests/view.rs @@ -185,6 +185,21 @@ fn single_page_layout_shows_one_page_at_a_time() { assert!(rect(&h, 0).is_none() && rect(&h, 1).is_some(), "only page 2"); } +#[test] +fn single_page_jump_draws_a_fitting_page_where_it_stays() { + // A page that fits has nothing to scroll, but the jump to it asked for the gap above it: + // drawn that much too high for a frame, it then hopped into place. + let mut h = harness_stepping(1.0 / 60.0, &[("layout", "single")]); + h.key_press_modifiers(Modifiers::COMMAND, Key::Num0); // fit page + h.run_steps(4); + h.state_mut().set_option("page", "2").unwrap(); + h.step(); + let first = rect(&h, 1).expect("page 2 drawn in the frame after the jump"); + h.run_steps(4); + let settled = rect(&h, 1).expect("page 2"); + assert!((first.min.y - settled.min.y).abs() < 0.5, "page 2 drawn where it settles: {first:?} then {settled:?}"); +} + #[test] fn single_page_wheel_pans_instead_when_zoomed_in() { // The zoomed-in guard: with room to pan, the wheel pans and must not turn pages. From f59320b1a0e70ef0f34eeed42a6f56b813c215c0 Mon Sep 17 00:00:00 2001 From: oracola91 <118899664+oracola91@users.noreply.github.com> Date: Thu, 15 Oct 2026 18:36:56 +0300 Subject: [PATCH 009/105] fix(web): host mode needs a parent page on the same origin With ?host=parent, saves are posted to the parent with the app's own origin as the target. A framing page on another origin never gets them: the browser drops each message without an error, post() returns Ok, and the document is marked saved and the leave-page warning cleared (#2167). Bridge::new now returns None unless the parent's origin is the app's own (reading another origin's location throws, which is None too). The app then runs as if unframed and saves download, and a console warning says why. Same-origin embedding is unchanged. --- apps/pdfcraft-web/src/main.rs | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/apps/pdfcraft-web/src/main.rs b/apps/pdfcraft-web/src/main.rs index a46a4674b9..73d14f4a6c 100644 --- a/apps/pdfcraft-web/src/main.rs +++ b/apps/pdfcraft-web/src/main.rs @@ -70,6 +70,10 @@ fn main() { app.host_save = Some(bridge.save_fn()); app.host_dirty = Some(bridge.dirty_fn()); bridge.post_ready(); + } else if query_param("host").as_deref() == Some("parent") { + eframe::web_sys::console::warn_1( + &"PdfCraft: ?host=parent needs a framing page on the same origin; saves download instead".into(), + ); } Ok(Box::new(app)) }), @@ -130,7 +134,8 @@ async fn fetch_bytes(url: &str) -> Result, String> { /// - `pdfcraft:dirty` (to the parent): `{ dirty }` whenever unsaved work appears or goes away. /// - `pdfcraft:failed` (to the parent): `{ error }` when the app couldn't start. /// -/// Only the parent window on the app's own origin is heard or answered. +/// Only the parent window on the app's own origin is heard or answered. With a parent on +/// another origin there is no bridge: the app runs as if unframed and saves download. #[cfg(target_arch = "wasm32")] mod host { use eframe::wasm_bindgen::{JsCast, JsValue, closure::Closure}; @@ -143,7 +148,9 @@ mod host { } impl Bridge { - /// `None` when the page isn't framed (there is no parent to talk to). + /// `None` when the page isn't framed (there is no parent to talk to), or when the parent + /// is on another origin: messages only go to this origin, so the browser would drop + /// every save without an error while the document showed as saved (#2167). pub fn new() -> Option { let window = web_sys::window()?; let parent = window.parent().ok().flatten()?; @@ -151,6 +158,10 @@ mod host { return None; } let origin = window.location().origin().ok()?; + // Reading another origin's location throws, which is `None` too. + if parent.location().origin().ok()? != origin { + return None; + } Some(Self { window, parent, origin }) } From 7cc073ab89f042036c523c726c1f8f021a7476d9 Mon Sep 17 00:00:00 2001 From: NetterSilver <133535268+Silvertree2010@users.noreply.github.com> Date: Thu, 15 Oct 2026 17:42:18 +0200 Subject: [PATCH 010/105] Shorten over-long part file names in doc_split by bookmarks Found while testing crates/automation/src/lib.rs:1747 Co-Authored-By: Claude Haiku 5.5 Co-Authored-By: Claude Sonnet 5.5 --- crates/automation/src/lib.rs | 15 +++++++++----- crates/automation/tests/automation.rs | 29 +++++++++++++++++++++++++++ 2 files changed, 39 insertions(+), 5 deletions(-) diff --git a/crates/automation/src/lib.rs b/crates/automation/src/lib.rs index 88856305b2..3cdc64217a 100644 --- a/crates/automation/src/lib.rs +++ b/crates/automation/src/lib.rs @@ -1746,16 +1746,21 @@ impl Automation { let mut used = std::collections::HashSet::new(); for (i, (first, last, bytes)) in parts.iter().enumerate() { let safe = |t: &str| t.chars().map(|c| if c.is_alphanumeric() || " -_.,()".contains(c) { c } else { '_' }).collect::(); - let file = match titles.iter().find(|t| t.0 + 1 == *first) { - Some((_, t)) => format!("{stem}-{}.pdf", safe(t)), - None => format!("{stem}-part{}.pdf", i + 1), + let mut base = match titles.iter().find(|t| t.0 + 1 == *first) { + Some((_, t)) => format!("{stem}-{}", safe(t)), + None => format!("{stem}-part{}", i + 1), }; + // A file name is at most 255 bytes: keep the base to 200, cut between characters, which leaves room for ".pdf" and a -N suffix. + if let Some((end, _)) = base.char_indices().find(|(at, c)| at + c.len_utf8() > 200) { + base.truncate(end); + } + let file = format!("{base}.pdf"); // Equal titles (or titles that sanitize alike) get -2, -3, ... so no part overwrites another; compared case-insensitively. - let mut unique = file.clone(); + let mut unique = file; let mut n = 1; while !used.insert(unique.to_lowercase()) { n += 1; - unique = format!("{}-{n}.pdf", file.trim_end_matches(".pdf")); + unique = format!("{base}-{n}.pdf"); } let path = child(&dir, &unique); write_atomic(&path, bytes)?; diff --git a/crates/automation/tests/automation.rs b/crates/automation/tests/automation.rs index 1e662d8f38..ed52805679 100644 --- a/crates/automation/tests/automation.rs +++ b/crates/automation/tests/automation.rs @@ -3806,6 +3806,35 @@ fn bookmark_split_with_equal_titles_keeps_every_part() { } } +#[test] +fn bookmark_split_with_long_titles_shortens_the_file_names() { + let dir = workdir("organize_long_titles"); + let mut a = auto(&dir); + let doc = ok(&mut a, "doc_open", json!({ "path": "a.pdf" }))["doc"].as_u64().unwrap(); + // Two equal titles far over the 255-byte name limit, then a multibyte one (3 bytes a character). + let long = format!("Chapter {}", "x".repeat(300)); + ok(&mut a, "bookmark_add", json!({ "doc": doc, "title": long, "page": 1 })); + ok(&mut a, "bookmark_add", json!({ "doc": doc, "title": long, "page": 2 })); + ok(&mut a, "bookmark_add", json!({ "doc": doc, "title": "章".repeat(100), "page": 3 })); + let s = ok(&mut a, "doc_split", json!({ "doc": doc, "bookmarks": true, "out_dir": "parts" })); + let files: Vec = s["files"] + .as_array() + .unwrap() + .iter() + .map(|f| std::path::Path::new(f["path"].as_str().unwrap()).file_name().unwrap().to_string_lossy().into_owned()) + .collect(); + assert_eq!(files.len(), 3, "{files:?}"); + let distinct: std::collections::HashSet<_> = files.iter().map(|f| f.to_lowercase()).collect(); + assert_eq!(distinct.len(), 3, "{files:?}"); + for f in &files { + assert!(f.len() <= 255, "{f} is {} bytes", f.len()); + assert!(f.ends_with(".pdf"), "{f}"); + assert!(dir.join("parts").join(f).exists(), "{f} missing"); + } + assert!(files[0].starts_with("a-Chapter x"), "{files:?}"); + assert!(files[2].starts_with("a-章"), "{files:?}"); +} + #[test] fn organizing_with_filters_bookmark_splits_and_extract_options() { let dir = workdir("organize2"); From db4157099af4f0342bb08430f560967cd6f30c56 Mon Sep 17 00:00:00 2001 From: NetterSilver <133535268+Silvertree2010@users.noreply.github.com> Date: Thu, 15 Oct 2026 15:43:14 +0000 Subject: [PATCH 011/105] Don't treat a date ending in "Sep" as PM in AFDate/AFTime parsing Found while testing crates/forms/src/af.rs:590 Co-Authored-By: Claude Haiku 5.5 Co-Authored-By: Claude Sonnet 5.5 --- crates/forms/src/af.rs | 26 +++++++++++++++++++++++++- 1 file changed, 25 insertions(+), 1 deletion(-) diff --git a/crates/forms/src/af.rs b/crates/forms/src/af.rs index b50ed4c1ed..129f7fa751 100644 --- a/crates/forms/src/af.rs +++ b/crates/forms/src/af.rs @@ -587,7 +587,9 @@ pub fn parse_date(text: &str, fmt: &str) -> Option { return None; } let lower = t.to_lowercase(); - let pm = lower.contains("pm") || lower.ends_with('p'); + // A bare trailing "p" ("5:30p") only counts when it follows a digit, not a word like "Sep". + 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(); @@ -1285,3 +1287,25 @@ mod never_crash_tests { let _ = format_date(dt, "dddd ddd"); } } + +#[cfg(test)] +mod pm_marker_tests { + use super::*; + + /// A month name ending in "p" ("Sep") is not a PM marker; only a "p" attached to a time is. + #[test] + fn month_name_ending_in_p_is_not_pm() { + let dt = parse_date("2026 05 Sep", "yyyy dd mmm HH:MM").expect("valid date"); + assert_eq!((dt.m, dt.d, dt.hh, dt.mm), (9, 5, 0, 0)); + let f = Format::Date("yyyy dd mmm HH:MM".into()); + assert_eq!(format_value(&f, "2026 05 Sep"), "2026 05 Sep 00:00"); + assert!(keystroke(&f, "date", "2026 05 Sep").is_ok()); + } + + #[test] + fn bare_p_after_a_time_is_still_pm() { + assert_eq!(parse_date("05/09/2026 5:30p", "mm/dd/yyyy h:MM").map(|d| d.hh), Some(17)); + assert_eq!(parse_date("05/09/2026 5:30 p", "mm/dd/yyyy h:MM").map(|d| d.hh), Some(17)); + assert_eq!(parse_date("05/09/2026 5:30 pm", "mm/dd/yyyy h:MM").map(|d| d.hh), Some(17)); + } +} From 67d35b70a0b55b8f7a3fd2bb135fc3dc0aaf2db9 Mon Sep 17 00:00:00 2001 From: magdale76 <16279778+magdale76@users.noreply.github.com> Date: Thu, 15 Oct 2026 18:09:16 +0200 Subject: [PATCH 012/105] M14: no PdfCraft lion over PDF thumbnails and the preview pane With PdfCraft as the default PDF program, Windows painted the program's icon, the lion, over the lower-right corner of every PDF thumbnail and of the preview pane, covering part of the page. Thumbnail overlays come from the ProgID's TypeOverlay value. When it is absent, Windows uses the default program's icon, and an empty string means no overlay ("Thumbnail Handlers", Microsoft Learn). The installer now writes TypeOverlay = "" on PdfCraft.Document. Co-Authored-By: Claude Opus 5.5 --- packaging/windows/pdfcraft.wxs | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/packaging/windows/pdfcraft.wxs b/packaging/windows/pdfcraft.wxs index 7fae1329f3..d96f2cd78d 100644 --- a/packaging/windows/pdfcraft.wxs +++ b/packaging/windows/pdfcraft.wxs @@ -111,6 +111,11 @@ Resource 1 is the app's lion; resource 2 is the PDF document (build.rs). --> + + From 294fea2fb8e16d027b3fd6ab3faae337070e4ed1 Mon Sep 17 00:00:00 2001 From: mtlcl410 Date: Fri, 16 Oct 2026 01:34:32 +0800 Subject: [PATCH 013/105] fix(ui): Reveal in File Explorer selects files whose path has a space (#2579) Explorer reads /select,"". The path went in as one ordinary argument, which Rust quotes whole when it contains a space ("/select,C:\a b.pdf"), and Explorer answers that by opening Documents. A file saved to a folder or name with a space (often after Save As) was therefore never shown. reveal_command now quotes the path after the comma and the Windows runner passes the arguments as written; a path containing a quote, which Windows doesn't allow, is refused. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01RUNQSPVXx3dDBabRZHAnjz --- crates/ui-egui/src/tab_context.rs | 32 ++++++++++++++++++++++++++----- 1 file changed, 27 insertions(+), 5 deletions(-) diff --git a/crates/ui-egui/src/tab_context.rs b/crates/ui-egui/src/tab_context.rs index aa16ef7105..6f5319d637 100644 --- a/crates/ui-egui/src/tab_context.rs +++ b/crates/ui-egui/src/tab_context.rs @@ -56,7 +56,19 @@ fn reveal(path: &std::path::Path) -> Result<(), String> { } let platform = std::env::consts::OS; let run = |program: &str, args: &[std::ffi::OsString]| { - std::process::Command::new(program).args(args).output().map(|o| o.status.success()).map_err(|e| e.to_string()) + let mut command = std::process::Command::new(program); + // Windows: `reveal_command` already quotes what Explorer needs quoted, and Rust's own + // quoting of an argument with a space breaks `/select,` (#2579), so pass them as written. + #[cfg(windows)] + { + use std::os::windows::process::CommandExt as _; + for arg in args { + command.raw_arg(arg); + } + } + #[cfg(not(windows))] + command.args(args); + command.output().map(|o| o.status.success()).map_err(|e| e.to_string()) }; if matches!(platform, "linux" | "freebsd") { // `dbus-send --print-reply` waits up to 2 s for the file manager (then xdg-open may run), @@ -95,9 +107,16 @@ fn reveal_via( fn reveal_command(platform: &str, path: &std::path::Path) -> Result<(&'static str, Vec), String> { match platform { "windows" => { - // Explorer uses /select,, rather than a separate option value. - let mut selection = std::ffi::OsString::from("/select,"); + // Explorer reads `/select,""`: the path quoted after the comma (#2579). Quoting + // the whole argument instead ("/select,C:\a b.pdf", what an ordinary argument with a + // space becomes) opens Documents. A Windows path can't contain `"`, so the quotes + // can't be closed early; one that does is refused. + if path.as_os_str().as_encoded_bytes().contains(&b'"') { + return Err("The file name can't be shown in File Explorer".into()); + } + let mut selection = std::ffi::OsString::from("/select,\""); selection.push(path); + selection.push("\""); Ok(("explorer.exe", vec![selection])) } "macos" => Ok(("open", vec!["-R".into(), path.as_os_str().into()])), @@ -229,10 +248,13 @@ mod tests { assert_eq!(args, [std::ffi::OsString::from("-R"), path.as_os_str().into()]); let (exe, args) = reveal_command("windows", path).unwrap(); assert_eq!(exe, "explorer.exe"); - assert_eq!(args, [std::ffi::OsString::from("/select,/tmp/年度 report.pdf")]); + assert_eq!(args, [std::ffi::OsString::from("/select,\"/tmp/年度 report.pdf\"")]); + // #2579: the path is quoted after the comma, never the whole argument, which Explorer + // reads as no selection and answers by opening Documents. let special = std::path::Path::new(r"C:\Reports\年度 report, #1 & (draft).pdf"); let (_, windows) = reveal_command("windows", special).unwrap(); - assert_eq!(windows, [std::ffi::OsString::from(r"/select,C:\Reports\年度 report, #1 & (draft).pdf")]); + assert_eq!(windows, [std::ffi::OsString::from(r#"/select,"C:\Reports\年度 report, #1 & (draft).pdf""#)]); + assert!(reveal_command("windows", std::path::Path::new(r#"C:\a" b.pdf"#)).is_err(), "a quote can't end the path early"); let (exe, args) = reveal_command("linux", path).unwrap(); assert_eq!(exe, "dbus-send"); assert_eq!(args[6], "array:string:file:///tmp/%E5%B9%B4%E5%BA%A6%20report.pdf"); From 64ab6b67ecc4a5fbabffb4e4930733d9fba46136 Mon Sep 17 00:00:00 2001 From: Mateus Bessa Date: Thu, 15 Oct 2026 15:14:30 -0300 Subject: [PATCH 014/105] fix(ui): Copy in the selection action bar closes the bar MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The bar beside selected text closed after Highlight, Underline, Strikethrough, Add comment and Replace text because those consume the selection. Copy kept the selection, so the bar stayed open. Remember the copied selection and hide the bar until the selection changes; the words stay selected for ⌘C or another action. Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/canvas.rs | 15 ++++++++++++++- crates/ui-egui/tests/comments.rs | 15 +++++++++++++++ 2 files changed, 29 insertions(+), 1 deletion(-) diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 989b731b7e..5c0bdbfc5c 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -371,6 +371,9 @@ pub struct DocView { pub(crate) text_failed: HashSet, pub find: Option, selection: Option, + /// The selection whose action bar was closed by its Copy button: the bar stays hidden + /// until the selection changes, as it does once a markup button consumes the selection. + actions_dismissed: Option, /// The drag in progress started a text selection: the wheel scrolls the page during it /// (#721), which egui's scroll area refuses while anything is dragged. selection_drag: bool, @@ -649,6 +652,7 @@ impl DocView { text_failed: HashSet::new(), find: None, selection: None, + actions_dismissed: None, selection_drag: false, clicks: 0, last_press_time: 0.0, @@ -955,6 +959,7 @@ impl DocView { pub(crate) fn clear_selection(&mut self) { self.selection = None; + self.actions_dismissed = None; } /// Select text on `page` from glyph `from` to glyph `to` (tests and automation). @@ -3794,7 +3799,13 @@ fn selection_actions( if ui.input(|input| input.pointer.is_decidedly_dragging()) { return false; } - let Some(selection) = view.selection else { return false }; + let Some(selection) = view.selection else { + view.actions_dismissed = None; + return false; + }; + if view.actions_dismissed == Some(selection) { + return false; + } let Some(text) = view.texts.get(&selection.page) else { return false }; let Some(xf) = view.page_xform(selection.page) else { return false }; let Some(last) = selection.rects(text).last().copied() else { return false }; @@ -3831,6 +3842,8 @@ fn selection_actions( }); let Some(action) = chosen else { return false }; if let SelectionAction::Copy = action { + // Copying closes the bar but keeps the words selected, for ⌘C or a second action. + view.actions_dismissed = Some(selection); return true; } let Some((page, quads)) = view.selection_quads(info) else { return false }; diff --git a/crates/ui-egui/tests/comments.rs b/crates/ui-egui/tests/comments.rs index 914bc281b8..aaea7990f1 100644 --- a/crates/ui-egui/tests/comments.rs +++ b/crates/ui-egui/tests/comments.rs @@ -364,6 +364,21 @@ fn selected_text_shows_actions_and_underlines_without_changing_tools() { assert_eq!(h.state().quick_tool, QuickTool::Select); } +#[test] +fn selected_text_copy_action_closes_the_bar_and_keeps_the_selection() { + let mut h = harness(|_| {}); + h.state_mut().views[0].select_text(0, 4, 8); + h.run_steps(3); + h.get_by_label("Copy").click(); + h.run_steps(3); + assert!(h.query_by_label("Add comment").is_none(), "the action bar closes after Copy"); + assert!(h.state().views[0].selected_text().is_some_and(|t| !t.is_empty()), "the words stay selected"); + // A new selection brings the bar back. + h.state_mut().views[0].select_text(0, 2, 6); + h.run_steps(3); + h.get_by_label("Add comment"); +} + #[test] fn selected_text_replace_action_opens_replacement_composer() { let mut h = harness(|_| {}); From 4a991b03b5ad2b755ac89e4fefb3f64ff5a34df9 Mon Sep 17 00:00:00 2001 From: outragedevs <243349561+outragedevs@users.noreply.github.com> Date: Thu, 15 Oct 2026 20:21:57 +0200 Subject: [PATCH 015/105] M7: Let the page range select page 1 alone MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Seitenbereich „1 bis 1“ springt nicht mehr auf die letzte Seite zurück. --- crates/ui-egui/src/marks_ui.rs | 12 ++++++--- crates/ui-egui/tests/ui_group/marks.rs | 34 ++++++++++++++++++++++++++ 2 files changed, 42 insertions(+), 4 deletions(-) diff --git a/crates/ui-egui/src/marks_ui.rs b/crates/ui-egui/src/marks_ui.rs index 4a60c40a6e..d91a69f937 100644 --- a/crates/ui-egui/src/marks_ui.rs +++ b/crates/ui-egui/src/marks_ui.rs @@ -28,8 +28,10 @@ pub struct PageRange { } impl Default for PageRange { + /// From the first page to the last: `to` starts past any page count, and the range field + /// shows (and clamps it to) the document's last page. fn default() -> Self { - Self { all: true, from: 1, to: 1, subset: Subset::All } + Self { all: true, from: 1, to: usize::MAX, subset: Subset::All } } } @@ -53,9 +55,6 @@ impl PageRange { widgets::radio_value(ui, &mut self.all, false, tl!("Pages from")); ui.add_enabled(!self.all, egui::DragValue::new(&mut self.from).range(1..=count.max(1))); ui.label(tl!("to")); - if self.to == 1 && self.from == 1 { - self.to = count.max(1); - } ui.add_enabled(!self.all, egui::DragValue::new(&mut self.to).range(1..=count.max(1))); ui.label(tl!("Subset")); let label = match self.subset { @@ -519,6 +518,11 @@ mod tests { assert_eq!(r.pages(6), [1, 3, 5]); let r = PageRange { all: true, subset: Subset::Odd, ..PageRange::default() }; assert_eq!(r.pages(5), [0, 2, 4]); + // Not all pages: by default up to the last one; a single page is a range too. + let r = PageRange { all: false, ..PageRange::default() }; + assert_eq!(r.pages(4), [0, 1, 2, 3]); + let r = PageRange { all: false, from: 1, to: 1, subset: Subset::All }; + assert_eq!(r.pages(38), [0]); } #[test] diff --git a/crates/ui-egui/tests/ui_group/marks.rs b/crates/ui-egui/tests/ui_group/marks.rs index 0a0e45b152..1f1515ad60 100644 --- a/crates/ui-egui/tests/ui_group/marks.rs +++ b/crates/ui-egui/tests/ui_group/marks.rs @@ -96,3 +96,37 @@ fn backgrounds_and_watermarks_from_a_file() { let s = h.state(); assert_eq!(s.session.get(s.views[0].id).unwrap().can_undo(), Some("Add background")); } + +/// Pages from 1 to 1 stays one page: the dialog used to turn "to" back into the last page on +/// every frame while both were 1, so only page 1 could not be chosen alone. The field starts on +/// the last page. +#[test] +fn page_range_from_one_to_one_selects_only_the_first_page() { + let mut h = harness(); + h.state_mut().execute("edit.background"); + h.run_steps(2); + assert_eq!(h.state().marks_draft.range.pages(3), [0, 1, 2]); + h.get_by_label("Pages from").click(); + h.run_steps(2); + let range = h.state().marks_draft.range; + assert_eq!((range.from, range.to), (1, 3), "from the first page to the last"); + h.state_mut().marks_draft.range.to = 1; + h.run_steps(3); + let range = h.state().marks_draft.range; + assert_eq!((range.from, range.to), (1, 1), "the range stays as typed"); + h.get_by_label("Applies to 1 page"); + h.get_by_label("OK").click(); + h.run_steps(3); + let s = h.state(); + let doc = s.session.get(s.views[0].id).unwrap(); + assert_eq!(doc.marks(), [MarkKind::Background]); + // Rendered: page 1 has the (default cream) background, page 2 is still blank. + let mut r = pdfcraft_render::PageRenderer::new(doc.bytes.clone(), pdfcraft_render::RenderConfig::default()); + let corner = |r: &mut pdfcraft_render::PageRenderer, page: usize| { + let p = r.render(pdfcraft_render::RenderRequest { page, scale: 1.0, ..Default::default() }); + assert!(p.error.is_none(), "{:?}", p.error); + p.rgba[..4].to_vec() + }; + assert_eq!(corner(&mut r, 0), vec![255, 255, 217, 255], "page 1 has the background"); + assert_eq!(corner(&mut r, 1), vec![255, 255, 255, 255], "page 2 does not"); +} From bafb9482267759eae4388a96d899553912393cbc Mon Sep 17 00:00:00 2001 From: NetterSilver <133535268+Silvertree2010@users.noreply.github.com> Date: Thu, 15 Oct 2026 22:19:58 +0200 Subject: [PATCH 016/105] Round FormCalc Str and Format exact ties away from zero Found while testing crates/js/src/formcalc.rs:2169 (Str) and :2258 (Format number picture) Co-Authored-By: Claude Haiku 5.5 Co-Authored-By: Claude Sonnet 5.5 --- crates/js/src/formcalc.rs | 11 +++++++++-- crates/js/src/tests.rs | 13 +++++++++++++ 2 files changed, 22 insertions(+), 2 deletions(-) diff --git a/crates/js/src/formcalc.rs b/crates/js/src/formcalc.rs index 059276e936..cf82eff466 100644 --- a/crates/js/src/formcalc.rs +++ b/crates/js/src/formcalc.rs @@ -712,6 +712,13 @@ fn num_text(n: f64) -> String { if s.is_empty() || s == "-0" { "0".into() } else { s.to_string() } } +/// `v` with `decimals` places, rounding exact halves away from zero (Rust's formatter rounds them to even). +fn fixed(v: f64, decimals: usize) -> String { + let k = 10f64.powi(decimals.min(20) as i32); + let scaled = (v * k).round() / k; + format!("{:.decimals$}", if scaled.is_finite() { scaled } else { v }) +} + /// The number a string holds: its leading numeric part (`12abc` → 12), `None` for none. fn parse_num(s: &str) -> Option { let t = s.trim(); @@ -2166,7 +2173,7 @@ impl Interp<'_> { let v = self.arg_num(args, 0)?; let width = if args.len() > 1 { self.arg_num(args, 1)?.clamp(0.0, 1000.0) as usize } else { 10 }; let prec = if args.len() > 2 { self.arg_num(args, 2)?.clamp(0.0, 20.0) as usize } else { 0 }; - let text = format!("{v:.prec$}"); + let text = fixed(v, prec); Val::Str(if text.len() > width { "*".repeat(width) } else { format!("{text:>width$}") }) } "substr" => { @@ -2255,7 +2262,7 @@ impl Interp<'_> { let num = self.to_num(&v); let decimals = pic.split_once('.').map_or(0, |(_, f)| f.chars().filter(|c| matches!(c, '9' | 'z' | 'Z')).count()).min(20); let grouped = pic.contains(','); - let mut text = format!("{:.decimals$}", num.abs()); + let mut text = fixed(num.abs(), decimals); if grouped { let (int, frac) = text.split_once('.').map_or((text.as_str(), ""), |(a, b)| (a, b)); let mut g = String::new(); diff --git a/crates/js/src/tests.rs b/crates/js/src/tests.rs index 55fa45760d..4fe5fd2906 100644 --- a/crates/js/src/tests.rs +++ b/crates/js/src/tests.rs @@ -636,6 +636,19 @@ mod formcalc { assert_eq!(calc("; a comment\n// another\nSum(1,\n 2,\n 3)"), "6"); } + #[test] + fn str_and_format_round_exact_ties_away_from_zero() { + assert_eq!(calc("Str(2.5, 3, 0)"), " 3"); + assert_eq!(calc("Str(0.5, 3, 0)"), " 1"); + assert_eq!(calc("Str(-2.5, 4, 0)"), " -3"); + assert_eq!(calc("Str(1.125, 6, 2)"), " 1.13"); + assert_eq!(calc(r#"Format("9", 2.5)"#), "3"); + assert_eq!(calc(r#"Format("9.99", 0.125)"#), "0.13"); + assert_eq!(calc(r#"Format("9.9", 0.25)"#), "0.3"); + assert_eq!(calc(r#"Format("9.9", -0.25)"#), "-0.3"); + assert_eq!(calc(r#"Format("9.99", 2.344)"#), "2.34"); + } + #[test] fn built_in_functions_cover_the_common_set() { assert_eq!(calc("Abs(-2.5)"), "2.5"); From 46ebc6dcf7358ab255b36be2e8a55c229f23cf5f Mon Sep 17 00:00:00 2001 From: Charan Rathore <180254320+charan-rathore@users.noreply.github.com> Date: Fri, 16 Oct 2026 01:55:31 +0530 Subject: [PATCH 017/105] fix(annot): percent-encode non-ASCII bytes written into link /URI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A link annotation whose target contains non-ASCII characters (for example https://example.com/ação) was written into the PDF /URI string as raw UTF-8 bytes, but URIs in PDF are 7-bit ASCII strings (ISO 32000 §12.6.4.8). The saved file then carried an out-of-spec URI that strict readers can reject or misparse. Percent-encode every byte outside the printable ASCII range when writing the /URI entry. Bytes already inside 0x21..=0x7E pass through unchanged, so existing percent-escapes like %20 are preserved. Adds a regression test that round-trips a non-ASCII URL and an already-escaped URL through save/reload and asserts the stored URIs are pure ASCII. Signed-off-by: Charan Rathore <180254320+charan-rathore@users.noreply.github.com> --- crates/annot/src/links.rs | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/crates/annot/src/links.rs b/crates/annot/src/links.rs index 2e45b4e4d4..4d3eef632b 100644 --- a/crates/annot/src/links.rs +++ b/crates/annot/src/links.rs @@ -178,6 +178,19 @@ fn write_style(d: &mut Dict, s: &LinkStyle) { d.set(b"H".to_vec(), Object::name(s.highlight.code())); } +/// Bytes of `u` as a 7-bit ASCII URI string (§12.6.4.8). +fn uri_bytes(u: &str) -> Vec { + let mut out = Vec::with_capacity(u.len()); + for &b in u.as_bytes() { + if (0x21..=0x7e).contains(&b) { + out.push(b); + } else { + out.extend_from_slice(format!("%{b:02X}").as_bytes()); + } + } + out +} + fn write_action(doc: &Document, d: &mut Dict, a: &LinkAction) -> Result<(), AnnotError> { match a { LinkAction::Page(p) => { @@ -194,7 +207,7 @@ fn write_action(doc: &Document, d: &mut Dict, a: &LinkAction) -> Result<(), Anno let mut act = Dict::new(); act.set(b"S".to_vec(), Object::name("URI")); // URIs are 7-bit ASCII strings (§12.6.4.8). - act.set(b"URI".to_vec(), PdfString::literal(u.as_bytes().to_vec())); + act.set(b"URI".to_vec(), PdfString::literal(uri_bytes(u))); d.set(b"A".to_vec(), Object::Dict(act)); } LinkAction::Other(_) => {} From 8947465af75a11b1016d99b3fc10163e4686c019 Mon Sep 17 00:00:00 2001 From: Charan Rathore <180254320+charan-rathore@users.noreply.github.com> Date: Fri, 16 Oct 2026 01:56:26 +0530 Subject: [PATCH 018/105] test(annot): cover 7-bit ASCII percent-encoding of link /URI Regression test for the /URI percent-encoding fix: round-trips a non-ASCII URL and an already-escaped URL through save/reload and asserts the stored URIs are pure ASCII with existing escapes preserved. Signed-off-by: Charan Rathore <180254320+charan-rathore@users.noreply.github.com> --- crates/annot/src/tests.rs | 21 ++++++++++++++++++++- 1 file changed, 20 insertions(+), 1 deletion(-) diff --git a/crates/annot/src/tests.rs b/crates/annot/src/tests.rs index faed409abb..63acc5fc6b 100644 --- a/crates/annot/src/tests.rs +++ b/crates/annot/src/tests.rs @@ -588,6 +588,25 @@ fn links_are_added_edited_listed_and_removed() { assert!(links::add(&mut doc, 0, [0.0, 0.0, 1.0, 1.0], &LinkAction::Page(0), &LinkStyle::default()).is_err(), "too small"); } +#[test] +fn link_uri_is_percent_encoded_to_7_bit_ascii() { + use crate::links::{self, LinkAction, LinkStyle}; + let mut doc = fixture(); + for url in ["https://example.com/ação", "https://example.com/a%20b"] { + links::add(&mut doc, 0, [10.0, 10.0, 110.0, 30.0], &LinkAction::Uri(url.into()), &LinkStyle::default()).unwrap(); + } + let doc = reopen(&doc); + let urls: Vec = links::list(&doc) + .into_iter() + .filter_map(|link| match link.action { + LinkAction::Uri(url) => Some(url), + _ => None, + }) + .collect(); + assert_eq!(urls, ["https://example.com/a%C3%A7%C3%A3o", "https://example.com/a%20b"]); + assert!(urls.iter().all(|url| url.is_ascii())); +} + #[test] fn link_edit_rejects_non_finite_and_overflowed_rectangles_without_mutating_the_pdf() { use crate::links::{self, LinkAction, LinkStyle}; @@ -1307,4 +1326,4 @@ fn text_box_font_size_changes_the_da_and_grows_the_box() { assert!(set_font_size(&mut doc, 0, i, 12.0, &meta("")).is_err()); assert_eq!(props(&doc, 0, i).unwrap().font_size, None); } -} + } From 53425fc60602688fb556829d1b71be23caab5c7c Mon Sep 17 00:00:00 2001 From: Charan Rathore <180254320+charan-rathore@users.noreply.github.com> Date: Fri, 16 Oct 2026 01:57:38 +0530 Subject: [PATCH 019/105] test(annot): fix final-line indentation Corrects the closing-brace indentation on the last line so cargo fmt --check stays clean. Signed-off-by: Charan Rathore <180254320+charan-rathore@users.noreply.github.com> --- crates/annot/src/tests.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/annot/src/tests.rs b/crates/annot/src/tests.rs index 63acc5fc6b..d2105984d1 100644 --- a/crates/annot/src/tests.rs +++ b/crates/annot/src/tests.rs @@ -1326,4 +1326,4 @@ fn text_box_font_size_changes_the_da_and_grows_the_box() { assert!(set_font_size(&mut doc, 0, i, 12.0, &meta("")).is_err()); assert_eq!(props(&doc, 0, i).unwrap().font_size, None); } - } +} From c9956bf77f01d205e6954d385b4871cebc45eb76 Mon Sep 17 00:00:00 2001 From: Muhammad Saad Date: Fri, 16 Oct 2026 00:34:21 +0400 Subject: [PATCH 020/105] Replace Text: give the caret its own /NM (#2666) add_text_replacement built the StrikeOut and the Caret from one Meta, and add_annotation stamps /NM from meta.id, so the pair shared a single name. XFDF names a reply by the /NM of what it replies to, so the exporter wrote the caret's inreplyto as the caret's own name. Importing that file found a comment replying to itself, and the Replace Text comment did not arrive: the reporter's second document listed none. FDF was unaffected because it keeps the pair by object structure rather than by name. Take a second Meta for the caret. The ids stay supplied by the caller, as the rest of the crate does so edits remain deterministic, and the one production caller already has a generator and calls it twice. /IRT still points at the strikeout, so the group still moves, lists and deletes as one. Tests: the annotation test now asserts the two carry different names and that the caret's /IRT resolves to the strikeout's. A new XFDF test exports a Replace Text pair and imports it into another document, asserting both comments arrive with their thread intact; it fails if the two are given one id again. --- crates/annot/src/lib.rs | 8 +++++++- crates/annot/src/tests.rs | 12 ++++++++++-- crates/engine/src/lib.rs | 5 ++++- crates/xfdf/src/tests.rs | 28 +++++++++++++++++++++++++++- 4 files changed, 48 insertions(+), 5 deletions(-) diff --git a/crates/annot/src/lib.rs b/crates/annot/src/lib.rs index f4973aa021..b7f9d62697 100644 --- a/crates/annot/src/lib.rs +++ b/crates/annot/src/lib.rs @@ -2282,6 +2282,11 @@ fn line_endings_of(d: &Dict) -> Option> { /// Replace Text (Acrobat's proposal): strike out `quads` and add a caret at the end of the /// struck text holding `replacement`, grouped with the strikeout (`/IRT` + `/RT /Group`) so they /// move, list and delete as one. Returns the strikeout's index. +/// +/// The two annotations need their own `/NM`: XFDF names a reply by the `/NM` of what it replies +/// to, so one id for both writes `inreplyto` equal to the caret's own `name`, and a reader that +/// resolves names (Acrobat, and our own importer) loses the pair. `caret_meta` is a second id +/// from the caller, so the edit stays deterministic. #[allow(clippy::too_many_arguments)] pub fn add_text_replacement( doc: &mut Document, @@ -2292,6 +2297,7 @@ pub fn add_text_replacement( strike: &Style, caret: &Style, meta: &Meta, + caret_meta: &Meta, ) -> Result { let last = *quads.last().ok_or_else(|| AnnotError::Invalid("select the text to replace first".into()))?; let s = add_annotation( @@ -2317,7 +2323,7 @@ pub fn add_text_replacement( let c = add_annotation( doc, &NewAnnotation { page, shape: Shape::Caret { rect }, style: caret.clone(), contents: replacement.into(), author: author.into() }, - meta, + caret_meta, )?; let (_, cr) = annot_ref(doc, page, c)?; doc.update_dict(cr, |d| { diff --git a/crates/annot/src/tests.rs b/crates/annot/src/tests.rs index faed409abb..7c73b05083 100644 --- a/crates/annot/src/tests.rs +++ b/crates/annot/src/tests.rs @@ -784,7 +784,7 @@ fn replace_text_groups_a_strikeout_and_a_caret() { let quads = [[100.0, 720.0, 200.0, 720.0, 100.0, 706.0, 200.0, 706.0]]; let red = Style { color: [0.89, 0.13, 0.13], opacity: 1.0, width: 1.0, fill: None }; let blue = Style { color: [0.0, 0.47, 0.84], opacity: 1.0, width: 1.0, fill: None }; - let i = add_text_replacement(&mut doc, 0, &quads, "new words", "Ada", &red, &blue, &meta("r")).unwrap(); + let i = add_text_replacement(&mut doc, 0, &quads, "new words", "Ada", &red, &blue, &meta("r"), &meta("r-caret")).unwrap(); let doc = reopen(&doc); let a = list(&doc, 0); let strike = &a[i]; @@ -792,13 +792,21 @@ fn replace_text_groups_a_strikeout_and_a_caret() { let caret = a.iter().find(|d| d.name(b"Subtype") == Some(b"Caret")).unwrap(); assert_eq!((text(caret, b"Contents").as_str(), caret.name(b"RT")), ("new words", Some(&b"Group"[..]))); assert!(caret.reference(b"IRT").is_some()); + // #2666: the pair needs its own `/NM` each. XFDF names a reply by the `/NM` of what it + // replies to, so sharing one id writes `inreplyto` equal to the caret's own `name` and the + // pair does not survive an export and import. + assert_eq!(text(caret, b"NM").as_str(), "r-caret"); + assert_eq!(text(strike, b"NM").as_str(), "r"); + assert_ne!(text(caret, b"NM"), text(strike, b"NM"), "the caret must not carry the strikeout's name"); + let irt = caret.reference(b"IRT").unwrap(); + assert_eq!(text(doc.get(irt).as_dict().unwrap(), b"NM").as_str(), "r", "the caret replies to the strikeout"); let r = rect(caret); assert!(r[0] < 200.0 && r[2] > 200.0 && r[1] < 706.0, "at the end of the line, below its baseline: {r:?}"); // Deleting the strikeout takes the caret with it (the group). let mut doc = doc; delete_annotation(&mut doc, 0, i).unwrap(); assert!(!list(&doc, 0).iter().any(|d| d.name(b"Subtype") == Some(b"Caret"))); - assert!(add_text_replacement(&mut doc, 0, &[], "x", "Ada", &red, &blue, &meta("r")).is_err()); + assert!(add_text_replacement(&mut doc, 0, &[], "x", "Ada", &red, &blue, &meta("r"), &meta("r-caret")).is_err()); } #[test] diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 01f162ae7d..461950d42e 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -2368,7 +2368,10 @@ fn run_edit(doc: &mut pdfcraft_cos::Document, edit: &Edit, cx: &mut EditCtx) -> pdfcraft_annot::erase_ink(doc, *page, *index, path, *radius, &cx.meta())?; } Edit::ReplaceText { page, quads, text, author, strike, caret } => { - pdfcraft_annot::add_text_replacement(doc, *page, quads, text, author, strike, caret, &cx.meta())?; + // One `/NM` each: the caret replies to the strikeout, and XFDF names that reply by + // the strikeout's `/NM` (#2666). + let (strike_meta, caret_meta) = (cx.meta(), cx.meta()); + pdfcraft_annot::add_text_replacement(doc, *page, quads, text, author, strike, caret, &strike_meta, &caret_meta)?; } Edit::MarkAnnotation { page, index, marked, author } => { pdfcraft_annot::set_marked(doc, *page, *index, *marked, author, &cx.meta())?; diff --git a/crates/xfdf/src/tests.rs b/crates/xfdf/src/tests.rs index d244df1adf..a33f1a1013 100644 --- a/crates/xfdf/src/tests.rs +++ b/crates/xfdf/src/tests.rs @@ -1,6 +1,6 @@ use std::sync::Arc; -use pdfcraft_annot::{Markup, Meta, NewAnnotation, Shape, Style, add_annotation, add_reply, summaries}; +use pdfcraft_annot::{Markup, Meta, NewAnnotation, Shape, Style, add_annotation, add_reply, add_text_replacement, summaries}; use pdfcraft_cos::{SaveOptions, write_full, write_incremental}; use pdfcraft_forms::{NewField, add_field, fields, set_value}; @@ -142,6 +142,32 @@ fn values(doc: &Document) -> Vec<(String, Vec)> { fields(doc).into_iter().map(|f| (f.name, f.value)).collect() } +/// #2666: Replace Text writes a StrikeOut and a Caret that replies to it. XFDF names a reply by +/// the `/NM` of what it replies to, so when both carried one id the caret's `inreplyto` was its +/// own `name`: importing it found a comment replying to itself and the pair was lost. +#[test] +fn a_replace_text_pair_survives_an_xfdf_round_trip() { + let mut src = blank(); + let quads = [[100.0, 720.0, 200.0, 720.0, 100.0, 706.0, 200.0, 706.0]]; + let style = Style { color: [0.89, 0.13, 0.13], opacity: 1.0, width: 1.0, fill: None }; + let meta = |id: &str| Meta { date: Some("D:20261001120000Z".into()), id: id.into() }; + add_text_replacement(&mut src, 0, &quads, "new words", "Ada", &style, &style, &meta("strike"), &meta("caret")).unwrap(); + assert_eq!(summaries(&src).len(), 2, "a strikeout and a caret"); + + let xfdf = export_xfdf(&src, true, false, "a.pdf"); + assert!(xfdf.contains("name=\"strike\"") && xfdf.contains("name=\"caret\""), "each gets its own name: {xfdf}"); + assert!(xfdf.contains("inreplyto=\"strike\""), "the caret replies to the strikeout: {xfdf}"); + assert!(!xfdf.contains("name=\"caret\" ... inreplyto=\"caret\""), "never a reply to itself"); + + let mut dst = blank(); + let r = import(&mut dst, xfdf.as_bytes()).unwrap(); + assert_eq!(r.comments, 2, "both comments import: {r:?}"); + let found = summaries(&dst); + let kinds: Vec<&str> = found.iter().map(|s| s.subtype.as_str()).collect(); + assert!(kinds.contains(&"StrikeOut") && kinds.contains(&"Caret"), "{kinds:?}"); + assert_eq!(comment_view(&dst), comment_view(&src), "the pair and its thread survive"); +} + #[test] fn xfdf_round_trips_comments_and_values() { let src = filled(); From 0d35035e073cbec42b7e7c39f815dafcb1ee6162 Mon Sep 17 00:00:00 2001 From: Charan Rathore <180254320+charan-rathore@users.noreply.github.com> Date: Fri, 16 Oct 2026 02:37:47 +0530 Subject: [PATCH 021/105] Delete crates/render/src/inspect.rs --- crates/render/src/inspect.rs | 2984 ---------------------------------- 1 file changed, 2984 deletions(-) delete mode 100644 crates/render/src/inspect.rs diff --git a/crates/render/src/inspect.rs b/crates/render/src/inspect.rs deleted file mode 100644 index 3c3988b7b5..0000000000 --- a/crates/render/src/inspect.rs +++ /dev/null @@ -1,2984 +0,0 @@ -//! Document inspection: everything panels need that is not pixels. -//! -//! Page geometry comes from hayro (which resolves inheritance and rotation); the rest comes from -//! the lazy COS reader through a read-only lopdf object adapter. The original lopdf loader -//! remains the compatibility fallback. When neither can inspect a renderable file, panels -//! are empty and `warnings` says why. - -use std::cell::OnceCell; -use std::collections::{BTreeMap, HashMap, HashSet}; -use std::sync::Arc; - -use std::panic::{AssertUnwindSafe, catch_unwind}; - -use hayro::hayro_syntax::DecryptionError; -use hayro::hayro_syntax::LoadPdfError; -use hayro::hayro_syntax::Pdf; -use lopdf::{Dictionary, Document, LoadOptions, Object, ObjectId}; -use pdfcraft_cos::page_labels::{ - MAX_LABEL_BYTES, MAX_LABEL_TOTAL_BYTES, MAX_LABEL_TREE_DEPTH, MAX_LABEL_TREE_WORK, MAX_PREFIX_BYTES, MAX_PREFIX_TOTAL_BYTES, alpha, roman, -}; - -use crate::OpenError; -use crate::structure::{LazyStructure, Structure}; - -/// lopdf decodes object and cross-reference streams while it loads, with no limit unless one is -/// set: a few hundred bytes of nested FlateDecode then inflate to gigabytes. Real object and -/// xref streams are far below this. -const LOAD_STREAM_LIMIT: usize = 256 << 20; - -/// At most this many `/State` entries of a set-layer-visibility action are read. -const MAX_LAYER_STATE: usize = 1024; - -/// File specifications retained from a remote-Go-To action are deliberately small. -const MAX_REMOTE_FILE_SPEC_BYTES: usize = 4096; -const MAX_REMOTE_DESTINATION_NAME_BYTES: usize = 1024; - -/// At most this many layers are read from all of `/RBGroups` together. -const MAX_LAYER_GROUP_ENTRIES: usize = 4096; - -/// `DocInfo::outline` holds the first this many bookmarks in outline order. Opening a document -/// reads only this window; the rest are listed by page through `pdfcraft_organize`. -const OUTLINE_WINDOW: usize = 10_000; - -/// Deepest level `DocInfo::outline` nests (the top level is 0); deeper bookmarks are left out. -const OUTLINE_DEPTH: usize = 32; - -fn load_options(password: Option<&str>) -> LoadOptions { - LoadOptions { password: password.map(str::to_owned), max_decompressed_size: Some(LOAD_STREAM_LIMIT), ..LoadOptions::default() } -} - -#[derive(Clone, Debug, Default)] -pub struct DocInfo { - pub title: Option, - pub author: Option, - pub subject: Option, - pub keywords: Option, - pub creator: Option, - pub producer: Option, - pub pdf_version: String, - pub file_size: usize, - pub encrypted: bool, - pub tagged: bool, - pub has_javascript: bool, - /// The form is (also) an XFA form, which isn't read yet (`form.xfa-*` in the parity list). - pub xfa: Option, - pub pages: Vec, - pub outline: Vec, - /// The outline has bookmarks that `outline` leaves out (see `OUTLINE_WINDOW`, `OUTLINE_DEPTH`). - pub outline_more: bool, - pub annotations: Vec, - pub fields: Vec, - pub links: Vec, - pub layers: Vec, - /// Radio-button layer groups (`/OCProperties /D /RBGroups`): turning one layer of a group on - /// turns the others off. - pub layer_groups: Vec>, - pub fonts: Vec, - pub attachments: Vec, - /// Pages with a presentation transition (`/Trans`) or display time (`/Dur`), in page order. - pub transitions: Vec, - pub warnings: Vec, -} - -/// What kind of XFA form a document has (`/AcroForm /XFA`). -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub enum Xfa { - /// The pages and fields are ordinary PDF; the XFA packets describe the same form (and hold - /// its data, which Acrobat shows in preference to the fields). - Static, - /// The form is laid out from the XFA packets at open (`/NeedsRendering`, or no fields): the - /// PDF pages are only a placeholder. - Dynamic, -} - -/// A page's presentation transition and display time (ISO 32000-2 §12.4.4): how the page comes -/// into view in a full-screen presentation, and how long it stays before advancing. Only pages -/// carrying `/Trans` or `/Dur` are listed. Values outside the spec's ranges fall back to the -/// defaults rather than being refused. -#[derive(Clone, Debug, PartialEq)] -pub struct PageTransition { - pub page: usize, - /// `/Trans /S`; `None` when the page has only `/Dur`. - pub style: Option, - /// `/D`: how long the transition takes, in seconds (default 1). - pub duration: f32, - /// `/Dm /H` (true) or `/V` (Split, Blinds). - pub horizontal: bool, - /// `/M /I` (true) or `/O` (Split, Box, Fly). - pub inward: bool, - /// `/Di`: 0, 90, 180, 270 or 315 degrees (Wipe, Glitter, Fly, Cover, Uncover, Push); `None` - /// for `/None` (Fly only). - pub direction: Option, - /// `/SS`: the Fly scale (default 1). - pub scale: f32, - /// `/B`: Fly with an opaque backdrop. - pub opaque: bool, - /// `/Dur`: seconds the page shows before the presentation advances; `None` waits for the - /// presenter. - pub display: Option, -} - -/// The transition styles of ISO 32000-2 table 164 (`/Trans /S`). -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub enum TransitionStyle { - Split, - Blinds, - Box, - Wipe, - Dissolve, - Glitter, - /// `/R`: the next page replaces the old one with no effect (the default). - Replace, - Fly, - Push, - Cover, - Uncover, - Fade, -} - -impl TransitionStyle { - /// The style a `/S` name means; an unknown name is `Replace`, the spec's default. - pub fn from_name(name: &str) -> Self { - match name { - "Split" => Self::Split, - "Blinds" => Self::Blinds, - "Box" => Self::Box, - "Wipe" => Self::Wipe, - "Dissolve" => Self::Dissolve, - "Glitter" => Self::Glitter, - "Fly" => Self::Fly, - "Push" => Self::Push, - "Cover" => Self::Cover, - "Uncover" => Self::Uncover, - "Fade" => Self::Fade, - _ => Self::Replace, - } - } - - /// The name as written in a PDF (`/S`). - pub fn name(self) -> &'static str { - match self { - Self::Split => "Split", - Self::Blinds => "Blinds", - Self::Box => "Box", - Self::Wipe => "Wipe", - Self::Dissolve => "Dissolve", - Self::Glitter => "Glitter", - Self::Replace => "R", - Self::Fly => "Fly", - Self::Push => "Push", - Self::Cover => "Cover", - Self::Uncover => "Uncover", - Self::Fade => "Fade", - } - } -} - -#[derive(Clone, Debug)] -pub struct PageInfo { - /// Displayed size in points, after `/Rotate` and `/UserUnit`. - pub width: f32, - pub height: f32, - /// Page label (`/PageLabels`), falling back to the 1-based page number. - pub label: String, - /// Effective crop box in user space [x0, y0, x1, y1] (the visible region). - pub crop: [f32; 4], - /// Clockwise page rotation in degrees (0, 90, 180, 270). - pub rotation: u16, -} - -impl PageInfo { - /// A point in view space (points, y down, after `/Rotate`; what the text layer uses) → - /// PDF user space. - pub fn view_to_user(&self, x: f32, y: f32) -> [f32; 2] { - let [cx0, cy0, cx1, cy1] = self.crop; - let (a, b) = (x / self.width.max(1e-3), y / self.height.max(1e-3)); - let (u, v) = match self.rotation { - 90 => (b, 1.0 - a), - 180 => (1.0 - a, 1.0 - b), - 270 => (1.0 - b, a), - _ => (a, b), - }; - [cx0 + u * (cx1 - cx0), cy1 - v * (cy1 - cy0)] - } - - /// PDF user space → view space (the inverse of [`Self::view_to_user`]). - pub fn user_to_view(&self, x: f32, y: f32) -> [f32; 2] { - let [cx0, cy0, cx1, cy1] = self.crop; - let (u, v) = ((x - cx0) / (cx1 - cx0).max(1e-3), (cy1 - y) / (cy1 - cy0).max(1e-3)); - let (a, b) = match self.rotation { - 90 => (1.0 - v, u), - 180 => (1.0 - u, 1.0 - v), - 270 => (v, 1.0 - u), - _ => (u, v), - }; - [a * self.width, b * self.height] - } - - /// A destination's user-space point (each coordinate possibly unspecified) → the same point - /// as a fraction of the displayed page `[x, y]` (0..1 from its top-left corner, y down), - /// after `/Rotate` and a further clockwise `view_rotation` (0, 90, 180 or 270). On a page - /// turned a quarter, the user `x` decides the displayed `y` and vice versa, so each output - /// is `None` when the input it comes from is (or is not finite). Points off the page are - /// clamped to its edge. - pub fn dest_fraction(&self, x: Option, y: Option, view_rotation: u16) -> [Option; 2] { - let [cx0, cy0, cx1, cy1] = self.crop; - let frac = |v: f32| if v.is_finite() { Some(v.clamp(0.0, 1.0)) } else { None }; - // Unrotated fractions across (u, from the crop's left) and down (v, from its top). - let u = x.and_then(|x| frac((x - cx0) / (cx1 - cx0).max(1e-3))); - let v = y.and_then(|y| frac((cy1 - y) / (cy1 - cy0).max(1e-3))); - let flip = |f: Option| f.map(|f| 1.0 - f); - match (u32::from(self.rotation) + u32::from(view_rotation)) % 360 { - 90 => [flip(v), u], - 180 => [flip(u), flip(v)], - 270 => [v, flip(u)], - _ => [u, v], - } - } - - /// A view-space rectangle → the normalized user-space rectangle covering the same area of the - /// page (`[x0, y0, x1, y1]`, `x0 <= x1`, `y0 <= y1`). Under `/Rotate` the corners swap roles, - /// so a view rectangle's top-left is not in general the user rectangle's `[x0, y1]`. - pub fn view_rect_to_user(&self, r: [f32; 4]) -> [f32; 4] { - let (a, b) = (self.view_to_user(r[0], r[1]), self.view_to_user(r[2], r[3])); - [a[0].min(b[0]), a[1].min(b[1]), a[0].max(b[0]), a[1].max(b[1])] - } - - /// A view-space rectangle as a text-markup quad in user space: top-left, top-right, - /// bottom-left, bottom-right as read on screen. - pub fn view_rect_to_quad(&self, r: [f32; 4]) -> [f64; 8] { - let mut q = [0.0; 8]; - for (i, (x, y)) in [(r[0], r[1]), (r[2], r[1]), (r[0], r[3]), (r[2], r[3])].into_iter().enumerate() { - let [ux, uy] = self.view_to_user(x, y); - q[2 * i] = ux as f64; - q[2 * i + 1] = uy as f64; - } - q - } -} - -/// A clickable link annotation. -#[derive(Clone, Debug)] -pub struct Link { - pub page: usize, - /// Zero-based position in the page's raw `/Annots` array. - pub index: usize, - pub rect: [f32; 4], - pub target: LinkTarget, -} - -#[derive(Clone, Debug, PartialEq)] -pub enum LinkTarget { - /// A destination in this document: the 0-based page and where on it the view goes. - Page(usize, DestView), - Uri(String), - /// A set-layer-visibility action (`SetOCGState`, ISO 32000-2 §12.6.4.13): each change in - /// order, naming the layer by its optional content group. With `preserve_rb`, a layer turned - /// on turns off the other layers of its radio-button groups. - SetLayers { - changes: Vec<(LayerOp, (u32, u16))>, - preserve_rb: bool, - }, - /// A local-file remote Go-To action. The caller resolves `file` relative to the source path. - RemoteGoTo { - file: String, - destination: RemoteDestination, - new_window: Option, - }, - Other(String), -} - -#[derive(Clone, Debug, PartialEq)] -pub enum RemoteDestination { - Default, - Page { page: usize, view: DestView }, - Named(String), -} - -/// What a set-layer-visibility action does to a layer. -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub enum LayerOp { - On, - Off, - Toggle, -} - -/// Where an explicit destination (ISO 32000-2 §12.3.2.2, Table 149) places its page in the -/// window. Coordinates are PDF user space; `None` is a `null` (or unusable) operand, which keeps -/// that part of the view as it is. -#[derive(Clone, Copy, Debug, Default, PartialEq)] -pub enum DestView { - /// The top of the page at the current zoom: a destination with no position, or one whose - /// type is missing or unknown. - #[default] - Top, - /// `/XYZ left top zoom`: the point (left, top) at the window's upper-left corner, at `zoom` - /// (1.0 = 100%; 0 or null keeps the current zoom). - Xyz { left: Option, top: Option, zoom: Option }, - /// `/Fit`, and `/FitB` (fit the page's content box; read as the whole page). - Fit, - /// `/FitH top`, and `/FitBH`: fit the width, with `top` at the window's top edge. - FitH { top: Option }, - /// `/FitV left`, and `/FitBV`: fit the height, with `left` at the window's left edge. - FitV { left: Option }, - /// `/FitR left bottom right top`: fit the rectangle `[x0, y0, x1, y1]` (normalized). - FitR { rect: [f32; 4] }, -} - -impl DestView { - /// The view an explicit destination array asks for, from its elements after the page - /// (`/XYZ left top zoom`, `/FitH top`, …). Hostile input never fails: a missing or unknown - /// type, or a `/FitR` without four usable numbers, is [`DestView::Top`]; a non-numeric, - /// non-finite or missing operand is `None`; a zoom that is not positive is `None`. - pub fn from_operands(kind: Option<&[u8]>, operands: &[Option]) -> Self { - let n = |i: usize| operands.get(i).copied().flatten().filter(|v| v.is_finite()); - match kind { - Some(b"XYZ") => Self::Xyz { left: n(0), top: n(1), zoom: n(2).filter(|z| *z > 0.0) }, - Some(b"Fit" | b"FitB") => Self::Fit, - Some(b"FitH" | b"FitBH") => Self::FitH { top: n(0) }, - Some(b"FitV" | b"FitBV") => Self::FitV { left: n(0) }, - Some(b"FitR") => match (n(0), n(1), n(2), n(3)) { - (Some(x0), Some(y0), Some(x1), Some(y1)) => Self::FitR { rect: [x0.min(x1), y0.min(y1), x0.max(x1), y0.max(y1)] }, - _ => Self::Top, - }, - _ => Self::Top, - } - } -} - -#[derive(Clone, Debug)] -pub struct OutlineItem { - pub title: String, - pub page: Option, - /// Where on `page` the bookmark goes ([`DestView::Top`] for a bookmark without a page). - pub view: DestView, - pub children: Vec, - pub open: bool, -} - -#[derive(Clone, Debug)] -pub struct Annotation { - pub page: usize, - pub subtype: String, - pub author: Option, - pub contents: Option, - pub modified: Option, - /// `/NM` of this annotation, and of the one it replies to (`/IRT`), for threading. - pub name: Option, - pub in_reply_to: Option, - /// Rect in PDF user space: [x0, y0, x1, y1]. - pub rect: [f32; 4], - pub color: Option<[f32; 3]>, - /// Position in the page's `/Annots` (how edits address the comment). - pub index: usize, - /// For a status reply (`/State`, Acrobat's "Set status"): the state it sets. - pub state: Option, - /// Text markup quadrilaterals (`/QuadPoints`), 8 numbers each. - pub quads: Vec<[f32; 8]>, - /// The Locked flag (`/F` bit 8): the comment can't be moved, resized, restyled or deleted. - pub locked: bool, - /// `/IT`, the intent: `FreeTextCallout`, `PolygonCloud`, `FreeTextTypeWriter`… - pub intent: Option, - /// A Fill & Sign mark: typed text, a check, cross, dot or line, or a typed, drawn or picture - /// signature or initials (the rules of `pdfcraft_annot::is_fill_sign`). - pub fill_sign: bool, -} - -impl Annotation { - /// A checkmark reply (`/StateModel /Marked`): "Marked" or "Unmarked" rather than a review status. - pub fn is_mark(&self) -> bool { - matches!(self.state.as_deref(), Some("Marked" | "Unmarked")) - } -} - -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub enum FieldKind { - Text, - CheckBox, - Radio, - PushButton, - Combo, - List, - Signature, - Unknown, -} - -#[derive(Clone, Debug)] -pub struct Field { - pub name: String, - pub kind: FieldKind, - pub value: Option, - pub page: Option, - pub tooltip: Option, - pub has_actions: bool, - /// Rect of the first widget, in user space. - pub rect: Option<[f32; 4]>, -} - -#[derive(Clone, Debug)] -pub struct Layer { - /// Object number and generation of the optional content group. - pub id: (u32, u16), - pub name: String, - pub visible: bool, -} - -#[derive(Clone, Debug)] -pub struct Attachment { - pub name: String, - pub description: Option, - pub size: Option, - pub source: AttachmentSource, -} - -/// Where an attachment lives (so its bytes can be fetched on demand). -#[derive(Clone, Debug, PartialEq, Eq)] -pub enum AttachmentSource { - /// Document-level: `/Names /EmbeddedFiles` entry with this key. - Document { key: String }, - /// A FileAttachment annotation: page index and position in the page's `/Annots`. - Annotation { page: usize, index: usize }, -} - -/// A font used by the document (Document Properties ▸ Fonts). -#[derive(Clone, Debug, PartialEq, Eq)] -pub struct FontInfo { - /// BaseFont without the subset tag. - pub name: String, - /// Type1, TrueType, Type0, Type3, MMType1, CIDFontType0/2 … - pub kind: String, - pub embedded: bool, - pub subset: bool, - pub encoding: Option, - /// The face standing in for a program the document does not embed. `None` when the font is - /// embedded (nothing stands in) or when no face was available at all -- `substitution` then - /// says which of the two it is. - pub substitute: Option, - /// Why a face was substituted, as a sentence for whoever has to act on it. `None` only when - /// the document's own glyphs are being shown. - pub substitution: Option, -} - -/// Check that `bytes` still open as the same document: the parser reads them and the page -/// count matches. Content-only edits skip the full [`inspect`] (seconds on very large files) -/// but must still catch an edit that produced something the viewer cannot display, so the -/// caller can roll it back. Never panics: parser crashes are caught and reported. -pub fn check_pages(bytes: Arc>, password: Option<&str>, expected: usize) -> Result<(), OpenError> { - let pdf = catch_unwind(AssertUnwindSafe(|| Pdf::new_with_password(bytes, password.unwrap_or("")))) - .map_err(|p| OpenError::Invalid(format!("the parser crashed: {}", crate::raster::panic_message(&p))))? - .map_err(|e| match e { - LoadPdfError::Decryption(DecryptionError::PasswordProtected) => { - if password.is_some() { - OpenError::WrongPassword - } else { - OpenError::NeedsPassword - } - } - LoadPdfError::Decryption(other) => OpenError::Unsupported(format!("encryption not supported: {other:?}")), - LoadPdfError::Invalid => OpenError::Invalid("no readable page tree or cross-reference data was found".into()), - })?; - let pages = catch_unwind(AssertUnwindSafe(|| pdf.pages().len())) - .map_err(|p| OpenError::Invalid(format!("the page tree could not be read: {}", crate::raster::panic_message(&p))))?; - if pages != expected { - return Err(OpenError::Invalid(format!("the edit changed the page count from {expected} to {pages}"))); - } - Ok(()) -} - -/// Inspect a document. Fails only if the renderer itself cannot open the file (or needs a -/// password). Never panics: parser crashes are caught and reported. -pub fn inspect(bytes: Arc>, password: Option<&str>) -> Result { - let pdf = catch_unwind(AssertUnwindSafe(|| Pdf::new_with_password(bytes.clone(), password.unwrap_or("")))) - .map_err(|p| OpenError::Invalid(format!("the parser crashed: {}", crate::raster::panic_message(&p))))? - .map_err(|e| match e { - LoadPdfError::Decryption(DecryptionError::PasswordProtected) => { - if password.is_some() { - OpenError::WrongPassword - } else { - OpenError::NeedsPassword - } - } - LoadPdfError::Decryption(other) => OpenError::Unsupported(format!("encryption not supported: {other:?}")), - LoadPdfError::Invalid => OpenError::Invalid("no readable page tree or cross-reference data was found".into()), - })?; - let mut info = DocInfo { file_size: bytes.len(), pdf_version: version_label(&format!("{:?}", pdf.version())), ..Default::default() }; - let pages = catch_unwind(AssertUnwindSafe(|| { - let mut out = Vec::new(); - for (i, page) in pdf.pages().iter().enumerate() { - let (w, h) = page.render_dimensions(); - let c = page.intersected_crop_box(); - let rotation = match page.rotation() { - hayro::hayro_syntax::page::Rotation::None => 0, - hayro::hayro_syntax::page::Rotation::Horizontal => 90, - hayro::hayro_syntax::page::Rotation::Flipped => 180, - hayro::hayro_syntax::page::Rotation::FlippedHorizontal => 270, - }; - let crop = [c.x0 as f32, c.y0 as f32, c.x1 as f32, c.y1 as f32]; - // Degenerate boxes still get a usable placeholder size so layout never divides by zero. - let (w, h) = if w.is_finite() && h.is_finite() && w >= 1.0 && h >= 1.0 { (w, h) } else { (612.0, 792.0) }; - out.push(PageInfo { width: w, height: h, label: (i + 1).to_string(), crop, rotation }); - } - out - })); - match pages { - Ok(p) => info.pages = p, - Err(p) => return Err(OpenError::Invalid(format!("the page tree could not be read: {}", crate::raster::panic_message(&p)))), - } - let options = load_options(password); - // Geometry no longer needs the renderer parser. Do not overlap its allocations with - // structural inspection, including the compatibility fallback for damaged files. - drop(pdf); - inspect_structure(&mut info, |tmp| { - // The lazy reader first; lopdf's repair/tolerance path when it can't expose the same - // page tree or an object a panel needs. No partial result is published. - let lazy = catch_unwind(AssertUnwindSafe(|| { - let doc = LazyStructure::new(bytes.clone(), password).ok()?; - let mut t = DocInfo { pages: tmp.pages.clone(), ..Default::default() }; - let inspector = Inspector::new(&doc); - if inspector.page_index.len() != t.pages.len() { - return None; - } - inspector.fill(&mut t); - (!doc.failed()).then_some(t) - })) - .ok() - .flatten(); - if let Some(t) = lazy { - *tmp = t; - return Ok(()); - } - let doc = Document::load_mem_with_options(&bytes, options).map_err(|e| e.to_string())?; - Inspector::new(&doc).fill(tmp); - Ok(()) - }); - if info.pages.is_empty() { - return Err(OpenError::Invalid("the document has no pages".into())); - } - Ok(info) -} - -// Commit auxiliary metadata only after inspection succeeds; renderer geometry is the fallback. -fn inspect_structure(info: &mut DocInfo, fill: impl FnOnce(&mut DocInfo) -> Result<(), String>) { - let structure = catch_unwind(AssertUnwindSafe(|| { - let mut tmp = DocInfo { pages: info.pages.clone(), ..Default::default() }; - fill(&mut tmp)?; - Ok::<_, String>(tmp) - })); - match structure { - Ok(Ok(mut filled)) => { - filled.file_size = info.file_size; - filled.pdf_version = std::mem::take(&mut info.pdf_version); - *info = filled; - } - Ok(Err(e)) => info.warnings.push(format!("Some document structure (bookmarks, comments, fields) could not be read: {e}")), - Err(p) => { - info.warnings.push(format!("Document structure inspection crashed and was skipped: {}", crate::raster::panic_message(&p))); - } - } -} - -struct Inspector<'a> { - doc: &'a dyn Structure, - page_ids: Vec, - page_index: HashMap, - /// Named destinations (`/Names /Dests` tree), keyed by raw string bytes, built once. - /// Looking each name up by walking the tree was O(links × names): minutes on manuals. - named: HashMap, &'a Object>, - /// The page of each annotation (the first page that lists it), built when a field's widget - /// has no `/P`. Searching every page's `/Annots` for each such field was O(fields × - /// annotations): a second on a form of 8,000 fields. - annotation_pages: OnceCell>, -} - -impl<'a> Inspector<'a> { - fn new(doc: &'a dyn Structure) -> Self { - let page_ids = doc.page_ids(); - let page_index = page_ids.iter().copied().enumerate().map(|(index, id)| (id, index)).collect(); - let mut me = Self { doc, page_ids, page_index, named: HashMap::new(), annotation_pages: OnceCell::new() }; - let mut named = HashMap::new(); - if let Some(tree) = - doc.catalog().ok().and_then(|c| c.get(b"Names").ok()).and_then(|o| me.dict(o)).and_then(|n| n.get(b"Dests").ok()).and_then(|o| me.dict(o)) - { - me.name_tree_raw(tree, &mut HashSet::new(), 0, &mut named); - } - me.named = named; - me - } - - fn fill(&self, info: &mut DocInfo) { - // lopdf drops /Encrypt from the trailer once it has decrypted the file. - info.encrypted = self.doc.encrypted(); - if let Some(d) = self.doc.trailer().get(b"Info").ok().and_then(|o| self.dict(o)) { - info.title = self.text(d, b"Title"); - info.author = self.text(d, b"Author"); - info.subject = self.text(d, b"Subject"); - info.keywords = self.text(d, b"Keywords"); - info.creator = self.text(d, b"Creator"); - info.producer = self.text(d, b"Producer"); - } - let Ok(catalog) = self.doc.catalog() else { return }; - info.tagged = catalog - .get(b"MarkInfo") - .ok() - .and_then(|o| self.dict(o)) - .and_then(|m| m.get(b"Marked").ok()) - .and_then(|o| o.as_bool().ok()) - .unwrap_or(false); - match self.page_labels(catalog, &mut info.pages) { - Ok(notes) => info.warnings.extend(notes.into_iter().map(|note| format!("Page labels: {note}."))), - Err(reason) => info.warnings.push(format!("Page labels were skipped: {reason}; using physical page numbers.")), - } - if let Some(first) = catalog.get(b"Outlines").ok().and_then(|o| self.dict(o)).and_then(|d| d.get(b"First").ok()) { - (info.outline, info.outline_more) = self.outline_window(first); - } - self.annotations(info); - self.transitions(info); - let mut field_seen = HashSet::new(); - if let Some(form) = catalog.get(b"AcroForm").ok().and_then(|o| self.dict(o)) - && let Ok(fields) = form.get(b"Fields").and_then(|o| self.resolve(o).as_array()) - { - for f in fields { - self.field(f, None, &mut info.fields, &mut field_seen, 0); - } - } - // Widgets no `/Fields` entry reaches are fields too (Acrobat and the browsers fill them); - // each is adopted through its topmost unlisted `/Parent`, so a split field stays one. - for &pid in &self.page_ids { - let Ok(page_dict) = self.doc.get_dictionary(pid) else { continue }; - let Ok(Object::Array(annots)) = page_dict.get(b"Annots").map(|o| self.resolve(o)) else { continue }; - for a in annots { - let Some(d) = self.dict(a) else { continue }; - if self.name(d, b"Subtype").as_deref() != Some("Widget") - || (d.get(b"FT").is_err() && d.get(b"T").is_err() && d.get(b"Parent").is_err()) - { - continue; - } - if let Object::Reference(id) = a - && let Some(root) = self.unlisted_field(*id, &field_seen) - { - self.field(&Object::Reference(root), None, &mut info.fields, &mut field_seen, 0); - } - } - } - info.has_javascript |= info.fields.iter().any(|f| f.has_actions); - let needs_rendering = catalog.get(b"NeedsRendering").ok().and_then(|o| self.resolve(o).as_bool().ok()).unwrap_or(false); - if catalog.get(b"AcroForm").ok().and_then(|o| self.dict(o)).is_some_and(|form| form.get(b"XFA").is_ok()) { - info.xfa = Some(if needs_rendering || info.fields.is_empty() { Xfa::Dynamic } else { Xfa::Static }); - } - self.layers(catalog, &mut info.layers); - info.layer_groups = self.layer_groups(catalog); - self.fonts(&mut info.fonts); - if let Some(names) = catalog.get(b"Names").ok().and_then(|o| self.dict(o)) { - if let Some(ef) = names.get(b"EmbeddedFiles").ok().and_then(|o| self.dict(o)) { - let mut seen = HashSet::new(); - for (name, spec) in self.name_tree(ef, &mut seen, 0) { - info.attachments.push(self.attachment(name.clone(), spec, AttachmentSource::Document { key: name })); - } - } - info.has_javascript |= names.get(b"JavaScript").is_ok(); - } - } - - // ── object helpers ────────────────────────────────────────────────────────────────────── - - /// `/Trans` and `/Dur` of every page (ISO 32000-2 §12.4.4), in page order. Lenient: a value - /// outside the spec's choices takes that entry's default, and a page with neither entry is - /// not listed. Bounded by the page count. - fn transitions(&self, info: &mut DocInfo) { - for (page, &pid) in self.page_ids.iter().enumerate() { - let Ok(d) = self.doc.get_dictionary(pid) else { continue }; - let display = d.get(b"Dur").ok().and_then(|o| self.resolve(o).as_float().ok()).filter(|s| s.is_finite() && *s >= 0.0); - let trans = d.get(b"Trans").ok().and_then(|o| self.dict(o)); - if trans.is_none() && display.is_none() { - continue; - } - let mut t = PageTransition { - page, - style: None, - duration: 1.0, - horizontal: true, - inward: true, - direction: Some(0), - scale: 1.0, - opaque: false, - display, - }; - if let Some(tr) = trans { - t.style = Some(self.name(tr, b"S").map_or(TransitionStyle::Replace, |n| TransitionStyle::from_name(&n))); - let num = |key: &[u8]| tr.get(key).ok().and_then(|o| self.resolve(o).as_float().ok()).filter(|v| v.is_finite()); - t.duration = num(b"D").filter(|d| *d >= 0.0).unwrap_or(1.0); - t.horizontal = self.name(tr, b"Dm").as_deref() != Some("V"); - t.inward = self.name(tr, b"M").as_deref() != Some("O"); - t.direction = match tr.get(b"Di").map(|o| self.resolve(o)) { - Ok(Object::Name(n)) if n.as_slice() == b"None" => None, - Ok(o) => match o.as_i64().ok() { - Some(0) => Some(0), - Some(90) => Some(90), - Some(180) => Some(180), - Some(270) => Some(270), - Some(315) => Some(315), - _ => Some(0), - }, - Err(_) => Some(0), - }; - t.scale = num(b"SS").filter(|s| *s > 0.0).unwrap_or(1.0); - t.opaque = matches!(tr.get(b"B").map(|o| self.resolve(o)), Ok(Object::Boolean(true))); - } - info.transitions.push(t); - } - } - - fn resolve(&self, o: &'a Object) -> &'a Object { - self.doc.dereference(o).map(|(_, o)| o).unwrap_or(o) - } - - fn dict(&self, o: &'a Object) -> Option<&'a Dictionary> { - match self.resolve(o) { - Object::Dictionary(d) => Some(d), - Object::Stream(s) => Some(&s.dict), - _ => None, - } - } - - fn text(&self, d: &Dictionary, key: &[u8]) -> Option { - let o = self.resolve(d.get(key).ok()?); - let s = match o { - Object::String(bytes, _) => text_string(bytes), - Object::Name(n) => String::from_utf8_lossy(n).into_owned(), - _ => return None, - }; - let s = s.trim_matches('\0').trim().to_string(); - (!s.is_empty()).then_some(s) - } - - fn name(&self, d: &Dictionary, key: &[u8]) -> Option { - self.resolve(d.get(key).ok()?).as_name().ok().map(|n| String::from_utf8_lossy(n).into_owned()) - } - - /// Whether an annotation is a Fill & Sign mark. Keep in step with - /// `pdfcraft_annot::is_fill_sign`, which the engine's comment list uses. - fn is_fill_sign(&self, d: &Dictionary, subtype: &str) -> bool { - let yes = |key: &[u8]| matches!(d.get(key).map(|o| self.resolve(o)), Ok(Object::Boolean(true))); - if yes(b"PCFillSign") { - return true; - } - match subtype { - "FreeText" => self.name(d, b"IT").as_deref() == Some("FreeTextTypeWriter"), - "Stamp" => match self.name(d, b"Name").as_deref() { - Some("PCCheck" | "PCCross" | "PCDot" | "PCLine" | "PCTypedSignature") => true, - Some("PCCustomSignature" | "PCCustomInitials") => yes(b"PCPictureImage"), - _ => false, - }, - "Ink" => self.text(d, b"Subj").as_deref() == Some("Signature"), - _ => false, - } - } - - fn page_of(&self, o: &Object) -> Option { - match o { - Object::Reference(id) => self.page_index.get(id).copied(), - // Some producers write page *numbers* in remote-style destinations. - Object::Integer(n) => usize::try_from(*n).ok(), - _ => None, - } - } - - // ── destinations ──────────────────────────────────────────────────────────────────────── - - /// The page and view of a destination: an explicit array (`[page /XYZ left top zoom]`, …), - /// a dictionary with `/D`, or a named destination that resolves to one. - fn dest(&self, dest: &Object, depth: u32) -> Option<(usize, DestView)> { - if depth > 8 { - return None; - } - match self.resolve(dest) { - Object::Array(a) => { - let page = a.first().and_then(|p| self.page_of(p))?; - let kind = a.get(1).map(|k| self.resolve(k)).and_then(|k| k.as_name().ok()); - let operands: Vec> = a.iter().skip(2).take(4).map(|o| self.resolve(o).as_float().ok()).collect(); - Some((page, DestView::from_operands(kind, &operands))) - } - Object::Dictionary(d) => d.get(b"D").ok().and_then(|d| self.dest(d, depth + 1)), - Object::String(key, _) | Object::Name(key) => self.named_dest(key).and_then(|d| self.dest(d, depth + 1)), - _ => None, - } - } - - fn named_dest(&self, key: &[u8]) -> Option<&'a Object> { - let catalog = self.doc.catalog().ok()?; - if let Some(v) = self.named.get(key) { - return Some(v); - } - // PDF 1.1 style /Dests dictionary. - catalog.get(b"Dests").ok().and_then(|o| self.dict(o)).and_then(|d| d.get(key).ok()) - } - - /// A name tree's leaves keyed by their raw key bytes (first occurrence wins). - fn name_tree_raw(&self, node: &'a Dictionary, seen: &mut HashSet<*const Dictionary>, depth: u32, out: &mut HashMap, &'a Object>) { - if depth > 32 || !seen.insert(node as *const _) { - return; - } - if let Ok(Object::Array(pairs)) = node.get(b"Names").map(|o| self.resolve(o)) { - for pair in pairs.chunks(2) { - if let [k, v] = pair { - let key = match self.resolve(k) { - Object::String(s, _) => s.clone(), - Object::Name(n) => n.clone(), - _ => continue, - }; - out.entry(key).or_insert(v); - } - } - } - if let Ok(Object::Array(kids)) = node.get(b"Kids").map(|o| self.resolve(o)) { - for k in kids { - if let Some(d) = self.dict(k) { - self.name_tree_raw(d, seen, depth + 1, out); - } - } - } - } - - fn name_tree(&self, node: &'a Dictionary, seen: &mut HashSet<*const Dictionary>, depth: u32) -> Vec<(String, &'a Object)> { - let mut out = Vec::new(); - if depth > 32 || !seen.insert(node as *const _) { - return out; - } - if let Ok(Object::Array(pairs)) = node.get(b"Names").map(|o| self.resolve(o)) { - for pair in pairs.chunks(2) { - if let [k, v] = pair { - let key = match self.resolve(k) { - Object::String(bytes, _) => text_string(bytes), - _ => String::new(), - }; - out.push((key, v)); - } - } - } - if let Ok(Object::Array(kids)) = node.get(b"Kids").map(|o| self.resolve(o)) { - for k in kids { - if let Some(d) = self.dict(k) { - out.extend(self.name_tree(d, seen, depth + 1)); - } - } - } - out - } - - // ── outline ───────────────────────────────────────────────────────────────────────────── - - /// The window `DocInfo::outline` keeps: the first `OUTLINE_WINDOW` bookmarks in outline order, - /// nested no deeper than `OUTLINE_DEPTH`, and whether any bookmark was left out. Iterative, and - /// each bookmark is read once, so the work and memory stay within the window. - fn outline_window(&self, first: &'a Object) -> (Vec, bool) { - let mut seen = HashSet::new(); - // The listed bookmarks in outline order, with their depth. - let mut listed: Vec<(usize, OutlineItem)> = Vec::new(); - // One entry per open level: its next sibling, or `None` once that run of siblings has ended. - let mut levels: Vec> = vec![Some(first)]; - let mut more = false; - while let Some(depth) = levels.len().checked_sub(1) { - let Some(slot) = levels.get_mut(depth) else { break }; - let Some(o) = slot.take() else { - levels.pop(); - continue; - }; - if let Object::Reference(id) = o - && !seen.insert(*id) - { - continue; // cycle: this run of siblings ends here - } - let Some(d) = self.dict(o) else { continue }; - if listed.len() >= OUTLINE_WINDOW { - more = true; - break; - } - *slot = d.get(b"Next").ok(); - let dest = d - .get(b"Dest") - .ok() - .and_then(|dest| self.dest(dest, 0)) - .or_else(|| d.get(b"A").ok().and_then(|a| self.dict(a)).and_then(|a| a.get(b"D").ok()).and_then(|dest| self.dest(dest, 0))); - let (page, view) = dest.map_or((None, DestView::Top), |(p, v)| (Some(p), v)); - match d.get(b"First").ok() { - Some(child) if depth < OUTLINE_DEPTH => levels.push(Some(child)), - Some(_) => more = true, - None => {} - } - let open = d.get(b"Count").ok().and_then(|c| c.as_i64().ok()).is_some_and(|c| c > 0); - let title = self.text(d, b"Title").unwrap_or_default(); - listed.push((depth, OutlineItem { title, page, view, children: Vec::new(), open })); - } - (nest_outline(listed), more) - } - - // ── page labels (ISO 32000-2 §12.4.2) ─────────────────────────────────────────────────── - - fn label_object(&self, object: &'a Object) -> Result<&'a Object, &'static str> { - self.doc.dereference(object).map(|(_, o)| o).map_err(|_| "a number tree reference is missing or cyclic") - } - - fn label_dict(&self, object: &'a Object) -> Result, &'static str> { - Ok(match self.label_object(object)? { - Object::Dictionary(d) => Some(d), - Object::Stream(s) => Some(&s.dict), - _ => None, - }) - } - - /// Apply `/PageLabels` to `pages`. `Err`: the tree is unusable and no label is applied. - /// `Ok(notes)`: labels applied, with what was repaired on the way (a duplicated start page, - /// a range whose labels are too long to show). - fn page_labels(&self, catalog: &Dictionary, pages: &mut [PageInfo]) -> Result, &'static str> { - let mut notes = Vec::new(); - let Ok(tree) = catalog.get(b"PageLabels") else { return Ok(notes) }; - let Some(tree) = self.label_dict(tree)? else { return Ok(notes) }; - let mut ranges: BTreeMap = BTreeMap::new(); - let mut stack = vec![(tree, 0usize)]; - let mut seen = HashSet::new(); - let mut left = MAX_LABEL_TREE_WORK; - while let Some((node, depth)) = stack.pop() { - label_work(&mut left, 1)?; - if depth > MAX_LABEL_TREE_DEPTH { - return Err("the number tree is too deep"); - } - if !seen.insert(node as *const Dictionary) { - return Err("the number tree repeats a node or contains a cycle"); - } - if let Ok(nums) = node.get(b"Nums") - && let Object::Array(nums) = self.label_object(nums)? - { - label_work(&mut left, nums.len().div_ceil(2))?; - for pair in nums.chunks(2) { - if let [k, v] = pair - && let (Ok(start), Some(d)) = (self.label_object(k)?.as_i64(), self.label_dict(v)?) - && let Ok(start) = usize::try_from(start) - && ranges.insert(start, d).is_some() - { - // The later range wins, as in readers that overwrite. - notes.push(format!("a range starts on page {} twice; the later one is used", start.saturating_add(1))); - } - } - } - if let Ok(kids) = node.get(b"Kids") - && let Object::Array(kids) = self.label_object(kids)? - { - label_work(&mut left, kids.len())?; - if kids.len() > MAX_LABEL_TREE_WORK.saturating_sub(stack.len()) { - return Err("the number tree has too many pending nodes"); - } - if !kids.is_empty() && depth == MAX_LABEL_TREE_DEPTH { - return Err("the number tree is too deep"); - } - for kid in kids { - if let Some(d) = self.label_dict(kid)? { - stack.push((d, depth + 1)); - } - } - } - } - // Decode each bounded prefix once, rather than once for every page in its range. - let mut specs = BTreeMap::new(); - let mut prefix_total = 0usize; - for (start, d) in ranges { - let first = d.get(b"St").ok().and_then(|o| self.resolve(o).as_i64().ok()).unwrap_or(1).max(1) as u64; - let prefix = match d.get(b"P").ok().map(|o| self.resolve(o)) { - Some(Object::String(raw, _)) => { - if raw.len() > MAX_PREFIX_BYTES { - return Err("a raw prefix exceeds 2050 bytes"); - } - // A label prefix is literal text, not trimmed document metadata. - text_string(raw).trim_matches('\0').to_string() - } - Some(Object::Name(raw)) => { - if raw.len() > MAX_PREFIX_BYTES { - return Err("a raw prefix exceeds 2050 bytes"); - } - // Retain the existing name compatibility and boundary-NUL handling. - String::from_utf8_lossy(raw).trim_matches('\0').to_string() - } - _ => String::new(), - }; - if prefix.len() > MAX_LABEL_BYTES { - return Err("a decoded prefix exceeds 1024 UTF-8 bytes"); - } - // A prefix object shared by many ranges is decoded once per range: count every copy. - prefix_total = prefix_total.saturating_add(prefix.len()); - if prefix_total > MAX_PREFIX_TOTAL_BYTES { - return Err("decoded prefixes exceed 4 MiB in total"); - } - let style = d.get(b"S").ok().and_then(|o| self.resolve(o).as_name().ok()); - specs.insert(start, (first, prefix, style)); - } - let mut staged = Vec::new(); - let mut total = 0usize; - for (i, _) in pages.iter().enumerate() { - let Some((&start, (first, prefix, style))) = specs.range(..=i).next_back() else { continue }; - let n = if matches!(*style, Some(b"D" | b"R" | b"r" | b"A" | b"a")) { - let offset = u64::try_from(i - start).map_err(|_| "a page number is too large")?; - first.checked_add(offset).ok_or("a page number overflows")? - } else { - 0 // /St is unused when the label consists only of its prefix. - }; - let remaining = MAX_LABEL_BYTES - prefix.len(); - let number = match *style { - Some(b"D") => Some(n.to_string()), - Some(b"R" | b"r") => roman(n, remaining), - Some(b"A" | b"a") => alpha(n, remaining), - _ => Some(String::new()), - } - .filter(|number| number.len() <= remaining); - // A label too long to show keeps that page's physical number; other ranges stay. - let Some(mut number) = number else { - let note = format!( - "labels from page {} exceed {MAX_LABEL_BYTES} UTF-8 bytes; those pages show their physical page numbers", - start.saturating_add(1) - ); - if !notes.contains(¬e) { - notes.push(note); - } - continue; - }; - if matches!(*style, Some(b"R" | b"A")) { - number.make_ascii_uppercase(); - } - let mut label = prefix.clone(); - label.push_str(&number); - if label.is_empty() { - continue; // Keep the existing physical number for an empty custom label. - } - total = total.checked_add(label.len()).ok_or("the total label size overflows")?; - if total > MAX_LABEL_TOTAL_BYTES { - return Err("custom labels exceed 4 MiB in total"); - } - staged.push((i, label)); - } - // A malformed later range must not leave a partially applied custom label sequence. - for (i, label) in staged { - if let Some(page) = pages.get_mut(i) { - page.label = label; - } - } - Ok(notes) - } - - // ── annotations ───────────────────────────────────────────────────────────────────────── - - fn annotations(&self, info: &mut DocInfo) { - for (&id, &page) in &self.page_index { - let Ok(page_dict) = self.doc.get_dictionary(id) else { continue }; - let Ok(Object::Array(annots)) = page_dict.get(b"Annots").map(|o| self.resolve(o)) else { continue }; - for (index, a) in annots.iter().enumerate() { - let Some(d) = self.dict(a) else { continue }; - let Some(subtype) = self.name(d, b"Subtype") else { continue }; - if subtype == "FileAttachment" - && let Ok(fs) = d.get(b"FS") - { - let name = self.text(d, b"Contents").unwrap_or_else(|| "attachment".into()); - info.attachments.push(self.attachment(name, fs, AttachmentSource::Annotation { page, index })); - } - if subtype == "Link" { - if let Some(link) = self.link(page, index, d) { - if matches!(&link.target, LinkTarget::Other(s) if s == "JavaScript") { - info.has_javascript = true; - } - info.links.push(link); - } - continue; - } - // Not comments: form widgets, pop-ups and non-markup annotations such as the - // Screen annotation that drives a LaTeX `animate` player (keep in step with - // `pdfcraft_annot::is_comment_subtype`). - if matches!(subtype.as_str(), "Widget" | "Popup" | "Screen" | "Movie" | "RichMedia" | "3D" | "PrinterMark" | "TrapNet" | "Watermark") - { - continue; - } - let rect = rect4(self.resolve(d.get(b"Rect").unwrap_or(&Object::Null))); - let mut color = match d.get(b"C").map(|o| self.resolve(o)) { - Ok(Object::Array(c)) if c.len() == 3 => { - let f = |i: usize| c[i].as_float().unwrap_or(0.0); - Some([f(0), f(1), f(2)]) - } - _ => None, - }; - // A text box's /C is its background; its text colour is in /DA. - if subtype == "FreeText" - && let Some(da) = self.text(d, b"DA") - { - let t: Vec<&str> = da.split_whitespace().collect(); - if let Some(i) = t.iter().position(|x| *x == "rg") - && i >= 3 - { - let f = |k: usize| t[k].parse::().unwrap_or(0.0); - color = Some([f(i - 3), f(i - 2), f(i - 1)]); - } - } - let quads = match d.get(b"QuadPoints").map(|o| self.resolve(o)) { - Ok(Object::Array(q)) => { - let v: Vec = q.iter().map(|o| o.as_float().unwrap_or(0.0)).collect(); - v.as_chunks::<8>().0.to_vec() - } - _ => Vec::new(), - }; - let in_reply_to = d.get(b"IRT").ok().and_then(|o| self.dict(o)).and_then(|p| self.text(p, b"NM")); - let fill_sign = self.is_fill_sign(d, &subtype); - info.annotations.push(Annotation { - page, - subtype, - author: self.text(d, b"T"), - // Acrobat ends lines with CR, which the interface would draw as nothing (keep - // in step with `pdfcraft_annot::summaries`). - contents: self.text(d, b"Contents").map(|s| s.replace("\r\n", "\n").replace('\r', "\n")), - modified: self.text(d, b"M").map(|m| pretty_date(&m)), - name: self.text(d, b"NM"), - in_reply_to, - rect, - color, - index, - state: self.text(d, b"State"), - quads, - locked: d.get(b"F").ok().and_then(|f| self.resolve(f).as_i64().ok()).unwrap_or(0) & 128 != 0, - intent: self.name(d, b"IT"), - fill_sign, - }); - } - } - info.annotations.sort_by(|a, b| a.page.cmp(&b.page).then(b.rect[3].total_cmp(&a.rect[3]))); - } - - fn link(&self, page: usize, index: usize, d: &Dictionary) -> Option { - let rect = rect4(self.resolve(d.get(b"Rect").ok()?)); - let target = if let Ok(dest) = d.get(b"Dest") { - let (page, view) = self.dest(dest, 0)?; - LinkTarget::Page(page, view) - } else { - let a = d.get(b"A").ok().and_then(|a| self.dict(a))?; - match self.name(a, b"S").as_deref() { - Some("GoTo") => { - let (page, view) = self.dest(a.get(b"D").ok()?, 0)?; - LinkTarget::Page(page, view) - } - Some("URI") => { - LinkTarget::Uri(a.get(b"URI").ok().and_then(|u| self.resolve(u).as_str().ok()).map(|b| String::from_utf8_lossy(b).into_owned())?) - } - Some("SetOCGState") => self.layer_state(a), - Some("GoToR") => self.remote_go_to(a).unwrap_or_else(|| LinkTarget::Other("GoToR".into())), - Some(other) => LinkTarget::Other(other.to_string()), - None => return None, - } - }; - Some(Link { page, index, rect, target }) - } - - fn remote_go_to(&self, action: &Dictionary) -> Option { - if self.name(action, b"FS").as_deref() == Some("URL") { - return None; - } - let file = match self.resolve(action.get(b"F").ok()?) { - Object::String(bytes, _) => limited_pdf_string(bytes, MAX_REMOTE_FILE_SPEC_BYTES)?, - Object::Dictionary(spec) => { - if self.name(spec, b"FS").as_deref() == Some("URL") { - return None; - } - spec.get(b"UF") - .ok() - .and_then(|o| self.pdf_text(o, MAX_REMOTE_FILE_SPEC_BYTES)) - .or_else(|| spec.get(b"F").ok().and_then(|o| self.pdf_text(o, MAX_REMOTE_FILE_SPEC_BYTES)))? - } - _ => return None, - }; - if file.is_empty() { - return None; - } - let destination = match action.get(b"D") { - Err(_) => RemoteDestination::Default, - Ok(o) => match self.resolve(o) { - Object::Array(items) => { - let page = items.first().and_then(|o| self.resolve(o).as_i64().ok()).and_then(|n| usize::try_from(n).ok())?; - let kind = items.get(1).map(|o| self.resolve(o)).and_then(|o| o.as_name().ok()); - let operands: [Option; 4] = std::array::from_fn(|i| items.get(i + 2).and_then(|o| self.resolve(o).as_float().ok())); - RemoteDestination::Page { page, view: DestView::from_operands(kind, &operands) } - } - Object::Name(name) if name.len() <= MAX_REMOTE_DESTINATION_NAME_BYTES => { - let name = String::from_utf8_lossy(name).into_owned(); - if name.is_empty() || name.len() > MAX_REMOTE_DESTINATION_NAME_BYTES { - return None; - } - RemoteDestination::Named(name) - } - Object::String(bytes, _) if bytes.len() <= MAX_REMOTE_DESTINATION_NAME_BYTES => { - let name = text_string(bytes); - if name.is_empty() || name.len() > MAX_REMOTE_DESTINATION_NAME_BYTES { - return None; - } - RemoteDestination::Named(name) - } - _ => return None, - }, - }; - let new_window = match action.get(b"NewWindow").map(|o| self.resolve(o)) { - Ok(Object::Boolean(value)) => Some(*value), - _ => None, - }; - Some(LinkTarget::RemoteGoTo { file, destination, new_window }) - } - - fn pdf_text(&self, o: &Object, max_bytes: usize) -> Option { - match self.resolve(o) { - Object::String(bytes, _) => limited_pdf_string(bytes, max_bytes), - _ => None, - } - } - - /// A set-OCG-state action: `/State` is `ON`, `OFF` or `Toggle`, each followed by the groups - /// it applies to; `/PreserveRB` is true unless it is `false`. - fn layer_state(&self, a: &Dictionary) -> LinkTarget { - let mut changes = Vec::new(); - if let Ok(Object::Array(items)) = a.get(b"State").map(|o| self.resolve(o)) { - let mut op = None; - for item in items.iter().take(MAX_LAYER_STATE) { - match item { - Object::Name(n) => { - op = match n.as_slice() { - b"ON" => Some(LayerOp::On), - b"OFF" => Some(LayerOp::Off), - b"Toggle" => Some(LayerOp::Toggle), - _ => None, - } - } - Object::Reference(id) => changes.extend(op.map(|op| (op, *id))), - _ => {} - } - } - } - let preserve_rb = !matches!(a.get(b"PreserveRB").map(|o| self.resolve(o)), Ok(Object::Boolean(false))); - LinkTarget::SetLayers { changes, preserve_rb } - } - - // ── form fields ───────────────────────────────────────────────────────────────────────── - - fn field( - &self, - o: &'a Object, - parent: Option<(&str, Option, Option)>, - out: &mut Vec, - seen: &mut HashSet, - depth: u32, - ) { - if depth > 32 { - return; - } - if let Object::Reference(id) = o - && !seen.insert(*id) - { - return; - } - let Some(d) = self.dict(o) else { return }; - let (parent_name, parent_ft, parent_ff) = parent.unwrap_or(("", None, None)); - let partial = self.text(d, b"T"); - let full = match (&partial, parent_name.is_empty()) { - (Some(t), true) => t.clone(), - (Some(t), false) => format!("{parent_name}.{t}"), - (None, _) => parent_name.to_string(), - }; - let ft = self.name(d, b"FT").or(parent_ft); - let ff = d.get(b"Ff").ok().and_then(|x| x.as_i64().ok()).or(parent_ff); - // Non-terminal: has kids that are fields (they carry /T). Widgets-only kids are terminal. - let kids = match d.get(b"Kids").map(|k| self.resolve(k)) { - Ok(Object::Array(k)) => k.as_slice(), - _ => &[], - }; - let field_kids: Vec<_> = kids.iter().filter(|k| self.dict(k).is_some_and(|kd| kd.get(b"T").is_ok())).collect(); - if !field_kids.is_empty() { - for k in field_kids { - self.field(k, Some((&full, ft.clone(), ff)), out, seen, depth + 1); - } - return; - } - let flags = ff.unwrap_or(0); - let kind = match ft.as_deref() { - Some("Tx") => FieldKind::Text, - Some("Btn") if flags & (1 << 16) != 0 => FieldKind::PushButton, - Some("Btn") if flags & (1 << 15) != 0 => FieldKind::Radio, - Some("Btn") => FieldKind::CheckBox, - Some("Ch") if flags & (1 << 17) != 0 => FieldKind::Combo, - Some("Ch") => FieldKind::List, - Some("Sig") => FieldKind::Signature, - _ => FieldKind::Unknown, - }; - let value = match d.get(b"V").map(|v| self.resolve(v)) { - Ok(Object::Name(n)) => Some(String::from_utf8_lossy(n).into_owned()), - Ok(Object::String(bytes, _)) => Some(text_string(bytes)), - Ok(Object::Array(a)) => Some( - a.iter() - .filter_map(|x| match self.resolve(x) { - Object::String(bytes, _) => Some(text_string(bytes)), - _ => None, - }) - .collect::>() - .join(", "), - ), - Ok(Object::Dictionary(_)) if kind == FieldKind::Signature => Some("signed".into()), - _ => None, - }; - // The widget is either this dict (merged field/widget) or its first kid. - let widget = if d.get(b"Rect").is_ok() { Some(d) } else { kids.first().and_then(|k| self.dict(k)) }; - let page = widget.and_then(|w| w.get(b"P").ok()).and_then(|p| self.page_of(p)).or_else(|| self.find_widget_page(o, kids)); - let has_actions = d.get(b"AA").is_ok() || widget.is_some_and(|w| w.get(b"AA").is_ok() || w.get(b"A").is_ok()); - let rect = widget.and_then(|w| w.get(b"Rect").ok()).map(|r| rect4(self.resolve(r))); - out.push(Field { name: full, kind, value, page, tooltip: self.text(d, b"TU"), has_actions, rect }); - } - - /// The first page whose `/Annots` lists the field or one of its kids. - fn find_widget_page(&self, field: &Object, kids: &[Object]) -> Option { - let pages = self.annotation_pages.get_or_init(|| self.annotation_pages()); - std::iter::once(field).chain(kids).filter_map(|o| if let Object::Reference(id) = o { pages.get(id).copied() } else { None }).min() - } - - /// Each annotation listed in a page's `/Annots`, with the first page that lists it. - fn annotation_pages(&self) -> HashMap { - let mut pages = HashMap::new(); - for (idx, &pid) in self.page_ids.iter().enumerate() { - let Some(annots) = self.doc.get_dictionary(pid).ok().and_then(|d| d.get(b"Annots").ok()) else { continue }; - let Ok(annots) = self.resolve(annots).as_array() else { continue }; - for a in annots { - if let Object::Reference(id) = a { - pages.entry(*id).or_insert(idx); - } - } - } - pages - } - - /// The topmost field above widget `id` (itself without a `/Parent`), or `None` when it or an - /// ancestor is already reachable from the `/Fields` tree, or a `/Parent` loop hides the top. - fn unlisted_field(&self, id: ObjectId, seen: &HashSet) -> Option { - let mut top = id; - let mut visited = HashSet::from([id]); - loop { - let o = Object::Reference(top); - let d = self.dict(&o)?; - let parent = match d.get(b"Parent") { - Ok(Object::Reference(p)) => Some(*p), - _ => None, - }; - let Some(p) = parent else { - return if seen.contains(&top) { None } else { Some(top) }; - }; - if !visited.insert(p) || visited.len() > 64 { - return None; - } - if seen.contains(&p) { - return None; - } - top = p; - } - } - - // ── optional content ──────────────────────────────────────────────────────────────────── - - fn layers(&self, catalog: &Dictionary, out: &mut Vec) { - let Some(props) = catalog.get(b"OCProperties").ok().and_then(|o| self.dict(o)) else { return }; - let config = props.get(b"D").ok().and_then(|o| self.dict(o)); - let off: HashSet = config - .and_then(|c| c.get(b"OFF").ok()) - .and_then(|o| self.resolve(o).as_array().ok()) - .map(|a| a.iter().filter_map(|x| x.as_reference().ok()).collect()) - .unwrap_or_default(); - let base_off = config.and_then(|c| self.name(c, b"BaseState")).as_deref() == Some("OFF"); - let on: HashSet = config - .and_then(|c| c.get(b"ON").ok()) - .and_then(|o| self.resolve(o).as_array().ok()) - .map(|a| a.iter().filter_map(|x| x.as_reference().ok()).collect()) - .unwrap_or_default(); - let Ok(Object::Array(ocgs)) = props.get(b"OCGs").map(|o| self.resolve(o)) else { return }; - for g in ocgs { - let Some(d) = self.dict(g) else { continue }; - let id = g.as_reference().ok(); - let visible = match id { - Some(id) if off.contains(&id) => false, - Some(id) if on.contains(&id) => true, - _ => !base_off, - }; - let Some(id) = id else { continue }; - out.push(Layer { id, name: self.text(d, b"Name").unwrap_or_else(|| "Layer".into()), visible }); - } - } - - /// The default configuration's radio-button groups (`/RBGroups`) of two or more layers. - fn layer_groups(&self, catalog: &Dictionary) -> Vec> { - let groups = catalog - .get(b"OCProperties") - .ok() - .and_then(|o| self.dict(o)) - .and_then(|p| p.get(b"D").ok()) - .and_then(|o| self.dict(o)) - .and_then(|c| c.get(b"RBGroups").ok()) - .and_then(|o| self.resolve(o).as_array().ok()); - let mut left = MAX_LAYER_GROUP_ENTRIES; - let mut out = Vec::new(); - for g in groups.into_iter().flatten() { - let Ok(members) = self.resolve(g).as_array() else { continue }; - let group: Vec = members.iter().filter_map(|x| x.as_reference().ok()).take(left).collect(); - left = left.saturating_sub(group.len()); - if group.len() > 1 { - out.push(group); - } - if left == 0 { - break; - } - } - out - } - - fn fonts(&self, out: &mut Vec) { - let mut seen = HashSet::new(); - for &pid in &self.page_ids { - let Ok(fonts) = self.doc.get_page_fonts(pid) else { continue }; - for (_, f) in fonts { - let base = f - .get(b"BaseFont") - .ok() - .and_then(|o| o.as_name().ok()) - .map(|n| String::from_utf8_lossy(n).into_owned()) - .unwrap_or_else(|| "(unnamed)".into()); - let kind = self.name(f, b"Subtype").unwrap_or_default(); - let encoding = match f.get(b"Encoding").map(|o| self.resolve(o)) { - Ok(Object::Name(n)) => Some(String::from_utf8_lossy(n).into_owned()), - Ok(Object::Dictionary(_)) => Some("Custom".into()), - Ok(Object::Stream(_)) => Some("Embedded CMap".into()), - _ => None, - }; - // Embedded: FontFile/2/3 in the descriptor (for Type0, in the descendant font). - let descriptor_of = |d: &'a Dictionary| d.get(b"FontDescriptor").ok().and_then(|o| self.dict(o)); - let descendant = - f.get(b"DescendantFonts").ok().and_then(|o| self.resolve(o).as_array().ok()).and_then(|a| a.first()).and_then(|o| self.dict(o)); - let desc = descriptor_of(f).or_else(|| descendant.and_then(descriptor_of)); - let embedded = - kind == "Type3" || desc.is_some_and(|d| d.get(b"FontFile").is_ok() || d.get(b"FontFile2").is_ok() || d.get(b"FontFile3").is_ok()); - // What the substitution chain needs beyond the name: the descriptor flags, and for - // a composite font the script its descendant declares (ISO 32000-2 9.7.3). - let flags = desc - .and_then(|d| d.get(b"Flags").ok()) - .and_then(|o| self.resolve(o).as_i64().ok()) - .and_then(|n| u32::try_from(n).ok()) - .unwrap_or(0); - let ordering = - descendant.and_then(|d| d.get(b"CIDSystemInfo").ok()).and_then(|o| self.dict(o)).and_then(|c| self.text(c, b"Ordering")); - let subset = base.len() > 7 && base.as_bytes()[6] == b'+' && base[..6].bytes().all(|b| b.is_ascii_uppercase()); - let name = if subset { base[7..].to_string() } else { base }; - let chosen = pdfcraft_fonts::fallback::substitute(&pdfcraft_fonts::fallback::Request { - base_font: &name, - embedded, - flags, - ordering: ordering.as_deref(), - }); - let info = FontInfo { - name, - kind, - embedded, - subset, - encoding, - substitute: chosen.face_name(), - substitution: (!chosen.why.is_faithful()).then(|| chosen.why.note().to_owned()), - }; - if seen.insert((info.name.clone(), info.kind.clone(), info.embedded)) { - out.push(info); - } - } - } - out.sort_by(|a, b| a.name.cmp(&b.name)); - } - - fn attachment(&self, name: String, spec: &Object, source: AttachmentSource) -> Attachment { - let d = self.dict(spec); - let size = - d.and_then(|d| d.get(b"EF").ok()).and_then(|o| self.dict(o)).and_then(|ef| ef.get(b"F").ok().or_else(|| ef.get(b"UF").ok())).and_then( - |f| match self.resolve(f) { - Object::Stream(s) => s - .dict - .get(b"Params") - .ok() - .and_then(|p| self.dict(p)) - .and_then(|p| p.get(b"Size").ok()) - .and_then(|s| s.as_i64().ok()) - .map(|s| s as usize) - .or(Some(s.content.len())), - _ => None, - }, - ); - let display = d.and_then(|d| self.text(d, b"UF").or_else(|| self.text(d, b"F"))).unwrap_or(name); - Attachment { name: display, description: d.and_then(|d| self.text(d, b"Desc")), size, source } - } -} - -/// A text string's value, decoded by the same rules as the engine's object model -/// ([`pdfcraft_cos::PdfString::to_text`]) so the panels and outline editing agree. -fn text_string(bytes: &[u8]) -> String { - pdfcraft_cos::PdfString::literal(bytes).to_text() -} - -fn limited_pdf_string(bytes: &[u8], max_bytes: usize) -> Option { - if bytes.len() > max_bytes { - return None; - } - let text = text_string(bytes); - (text.len() <= max_bytes).then_some(text) -} - -/// Nests a pre-order list of `(depth, item)` into the outline tree. -fn nest_outline(listed: Vec<(usize, OutlineItem)>) -> Vec { - let mut roots = Vec::new(); - // The open ancestors, outermost first; each collects its children until it closes. - let mut open: Vec<(usize, OutlineItem)> = Vec::new(); - for (depth, item) in listed { - while open.last().is_some_and(|(d, _)| *d >= depth) { - close_outline(&mut open, &mut roots); - } - open.push((depth, item)); - } - while !open.is_empty() { - close_outline(&mut open, &mut roots); - } - roots -} - -/// Closes the innermost open bookmark: it joins its parent's children, or the top level. -fn close_outline(open: &mut Vec<(usize, OutlineItem)>, roots: &mut Vec) { - if let Some((_, item)) = open.pop() { - match open.last_mut() { - Some((_, parent)) => parent.children.push(item), - None => roots.push(item), - } - } -} - -fn rect4(o: &Object) -> [f32; 4] { - match o { - Object::Array(a) if a.len() == 4 => { - let v: Vec = a.iter().map(|x| x.as_float().unwrap_or(0.0)).collect(); - [v[0].min(v[2]), v[1].min(v[3]), v[0].max(v[2]), v[1].max(v[3])] - } - _ => [0.0; 4], - } -} - -fn label_work(left: &mut usize, count: usize) -> Result<(), &'static str> { - *left = left.checked_sub(count).ok_or("the number tree has too many entries")?; - Ok(()) -} - -/// `D:20260930104512-04'00'` → `2026-09-30 10:45`. -/// A PDF date (`D:20261001123000Z`) as "2026-10-01 12:30"; other strings unchanged. -pub fn pretty_date(s: &str) -> String { - let d = s.trim_start_matches("D:"); - if let Some(d) = d.get(..12).filter(|d| d.bytes().all(|b| b.is_ascii_digit())) { - // Twelve ASCII digits make every slice below a UTF-8 character boundary. - format!("{}-{}-{} {}:{}", &d[0..4], &d[4..6], &d[6..8], &d[8..10], &d[10..12]) - } else { - s.to_string() - } -} - -/// Fetch an attachment's bytes (decoded). Capped at 1 GiB to defuse decompression bombs. -pub fn attachment_data(bytes: &[u8], password: Option<&str>, att: &Attachment) -> Result, String> { - let run = || -> Result, String> { - let options = load_options(password); - let doc = Document::load_mem_with_options(bytes, options).map_err(|e| e.to_string())?; - let insp = Inspector::new(&doc); - let spec: &Object = match &att.source { - AttachmentSource::Document { key } => { - let names = doc.catalog().ok().and_then(|c| c.get(b"Names").ok()).and_then(|o| insp.dict(o)).ok_or("no /Names")?; - let ef = names.get(b"EmbeddedFiles").ok().and_then(|o| insp.dict(o)).ok_or("no /EmbeddedFiles")?; - let mut seen = HashSet::new(); - insp.name_tree(ef, &mut seen, 0).into_iter().find(|(k, _)| k == key).map(|(_, v)| v).ok_or("attachment not found")? - } - AttachmentSource::Annotation { page, index } => { - let (&pid, _) = insp.page_index.iter().find(|(_, i)| **i == *page).ok_or("page not found")?; - let annots = doc.get_dictionary(pid).map_err(|e| e.to_string())?.get(b"Annots").map_err(|e| e.to_string())?; - let a = insp.resolve(annots).as_array().map_err(|e| e.to_string())?.get(*index).ok_or("annotation not found")?; - insp.dict(a).and_then(|d| d.get(b"FS").ok()).ok_or("annotation has no file")? - } - }; - let fs = insp.dict(spec).ok_or("bad file specification")?; - let ef = fs.get(b"EF").ok().and_then(|o| insp.dict(o)).ok_or("the file is not embedded (external reference)")?; - let stream = ef.get(b"UF").ok().or_else(|| ef.get(b"F").ok()).map(|o| insp.resolve(o)).ok_or("no embedded stream")?; - let stream = stream.as_stream().map_err(|e| e.to_string())?; - if stream.dict.get(b"Filter").is_ok() { - stream.decompressed_content_with_limit(1 << 30).map_err(|e| e.to_string()) - } else { - Ok(stream.content.clone()) - } - }; - catch_unwind(AssertUnwindSafe(run)).unwrap_or_else(|p| Err(format!("reading the attachment crashed: {}", crate::raster::panic_message(&p)))) -} - -#[cfg(test)] -mod tests { - #[test] - fn png_predictor_rows_longer_than_the_data_are_refused_before_allocating() { - // Vendored lopdf patch: a fuzzed `/Columns 4294967295` allocated two 4 GiB rows before - // reading any data. A row that cannot fit in the data is refused up front. - let e = lopdf::filters::png::decode_frame(&[2, 0, 0, 0], 1, 64 << 20).unwrap_err(); - assert!(e.to_string().contains("longer than the data"), "{e}"); - // A real frame still decodes: two 3-byte rows, the second Up-filtered. - assert_eq!(lopdf::filters::png::decode_frame(&[0, 1, 2, 3, 2, 1, 1, 1], 1, 3).unwrap(), [1, 2, 3, 2, 3, 4]); - } - - #[test] - fn empty_png_predictor_frames_do_not_allocate_rows() { - // usize::MAX cannot be reserved, so the old code safely errors before allocating. - // An empty frame needs no rows regardless of the declared width. - assert!(lopdf::filters::png::decode_frame(&[], 1, usize::MAX).unwrap().is_empty()); - } - - fn tiff_predictor_stream(data: Vec, columns: i64, colors: i64, bits: i64) -> lopdf::Stream { - let mut params = lopdf::Dictionary::new(); - params.set("Predictor", 2i64); - params.set("Columns", columns); - params.set("Colors", colors); - params.set("BitsPerComponent", bits); - let mut dict = lopdf::Dictionary::new(); - dict.set("DecodeParms", params); - let mut stream = lopdf::Stream::new(dict, data); - stream.compress().unwrap(); - assert_eq!(stream.dict.get(b"Filter").unwrap().as_name().unwrap(), b"FlateDecode"); - stream - } - - #[test] - fn tiff_subbyte_predictor_row_width_overflow_is_refused() { - // Each multiplication overflows before any scratch allocation in the old debug build. - for bits in [1, 2, 4] { - let stream = tiff_predictor_stream(vec![0; 64], i64::MAX, 3, bits); - assert!( - matches!(stream.decompressed_content_with_limit(64), Err(lopdf::Error::Decompress(lopdf::DecompressError::Predictor(_)))), - "{bits}-bit row" - ); - } - } - - #[test] - fn tiff_subbyte_predictor_scratch_is_bounded_by_available_samples() { - // 1/2-bit row widths fit usize on 32- and 64-bit hosts; Colors previously made Vec - // reject the capacity before allocating. A partial row has no preceding pixel. - for bits in [1, 2] { - let data = vec![0b1010_0110; 64]; - let stream = tiff_predictor_stream(data.clone(), 1, i64::try_from(isize::MAX).unwrap(), bits); - assert_eq!(stream.decompressed_content_with_limit(64).unwrap(), data, "{bits}-bit row"); - } - } - - #[test] - fn tiff_subbyte_predictors_keep_components_rows_and_padding() { - // Repeated tiny rows compress through the public Stream API. Rows remain independent, - // differences wrap at each component depth, and trailing padding bits survive. - for (columns, colors, bits, encoded, decoded) in [ - (16, 1, 1, vec![255, 170, 8, 255], vec![170, 204, 15, 85]), - (6, 1, 2, vec![85, 179], vec![108, 147]), - (3, 1, 4, vec![25, 16], vec![26, 176]), - (2, 2, 4, vec![18, 34], vec![18, 52]), - ] { - let stream = tiff_predictor_stream(encoded.repeat(32), columns, colors, bits); - assert_eq!(stream.decompressed_content_with_limit(128).unwrap(), decoded.repeat(32), "{bits}-bit, {colors} colours"); - } - } - - // Xref-stream fixtures exercise indirect /Length lookup during document loading: stream 2's /Length is `3 0 R`, - // `containers` lists compressed (id, container, index) entries and `objects` the other objects in the file. - fn compressed_length_pdf(containers: &[(u32, u32, u16)], objects: &[(u32, &[u8])]) -> Vec { - let mut pdf = b"%PDF-1.7\n".to_vec(); - let ids = containers.iter().flat_map(|&(id, container, _)| [id, container]).chain(objects.iter().map(|&(id, _)| id)); - let xref_id = ids.max().unwrap_or(3) + 1; - let mut records = vec![[0u8; 7]; (xref_id + 1) as usize]; - records[0] = [0, 0, 0, 0, 0, 255, 255]; - let mut append = |id: u32, body: &[u8]| { - records[id as usize][0] = 1; - records[id as usize][1..5].copy_from_slice(&(pdf.len() as u32).to_be_bytes()); - pdf.extend_from_slice(format!("{id} 0 obj\n").as_bytes()); - pdf.extend_from_slice(body); - pdf.extend_from_slice(b"\nendobj\n"); - }; - append(1, b"<< /Type /Catalog >>"); - append(2, b"<< /Length 3 0 R >>\nstream\nhello\nendstream"); - for &(id, body) in objects { - append(id, body); - } - for &(id, container, index) in containers { - records[id as usize][0] = 2; - records[id as usize][1..5].copy_from_slice(&container.to_be_bytes()); - records[id as usize][5..7].copy_from_slice(&index.to_be_bytes()); - } - let xref_offset = pdf.len(); - records[xref_id as usize][0] = 1; - records[xref_id as usize][1..5].copy_from_slice(&(xref_offset as u32).to_be_bytes()); - pdf.extend_from_slice( - format!("{xref_id} 0 obj\n<< /Type /XRef /Size {} /Root 1 0 R /W [1 4 2] /Length {} >>\nstream\n", records.len(), records.len() * 7) - .as_bytes(), - ); - for record in records { - pdf.extend_from_slice(&record); - } - pdf.extend_from_slice(format!("\nendstream\nendobj\nstartxref\n{xref_offset}\n%%EOF\n").as_bytes()); - pdf - } - - // Stream 2's /Length starts a chain of `hops` indirect /Length lookups, each naming a new object; only the last - // link's /Length is direct. Compressed: link k is the integer 3 + 2k (the length it measures) alone in the object - // stream 4 + 2k, whose own /Length is link k + 1. Plain: link k is the stream 3 + k, whose /Length is link k + 1. - fn length_chain_pdf(hops: u32, compressed: bool) -> Vec { - let length = |hop: u32, next: u32, data: &str| if hop + 1 < hops { format!("{next} 0 R") } else { data.len().to_string() }; - let (mut containers, mut objects) = (Vec::new(), Vec::new()); - let mut value = 5; // the length of stream 2's data, "hello" - for hop in 0..hops { - if compressed { - let (id, container) = (3 + 2 * hop, 4 + 2 * hop); - let index = format!("{id} 0 "); - let data = format!("{index}{value}"); - let dict = format!("/Type /ObjStm /N 1 /First {} /Length {}", index.len(), length(hop, id + 2, &data)); - objects.push((container, format!("<< {dict} >>\nstream\n{data}\nendstream"))); - containers.push((id, container, 0)); - value = data.len(); - } else { - objects.push((3 + hop, format!("<< /Length {} >>\nstream\n\nendstream", length(hop, 4 + hop, "")))); - } - } - let objects: Vec<_> = objects.iter().map(|(id, body)| (*id, body.as_bytes())).collect(); - compressed_length_pdf(&containers, &objects) - } - - // Lenient loading keeps a document whose stream 2 has an unresolvable /Length: the catalog, and stream 2's - // dictionary without its data. - fn load_without_stream_2_data(pdf: &[u8]) -> Document { - let doc = Document::load_mem(pdf).unwrap(); - assert!(doc.catalog().unwrap().has_type(b"Catalog")); - assert!(doc.get_object((2, 0)).unwrap().as_stream().unwrap().content.is_empty()); - doc - } - - #[test] - fn compressed_object_self_container_does_not_recurse() { - let doc = load_without_stream_2_data(&compressed_length_pdf(&[(3, 3, 0)], &[])); - assert!(doc.get_object((3, 0)).is_err()); - } - - #[test] - fn compressed_object_mutual_containers_do_not_recurse() { - let doc = load_without_stream_2_data(&compressed_length_pdf(&[(3, 4, 0), (4, 3, 0)], &[])); - assert!(doc.get_object((3, 0)).is_err()); - assert!(doc.get_object((4, 0)).is_err()); - } - - #[test] - fn object_stream_length_cycle_does_not_recurse() { - let pdf = compressed_length_pdf(&[(3, 4, 0)], &[(4, b"<< /Type /ObjStm /N 1 /First 4 /Length 3 0 R >>\nstream\n3 0 5\nendstream")]); - let doc = load_without_stream_2_data(&pdf); - // The container's /Length is the object it holds: the container is kept without data, so 3 never loads. - assert!(doc.get_object((4, 0)).unwrap().as_stream().unwrap().content.is_empty()); - assert!(doc.get_object((3, 0)).is_err()); - } - - #[test] - fn compressed_object_container_chain_does_not_recurse() { - let containers: Vec<_> = (3..100_003).map(|id| (id, id + 1, 0)).collect(); - let pdf = compressed_length_pdf(&containers, &[(100_003, b"<< /Type /ObjStm /N 1 /First 4 /Length 5 >>\nstream\n3 0 5\nendstream")]); - let doc = load_without_stream_2_data(&pdf); - // The object stream in the file loads, but the xref puts 3 in the compressed container 4, which is refused. - assert_eq!(doc.get_object((100_003, 0)).unwrap().as_stream().unwrap().content, b"3 0 5"); - assert!(doc.get_object((3, 0)).is_err()); - } - - #[test] - fn compressed_objects_and_indirect_stream_length_still_load() { - let pdf = compressed_length_pdf( - &[(3, 4, 0), (5, 4, 1)], - &[(4, b"<< /Type /ObjStm /N 2 /First 8 /Length 17 >>\nstream\n3 0 5 2 5 (value)\nendstream")], - ); - let doc = Document::load_mem_with_options(&pdf, LoadOptions { strict: true, ..Default::default() }).unwrap(); - assert_eq!(doc.get_object((3, 0)).unwrap().as_i64().unwrap(), 5); - assert_eq!(doc.get_object((5, 0)).unwrap().as_str().unwrap(), b"value"); - assert_eq!(doc.get_object((2, 0)).unwrap().as_stream().unwrap().content, b"hello"); - } - - #[test] - fn compressed_length_in_a_container_with_an_indirect_length_still_loads() { - // Two hops, as in a valid file: stream 2's /Length (3) is compressed in container 4, whose own /Length (5) is - // a plain indirect integer. - let pdf = - compressed_length_pdf(&[(3, 4, 0)], &[(4, b"<< /Type /ObjStm /N 1 /First 4 /Length 5 0 R >>\nstream\n3 0 5\nendstream"), (5, b"5")]); - let doc = Document::load_mem_with_options(&pdf, LoadOptions { strict: true, ..Default::default() }).unwrap(); - assert_eq!(doc.get_object((3, 0)).unwrap().as_i64().unwrap(), 5); - assert_eq!(doc.get_object((5, 0)).unwrap().as_i64().unwrap(), 5); - assert_eq!(doc.get_object((2, 0)).unwrap().as_stream().unwrap().content, b"hello"); - } - - #[test] - fn compressed_length_chain_does_not_recurse() { - // Every container is an ordinary object in the file and every hop names a new object, so neither the - // container check nor the cycle history stops the chain; each hop recursed once more (a 512 KiB stack - // overflowed within 150 hops). Lookups past the cap fail: the first link never loads, while the last one, - // next to the direct /Length, does. - let hops = 5_000; - let doc = load_without_stream_2_data(&length_chain_pdf(hops, true)); - assert!(doc.get_object((3, 0)).is_err()); - assert!(doc.get_object((3 + 2 * (hops - 1), 0)).unwrap().as_i64().is_ok()); - // The cap itself: loading link j reads its container's /Length, a chain of 2 * (hops - 1 - j) lookups, and - // 16 are allowed, so the 9th link from the end loads and the 10th does not. - assert!(doc.get_object((3 + 2 * (hops - 9), 0)).is_ok()); - assert!(doc.get_object((3 + 2 * (hops - 10), 0)).is_err()); - } - - #[test] - fn plain_stream_length_chain_does_not_recurse() { - // The same recursion without object streams: each stream's /Length names the next stream (a 512 KiB stack - // overflowed within 200 hops). A stream is no length, so every link is kept as a stream without data. - let hops = 5_000; - let doc = load_without_stream_2_data(&length_chain_pdf(hops, false)); - assert!((3..3 + hops).all(|id| doc.get_object((id, 0)).is_ok_and(|link| link.as_stream().is_ok_and(|s| s.content.is_empty())))); - } - - fn indexed_object_stream(pairs: &[(u32, usize)], members: &[u8]) -> lopdf::Stream { - let mut content = Vec::new(); - for &(id, offset) in pairs { - content.extend_from_slice(format!("{id} {offset} ").as_bytes()); - } - let mut dict = Dictionary::new(); - dict.set("Type", "ObjStm"); - dict.set("N", pairs.len() as i64); - dict.set("First", content.len() as i64); - content.extend_from_slice(members); - lopdf::Stream::new(dict, content) - } - - #[test] - fn object_stream_member_extents_bound_owned_strings() { - let mut members = vec![b'('; 32]; - members.extend(vec![b'x'; 1 << 20]); - members.extend([b')'; 32]); - // Put the innermost (complete) string first in index order, then overlapping starts - // and duplicates. Only this first member at offset 31 may own the large string. - let mut pairs = vec![(100, 31)]; - pairs.extend((0..31).map(|offset| (offset as u32 + 1, offset))); - pairs.extend([(200, 31), (201, 31)]); - let stream = indexed_object_stream(&pairs, &members); - let parsed = lopdf::ObjectStream::new_with_limit(&stream, Some(2 << 20)).unwrap(); - assert_eq!(parsed.objects.get(&(100, 0)).unwrap().as_str().unwrap().len(), 1 << 20); - let total: usize = parsed.objects.values().filter_map(|object| object.as_str().ok()).map(<[u8]>::len).sum(); - assert!(total <= stream.content.len(), "owned {total} string bytes from {} decoded bytes", stream.content.len()); - assert!(!parsed.objects.contains_key(&(200, 0))); - assert!(!parsed.objects.contains_key(&(201, 0))); - } - - #[test] - fn object_stream_member_extents_preserve_index_order_and_whitespace() { - let members = b" \n42\n /Name \n[true 7]\n << /Key (value) >>\n (last)"; - // Unsorted offsets, and object 9 appears twice: its last index entry still wins. - let pairs = [(9, 0), (12, 21), (10, 5), (11, 12), (9, 41)]; - let parsed = lopdf::ObjectStream::new(&indexed_object_stream(&pairs, members)).unwrap(); - assert_eq!(parsed.objects.len(), 4); - assert_eq!(parsed.objects.get(&(9, 0)).unwrap().as_str().unwrap(), b"last"); - assert_eq!(parsed.objects.get(&(10, 0)).unwrap().as_name().unwrap(), b"Name"); - assert_eq!(parsed.objects.get(&(11, 0)).unwrap().as_array().unwrap(), &[Object::Boolean(true), Object::Integer(7)]); - assert_eq!(parsed.objects.get(&(12, 0)).unwrap().as_dict().unwrap().get(b"Key").unwrap().as_str().unwrap(), b"value"); - } - - #[test] - fn view_and_user_space_round_trip_for_every_rotation() { - for rotation in [0u16, 90, 180, 270] { - let (w, h) = if rotation % 180 == 0 { (200.0, 300.0) } else { (300.0, 200.0) }; - let p = super::PageInfo { width: w, height: h, label: String::new(), crop: [10.0, 20.0, 210.0, 320.0], rotation }; - for (x, y) in [(0.0, 0.0), (15.0, 40.0), (w, h)] { - let [ux, uy] = p.view_to_user(x, y); - let [vx, vy] = p.user_to_view(ux, uy); - assert!((vx - x).abs() < 1e-3 && (vy - y).abs() < 1e-3, "{rotation}: {x},{y} → {ux},{uy} → {vx},{vy}"); - } - } - // Unrotated: the view's top-left is the crop box's top-left. - let p = super::PageInfo { width: 200.0, height: 300.0, label: String::new(), crop: [10.0, 20.0, 210.0, 320.0], rotation: 0 }; - assert_eq!(p.view_to_user(0.0, 0.0), [10.0, 320.0]); - assert_eq!(p.view_rect_to_quad([0.0, 0.0, 10.0, 5.0]), [10.0, 320.0, 20.0, 320.0, 10.0, 315.0, 20.0, 315.0]); - } - - use super::*; - - #[test] - fn roman_and_alpha_labels() { - assert_eq!(roman(4, MAX_LABEL_BYTES).as_deref(), Some("iv")); - assert_eq!(roman(1994, MAX_LABEL_BYTES).as_deref(), Some("mcmxciv")); - assert_eq!(alpha(1, MAX_LABEL_BYTES).as_deref(), Some("a")); - assert_eq!(alpha(27, MAX_LABEL_BYTES).as_deref(), Some("aa")); - assert_eq!(alpha(53, MAX_LABEL_BYTES).as_deref(), Some("aaa")); - } - - // Synthetic label-only inspection fixtures: never construct an explosive branching tree. - fn label_info(nums: Vec, kids: Vec) -> DocInfo { - let mut doc = Document::with_version("1.7"); - let mut tree = Dictionary::new(); - tree.set("Nums", nums); - tree.set("Kids", kids); - doc.objects.insert((6, 0), Object::Dictionary(tree)); - let mut catalog = Dictionary::new(); - catalog.set("Type", Object::Name(b"Catalog".to_vec())); - catalog.set("PageLabels", Object::Reference((6, 0))); - doc.objects.insert((1, 0), Object::Dictionary(catalog)); - doc.trailer.set("Root", Object::Reference((1, 0))); - let mut info = DocInfo { - pages: (1..=3) - .map(|n| PageInfo { width: 200.0, height: 300.0, crop: [0.0, 0.0, 200.0, 300.0], rotation: 0, label: n.to_string() }) - .collect(), - ..Default::default() - }; - Inspector::new(&doc).fill(&mut info); - info - } - - fn label_spec(style: &str, prefix: Vec, first: i64) -> Object { - let mut d = Dictionary::new(); - d.set("S", Object::Name(style.as_bytes().to_vec())); - d.set("P", Object::String(prefix, lopdf::StringFormat::Hexadecimal)); - d.set("St", first); - Object::Dictionary(d) - } - - fn assert_label_fallback(info: &DocInfo) { - assert_eq!(info.pages.iter().map(|p| p.label.as_str()).collect::>(), ["1", "2", "3"]); - assert_eq!(info.warnings.len(), 1, "{:?}", info.warnings); - assert!(info.warnings[0].contains("Page labels"), "{:?}", info.warnings); - assert!(info.warnings[0].contains("physical page numbers"), "{:?}", info.warnings); - } - - // Contributor-original, blank three-page PDFs. The public importer reads both labels and metadata. - fn page_prefix_space_pdf(prefix: Vec, decimal: bool, indirect: bool) -> Vec { - use lopdf::dictionary; - let mut doc = Document::with_version("1.7"); - let pages_id = doc.new_object_id(); - let kids: Vec = (0..3) - .map(|_| { - doc.add_object(dictionary! { - "Type" => "Page", - "Parent" => pages_id, - "MediaBox" => vec![0.into(), 0.into(), 200.into(), 300.into()], - "Resources" => Dictionary::new(), - }) - .into() - }) - .collect(); - doc.objects.insert(pages_id, dictionary! { "Type" => "Pages", "Kids" => kids, "Count" => 3 }.into()); - let prefix = Object::String(prefix, lopdf::StringFormat::Hexadecimal); - let prefix = if indirect { Object::Reference(doc.add_object(prefix)) } else { prefix }; - let mut label = Dictionary::new(); - label.set("P", prefix); - if decimal { - label.set("S", Object::Name(b"D".to_vec())); - } - let labels = dictionary! { "Nums" => vec![Object::Integer(0), Object::Dictionary(label)] }; - let catalog_id = doc.add_object(dictionary! { "Type" => "Catalog", "Pages" => pages_id, "PageLabels" => labels }); - let info_id = doc.add_object(dictionary! { "Title" => Object::string_literal(" Metadata title ") }); - doc.trailer.set("Root", catalog_id); - doc.trailer.set("Info", info_id); - let mut bytes = Vec::new(); - doc.save_to(&mut bytes).expect("write original page-prefix fixture"); - bytes - } - - #[test] - fn page_label_prefix_spaces_survive_public_inspection() { - let mut utf16 = vec![0xfe, 0xff]; - utf16.extend(" Ω ".encode_utf16().flat_map(u16::to_be_bytes)); - for (raw, prefix, decimal, indirect) in [ - (b" leading".to_vec(), " leading", true, false), - (b"trailing ".to_vec(), "trailing ", true, false), - (b" both ".to_vec(), " both ", false, false), - (b" ".to_vec(), " ", false, false), - (b" Caf\xe9 ".to_vec(), " Café ", true, false), - (utf16.clone(), " Ω ", true, false), - (utf16, " Ω ", false, true), - (Vec::new(), "", true, false), - (Vec::new(), "", false, false), - ] { - let bytes = page_prefix_space_pdf(raw, decimal, indirect); - let info = inspect(Arc::new(bytes), None).expect("public import of original page-prefix fixture"); - let expected: Vec = (1..=3) - .map(|n| { - if decimal { - format!("{prefix}{n}") - } else if prefix.is_empty() { - n.to_string() - } else { - prefix.to_owned() - } - }) - .collect(); - assert_eq!( - info.pages.iter().map(|p| p.label.clone()).collect::>(), - expected, - "{prefix:?}, decimal={decimal}, indirect={indirect}" - ); - assert!(info.warnings.is_empty(), "{:?}", info.warnings); - assert_eq!(info.title.as_deref(), Some("Metadata title"), "page-prefix decoding must not change metadata normalization"); - } - } - - #[test] - fn page_label_limits_keep_normal_pdf_numbering() { - for (style, first, expected) in [ - ("r", 4, ["iv", "v", "vi"]), - ("R", 1994, ["MCMXCIV", "MCMXCV", "MCMXCVI"]), - ("a", 26, ["z", "aa", "bb"]), - ("A", 52, ["ZZ", "AAA", "BBB"]), - ] { - let info = label_info(vec![Object::Integer(0), label_spec(style, Vec::new(), first)], Vec::new()); - assert_eq!(info.pages.iter().map(|p| p.label.as_str()).collect::>(), expected); - assert!(info.warnings.is_empty(), "{:?}", info.warnings); - } - } - - #[test] - fn page_label_limits_reject_long_prefixes_without_partial_labels() { - for prefix in [vec![b'x'; 1025], vec![b'x'; 2051]] { - let nums = vec![Object::Integer(0), label_spec("D", b"ok-".to_vec(), 1), Object::Integer(1), label_spec("D", prefix, 1)]; - assert_label_fallback(&label_info(nums, Vec::new())); - } - } - - #[test] - fn page_label_limits_count_decoded_utf8_bytes() { - let mut prefix = vec![0xfe, 0xff]; - for _ in 0..342 { - prefix.extend_from_slice(&0x2022u16.to_be_bytes()); - } - assert_label_fallback(&label_info(vec![Object::Integer(0), label_spec("", prefix, 1)], Vec::new())); - } - - #[test] - fn page_label_limits_bound_roman_and_alpha_output() { - // Just 1025 output bytes on the old path, so RED does not consume excessive memory. - for (style, first) in [("r", 1_025_000), ("a", 26 * 1024 + 1)] { - assert_label_fallback(&label_info(vec![Object::Integer(0), label_spec(style, Vec::new(), first)], Vec::new())); - } - } - - #[test] - fn page_label_limits_detect_one_node_cycle() { - let nums = vec![Object::Integer(0), label_spec("D", b"custom-".to_vec(), 1)]; - assert_label_fallback(&label_info(nums, vec![Object::Reference((6, 0))])); - } - - #[test] - fn page_label_limits_accept_utf16_ascii_at_the_output_boundary() { - let mut prefix = vec![0xfe, 0xff]; - for _ in 0..1023 { - prefix.extend_from_slice(&u16::from(b'x').to_be_bytes()); - } - let info = label_info(vec![Object::Integer(0), label_spec("D", prefix, 1)], Vec::new()); - assert!(info.warnings.is_empty(), "{:?}", info.warnings); - assert_eq!(info.pages[0].label, format!("{}1", "x".repeat(1023))); - assert!(info.pages.iter().all(|p| p.label.len() == MAX_LABEL_BYTES)); - } - - #[test] - fn page_label_limits_refuse_hostile_numbers_before_allocating() { - for style in ["r", "R", "a", "A"] { - assert_label_fallback(&label_info(vec![Object::Integer(0), label_spec(style, Vec::new(), i64::MAX)], Vec::new())); - } - // Decimal labels remain small even when their first number exceeds 32-bit usize. - let info = label_info(vec![Object::Integer(0), label_spec("D", Vec::new(), i64::MAX)], Vec::new()); - assert_eq!(info.pages[0].label, i64::MAX.to_string()); - assert_eq!(info.pages[1].label, (i64::MAX as u64 + 1).to_string()); - assert!(info.warnings.is_empty()); - } - - #[test] - fn page_label_limits_bound_wide_number_trees() { - assert_label_fallback(&label_info(Vec::new(), vec![Object::Null; MAX_LABEL_TREE_WORK + 1])); - let pair = [Object::Integer(0), label_spec("D", Vec::new(), 1)]; - let nums = (0..MAX_LABEL_TREE_WORK).flat_map(|_| pair.clone()).collect(); - assert_label_fallback(&label_info(nums, Vec::new())); - } - - #[test] - fn page_label_limits_bound_total_custom_output_atomically() { - let doc = Document::with_version("1.7"); - let mut tree = Dictionary::new(); - tree.set("Nums", vec![Object::Integer(0), label_spec("", vec![b'x'; MAX_LABEL_BYTES], 1)]); - let mut catalog = Dictionary::new(); - catalog.set("PageLabels", Object::Dictionary(tree)); - let count = MAX_LABEL_TOTAL_BYTES / MAX_LABEL_BYTES + 1; - let mut pages: Vec<_> = (1..=count) - .map(|n| PageInfo { width: 200.0, height: 300.0, crop: [0.0, 0.0, 200.0, 300.0], rotation: 0, label: n.to_string() }) - .collect(); - let err = Inspector::new(&doc).page_labels(&catalog, &mut pages).unwrap_err(); - assert!(err.contains("4 MiB"), "{err}"); - assert!(pages.iter().enumerate().all(|(i, p)| p.label == (i + 1).to_string())); - // Exact aggregate boundary remains supported. - pages.pop(); - Inspector::new(&doc).page_labels(&catalog, &mut pages).unwrap(); - assert!(pages.iter().all(|p| p.label.len() == MAX_LABEL_BYTES)); - } - - #[test] - fn page_label_limits_bound_the_total_of_decoded_prefixes() { - // `ranges` ranges naming one shared prefix object of `prefix` bytes, without a style. - let shared = |ranges: i64, prefix: usize| { - let mut doc = Document::with_version("1.7"); - doc.objects.insert((7, 0), Object::String(vec![b'x'; prefix], lopdf::StringFormat::Literal)); - let mut spec = Dictionary::new(); - spec.set("P", Object::Reference((7, 0))); - doc.objects.insert((8, 0), Object::Dictionary(spec)); - let mut tree = Dictionary::new(); - tree.set("Nums", (0..ranges).flat_map(|i| [Object::Integer(i), Object::Reference((8, 0))]).collect::>()); - let mut catalog = Dictionary::new(); - catalog.set("PageLabels", Object::Dictionary(tree)); - (doc, catalog) - }; - let mut pages: Vec<_> = - (1..=3).map(|n| PageInfo { width: 200.0, height: 300.0, crop: [0.0, 0.0, 200.0, 300.0], rotation: 0, label: n.to_string() }).collect(); - // Each range decodes its prefix, so a small file sharing one prefix object asks for a - // copy per range: 5,000 ranges and a 1,000-byte prefix make 5 MB, past the 4 MiB total. - // Three pages make three labels, far below the label total: the prefixes alone refuse it. - let (doc, catalog) = shared(5000, 1000); - let err = Inspector::new(&doc).page_labels(&catalog, &mut pages).unwrap_err(); - assert!(err.contains("prefixes exceed 4 MiB"), "{err}"); - assert!(pages.iter().enumerate().all(|(i, p)| p.label == (i + 1).to_string())); - // Exactly 4 MiB of prefixes is still read. - let (doc, catalog) = shared((MAX_PREFIX_TOTAL_BYTES / MAX_LABEL_BYTES) as i64, MAX_LABEL_BYTES); - Inspector::new(&doc).page_labels(&catalog, &mut pages).unwrap(); - assert!(pages.iter().all(|p| p.label.len() == MAX_LABEL_BYTES)); - } - - #[test] - fn page_label_limits_handle_prefix_only_and_reference_cycles() { - let info = label_info(vec![Object::Integer(0), label_spec("", b"prefix".to_vec(), i64::MAX)], Vec::new()); - assert!(info.warnings.is_empty()); - assert!(info.pages.iter().all(|p| p.label == "prefix")); - let mut doc = Document::with_version("1.7"); - doc.objects.insert((6, 0), Object::Reference((6, 0))); - let mut catalog = Dictionary::new(); - catalog.set("PageLabels", Object::Reference((6, 0))); - assert!(Inspector::new(&doc).page_labels(&catalog, &mut []).unwrap_err().contains("cyclic")); - } - - #[test] - fn page_label_limits_bound_depth_and_count_combined_work() { - for depth in [MAX_LABEL_TREE_DEPTH, MAX_LABEL_TREE_DEPTH + 1] { - let mut child = Dictionary::new(); - child.set("Nums", vec![Object::Integer(0), label_spec("D", b"custom-".to_vec(), 1)]); - let mut child = Object::Dictionary(child); - for _ in 1..depth { - let mut parent = Dictionary::new(); - parent.set("Kids", vec![child]); - child = Object::Dictionary(parent); - } - let info = label_info(Vec::new(), vec![child]); - if depth == MAX_LABEL_TREE_DEPTH { - assert!(info.warnings.is_empty()); - assert_eq!(info.pages[0].label, "custom-1"); - } else { - assert_label_fallback(&info); - } - } - // Each array alone fits: their combined work still must be bounded. - let half = MAX_LABEL_TREE_WORK / 2; - let spec = label_spec("D", Vec::new(), 1); - let nums = (0..half).flat_map(|i| [Object::Integer(i as i64), spec.clone()]).collect(); - assert_label_fallback(&label_info(nums, vec![Object::Null; half])); - } - - #[test] - fn page_label_limits_keep_the_later_of_duplicate_ranges() { - let nums = vec![Object::Integer(0), label_spec("D", b"first-".to_vec(), 1), Object::Integer(0), label_spec("D", b"last-".to_vec(), 1)]; - let info = label_info(nums, Vec::new()); - assert_eq!(info.pages.iter().map(|p| p.label.as_str()).collect::>(), ["last-1", "last-2", "last-3"]); - assert_eq!(info.warnings.len(), 1, "{:?}", info.warnings); - assert!(info.warnings[0].contains("twice"), "{:?}", info.warnings); - } - - #[test] - fn page_label_limits_keep_other_ranges_when_one_is_too_long() { - // Page 1 is labelled normally; pages 2–3 would need over 1024 bytes of roman numerals. - let nums = vec![Object::Integer(0), label_spec("D", b"ok-".to_vec(), 1), Object::Integer(1), label_spec("r", Vec::new(), 1_025_000)]; - let info = label_info(nums, Vec::new()); - assert_eq!(info.pages.iter().map(|p| p.label.as_str()).collect::>(), ["ok-1", "2", "3"]); - assert_eq!(info.warnings.len(), 1, "{:?}", info.warnings); - assert!(info.warnings[0].contains("physical page numbers"), "{:?}", info.warnings); - } - - const ATTACHMENTS: &[u8] = b"%PDF-1.7 -1 0 obj << /Type /Catalog /Pages 2 0 R /Names << /EmbeddedFiles << /Names [(notes.txt) 6 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 200 200] /Annots [4 0 R] /Resources << /Font << /F1 9 0 R >> >> >> endobj -4 0 obj << /Type /Annot /Subtype /FileAttachment /Rect [10 10 30 30] /Contents (data.csv) /FS 8 0 R >> endobj -5 0 obj << /Type /EmbeddedFile /Length 11 >> stream -hello notes -endstream endobj -6 0 obj << /Type /Filespec /F (notes.txt) /UF (notes.txt) /EF << /F 5 0 R >> /Desc (Doc-level) >> endobj -7 0 obj << /Type /EmbeddedFile /Length 5 >> stream -a,b,c -endstream endobj -8 0 obj << /Type /Filespec /F (data.csv) /EF << /F 7 0 R >> >> endobj -9 0 obj << /Type /Font /Subtype /Type1 /BaseFont /ABCDEF+Helvetica >> endobj -trailer << /Root 1 0 R >> -%%EOF"; - - #[test] - fn attachments_of_both_kinds_are_listed_and_extracted() { - let bytes = Arc::new(ATTACHMENTS.to_vec()); - let info = inspect(bytes.clone(), None).expect("opens"); - let names: Vec<_> = info.attachments.iter().map(|a| a.name.as_str()).collect(); - assert!(names.contains(&"notes.txt") && names.contains(&"data.csv"), "{names:?}"); - for a in &info.attachments { - let data = attachment_data(&bytes, None, a).expect("extracts"); - let expected: &[u8] = if a.name == "notes.txt" { b"hello notes" } else { b"a,b,c" }; - assert_eq!(data, expected, "{}", a.name); - } - // A non-embedded /Helvetica is reported as standing in for itself: the standard-14 - // metrics are exact, so the substitution is named rather than left implicit. - assert_eq!( - info.fonts, - vec![FontInfo { - name: "Helvetica".into(), - kind: "Type1".into(), - embedded: false, - subset: true, - encoding: None, - substitute: Some("Helvetica".into()), - substitution: Some(pdfcraft_fonts::fallback::Why::NamedStandard14.note().into()), - }] - ); - } - - const LAYERS: &[u8] = b"%PDF-1.7 -1 0 obj << /Type /Catalog /Pages 2 0 R /OCProperties << /OCGs [5 0 R 6 0 R 7 0 R] /D << /OFF [6 0 R] /RBGroups [[5 0 R 6 0 R] [7 0 R] 8 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 200 200] /Annots [10 0 R 11 0 R] >> endobj -5 0 obj << /Type /OCG /Name (Red) >> endobj -6 0 obj << /Type /OCG /Name (Green) >> endobj -7 0 obj << /Type /OCG /Name (Blue) >> endobj -8 0 obj [6 0 R 7 0 R] endobj -9 0 obj [/OFF 5 0 R] endobj -10 0 obj << /Type /Annot /Subtype /Link /Rect [10 10 50 30] /A << /S /SetOCGState /State [7 0 R /ON 6 0 R /Bogus 5 0 R /Toggle 5 0 R 1 7 0 R] /PreserveRB false >> >> endobj -11 0 obj << /Type /Annot /Subtype /Link /Rect [60 10 100 30] /A << /S /SetOCGState /State 9 0 R >> >> endobj -trailer << /Root 1 0 R >> -%%EOF"; - - /// `/Trans` and `/Dur` (ISO 32000-2 §12.4.4): read with the spec's defaults for what a page - /// leaves out, lenient about what is out of range, and only for pages that have either. - const TRANSITIONS: &[u8] = b"%PDF-1.7 -1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj -2 0 obj << /Type /Pages /Kids [3 0 R 4 0 R 5 0 R 6 0 R 7 0 R] /Count 5 /MediaBox [0 0 200 200] >> endobj -3 0 obj << /Type /Page /Parent 2 0 R /Trans << /Type /Trans /S /Wipe /D 2.5 /Di 270 >> /Dur 5 >> endobj -4 0 obj << /Type /Page /Parent 2 0 R /Dur 3 >> endobj -5 0 obj << /Type /Page /Parent 2 0 R /Trans 8 0 R >> endobj -6 0 obj << /Type /Page /Parent 2 0 R /Trans << /S /Bogus /D (slow) /Di 45 /Dm /Q >> /Dur -1 >> endobj -7 0 obj << /Type /Page /Parent 2 0 R >> endobj -8 0 obj << /S /Fly /M /O /SS 0.5 /B true /Di /None /Dm /V >> endobj -trailer << /Root 1 0 R >> -%%EOF"; - - #[test] - fn page_transitions_and_display_times_are_read() { - use super::TransitionStyle as S; - let info = inspect(Arc::new(TRANSITIONS.to_vec()), None).expect("opens"); - let t = &info.transitions; - assert_eq!(t.iter().map(|t| t.page).collect::>(), [0, 1, 2, 3], "pages with /Trans or /Dur, in order; page 5 has neither"); - assert_eq!((t[0].style, t[0].duration, t[0].direction, t[0].display), (Some(S::Wipe), 2.5, Some(270), Some(5.0))); - assert!((t[0].horizontal, t[0].inward, t[0].scale, t[0].opaque) == (true, true, 1.0, false), "defaults: {:?}", t[0]); - assert_eq!((t[1].style, t[1].duration, t[1].display), (None, 1.0, Some(3.0)), "/Dur without /Trans"); - assert_eq!( - (t[2].style, t[2].inward, t[2].scale, t[2].opaque, t[2].direction, t[2].horizontal), - (Some(S::Fly), false, 0.5, true, None, false), - "an indirect /Trans: {:?}", - t[2] - ); - // Out of range: an unknown style is the spec's default R, a non-numeric /D the default - // second, a direction that is not one of the five falls back to 0, a bad /Dm to - // horizontal, and a negative /Dur is no display time. - assert_eq!( - (t[3].style, t[3].duration, t[3].direction, t[3].horizontal, t[3].display), - (Some(S::Replace), 1.0, Some(0), true, None), - "{:?}", - t[3] - ); - } - - #[test] - fn radio_button_layer_groups_are_read() { - let info = inspect(Arc::new(LAYERS.to_vec()), None).expect("opens"); - let layers: Vec<_> = info.layers.iter().map(|l| (l.id, l.name.as_str(), l.visible)).collect(); - assert_eq!(layers, [((5, 0), "Red", true), ((6, 0), "Green", false), ((7, 0), "Blue", true)]); - // A group of one constrains nothing; an indirect group is read. - assert_eq!(info.layer_groups, [vec![(5, 0), (6, 0)], vec![(6, 0), (7, 0)]]); - } - - const ORPHAN_FIELDS: &[u8] = b"%PDF-1.7 -1 0 obj << /Type /Catalog /Pages 2 0 R /AcroForm 4 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 200 200] /Annots [5 0 R 6 0 R] >> endobj -4 0 obj << /Fields [] >> endobj -5 0 obj << /Type /Annot /Subtype /Widget /FT /Tx /T (alpha) /V (one) /Rect [10 100 90 120] >> endobj -6 0 obj << /Type /Annot /Subtype /Widget /FT /Tx /T (beta) /Rect [10 60 90 80] >> endobj -trailer << /Root 1 0 R >> -%%EOF"; - - #[test] - fn fields_listed_only_on_the_pages_are_listed() { - let info = inspect(Arc::new(ORPHAN_FIELDS.to_vec()), None).expect("opens"); - let listed: Vec<_> = info.fields.iter().map(|f| (f.name.as_str(), f.value.as_deref(), f.page)).collect(); - assert_eq!(listed, [("alpha", Some("one"), Some(0)), ("beta", None, Some(0))], "{:?}", info.fields); - } - - /// A `Structure` that counts the objects read through it. - struct CountingReads<'a> { - doc: &'a Document, - reads: std::cell::Cell, - } - - impl Structure for CountingReads<'_> { - fn trailer(&self) -> &Dictionary { - &self.doc.trailer - } - fn get_object(&self, id: ObjectId) -> lopdf::Result<&Object> { - self.reads.set(self.reads.get() + 1); - self.doc.get_object(id) - } - fn encrypted(&self) -> bool { - false - } - } - - /// Widgets without `/P` are placed on the page that lists them with one pass over the pages' - /// `/Annots`, not a search of every page per field: that was O(fields × annotations), a - /// second to open a form of 8,000 fields. A widget two pages list is on the first of them. - #[test] - fn widgets_without_a_page_entry_are_placed_in_one_pass() { - const PAGES: usize = 100; - const PER_PAGE: usize = 10; - let field = |p: usize, k: usize| 4 + PAGES + p * PER_PAGE + k; - let mut pdf = String::from("%PDF-1.7\n1 0 obj << /Type /Catalog /Pages 2 0 R /AcroForm 3 0 R >> endobj\n"); - let kids: Vec = (0..PAGES).map(|p| format!("{} 0 R", 4 + p)).collect(); - pdf += &format!("2 0 obj << /Type /Pages /Kids [{}] /Count {PAGES} >> endobj\n", kids.join(" ")); - let fields: Vec = (0..PAGES).flat_map(|p| (0..PER_PAGE).map(move |k| format!("{} 0 R", field(p, k)))).collect(); - pdf += &format!("3 0 obj << /Fields [{}] >> endobj\n", fields.join(" ")); - for p in 0..PAGES { - let mut annots: Vec = (0..PER_PAGE).map(|k| format!("{} 0 R", field(p, k))).collect(); - // The last page lists the first page's first widget too. - if p == PAGES - 1 { - annots.push(format!("{} 0 R", field(0, 0))); - } - pdf += &format!("{} 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 200 200] /Annots [{}] >> endobj\n", 4 + p, annots.join(" ")); - } - for p in 0..PAGES { - for k in 0..PER_PAGE { - pdf += &format!("{} 0 obj << /Type /Annot /Subtype /Widget /FT /Tx /T (f{p}_{k}) /Rect [10 10 90 30] >> endobj\n", field(p, k)); - } - } - pdf += "trailer << /Root 1 0 R >>\n%%EOF"; - let doc = Document::load_mem(pdf.as_bytes()).expect("loads"); - let counting = CountingReads { doc: &doc, reads: std::cell::Cell::new(0) }; - let mut info = DocInfo { - pages: (0..PAGES) - .map(|_| PageInfo { width: 200.0, height: 200.0, crop: [0.0, 0.0, 200.0, 200.0], rotation: 0, label: String::new() }) - .collect(), - ..Default::default() - }; - Inspector::new(&counting).fill(&mut info); - let placed: Vec<_> = info.fields.iter().map(|f| (f.name.clone(), f.page)).collect(); - let want: Vec<_> = (0..PAGES).flat_map(|p| (0..PER_PAGE).map(move |k| (format!("f{p}_{k}"), Some(p)))).collect(); - assert_eq!(placed, want); - // A handful of reads per page and field; searching the pages for each field read the - // page dictionaries about 50,000 times. - let reads = counting.reads.get(); - assert!(reads < 20 * (PAGES + PAGES * PER_PAGE), "{reads} object reads"); - } - - #[test] - fn lazy_structure_matches_the_compatibility_inspector() { - // Each fixture as lopdf writes it, and with its objects in object streams. - let packed = |bytes: &[u8]| { - let doc = pdfcraft_cos::Document::open(Arc::new(bytes.to_vec())).unwrap(); - pdfcraft_cos::write_full(&doc, &pdfcraft_cos::SaveOptions { object_streams: true, ..Default::default() }).unwrap() - }; - let fixtures = [ATTACHMENTS, LAYERS, DESTS].into_iter().flat_map(|bytes| { - let mut original = Document::load_mem(bytes).unwrap(); - let mut plain = Vec::new(); - original.save_to(&mut plain).unwrap(); - let stm = packed(&plain); - [plain, stm] - }); - for bytes in fixtures { - let lazy = LazyStructure::new(Arc::new(bytes.clone()), None).unwrap(); - let eager = Document::load_mem(&bytes).unwrap(); - let mut a = inspect(Arc::new(bytes), None).unwrap(); - let mut b = DocInfo { pages: a.pages.clone(), ..Default::default() }; - let mut c = DocInfo { pages: a.pages.clone(), ..Default::default() }; - Inspector::new(&lazy).fill(&mut b); - Inspector::new(&eager).fill(&mut c); - assert!(!lazy.failed()); - assert_eq!(format!("{b:?}"), format!("{c:?}")); - a.file_size = 0; - a.pdf_version.clear(); - assert_eq!(format!("{a:?}"), format!("{c:?}")); - } - } - - #[test] - fn links_read_set_layer_actions() { - let info = inspect(Arc::new(LAYERS.to_vec()), None).expect("opens"); - let targets: Vec<_> = info.links.iter().map(|l| l.target.clone()).collect(); - use LayerOp::{Off, On, Toggle}; - assert_eq!( - targets, - [ - // Groups before the first name or after an unknown one are skipped, and so is - // anything that isn't a group. - LinkTarget::SetLayers { changes: vec![(On, (6, 0)), (Toggle, (5, 0)), (Toggle, (7, 0))], preserve_rb: false }, - // An indirect /State; /PreserveRB defaults to true. - LinkTarget::SetLayers { changes: vec![(Off, (5, 0))], preserve_rb: true }, - ] - ); - } - - #[test] - fn links_read_bounded_local_remote_go_to_actions() { - use DestView::{Fit, Xyz}; - let too_long = "a".repeat(MAX_REMOTE_FILE_SPEC_BYTES + 1); - let wide_file = format!("", "4E00".repeat(1400)); - let wide_name = format!("", "4E00".repeat(342)); - let pdf = format!( - "%PDF-1.7\n1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj\n\ - 2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 300 400] >> endobj\n\ - 3 0 obj << /Type /Page /Parent 2 0 R /Annots [99 0 R 30 0 R 31 0 R 32 0 R 33 0 R 34 0 R 35 0 R 36 0 R 37 0 R 38 0 R 39 0 R 40 0 R 41 0 R] >> endobj\n\ - 30 0 obj << /Type /Annot /Subtype /Link /Rect [0 0 10 10] /A << /S /GoToR /F (chapters/remote.pdf) /D [2 /XYZ 10 20 1.5] /NewWindow true >> >> endobj\n\ - 31 0 obj << /Type /Annot /Subtype /Link /Rect [0 20 10 30] /A << /S /GoToR /F << /Type /Filespec /F (fallback.pdf) /UF >> /D [0 /Fit] /NewWindow false >> >> endobj\n\ - 32 0 obj << /Type /Annot /Subtype /Link /Rect [0 40 10 50] /A << /S /GoToR /F (remote.pdf) >> >> endobj\n\ - 33 0 obj << /Type /Annot /Subtype /Link /Rect [0 60 10 70] /A << /S /GoToR /F (chapter.pdf) /D (chapter) >> >> endobj\n\ - 34 0 obj << /Type /Annot /Subtype /Link /Rect [0 80 10 90] /A << /S /GoToR /FS /URL /F (https://example.com) /D [0 /Fit] >> >> endobj\n\ - 35 0 obj << /Type /Annot /Subtype /Link /Rect [0 100 10 110] /A << /S /GoToR /F ({too_long}) /D [0 /Fit] >> >> endobj\n\ - 36 0 obj << /Type /Annot /Subtype /Link /Rect [0 120 10 130] /A << /S /GoToR /F (chapter.pdf) /D /chapter >> >> endobj\n\ - 37 0 obj << /Type /Annot /Subtype /Link /Rect [0 140 10 150] /A << /S /GoToR /F (chapter.pdf) /D [-1 /Fit] >> >> endobj\n\ - 38 0 obj << /Type /Annot /Subtype /Link /Rect [0 160 10 170] /A << /S /GoToR /F (chapter.pdf) /D [1.5 /Fit] >> >> endobj\n\ - 39 0 obj << /Type /Annot /Subtype /Link /Rect [0 180 10 190] /A << /S /GoToR /F (chapter.pdf) /D [9223372036854775807 /Fit] >> >> endobj\n\ - 40 0 obj << /Type /Annot /Subtype /Link /Rect [0 200 10 210] /A << /S /GoToR /F << /Type /Filespec /UF {wide_file} >> /D [0 /Fit] >> >> endobj\n\ - 41 0 obj << /Type /Annot /Subtype /Link /Rect [0 220 10 230] /A << /S /GoToR /F (chapter.pdf) /D {wide_name} >> >> endobj\n\ - trailer << /Root 1 0 R >>\n%%EOF" - ); - let info = inspect(Arc::new(pdf.into_bytes()), None).expect("opens"); - let targets: Vec<_> = info.links.iter().map(|l| (l.index, l.target.clone())).collect(); - let huge_page_target = usize::try_from(i64::MAX) - .map(|page| LinkTarget::RemoteGoTo { - file: "chapter.pdf".into(), - destination: RemoteDestination::Page { page, view: Fit }, - new_window: None, - }) - .unwrap_or_else(|_| LinkTarget::Other("GoToR".into())); - assert_eq!( - targets, - [ - ( - 1, - LinkTarget::RemoteGoTo { - file: "chapters/remote.pdf".into(), - destination: RemoteDestination::Page { page: 2, view: Xyz { left: Some(10.0), top: Some(20.0), zoom: Some(1.5) } }, - new_window: Some(true), - } - ), - ( - 2, - LinkTarget::RemoteGoTo { - file: "foo.pdf".into(), - destination: RemoteDestination::Page { page: 0, view: Fit }, - new_window: Some(false), - } - ), - (3, LinkTarget::RemoteGoTo { file: "remote.pdf".into(), destination: RemoteDestination::Default, new_window: None }), - (4, LinkTarget::RemoteGoTo { file: "chapter.pdf".into(), destination: RemoteDestination::Named("chapter".into()), new_window: None }), - (5, LinkTarget::Other("GoToR".into())), - (6, LinkTarget::Other("GoToR".into())), - (7, LinkTarget::RemoteGoTo { file: "chapter.pdf".into(), destination: RemoteDestination::Named("chapter".into()), new_window: None }), - (8, LinkTarget::Other("GoToR".into())), - (9, LinkTarget::Other("GoToR".into())), - (10, huge_page_target), - (11, LinkTarget::Other("GoToR".into())), - (12, LinkTarget::Other("GoToR".into())), - ] - ); - } - - #[test] - fn destination_operands_become_views() { - use DestView::*; - let v = |kind: &[u8], ops: &[Option]| DestView::from_operands(Some(kind), ops); - // ISO 32000-2 Table 149, each type. - assert_eq!(v(b"XYZ", &[Some(0.0), Some(420.0), Some(0.0)]), Xyz { left: Some(0.0), top: Some(420.0), zoom: None }); - assert_eq!(v(b"XYZ", &[Some(72.0), Some(500.0), Some(1.5)]), Xyz { left: Some(72.0), top: Some(500.0), zoom: Some(1.5) }); - assert_eq!(v(b"Fit", &[]), Fit); - assert_eq!(v(b"FitB", &[]), Fit); - assert_eq!(v(b"FitH", &[Some(250.0)]), FitH { top: Some(250.0) }); - assert_eq!(v(b"FitBH", &[None]), FitH { top: None }); - assert_eq!(v(b"FitV", &[Some(120.0)]), FitV { left: Some(120.0) }); - assert_eq!(v(b"FitBV", &[Some(5.0)]), FitV { left: Some(5.0) }); - // FitR's corners are normalized. - assert_eq!(v(b"FitR", &[Some(110.0), Some(220.0), Some(10.0), Some(20.0)]), FitR { rect: [10.0, 20.0, 110.0, 220.0] }); - // Missing operands are null; so are non-finite ones, and a zoom that isn't positive. - assert_eq!(v(b"XYZ", &[]), Xyz { left: None, top: None, zoom: None }); - assert_eq!(v(b"XYZ", &[Some(f32::NAN), Some(f32::INFINITY), Some(f32::NEG_INFINITY)]), Xyz { left: None, top: None, zoom: None }); - assert_eq!(v(b"XYZ", &[None, Some(-5.0), Some(-1.0)]), Xyz { left: None, top: Some(-5.0), zoom: None }); - assert_eq!(v(b"XYZ", &[None, None, Some(f32::NAN)]), Xyz { left: None, top: None, zoom: None }); - assert_eq!(v(b"FitH", &[Some(f32::NAN)]), FitH { top: None }); - // A FitR without four usable numbers, an unknown type or no type: the top of the page. - assert_eq!(v(b"FitR", &[Some(1.0), Some(2.0)]), Top); - assert_eq!(v(b"FitR", &[Some(1.0), Some(2.0), Some(f32::INFINITY), Some(4.0)]), Top); - assert_eq!(v(b"Zoom", &[Some(3.0)]), Top); - assert_eq!(v(b"xyz", &[Some(3.0)]), Top); - assert_eq!(DestView::from_operands(None, &[Some(1.0)]), Top); - } - - /// Two 300×400 pages. Bookmarks and links on page 1 go to page 2 in every destination form, - /// named (`/Names /Dests` and the PDF 1.1 `/Dests`) and malformed. - const DESTS: &[u8] = b"%PDF-1.7 -1 0 obj << /Type /Catalog /Pages 2 0 R /Outlines 10 0 R /Names << /Dests 40 0 R >> /Dests << /old << /D [4 0 R /FitV 120] >> >> >> endobj -2 0 obj << /Type /Pages /Kids [3 0 R 4 0 R] /Count 2 /MediaBox [0 0 300 400] >> endobj -3 0 obj << /Type /Page /Parent 2 0 R /Annots [30 0 R 31 0 R 32 0 R 33 0 R] >> endobj -4 0 obj << /Type /Page /Parent 2 0 R >> endobj -10 0 obj << /Type /Outlines /First 11 0 R /Last 20 0 R /Count 10 >> endobj -11 0 obj << /Title (xyz) /Parent 10 0 R /Next 12 0 R /Dest [4 0 R /XYZ 0 420 0] >> endobj -12 0 obj << /Title (fith) /Parent 10 0 R /Next 13 0 R /Dest [4 0 R /FitH 250] >> endobj -13 0 obj << /Title (named) /Parent 10 0 R /Next 14 0 R /Dest (sec) >> endobj -14 0 obj << /Title (old named) /Parent 10 0 R /Next 15 0 R /Dest /old >> endobj -15 0 obj << /Title (action) /Parent 10 0 R /Next 16 0 R /A << /S /GoTo /D [4 0 R /FitR 110 220 10 20] >> >> endobj -16 0 obj << /Title (wrong types) /Parent 10 0 R /Next 17 0 R /Dest [4 0 R /XYZ (left) /Top true] >> endobj -17 0 obj << /Title (short) /Parent 10 0 R /Next 18 0 R /Dest [4 0 R /FitR 1 2] >> endobj -18 0 obj << /Title (no type) /Parent 10 0 R /Next 19 0 R /Dest [4 0 R] >> endobj -19 0 obj << /Title (huge) /Parent 10 0 R /Next 20 0 R /Dest [4 0 R /XYZ 1000000000000 -5 -1] >> endobj -20 0 obj << /Title (no page) /Parent 10 0 R /Dest [/XYZ 0 420 0] >> endobj -30 0 obj << /Type /Annot /Subtype /Link /Rect [0 0 10 10] /Dest [4 0 R /XYZ 72 500 1.5] >> endobj -31 0 obj << /Type /Annot /Subtype /Link /Rect [0 20 10 30] /A << /S /GoTo /D (sec) >> >> endobj -32 0 obj << /Type /Annot /Subtype /Link /Rect [0 40 10 50] /Dest [4 0 R /FitBH 100] >> endobj -33 0 obj << /Type /Annot /Subtype /Link /Rect [0 60 10 70] /Dest [4 0 R /Fit] >> endobj -40 0 obj << /Names [(sec) << /D [4 0 R /XYZ null 300 2] >>] >> endobj -trailer << /Root 1 0 R >> -%%EOF"; - - #[test] - fn bookmarks_and_links_keep_their_destination_view() { - use DestView::*; - let info = inspect(Arc::new(DESTS.to_vec()), None).expect("opens"); - let outline: Vec<_> = info.outline.iter().map(|o| (o.title.as_str(), o.page, o.view)).collect(); - assert_eq!( - outline, - [ - ("xyz", Some(1), Xyz { left: Some(0.0), top: Some(420.0), zoom: None }), - ("fith", Some(1), FitH { top: Some(250.0) }), - ("named", Some(1), Xyz { left: None, top: Some(300.0), zoom: Some(2.0) }), - ("old named", Some(1), FitV { left: Some(120.0) }), - ("action", Some(1), FitR { rect: [10.0, 20.0, 110.0, 220.0] }), - // Hostile operands fall back without failing the bookmark. - ("wrong types", Some(1), Xyz { left: None, top: None, zoom: None }), - ("short", Some(1), Top), - ("no type", Some(1), Top), - ("huge", Some(1), Xyz { left: Some(1e12), top: Some(-5.0), zoom: None }), - ("no page", None, Top), - ] - ); - let links: Vec<_> = info.links.iter().map(|l| l.target.clone()).collect(); - assert_eq!( - links, - [ - LinkTarget::Page(1, Xyz { left: Some(72.0), top: Some(500.0), zoom: Some(1.5) }), - LinkTarget::Page(1, Xyz { left: None, top: Some(300.0), zoom: Some(2.0) }), - LinkTarget::Page(1, FitH { top: Some(100.0) }), - LinkTarget::Page(1, Fit), - ] - ); - } - - #[test] - fn outline_titles_decode_every_text_string_encoding() { - // Issue #142: CJK bookmark titles showed as mojibake or with a stray BOM. - let pdf = b"%PDF-1.7 -1 0 obj << /Type /Catalog /Pages 2 0 R /Outlines 10 0 R >> endobj -2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 300 400] >> endobj -3 0 obj << /Type /Page /Parent 2 0 R >> endobj -10 0 obj << /Type /Outlines /First 11 0 R /Last 16 0 R /Count 6 >> endobj -11 0 obj << /Title /Parent 10 0 R /Next 12 0 R /Dest [3 0 R /Fit] >> endobj -12 0 obj << /Title /Parent 10 0 R /Next 13 0 R /Dest [3 0 R /Fit] >> endobj -13 0 obj << /Title /Parent 10 0 R /Next 14 0 R /Dest [3 0 R /Fit] >> endobj -14 0 obj << /Title /Parent 10 0 R /Next 15 0 R /Dest [3 0 R /Fit] >> endobj -15 0 obj << /Title <436166E9> /Parent 10 0 R /Next 16 0 R /Dest [3 0 R /Fit] >> endobj -16 0 obj << /Title /Parent 10 0 R /Dest [3 0 R /Fit] >> endobj -trailer << /Root 1 0 R >> -%%EOF"; - let info = inspect(Arc::new(pdf.to_vec()), None).expect("opens"); - let titles: Vec<_> = info.outline.iter().map(|o| o.title.as_str()).collect(); - // UTF-16BE, UTF-8 with BOM, invalid UTF-8 after a BOM (lossy, not dropped), BOM-less - // raw UTF-8, PDFDocEncoding Latin-1, and a doubled BOM. - assert_eq!(titles, ["第一章", "目录", "A\u{FFFD}B", "概述", "Café", "A"]); - } - - /// A file whose objects are `bodies` in order (object 1 first), with a cross-reference table. - fn pdf_of(bodies: &[String]) -> Vec { - let mut out = b"%PDF-1.7\n".to_vec(); - let mut offsets = Vec::new(); - for (i, body) in bodies.iter().enumerate() { - offsets.push(out.len()); - out.extend_from_slice(format!("{} 0 obj\n{body}\nendobj\n", i + 1).as_bytes()); - } - let xref = out.len(); - out.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", bodies.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", bodies.len() + 1).as_bytes()); - out - } - - /// A one-page file whose outline is `items` (objects 5, 6, … in order). - fn outline_file(items: &[String]) -> Vec { - let mut bodies = vec![ - "<< /Type /Catalog /Pages 2 0 R /Outlines 4 0 R >>".to_string(), - "<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 300 400] >>".to_string(), - "<< /Type /Page /Parent 2 0 R >>".to_string(), - format!("<< /Type /Outlines /First 5 0 R /Count {} >>", items.len()), - ]; - bodies.extend(items.iter().cloned()); - pdf_of(&bodies) - } - - /// The titles of `items`, with children in brackets (`Parent[Child]`). - fn shape(items: &[OutlineItem]) -> Vec { - items.iter().map(|o| if o.children.is_empty() { o.title.clone() } else { format!("{}[{}]", o.title, shape(&o.children).join(",")) }).collect() - } - - #[test] - fn outline_window_keeps_the_first_bookmarks_and_says_when_more_follow() { - const COUNT: usize = OUTLINE_WINDOW + 5; - let items: Vec = (0..COUNT) - .map(|k| { - let prev = if k == 0 { String::new() } else { format!(" /Prev {} 0 R", 4 + k) }; - let next = if k + 1 == COUNT { String::new() } else { format!(" /Next {} 0 R", 6 + k) }; - format!("<< /Title (Item {k}) /Parent 4 0 R{prev}{next} /Dest [3 0 R /Fit] >>") - }) - .collect(); - let info = inspect(Arc::new(outline_file(&items)), None).expect("opens"); - assert_eq!(info.outline.len(), OUTLINE_WINDOW); - assert_eq!(info.outline.last().map(|o| o.title.as_str()), Some("Item 9999")); - assert!(info.outline_more); - - let one = inspect(Arc::new(outline_file(&["<< /Title (Only) /Parent 4 0 R /Dest [3 0 R /Fit] >>".into()])), None).expect("opens"); - assert_eq!(shape(&one.outline), ["Only"]); - assert!(!one.outline_more); - } - - #[test] - fn outline_window_nests_no_deeper_than_its_depth_limit() { - // A chain of 40 bookmarks, each the first child of the one before. - let items: Vec = (0..40) - .map(|k| { - let parent = if k == 0 { 4 } else { 4 + k }; - let first = if k + 1 == 40 { String::new() } else { format!(" /First {} 0 R", 6 + k) }; - format!("<< /Title (Deep {k}) /Parent {parent} 0 R{first} /Dest [3 0 R /Fit] >>") - }) - .collect(); - let info = inspect(Arc::new(outline_file(&items)), None).expect("opens"); - let mut levels = 0; - let mut level = info.outline.first(); - while let Some(item) = level { - levels += 1; - level = item.children.first(); - } - assert_eq!(levels, OUTLINE_DEPTH + 1); - assert!(info.outline_more); - } - - #[test] - fn outline_window_ends_cycles_and_broken_links_without_looping() { - let listed = |items: &[&str]| { - let items: Vec = items.iter().map(|s| s.to_string()).collect(); - shape(&inspect(Arc::new(outline_file(&items)), None).expect("opens").outline) - }; - assert_eq!(listed(&["<< /Title (Self) /Parent 4 0 R /Next 5 0 R >>"]), ["Self"]); - assert_eq!(listed(&["<< /Title (Self) /Parent 4 0 R /First 5 0 R >>"]), ["Self"]); - assert_eq!(listed(&["<< /Title (A) /Parent 4 0 R /Next 6 0 R >>", "<< /Title (B) /Parent 4 0 R /Next 5 0 R >>"]), ["A", "B"]); - assert_eq!(listed(&["<< /Title (A) /Parent 4 0 R /First 6 0 R >>", "<< /Title (B) /Parent 5 0 R /First 5 0 R >>"]), ["A[B]"]); - assert_eq!(listed(&["<< /Title (A) /Parent 4 0 R /Next 99 0 R >>"]), ["A"]); - assert_eq!(listed(&["<< /Title (A) /Parent 4 0 R /Next 6 0 R >>", "42"]), ["A"]); - } - - #[test] - fn destination_points_map_to_the_displayed_page() { - // A crop box away from the origin: x 100..400, y 50..450. - let mut p = PageInfo { width: 300.0, height: 400.0, label: "1".into(), crop: [100.0, 50.0, 400.0, 450.0], rotation: 0 }; - assert_eq!(p.dest_fraction(Some(100.0), Some(450.0), 0), [Some(0.0), Some(0.0)]); - assert_eq!(p.dest_fraction(Some(250.0), Some(350.0), 0), [Some(0.5), Some(0.25)]); - // Unspecified stays unspecified; off-page and huge values are clamped to the page. - assert_eq!(p.dest_fraction(None, Some(350.0), 0), [None, Some(0.25)]); - assert_eq!(p.dest_fraction(Some(-1e30), Some(1e30), 0), [Some(0.0), Some(0.0)]); - assert_eq!(p.dest_fraction(Some(f32::INFINITY), Some(f32::NAN), 0), [None, None]); - // /Rotate 90 (clockwise): user y runs left to right across the displayed page, user x - // top to bottom. A view rotation adds to it. - p.rotation = 90; - assert_eq!(p.dest_fraction(Some(250.0), Some(350.0), 0), [Some(0.75), Some(0.5)]); - assert_eq!(p.dest_fraction(None, Some(350.0), 0), [Some(0.75), None]); - assert_eq!(p.dest_fraction(Some(250.0), Some(350.0), 90), [Some(0.5), Some(0.75)]); - p.rotation = 180; - assert_eq!(p.dest_fraction(Some(250.0), Some(350.0), 0), [Some(0.5), Some(0.75)]); - p.rotation = 270; - assert_eq!(p.dest_fraction(Some(250.0), Some(350.0), 0), [Some(0.25), Some(0.5)]); - assert_eq!(p.dest_fraction(Some(250.0), Some(350.0), 90), [Some(0.5), Some(0.25)]); - // A degenerate crop box never divides by zero. - let flat = PageInfo { crop: [0.0, 0.0, 0.0, 0.0], rotation: 0, ..p }; - assert!(flat.dest_fraction(Some(5.0), Some(-5.0), 0).iter().all(|f| f.is_some_and(|f| (0.0..=1.0).contains(&f)))); - } - - #[test] - fn structure_failure_preserves_renderer_pages() { - for crash in [true, false] { - let mut info = DocInfo { - file_size: 321, - pdf_version: "1.7".into(), - pages: vec![PageInfo { width: 300.0, height: 200.0, label: "1".into(), crop: [10.0, 20.0, 210.0, 320.0], rotation: 90 }], - ..Default::default() - }; - inspect_structure(&mut info, |tmp| { - tmp.pages.clear(); - tmp.title = Some("partially inspected".into()); - if crash { - panic!("synthetic structure inspection failure"); - } - Err("synthetic structure inspection failure".into()) - }); - - assert_eq!(info.pages.len(), 1); - let page = &info.pages[0]; - assert_eq!((page.width, page.height, page.label.as_str(), page.crop, page.rotation), (300.0, 200.0, "1", [10.0, 20.0, 210.0, 320.0], 90)); - assert_eq!(info.file_size, 321); - assert_eq!(info.pdf_version, "1.7"); - assert!(info.title.is_none()); - assert_eq!(info.warnings.len(), 1); - assert!(info.warnings[0].contains("synthetic structure inspection failure")); - assert_eq!(info.warnings[0].contains("crashed"), crash); - } - } - - #[test] - fn invalid_dates_with_unicode_are_unchanged() { - for input in ["", "D:", "D:20260930104", "D:202609x01045", "yesterday"] { - assert_eq!(pretty_date(input), input); - } - // Cover every position before the 12-byte prefix, including characters that - // straddle its end. None of these strings is an ASCII PDF date. - for character in ['é', '€', '😀'] { - for prefix_len in 0..12 { - let input = format!("D:{}{character}123456789012", "1".repeat(prefix_len)); - assert_eq!(pretty_date(&input), input, "{character} after {prefix_len} digits"); - } - } - } - - #[test] - fn unicode_annotation_date_does_not_prevent_opening() { - use lopdf::dictionary; - - let date = "D:12345678901éX"; - let mut encoded_date = vec![0xFE, 0xFF]; - encoded_date.extend(date.encode_utf16().flat_map(u16::to_be_bytes)); - let mut doc = Document::with_version("1.7"); - let pages_id = doc.new_object_id(); - let annot_id = doc.add_object(dictionary! { - "Type" => "Annot", - "Subtype" => "Text", - "Rect" => vec![10.into(), 10.into(), 30.into(), 30.into()], - "M" => Object::String(encoded_date, lopdf::StringFormat::Hexadecimal), - }); - let page_id = doc.add_object(dictionary! { - "Type" => "Page", - "Parent" => pages_id, - "MediaBox" => vec![0.into(), 0.into(), 200.into(), 300.into()], - "Annots" => vec![annot_id.into()], - }); - doc.objects.insert( - pages_id, - dictionary! { - "Type" => "Pages", - "Kids" => vec![page_id.into()], - "Count" => 1, - } - .into(), - ); - let catalog_id = doc.add_object(dictionary! { "Type" => "Catalog", "Pages" => pages_id }); - doc.trailer.set("Root", catalog_id); - let mut bytes = Vec::new(); - doc.save_to(&mut bytes).expect("write synthetic fixture"); - - let info = inspect(Arc::new(bytes), None).expect("opens despite a non-date /M string"); - assert_eq!(info.pages.len(), 1); - assert_eq!((info.pages[0].width, info.pages[0].height), (200.0, 300.0)); - assert_eq!(info.annotations.len(), 1); - assert_eq!(info.annotations[0].modified.as_deref(), Some(date)); - assert!(info.warnings.is_empty(), "{:?}", info.warnings); - } - - #[test] - fn carriage_returns_in_comment_text_become_line_breaks() { - use lopdf::dictionary; - - let mut doc = Document::with_version("1.7"); - let pages_id = doc.new_object_id(); - let annot_id = doc.add_object(dictionary! { - "Type" => "Annot", - "Subtype" => "Text", - "Rect" => vec![10.into(), 10.into(), 30.into(), 30.into()], - // Acrobat ends lines in /Contents with CR; the interface only breaks lines at LF. - "Contents" => Object::string_literal("one\r\rtwo\r\nthree"), - }); - let page_id = doc.add_object(dictionary! { - "Type" => "Page", - "Parent" => pages_id, - "MediaBox" => vec![0.into(), 0.into(), 200.into(), 300.into()], - "Annots" => vec![annot_id.into()], - }); - doc.objects.insert(pages_id, dictionary! { "Type" => "Pages", "Kids" => vec![page_id.into()], "Count" => 1 }.into()); - let catalog_id = doc.add_object(dictionary! { "Type" => "Catalog", "Pages" => pages_id }); - doc.trailer.set("Root", catalog_id); - let mut bytes = Vec::new(); - doc.save_to(&mut bytes).expect("write synthetic fixture"); - - let info = inspect(Arc::new(bytes), None).expect("opens"); - assert_eq!(info.annotations[0].contents.as_deref(), Some("one\n\ntwo\nthree")); - } - - #[test] - fn dates_are_prettified() { - assert_eq!(pretty_date("D:20260930104512-04'00'"), "2026-09-30 10:45"); - assert_eq!(pretty_date("yesterday"), "yesterday"); - } - - #[test] - fn jspdf_high_precision_mediabox_does_not_overflow_dimensions() { - let pdf = b"%PDF-1.3 -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 3874.9606299212600788 5493.5433070866147318] >> endobj -trailer << /Root 1 0 R >> -%%EOF"; - let info = inspect(std::sync::Arc::new(pdf.to_vec()), None).expect("opens"); - assert_eq!(info.pages.len(), 1); - let p = &info.pages[0]; - assert!((p.width - 3874.96).abs() < 0.1, "width expected ~3874.96, got {}", p.width); - assert!((p.height - 5493.54).abs() < 0.1, "height expected ~5493.54, got {}", p.height); - } -} - -/// "1.7" from the parser's version name ("Pdf17", "V1_7", …). -fn version_label(name: &str) -> String { - let digits: Vec = name.chars().filter(char::is_ascii_digit).collect(); - match digits[..] { - [a, b] => format!("{a}.{b}"), - _ => name.to_owned(), - } -} - -#[cfg(test)] -mod version_tests { - #[test] - fn version_labels_read_as_numbers() { - assert_eq!(super::version_label("Pdf17"), "1.7"); - assert_eq!(super::version_label("V2_0"), "2.0"); - assert_eq!(super::version_label("Unknown"), "Unknown"); - } -} - -#[cfg(test)] -mod substitution_tests { - use std::sync::Arc; - - use super::{FontInfo, inspect}; - - /// One page naming four fonts the document does not embed: a Chinese CID font (Adobe-GB1), a - /// Japanese one (Adobe-Japan1), an unknown serif-flagged simple font, and an aliased - /// standard-14 name. - const NOT_EMBEDDED: &[u8] = b"%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 200 200] /Resources << /Font << /F1 4 0 R /F2 6 0 R /F3 8 0 R /F4 9 0 R /F5 11 0 R >> >> >> endobj -4 0 obj << /Type /Font /Subtype /Type0 /BaseFont /STSong-Light /Encoding /UniGB-UCS2-H /DescendantFonts [5 0 R] >> endobj -5 0 obj << /Type /Font /Subtype /CIDFontType0 /BaseFont /STSong-Light /CIDSystemInfo << /Registry (Adobe) /Ordering (GB1) /Supplement 2 >> >> endobj -6 0 obj << /Type /Font /Subtype /Type0 /BaseFont /KozMinPro-Regular /Encoding /UniJIS-UCS2-H /DescendantFonts [7 0 R] >> endobj -7 0 obj << /Type /Font /Subtype /CIDFontType0 /BaseFont /KozMinPro-Regular /CIDSystemInfo << /Registry (Adobe) /Ordering (Japan1) /Supplement 4 >> >> endobj -8 0 obj << /Type /Font /Subtype /TrueType /BaseFont /SomeHouseSerif /FontDescriptor 10 0 R >> endobj -9 0 obj << /Type /Font /Subtype /TrueType /BaseFont /ArialMT >> endobj -10 0 obj << /Type /FontDescriptor /FontName /SomeHouseSerif /Flags 34 >> endobj -11 0 obj << /Type /Font /Subtype /TrueType /BaseFont /HouseGrotesk /FontDescriptor 12 0 R >> endobj -12 0 obj << /Type /FontDescriptor /FontName /HouseGrotesk /Flags 33 >> endobj -trailer << /Size 13 /Root 1 0 R >> -%%EOF"; - - fn font<'a>(fonts: &'a [FontInfo], name: &str) -> &'a FontInfo { - fonts.iter().find(|f| f.name == name).unwrap_or_else(|| panic!("{name} listed; got {:?}", fonts.iter().map(|f| &f.name).collect::>())) - } - - #[test] - fn doc_info_says_which_face_stands_in_and_why() { - let info = inspect(Arc::new(NOT_EMBEDDED.to_vec()), None).expect("opens"); - - // The one that matters most to a user: a Chinese PDF that draws nothing. Before this, - // doc_info said only `embedded: false` and left them to guess. - let cjk = font(&info.fonts, "STSong-Light"); - assert!(!cjk.embedded); - assert_eq!(cjk.substitute, None, "no licensed Chinese face, so nothing stands in"); - let why = cjk.substitution.as_deref().unwrap_or_default(); - assert!(why.contains("no licensed face") && why.contains("rather than the wrong glyphs"), "{why}"); - - // An aliased standard-14 name resolves to the real face, whose metrics are exact. - let arial = font(&info.fonts, "ArialMT"); - assert_eq!(arial.substitute.as_deref(), Some("Helvetica")); - assert!(arial.substitution.as_deref().unwrap_or_default().contains("standard 14")); - - // A house font whose name merely contains "Serif" gets Times -- reported as the guess it - // is, not as a standard-14 name, which would be untrue of a font nobody has heard of. - let serif = font(&info.fonts, "SomeHouseSerif"); - assert_eq!(serif.substitute.as_deref(), Some("Times-Roman")); - assert!(serif.substitution.as_deref().unwrap_or_default().contains("hints at one"), "{serif:?}"); - - // No hint in the name at all, so the descriptor flags decide: 33 = fixed pitch + nonsymbolic. - let grotesk = font(&info.fonts, "HouseGrotesk"); - assert_eq!(grotesk.substitute.as_deref(), Some("Courier")); - assert!(grotesk.substitution.as_deref().unwrap_or_default().contains("descriptor flags"), "{grotesk:?}"); - - // Japanese depends on the optional craft-fonts build input; either way it is reported, - // and never as a Latin face. - let jpan = font(&info.fonts, "KozMinPro-Regular"); - assert!(jpan.substitution.is_some(), "a substitution is always explained"); - match &jpan.substitute { - Some(face) => { - assert!(!face.starts_with("Helvetica") && !face.starts_with("Times"), "a Latin face is never a Japanese substitute: {face}") - } - None => assert!(jpan.substitution.as_deref().unwrap_or_default().contains("no licensed face")), - } - - // Nothing claims to be embedded, so every font here carries a reason. - assert!(info.fonts.iter().all(|f| !f.embedded && f.substitution.is_some()), "{:?}", info.fonts); - } - - /// An embedded font is reported as faithful: nothing stands in, and there is nothing to explain. - const EMBEDDED: &[u8] = b"%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 200 200] /Resources << /Font << /F1 4 0 R >> >> >> endobj -4 0 obj << /Type /Font /Subtype /TrueType /BaseFont /ABCDEF+SomeHouseSans /FontDescriptor 5 0 R >> endobj -5 0 obj << /Type /FontDescriptor /FontName /ABCDEF+SomeHouseSans /Flags 32 /FontFile2 6 0 R >> endobj -6 0 obj << /Length 4 >> stream -test -endstream endobj -trailer << /Size 7 /Root 1 0 R >> -%%EOF"; - - #[test] - fn an_embedded_font_reports_no_substitution_at_all() { - let info = inspect(Arc::new(EMBEDDED.to_vec()), None).expect("opens"); - let f = font(&info.fonts, "SomeHouseSans"); - assert!(f.embedded && f.subset, "{f:?}"); - assert_eq!((f.substitute.as_deref(), f.substitution.as_deref()), (None, None), "the document's own glyphs are shown"); - } -} From b4af74dd474e8714a8bcdbe29f96c3a21063ee54 Mon Sep 17 00:00:00 2001 From: Charan Rathore <180254320+charan-rathore@users.noreply.github.com> Date: Fri, 16 Oct 2026 02:40:29 +0530 Subject: [PATCH 022/105] fix(inspect): report /OpenAction JavaScript in document info --- crates/render/src/inspect.rs | 3036 ++++++++++++++++++++++++++++++++++ 1 file changed, 3036 insertions(+) create mode 100644 crates/render/src/inspect.rs diff --git a/crates/render/src/inspect.rs b/crates/render/src/inspect.rs new file mode 100644 index 0000000000..1b6e8dd4dc --- /dev/null +++ b/crates/render/src/inspect.rs @@ -0,0 +1,3036 @@ +//! Document inspection: everything panels need that is not pixels. +//! +//! Page geometry comes from hayro (which resolves inheritance and rotation); the rest comes from +//! the lazy COS reader through a read-only lopdf object adapter. The original lopdf loader +//! remains the compatibility fallback. When neither can inspect a renderable file, panels +//! are empty and `warnings` says why. + +use std::cell::OnceCell; +use std::collections::{BTreeMap, HashMap, HashSet}; +use std::sync::Arc; + +use std::panic::{AssertUnwindSafe, catch_unwind}; + +use hayro::hayro_syntax::DecryptionError; +use hayro::hayro_syntax::LoadPdfError; +use hayro::hayro_syntax::Pdf; +use lopdf::{Dictionary, Document, LoadOptions, Object, ObjectId}; +use pdfcraft_cos::page_labels::{ + MAX_LABEL_BYTES, MAX_LABEL_TOTAL_BYTES, MAX_LABEL_TREE_DEPTH, MAX_LABEL_TREE_WORK, MAX_PREFIX_BYTES, MAX_PREFIX_TOTAL_BYTES, alpha, roman, +}; + +use crate::OpenError; +use crate::structure::{LazyStructure, Structure}; + +/// lopdf decodes object and cross-reference streams while it loads, with no limit unless one is +/// set: a few hundred bytes of nested FlateDecode then inflate to gigabytes. Real object and +/// xref streams are far below this. +const LOAD_STREAM_LIMIT: usize = 256 << 20; + +/// At most this many `/State` entries of a set-layer-visibility action are read. +const MAX_LAYER_STATE: usize = 1024; + +/// File specifications retained from a remote-Go-To action are deliberately small. +const MAX_REMOTE_FILE_SPEC_BYTES: usize = 4096; +const MAX_REMOTE_DESTINATION_NAME_BYTES: usize = 1024; + +/// At most this many layers are read from all of `/RBGroups` together. +const MAX_LAYER_GROUP_ENTRIES: usize = 4096; + +/// `DocInfo::outline` holds the first this many bookmarks in outline order. Opening a document +/// reads only this window; the rest are listed by page through `pdfcraft_organize`. +const OUTLINE_WINDOW: usize = 10_000; + +/// Deepest level `DocInfo::outline` nests (the top level is 0); deeper bookmarks are left out. +const OUTLINE_DEPTH: usize = 32; + +fn load_options(password: Option<&str>) -> LoadOptions { + LoadOptions { password: password.map(str::to_owned), max_decompressed_size: Some(LOAD_STREAM_LIMIT), ..LoadOptions::default() } +} + +#[derive(Clone, Debug, Default)] +pub struct DocInfo { + pub title: Option, + pub author: Option, + pub subject: Option, + pub keywords: Option, + pub creator: Option, + pub producer: Option, + pub pdf_version: String, + pub file_size: usize, + pub encrypted: bool, + pub tagged: bool, + pub has_javascript: bool, + /// The form is (also) an XFA form, which isn't read yet (`form.xfa-*` in the parity list). + pub xfa: Option, + pub pages: Vec, + pub outline: Vec, + /// The outline has bookmarks that `outline` leaves out (see `OUTLINE_WINDOW`, `OUTLINE_DEPTH`). + pub outline_more: bool, + pub annotations: Vec, + pub fields: Vec, + pub links: Vec, + pub layers: Vec, + /// Radio-button layer groups (`/OCProperties /D /RBGroups`): turning one layer of a group on + /// turns the others off. + pub layer_groups: Vec>, + pub fonts: Vec, + pub attachments: Vec, + /// Pages with a presentation transition (`/Trans`) or display time (`/Dur`), in page order. + pub transitions: Vec, + pub warnings: Vec, +} + +/// What kind of XFA form a document has (`/AcroForm /XFA`). +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Xfa { + /// The pages and fields are ordinary PDF; the XFA packets describe the same form (and hold + /// its data, which Acrobat shows in preference to the fields). + Static, + /// The form is laid out from the XFA packets at open (`/NeedsRendering`, or no fields): the + /// PDF pages are only a placeholder. + Dynamic, +} + +/// A page's presentation transition and display time (ISO 32000-2 §12.4.4): how the page comes +/// into view in a full-screen presentation, and how long it stays before advancing. Only pages +/// carrying `/Trans` or `/Dur` are listed. Values outside the spec's ranges fall back to the +/// defaults rather than being refused. +#[derive(Clone, Debug, PartialEq)] +pub struct PageTransition { + pub page: usize, + /// `/Trans /S`; `None` when the page has only `/Dur`. + pub style: Option, + /// `/D`: how long the transition takes, in seconds (default 1). + pub duration: f32, + /// `/Dm /H` (true) or `/V` (Split, Blinds). + pub horizontal: bool, + /// `/M /I` (true) or `/O` (Split, Box, Fly). + pub inward: bool, + /// `/Di`: 0, 90, 180, 270 or 315 degrees (Wipe, Glitter, Fly, Cover, Uncover, Push); `None` + /// for `/None` (Fly only). + pub direction: Option, + /// `/SS`: the Fly scale (default 1). + pub scale: f32, + /// `/B`: Fly with an opaque backdrop. + pub opaque: bool, + /// `/Dur`: seconds the page shows before the presentation advances; `None` waits for the + /// presenter. + pub display: Option, +} + +/// The transition styles of ISO 32000-2 table 164 (`/Trans /S`). +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum TransitionStyle { + Split, + Blinds, + Box, + Wipe, + Dissolve, + Glitter, + /// `/R`: the next page replaces the old one with no effect (the default). + Replace, + Fly, + Push, + Cover, + Uncover, + Fade, +} + +impl TransitionStyle { + /// The style a `/S` name means; an unknown name is `Replace`, the spec's default. + pub fn from_name(name: &str) -> Self { + match name { + "Split" => Self::Split, + "Blinds" => Self::Blinds, + "Box" => Self::Box, + "Wipe" => Self::Wipe, + "Dissolve" => Self::Dissolve, + "Glitter" => Self::Glitter, + "Fly" => Self::Fly, + "Push" => Self::Push, + "Cover" => Self::Cover, + "Uncover" => Self::Uncover, + "Fade" => Self::Fade, + _ => Self::Replace, + } + } + + /// The name as written in a PDF (`/S`). + pub fn name(self) -> &'static str { + match self { + Self::Split => "Split", + Self::Blinds => "Blinds", + Self::Box => "Box", + Self::Wipe => "Wipe", + Self::Dissolve => "Dissolve", + Self::Glitter => "Glitter", + Self::Replace => "R", + Self::Fly => "Fly", + Self::Push => "Push", + Self::Cover => "Cover", + Self::Uncover => "Uncover", + Self::Fade => "Fade", + } + } +} + +#[derive(Clone, Debug)] +pub struct PageInfo { + /// Displayed size in points, after `/Rotate` and `/UserUnit`. + pub width: f32, + pub height: f32, + /// Page label (`/PageLabels`), falling back to the 1-based page number. + pub label: String, + /// Effective crop box in user space [x0, y0, x1, y1] (the visible region). + pub crop: [f32; 4], + /// Clockwise page rotation in degrees (0, 90, 180, 270). + pub rotation: u16, +} + +impl PageInfo { + /// A point in view space (points, y down, after `/Rotate`; what the text layer uses) → + /// PDF user space. + pub fn view_to_user(&self, x: f32, y: f32) -> [f32; 2] { + let [cx0, cy0, cx1, cy1] = self.crop; + let (a, b) = (x / self.width.max(1e-3), y / self.height.max(1e-3)); + let (u, v) = match self.rotation { + 90 => (b, 1.0 - a), + 180 => (1.0 - a, 1.0 - b), + 270 => (1.0 - b, a), + _ => (a, b), + }; + [cx0 + u * (cx1 - cx0), cy1 - v * (cy1 - cy0)] + } + + /// PDF user space → view space (the inverse of [`Self::view_to_user`]). + pub fn user_to_view(&self, x: f32, y: f32) -> [f32; 2] { + let [cx0, cy0, cx1, cy1] = self.crop; + let (u, v) = ((x - cx0) / (cx1 - cx0).max(1e-3), (cy1 - y) / (cy1 - cy0).max(1e-3)); + let (a, b) = match self.rotation { + 90 => (1.0 - v, u), + 180 => (1.0 - u, 1.0 - v), + 270 => (v, 1.0 - u), + _ => (u, v), + }; + [a * self.width, b * self.height] + } + + /// A destination's user-space point (each coordinate possibly unspecified) → the same point + /// as a fraction of the displayed page `[x, y]` (0..1 from its top-left corner, y down), + /// after `/Rotate` and a further clockwise `view_rotation` (0, 90, 180 or 270). On a page + /// turned a quarter, the user `x` decides the displayed `y` and vice versa, so each output + /// is `None` when the input it comes from is (or is not finite). Points off the page are + /// clamped to its edge. + pub fn dest_fraction(&self, x: Option, y: Option, view_rotation: u16) -> [Option; 2] { + let [cx0, cy0, cx1, cy1] = self.crop; + let frac = |v: f32| if v.is_finite() { Some(v.clamp(0.0, 1.0)) } else { None }; + // Unrotated fractions across (u, from the crop's left) and down (v, from its top). + let u = x.and_then(|x| frac((x - cx0) / (cx1 - cx0).max(1e-3))); + let v = y.and_then(|y| frac((cy1 - y) / (cy1 - cy0).max(1e-3))); + let flip = |f: Option| f.map(|f| 1.0 - f); + match (u32::from(self.rotation) + u32::from(view_rotation)) % 360 { + 90 => [flip(v), u], + 180 => [flip(u), flip(v)], + 270 => [v, flip(u)], + _ => [u, v], + } + } + + /// A view-space rectangle → the normalized user-space rectangle covering the same area of the + /// page (`[x0, y0, x1, y1]`, `x0 <= x1`, `y0 <= y1`). Under `/Rotate` the corners swap roles, + /// so a view rectangle's top-left is not in general the user rectangle's `[x0, y1]`. + pub fn view_rect_to_user(&self, r: [f32; 4]) -> [f32; 4] { + let (a, b) = (self.view_to_user(r[0], r[1]), self.view_to_user(r[2], r[3])); + [a[0].min(b[0]), a[1].min(b[1]), a[0].max(b[0]), a[1].max(b[1])] + } + + /// A view-space rectangle as a text-markup quad in user space: top-left, top-right, + /// bottom-left, bottom-right as read on screen. + pub fn view_rect_to_quad(&self, r: [f32; 4]) -> [f64; 8] { + let mut q = [0.0; 8]; + for (i, (x, y)) in [(r[0], r[1]), (r[2], r[1]), (r[0], r[3]), (r[2], r[3])].into_iter().enumerate() { + let [ux, uy] = self.view_to_user(x, y); + q[2 * i] = ux as f64; + q[2 * i + 1] = uy as f64; + } + q + } +} + +/// A clickable link annotation. +#[derive(Clone, Debug)] +pub struct Link { + pub page: usize, + /// Zero-based position in the page's raw `/Annots` array. + pub index: usize, + pub rect: [f32; 4], + pub target: LinkTarget, +} + +#[derive(Clone, Debug, PartialEq)] +pub enum LinkTarget { + /// A destination in this document: the 0-based page and where on it the view goes. + Page(usize, DestView), + Uri(String), + /// A set-layer-visibility action (`SetOCGState`, ISO 32000-2 §12.6.4.13): each change in + /// order, naming the layer by its optional content group. With `preserve_rb`, a layer turned + /// on turns off the other layers of its radio-button groups. + SetLayers { + changes: Vec<(LayerOp, (u32, u16))>, + preserve_rb: bool, + }, + /// A local-file remote Go-To action. The caller resolves `file` relative to the source path. + RemoteGoTo { + file: String, + destination: RemoteDestination, + new_window: Option, + }, + Other(String), +} + +#[derive(Clone, Debug, PartialEq)] +pub enum RemoteDestination { + Default, + Page { page: usize, view: DestView }, + Named(String), +} + +/// What a set-layer-visibility action does to a layer. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum LayerOp { + On, + Off, + Toggle, +} + +/// Where an explicit destination (ISO 32000-2 §12.3.2.2, Table 149) places its page in the +/// window. Coordinates are PDF user space; `None` is a `null` (or unusable) operand, which keeps +/// that part of the view as it is. +#[derive(Clone, Copy, Debug, Default, PartialEq)] +pub enum DestView { + /// The top of the page at the current zoom: a destination with no position, or one whose + /// type is missing or unknown. + #[default] + Top, + /// `/XYZ left top zoom`: the point (left, top) at the window's upper-left corner, at `zoom` + /// (1.0 = 100%; 0 or null keeps the current zoom). + Xyz { left: Option, top: Option, zoom: Option }, + /// `/Fit`, and `/FitB` (fit the page's content box; read as the whole page). + Fit, + /// `/FitH top`, and `/FitBH`: fit the width, with `top` at the window's top edge. + FitH { top: Option }, + /// `/FitV left`, and `/FitBV`: fit the height, with `left` at the window's left edge. + FitV { left: Option }, + /// `/FitR left bottom right top`: fit the rectangle `[x0, y0, x1, y1]` (normalized). + FitR { rect: [f32; 4] }, +} + +impl DestView { + /// The view an explicit destination array asks for, from its elements after the page + /// (`/XYZ left top zoom`, `/FitH top`, …). Hostile input never fails: a missing or unknown + /// type, or a `/FitR` without four usable numbers, is [`DestView::Top`]; a non-numeric, + /// non-finite or missing operand is `None`; a zoom that is not positive is `None`. + pub fn from_operands(kind: Option<&[u8]>, operands: &[Option]) -> Self { + let n = |i: usize| operands.get(i).copied().flatten().filter(|v| v.is_finite()); + match kind { + Some(b"XYZ") => Self::Xyz { left: n(0), top: n(1), zoom: n(2).filter(|z| *z > 0.0) }, + Some(b"Fit" | b"FitB") => Self::Fit, + Some(b"FitH" | b"FitBH") => Self::FitH { top: n(0) }, + Some(b"FitV" | b"FitBV") => Self::FitV { left: n(0) }, + Some(b"FitR") => match (n(0), n(1), n(2), n(3)) { + (Some(x0), Some(y0), Some(x1), Some(y1)) => Self::FitR { rect: [x0.min(x1), y0.min(y1), x0.max(x1), y0.max(y1)] }, + _ => Self::Top, + }, + _ => Self::Top, + } + } +} + +#[derive(Clone, Debug)] +pub struct OutlineItem { + pub title: String, + pub page: Option, + /// Where on `page` the bookmark goes ([`DestView::Top`] for a bookmark without a page). + pub view: DestView, + pub children: Vec, + pub open: bool, +} + +#[derive(Clone, Debug)] +pub struct Annotation { + pub page: usize, + pub subtype: String, + pub author: Option, + pub contents: Option, + pub modified: Option, + /// `/NM` of this annotation, and of the one it replies to (`/IRT`), for threading. + pub name: Option, + pub in_reply_to: Option, + /// Rect in PDF user space: [x0, y0, x1, y1]. + pub rect: [f32; 4], + pub color: Option<[f32; 3]>, + /// Position in the page's `/Annots` (how edits address the comment). + pub index: usize, + /// For a status reply (`/State`, Acrobat's "Set status"): the state it sets. + pub state: Option, + /// Text markup quadrilaterals (`/QuadPoints`), 8 numbers each. + pub quads: Vec<[f32; 8]>, + /// The Locked flag (`/F` bit 8): the comment can't be moved, resized, restyled or deleted. + pub locked: bool, + /// `/IT`, the intent: `FreeTextCallout`, `PolygonCloud`, `FreeTextTypeWriter`… + pub intent: Option, + /// A Fill & Sign mark: typed text, a check, cross, dot or line, or a typed, drawn or picture + /// signature or initials (the rules of `pdfcraft_annot::is_fill_sign`). + pub fill_sign: bool, +} + +impl Annotation { + /// A checkmark reply (`/StateModel /Marked`): "Marked" or "Unmarked" rather than a review status. + pub fn is_mark(&self) -> bool { + matches!(self.state.as_deref(), Some("Marked" | "Unmarked")) + } +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum FieldKind { + Text, + CheckBox, + Radio, + PushButton, + Combo, + List, + Signature, + Unknown, +} + +#[derive(Clone, Debug)] +pub struct Field { + pub name: String, + pub kind: FieldKind, + pub value: Option, + pub page: Option, + pub tooltip: Option, + pub has_actions: bool, + /// Rect of the first widget, in user space. + pub rect: Option<[f32; 4]>, +} + +#[derive(Clone, Debug)] +pub struct Layer { + /// Object number and generation of the optional content group. + pub id: (u32, u16), + pub name: String, + pub visible: bool, +} + +#[derive(Clone, Debug)] +pub struct Attachment { + pub name: String, + pub description: Option, + pub size: Option, + pub source: AttachmentSource, +} + +/// Where an attachment lives (so its bytes can be fetched on demand). +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum AttachmentSource { + /// Document-level: `/Names /EmbeddedFiles` entry with this key. + Document { key: String }, + /// A FileAttachment annotation: page index and position in the page's `/Annots`. + Annotation { page: usize, index: usize }, +} + +/// A font used by the document (Document Properties ▸ Fonts). +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct FontInfo { + /// BaseFont without the subset tag. + pub name: String, + /// Type1, TrueType, Type0, Type3, MMType1, CIDFontType0/2 … + pub kind: String, + pub embedded: bool, + pub subset: bool, + pub encoding: Option, + /// The face standing in for a program the document does not embed. `None` when the font is + /// embedded (nothing stands in) or when no face was available at all -- `substitution` then + /// says which of the two it is. + pub substitute: Option, + /// Why a face was substituted, as a sentence for whoever has to act on it. `None` only when + /// the document's own glyphs are being shown. + pub substitution: Option, +} + +/// Check that `bytes` still open as the same document: the parser reads them and the page +/// count matches. Content-only edits skip the full [`inspect`] (seconds on very large files) +/// but must still catch an edit that produced something the viewer cannot display, so the +/// caller can roll it back. Never panics: parser crashes are caught and reported. +pub fn check_pages(bytes: Arc>, password: Option<&str>, expected: usize) -> Result<(), OpenError> { + let pdf = catch_unwind(AssertUnwindSafe(|| Pdf::new_with_password(bytes, password.unwrap_or("")))) + .map_err(|p| OpenError::Invalid(format!("the parser crashed: {}", crate::raster::panic_message(&p))))? + .map_err(|e| match e { + LoadPdfError::Decryption(DecryptionError::PasswordProtected) => { + if password.is_some() { + OpenError::WrongPassword + } else { + OpenError::NeedsPassword + } + } + LoadPdfError::Decryption(other) => OpenError::Unsupported(format!("encryption not supported: {other:?}")), + LoadPdfError::Invalid => OpenError::Invalid("no readable page tree or cross-reference data was found".into()), + })?; + let pages = catch_unwind(AssertUnwindSafe(|| pdf.pages().len())) + .map_err(|p| OpenError::Invalid(format!("the page tree could not be read: {}", crate::raster::panic_message(&p))))?; + if pages != expected { + return Err(OpenError::Invalid(format!("the edit changed the page count from {expected} to {pages}"))); + } + Ok(()) +} + +/// Inspect a document. Fails only if the renderer itself cannot open the file (or needs a +/// password). Never panics: parser crashes are caught and reported. +pub fn inspect(bytes: Arc>, password: Option<&str>) -> Result { + let pdf = catch_unwind(AssertUnwindSafe(|| Pdf::new_with_password(bytes.clone(), password.unwrap_or("")))) + .map_err(|p| OpenError::Invalid(format!("the parser crashed: {}", crate::raster::panic_message(&p))))? + .map_err(|e| match e { + LoadPdfError::Decryption(DecryptionError::PasswordProtected) => { + if password.is_some() { + OpenError::WrongPassword + } else { + OpenError::NeedsPassword + } + } + LoadPdfError::Decryption(other) => OpenError::Unsupported(format!("encryption not supported: {other:?}")), + LoadPdfError::Invalid => OpenError::Invalid("no readable page tree or cross-reference data was found".into()), + })?; + let mut info = DocInfo { file_size: bytes.len(), pdf_version: version_label(&format!("{:?}", pdf.version())), ..Default::default() }; + let pages = catch_unwind(AssertUnwindSafe(|| { + let mut out = Vec::new(); + for (i, page) in pdf.pages().iter().enumerate() { + let (w, h) = page.render_dimensions(); + let c = page.intersected_crop_box(); + let rotation = match page.rotation() { + hayro::hayro_syntax::page::Rotation::None => 0, + hayro::hayro_syntax::page::Rotation::Horizontal => 90, + hayro::hayro_syntax::page::Rotation::Flipped => 180, + hayro::hayro_syntax::page::Rotation::FlippedHorizontal => 270, + }; + let crop = [c.x0 as f32, c.y0 as f32, c.x1 as f32, c.y1 as f32]; + // Degenerate boxes still get a usable placeholder size so layout never divides by zero. + let (w, h) = if w.is_finite() && h.is_finite() && w >= 1.0 && h >= 1.0 { (w, h) } else { (612.0, 792.0) }; + out.push(PageInfo { width: w, height: h, label: (i + 1).to_string(), crop, rotation }); + } + out + })); + match pages { + Ok(p) => info.pages = p, + Err(p) => return Err(OpenError::Invalid(format!("the page tree could not be read: {}", crate::raster::panic_message(&p)))), + } + let options = load_options(password); + // Geometry no longer needs the renderer parser. Do not overlap its allocations with + // structural inspection, including the compatibility fallback for damaged files. + drop(pdf); + inspect_structure(&mut info, |tmp| { + // The lazy reader first; lopdf's repair/tolerance path when it can't expose the same + // page tree or an object a panel needs. No partial result is published. + let lazy = catch_unwind(AssertUnwindSafe(|| { + let doc = LazyStructure::new(bytes.clone(), password).ok()?; + let mut t = DocInfo { pages: tmp.pages.clone(), ..Default::default() }; + let inspector = Inspector::new(&doc); + if inspector.page_index.len() != t.pages.len() { + return None; + } + inspector.fill(&mut t); + (!doc.failed()).then_some(t) + })) + .ok() + .flatten(); + if let Some(t) = lazy { + *tmp = t; + return Ok(()); + } + let doc = Document::load_mem_with_options(&bytes, options).map_err(|e| e.to_string())?; + Inspector::new(&doc).fill(tmp); + Ok(()) + }); + if info.pages.is_empty() { + return Err(OpenError::Invalid("the document has no pages".into())); + } + Ok(info) +} + +// Commit auxiliary metadata only after inspection succeeds; renderer geometry is the fallback. +fn inspect_structure(info: &mut DocInfo, fill: impl FnOnce(&mut DocInfo) -> Result<(), String>) { + let structure = catch_unwind(AssertUnwindSafe(|| { + let mut tmp = DocInfo { pages: info.pages.clone(), ..Default::default() }; + fill(&mut tmp)?; + Ok::<_, String>(tmp) + })); + match structure { + Ok(Ok(mut filled)) => { + filled.file_size = info.file_size; + filled.pdf_version = std::mem::take(&mut info.pdf_version); + *info = filled; + } + Ok(Err(e)) => info.warnings.push(format!("Some document structure (bookmarks, comments, fields) could not be read: {e}")), + Err(p) => { + info.warnings.push(format!("Document structure inspection crashed and was skipped: {}", crate::raster::panic_message(&p))); + } + } +} + +struct Inspector<'a> { + doc: &'a dyn Structure, + page_ids: Vec, + page_index: HashMap, + /// Named destinations (`/Names /Dests` tree), keyed by raw string bytes, built once. + /// Looking each name up by walking the tree was O(links × names): minutes on manuals. + named: HashMap, &'a Object>, + /// The page of each annotation (the first page that lists it), built when a field's widget + /// has no `/P`. Searching every page's `/Annots` for each such field was O(fields × + /// annotations): a second on a form of 8,000 fields. + annotation_pages: OnceCell>, +} + +impl<'a> Inspector<'a> { + fn new(doc: &'a dyn Structure) -> Self { + let page_ids = doc.page_ids(); + let page_index = page_ids.iter().copied().enumerate().map(|(index, id)| (id, index)).collect(); + let mut me = Self { doc, page_ids, page_index, named: HashMap::new(), annotation_pages: OnceCell::new() }; + let mut named = HashMap::new(); + if let Some(tree) = + doc.catalog().ok().and_then(|c| c.get(b"Names").ok()).and_then(|o| me.dict(o)).and_then(|n| n.get(b"Dests").ok()).and_then(|o| me.dict(o)) + { + me.name_tree_raw(tree, &mut HashSet::new(), 0, &mut named); + } + me.named = named; + me + } + + fn fill(&self, info: &mut DocInfo) { + // lopdf drops /Encrypt from the trailer once it has decrypted the file. + info.encrypted = self.doc.encrypted(); + if let Some(d) = self.doc.trailer().get(b"Info").ok().and_then(|o| self.dict(o)) { + info.title = self.text(d, b"Title"); + info.author = self.text(d, b"Author"); + info.subject = self.text(d, b"Subject"); + info.keywords = self.text(d, b"Keywords"); + info.creator = self.text(d, b"Creator"); + info.producer = self.text(d, b"Producer"); + } + let Ok(catalog) = self.doc.catalog() else { return }; + info.tagged = catalog + .get(b"MarkInfo") + .ok() + .and_then(|o| self.dict(o)) + .and_then(|m| m.get(b"Marked").ok()) + .and_then(|o| o.as_bool().ok()) + .unwrap_or(false); + match self.page_labels(catalog, &mut info.pages) { + Ok(notes) => info.warnings.extend(notes.into_iter().map(|note| format!("Page labels: {note}."))), + Err(reason) => info.warnings.push(format!("Page labels were skipped: {reason}; using physical page numbers.")), + } + if let Some(first) = catalog.get(b"Outlines").ok().and_then(|o| self.dict(o)).and_then(|d| d.get(b"First").ok()) { + (info.outline, info.outline_more) = self.outline_window(first); + } + self.annotations(info); + self.transitions(info); + let mut field_seen = HashSet::new(); + if let Some(form) = catalog.get(b"AcroForm").ok().and_then(|o| self.dict(o)) + && let Ok(fields) = form.get(b"Fields").and_then(|o| self.resolve(o).as_array()) + { + for f in fields { + self.field(f, None, &mut info.fields, &mut field_seen, 0); + } + } + // Widgets no `/Fields` entry reaches are fields too (Acrobat and the browsers fill them); + // each is adopted through its topmost unlisted `/Parent`, so a split field stays one. + for &pid in &self.page_ids { + let Ok(page_dict) = self.doc.get_dictionary(pid) else { continue }; + let Ok(Object::Array(annots)) = page_dict.get(b"Annots").map(|o| self.resolve(o)) else { continue }; + for a in annots { + let Some(d) = self.dict(a) else { continue }; + if self.name(d, b"Subtype").as_deref() != Some("Widget") + || (d.get(b"FT").is_err() && d.get(b"T").is_err() && d.get(b"Parent").is_err()) + { + continue; + } + if let Object::Reference(id) = a + && let Some(root) = self.unlisted_field(*id, &field_seen) + { + self.field(&Object::Reference(root), None, &mut info.fields, &mut field_seen, 0); + } + } + } + info.has_javascript |= info.fields.iter().any(|f| f.has_actions); + let needs_rendering = catalog.get(b"NeedsRendering").ok().and_then(|o| self.resolve(o).as_bool().ok()).unwrap_or(false); + if catalog.get(b"AcroForm").ok().and_then(|o| self.dict(o)).is_some_and(|form| form.get(b"XFA").is_ok()) { + info.xfa = Some(if needs_rendering || info.fields.is_empty() { Xfa::Dynamic } else { Xfa::Static }); + } + self.layers(catalog, &mut info.layers); + info.layer_groups = self.layer_groups(catalog); + self.fonts(&mut info.fonts); + if let Some(names) = catalog.get(b"Names").ok().and_then(|o| self.dict(o)) { + if let Some(ef) = names.get(b"EmbeddedFiles").ok().and_then(|o| self.dict(o)) { + let mut seen = HashSet::new(); + for (name, spec) in self.name_tree(ef, &mut seen, 0) { + info.attachments.push(self.attachment(name.clone(), spec, AttachmentSource::Document { key: name })); + } + } + info.has_javascript |= names.get(b"JavaScript").is_ok(); + } + if let Ok(action) = catalog.get(b"OpenAction") + && let Some(dict) = self.dict(action) + && dict.get(b"S").ok().and_then(|o| self.resolve(o).as_name().ok()) == Some(&b"JavaScript"[..]) + { + info.has_javascript = true; + } + } + + // ── object helpers ────────────────────────────────────────────────────────────────────── + + /// `/Trans` and `/Dur` of every page (ISO 32000-2 §12.4.4), in page order. Lenient: a value + /// outside the spec's choices takes that entry's default, and a page with neither entry is + /// not listed. Bounded by the page count. + fn transitions(&self, info: &mut DocInfo) { + for (page, &pid) in self.page_ids.iter().enumerate() { + let Ok(d) = self.doc.get_dictionary(pid) else { continue }; + let display = d.get(b"Dur").ok().and_then(|o| self.resolve(o).as_float().ok()).filter(|s| s.is_finite() && *s >= 0.0); + let trans = d.get(b"Trans").ok().and_then(|o| self.dict(o)); + if trans.is_none() && display.is_none() { + continue; + } + let mut t = PageTransition { + page, + style: None, + duration: 1.0, + horizontal: true, + inward: true, + direction: Some(0), + scale: 1.0, + opaque: false, + display, + }; + if let Some(tr) = trans { + t.style = Some(self.name(tr, b"S").map_or(TransitionStyle::Replace, |n| TransitionStyle::from_name(&n))); + let num = |key: &[u8]| tr.get(key).ok().and_then(|o| self.resolve(o).as_float().ok()).filter(|v| v.is_finite()); + t.duration = num(b"D").filter(|d| *d >= 0.0).unwrap_or(1.0); + t.horizontal = self.name(tr, b"Dm").as_deref() != Some("V"); + t.inward = self.name(tr, b"M").as_deref() != Some("O"); + t.direction = match tr.get(b"Di").map(|o| self.resolve(o)) { + Ok(Object::Name(n)) if n.as_slice() == b"None" => None, + Ok(o) => match o.as_i64().ok() { + Some(0) => Some(0), + Some(90) => Some(90), + Some(180) => Some(180), + Some(270) => Some(270), + Some(315) => Some(315), + _ => Some(0), + }, + Err(_) => Some(0), + }; + t.scale = num(b"SS").filter(|s| *s > 0.0).unwrap_or(1.0); + t.opaque = matches!(tr.get(b"B").map(|o| self.resolve(o)), Ok(Object::Boolean(true))); + } + info.transitions.push(t); + } + } + + fn resolve(&self, o: &'a Object) -> &'a Object { + self.doc.dereference(o).map(|(_, o)| o).unwrap_or(o) + } + + fn dict(&self, o: &'a Object) -> Option<&'a Dictionary> { + match self.resolve(o) { + Object::Dictionary(d) => Some(d), + Object::Stream(s) => Some(&s.dict), + _ => None, + } + } + + fn text(&self, d: &Dictionary, key: &[u8]) -> Option { + let o = self.resolve(d.get(key).ok()?); + let s = match o { + Object::String(bytes, _) => text_string(bytes), + Object::Name(n) => String::from_utf8_lossy(n).into_owned(), + _ => return None, + }; + let s = s.trim_matches('\0').trim().to_string(); + (!s.is_empty()).then_some(s) + } + + fn name(&self, d: &Dictionary, key: &[u8]) -> Option { + self.resolve(d.get(key).ok()?).as_name().ok().map(|n| String::from_utf8_lossy(n).into_owned()) + } + + /// Whether an annotation is a Fill & Sign mark. Keep in step with + /// `pdfcraft_annot::is_fill_sign`, which the engine's comment list uses. + fn is_fill_sign(&self, d: &Dictionary, subtype: &str) -> bool { + let yes = |key: &[u8]| matches!(d.get(key).map(|o| self.resolve(o)), Ok(Object::Boolean(true))); + if yes(b"PCFillSign") { + return true; + } + match subtype { + "FreeText" => self.name(d, b"IT").as_deref() == Some("FreeTextTypeWriter"), + "Stamp" => match self.name(d, b"Name").as_deref() { + Some("PCCheck" | "PCCross" | "PCDot" | "PCLine" | "PCTypedSignature") => true, + Some("PCCustomSignature" | "PCCustomInitials") => yes(b"PCPictureImage"), + _ => false, + }, + "Ink" => self.text(d, b"Subj").as_deref() == Some("Signature"), + _ => false, + } + } + + fn page_of(&self, o: &Object) -> Option { + match o { + Object::Reference(id) => self.page_index.get(id).copied(), + // Some producers write page *numbers* in remote-style destinations. + Object::Integer(n) => usize::try_from(*n).ok(), + _ => None, + } + } + + // ── destinations ──────────────────────────────────────────────────────────────────────── + + /// The page and view of a destination: an explicit array (`[page /XYZ left top zoom]`, …), + /// a dictionary with `/D`, or a named destination that resolves to one. + fn dest(&self, dest: &Object, depth: u32) -> Option<(usize, DestView)> { + if depth > 8 { + return None; + } + match self.resolve(dest) { + Object::Array(a) => { + let page = a.first().and_then(|p| self.page_of(p))?; + let kind = a.get(1).map(|k| self.resolve(k)).and_then(|k| k.as_name().ok()); + let operands: Vec> = a.iter().skip(2).take(4).map(|o| self.resolve(o).as_float().ok()).collect(); + Some((page, DestView::from_operands(kind, &operands))) + } + Object::Dictionary(d) => d.get(b"D").ok().and_then(|d| self.dest(d, depth + 1)), + Object::String(key, _) | Object::Name(key) => self.named_dest(key).and_then(|d| self.dest(d, depth + 1)), + _ => None, + } + } + + fn named_dest(&self, key: &[u8]) -> Option<&'a Object> { + let catalog = self.doc.catalog().ok()?; + if let Some(v) = self.named.get(key) { + return Some(v); + } + // PDF 1.1 style /Dests dictionary. + catalog.get(b"Dests").ok().and_then(|o| self.dict(o)).and_then(|d| d.get(key).ok()) + } + + /// A name tree's leaves keyed by their raw key bytes (first occurrence wins). + fn name_tree_raw(&self, node: &'a Dictionary, seen: &mut HashSet<*const Dictionary>, depth: u32, out: &mut HashMap, &'a Object>) { + if depth > 32 || !seen.insert(node as *const _) { + return; + } + if let Ok(Object::Array(pairs)) = node.get(b"Names").map(|o| self.resolve(o)) { + for pair in pairs.chunks(2) { + if let [k, v] = pair { + let key = match self.resolve(k) { + Object::String(s, _) => s.clone(), + Object::Name(n) => n.clone(), + _ => continue, + }; + out.entry(key).or_insert(v); + } + } + } + if let Ok(Object::Array(kids)) = node.get(b"Kids").map(|o| self.resolve(o)) { + for k in kids { + if let Some(d) = self.dict(k) { + self.name_tree_raw(d, seen, depth + 1, out); + } + } + } + } + + fn name_tree(&self, node: &'a Dictionary, seen: &mut HashSet<*const Dictionary>, depth: u32) -> Vec<(String, &'a Object)> { + let mut out = Vec::new(); + if depth > 32 || !seen.insert(node as *const _) { + return out; + } + if let Ok(Object::Array(pairs)) = node.get(b"Names").map(|o| self.resolve(o)) { + for pair in pairs.chunks(2) { + if let [k, v] = pair { + let key = match self.resolve(k) { + Object::String(bytes, _) => text_string(bytes), + _ => String::new(), + }; + out.push((key, v)); + } + } + } + if let Ok(Object::Array(kids)) = node.get(b"Kids").map(|o| self.resolve(o)) { + for k in kids { + if let Some(d) = self.dict(k) { + out.extend(self.name_tree(d, seen, depth + 1)); + } + } + } + out + } + + // ── outline ───────────────────────────────────────────────────────────────────────────── + + /// The window `DocInfo::outline` keeps: the first `OUTLINE_WINDOW` bookmarks in outline order, + /// nested no deeper than `OUTLINE_DEPTH`, and whether any bookmark was left out. Iterative, and + /// each bookmark is read once, so the work and memory stay within the window. + fn outline_window(&self, first: &'a Object) -> (Vec, bool) { + let mut seen = HashSet::new(); + // The listed bookmarks in outline order, with their depth. + let mut listed: Vec<(usize, OutlineItem)> = Vec::new(); + // One entry per open level: its next sibling, or `None` once that run of siblings has ended. + let mut levels: Vec> = vec![Some(first)]; + let mut more = false; + while let Some(depth) = levels.len().checked_sub(1) { + let Some(slot) = levels.get_mut(depth) else { break }; + let Some(o) = slot.take() else { + levels.pop(); + continue; + }; + if let Object::Reference(id) = o + && !seen.insert(*id) + { + continue; // cycle: this run of siblings ends here + } + let Some(d) = self.dict(o) else { continue }; + if listed.len() >= OUTLINE_WINDOW { + more = true; + break; + } + *slot = d.get(b"Next").ok(); + let dest = d + .get(b"Dest") + .ok() + .and_then(|dest| self.dest(dest, 0)) + .or_else(|| d.get(b"A").ok().and_then(|a| self.dict(a)).and_then(|a| a.get(b"D").ok()).and_then(|dest| self.dest(dest, 0))); + let (page, view) = dest.map_or((None, DestView::Top), |(p, v)| (Some(p), v)); + match d.get(b"First").ok() { + Some(child) if depth < OUTLINE_DEPTH => levels.push(Some(child)), + Some(_) => more = true, + None => {} + } + let open = d.get(b"Count").ok().and_then(|c| c.as_i64().ok()).is_some_and(|c| c > 0); + let title = self.text(d, b"Title").unwrap_or_default(); + listed.push((depth, OutlineItem { title, page, view, children: Vec::new(), open })); + } + (nest_outline(listed), more) + } + + // ── page labels (ISO 32000-2 §12.4.2) ─────────────────────────────────────────────────── + + fn label_object(&self, object: &'a Object) -> Result<&'a Object, &'static str> { + self.doc.dereference(object).map(|(_, o)| o).map_err(|_| "a number tree reference is missing or cyclic") + } + + fn label_dict(&self, object: &'a Object) -> Result, &'static str> { + Ok(match self.label_object(object)? { + Object::Dictionary(d) => Some(d), + Object::Stream(s) => Some(&s.dict), + _ => None, + }) + } + + /// Apply `/PageLabels` to `pages`. `Err`: the tree is unusable and no label is applied. + /// `Ok(notes)`: labels applied, with what was repaired on the way (a duplicated start page, + /// a range whose labels are too long to show). + fn page_labels(&self, catalog: &Dictionary, pages: &mut [PageInfo]) -> Result, &'static str> { + let mut notes = Vec::new(); + let Ok(tree) = catalog.get(b"PageLabels") else { return Ok(notes) }; + let Some(tree) = self.label_dict(tree)? else { return Ok(notes) }; + let mut ranges: BTreeMap = BTreeMap::new(); + let mut stack = vec![(tree, 0usize)]; + let mut seen = HashSet::new(); + let mut left = MAX_LABEL_TREE_WORK; + while let Some((node, depth)) = stack.pop() { + label_work(&mut left, 1)?; + if depth > MAX_LABEL_TREE_DEPTH { + return Err("the number tree is too deep"); + } + if !seen.insert(node as *const Dictionary) { + return Err("the number tree repeats a node or contains a cycle"); + } + if let Ok(nums) = node.get(b"Nums") + && let Object::Array(nums) = self.label_object(nums)? + { + label_work(&mut left, nums.len().div_ceil(2))?; + for pair in nums.chunks(2) { + if let [k, v] = pair + && let (Ok(start), Some(d)) = (self.label_object(k)?.as_i64(), self.label_dict(v)?) + && let Ok(start) = usize::try_from(start) + && ranges.insert(start, d).is_some() + { + // The later range wins, as in readers that overwrite. + notes.push(format!("a range starts on page {} twice; the later one is used", start.saturating_add(1))); + } + } + } + if let Ok(kids) = node.get(b"Kids") + && let Object::Array(kids) = self.label_object(kids)? + { + label_work(&mut left, kids.len())?; + if kids.len() > MAX_LABEL_TREE_WORK.saturating_sub(stack.len()) { + return Err("the number tree has too many pending nodes"); + } + if !kids.is_empty() && depth == MAX_LABEL_TREE_DEPTH { + return Err("the number tree is too deep"); + } + for kid in kids { + if let Some(d) = self.label_dict(kid)? { + stack.push((d, depth + 1)); + } + } + } + } + // Decode each bounded prefix once, rather than once for every page in its range. + let mut specs = BTreeMap::new(); + let mut prefix_total = 0usize; + for (start, d) in ranges { + let first = d.get(b"St").ok().and_then(|o| self.resolve(o).as_i64().ok()).unwrap_or(1).max(1) as u64; + let prefix = match d.get(b"P").ok().map(|o| self.resolve(o)) { + Some(Object::String(raw, _)) => { + if raw.len() > MAX_PREFIX_BYTES { + return Err("a raw prefix exceeds 2050 bytes"); + } + // A label prefix is literal text, not trimmed document metadata. + text_string(raw).trim_matches('\0').to_string() + } + Some(Object::Name(raw)) => { + if raw.len() > MAX_PREFIX_BYTES { + return Err("a raw prefix exceeds 2050 bytes"); + } + // Retain the existing name compatibility and boundary-NUL handling. + String::from_utf8_lossy(raw).trim_matches('\0').to_string() + } + _ => String::new(), + }; + if prefix.len() > MAX_LABEL_BYTES { + return Err("a decoded prefix exceeds 1024 UTF-8 bytes"); + } + // A prefix object shared by many ranges is decoded once per range: count every copy. + prefix_total = prefix_total.saturating_add(prefix.len()); + if prefix_total > MAX_PREFIX_TOTAL_BYTES { + return Err("decoded prefixes exceed 4 MiB in total"); + } + let style = d.get(b"S").ok().and_then(|o| self.resolve(o).as_name().ok()); + specs.insert(start, (first, prefix, style)); + } + let mut staged = Vec::new(); + let mut total = 0usize; + for (i, _) in pages.iter().enumerate() { + let Some((&start, (first, prefix, style))) = specs.range(..=i).next_back() else { continue }; + let n = if matches!(*style, Some(b"D" | b"R" | b"r" | b"A" | b"a")) { + let offset = u64::try_from(i - start).map_err(|_| "a page number is too large")?; + first.checked_add(offset).ok_or("a page number overflows")? + } else { + 0 // /St is unused when the label consists only of its prefix. + }; + let remaining = MAX_LABEL_BYTES - prefix.len(); + let number = match *style { + Some(b"D") => Some(n.to_string()), + Some(b"R" | b"r") => roman(n, remaining), + Some(b"A" | b"a") => alpha(n, remaining), + _ => Some(String::new()), + } + .filter(|number| number.len() <= remaining); + // A label too long to show keeps that page's physical number; other ranges stay. + let Some(mut number) = number else { + let note = format!( + "labels from page {} exceed {MAX_LABEL_BYTES} UTF-8 bytes; those pages show their physical page numbers", + start.saturating_add(1) + ); + if !notes.contains(¬e) { + notes.push(note); + } + continue; + }; + if matches!(*style, Some(b"R" | b"A")) { + number.make_ascii_uppercase(); + } + let mut label = prefix.clone(); + label.push_str(&number); + if label.is_empty() { + continue; // Keep the existing physical number for an empty custom label. + } + total = total.checked_add(label.len()).ok_or("the total label size overflows")?; + if total > MAX_LABEL_TOTAL_BYTES { + return Err("custom labels exceed 4 MiB in total"); + } + staged.push((i, label)); + } + // A malformed later range must not leave a partially applied custom label sequence. + for (i, label) in staged { + if let Some(page) = pages.get_mut(i) { + page.label = label; + } + } + Ok(notes) + } + + // ── annotations ───────────────────────────────────────────────────────────────────────── + + fn annotations(&self, info: &mut DocInfo) { + for (&id, &page) in &self.page_index { + let Ok(page_dict) = self.doc.get_dictionary(id) else { continue }; + let Ok(Object::Array(annots)) = page_dict.get(b"Annots").map(|o| self.resolve(o)) else { continue }; + for (index, a) in annots.iter().enumerate() { + let Some(d) = self.dict(a) else { continue }; + let Some(subtype) = self.name(d, b"Subtype") else { continue }; + if subtype == "FileAttachment" + && let Ok(fs) = d.get(b"FS") + { + let name = self.text(d, b"Contents").unwrap_or_else(|| "attachment".into()); + info.attachments.push(self.attachment(name, fs, AttachmentSource::Annotation { page, index })); + } + if subtype == "Link" { + if let Some(link) = self.link(page, index, d) { + if matches!(&link.target, LinkTarget::Other(s) if s == "JavaScript") { + info.has_javascript = true; + } + info.links.push(link); + } + continue; + } + // Not comments: form widgets, pop-ups and non-markup annotations such as the + // Screen annotation that drives a LaTeX `animate` player (keep in step with + // `pdfcraft_annot::is_comment_subtype`). + if matches!(subtype.as_str(), "Widget" | "Popup" | "Screen" | "Movie" | "RichMedia" | "3D" | "PrinterMark" | "TrapNet" | "Watermark") + { + continue; + } + let rect = rect4(self.resolve(d.get(b"Rect").unwrap_or(&Object::Null))); + let mut color = match d.get(b"C").map(|o| self.resolve(o)) { + Ok(Object::Array(c)) if c.len() == 3 => { + let f = |i: usize| c[i].as_float().unwrap_or(0.0); + Some([f(0), f(1), f(2)]) + } + _ => None, + }; + // A text box's /C is its background; its text colour is in /DA. + if subtype == "FreeText" + && let Some(da) = self.text(d, b"DA") + { + let t: Vec<&str> = da.split_whitespace().collect(); + if let Some(i) = t.iter().position(|x| *x == "rg") + && i >= 3 + { + let f = |k: usize| t[k].parse::().unwrap_or(0.0); + color = Some([f(i - 3), f(i - 2), f(i - 1)]); + } + } + let quads = match d.get(b"QuadPoints").map(|o| self.resolve(o)) { + Ok(Object::Array(q)) => { + let v: Vec = q.iter().map(|o| o.as_float().unwrap_or(0.0)).collect(); + v.as_chunks::<8>().0.to_vec() + } + _ => Vec::new(), + }; + let in_reply_to = d.get(b"IRT").ok().and_then(|o| self.dict(o)).and_then(|p| self.text(p, b"NM")); + let fill_sign = self.is_fill_sign(d, &subtype); + info.annotations.push(Annotation { + page, + subtype, + author: self.text(d, b"T"), + // Acrobat ends lines with CR, which the interface would draw as nothing (keep + // in step with `pdfcraft_annot::summaries`). + contents: self.text(d, b"Contents").map(|s| s.replace("\r\n", "\n").replace('\r', "\n")), + modified: self.text(d, b"M").map(|m| pretty_date(&m)), + name: self.text(d, b"NM"), + in_reply_to, + rect, + color, + index, + state: self.text(d, b"State"), + quads, + locked: d.get(b"F").ok().and_then(|f| self.resolve(f).as_i64().ok()).unwrap_or(0) & 128 != 0, + intent: self.name(d, b"IT"), + fill_sign, + }); + } + } + info.annotations.sort_by(|a, b| a.page.cmp(&b.page).then(b.rect[3].total_cmp(&a.rect[3]))); + } + + fn link(&self, page: usize, index: usize, d: &Dictionary) -> Option { + let rect = rect4(self.resolve(d.get(b"Rect").ok()?)); + let target = if let Ok(dest) = d.get(b"Dest") { + let (page, view) = self.dest(dest, 0)?; + LinkTarget::Page(page, view) + } else { + let a = d.get(b"A").ok().and_then(|a| self.dict(a))?; + match self.name(a, b"S").as_deref() { + Some("GoTo") => { + let (page, view) = self.dest(a.get(b"D").ok()?, 0)?; + LinkTarget::Page(page, view) + } + Some("URI") => { + LinkTarget::Uri(a.get(b"URI").ok().and_then(|u| self.resolve(u).as_str().ok()).map(|b| String::from_utf8_lossy(b).into_owned())?) + } + Some("SetOCGState") => self.layer_state(a), + Some("GoToR") => self.remote_go_to(a).unwrap_or_else(|| LinkTarget::Other("GoToR".into())), + Some(other) => LinkTarget::Other(other.to_string()), + None => return None, + } + }; + Some(Link { page, index, rect, target }) + } + + fn remote_go_to(&self, action: &Dictionary) -> Option { + if self.name(action, b"FS").as_deref() == Some("URL") { + return None; + } + let file = match self.resolve(action.get(b"F").ok()?) { + Object::String(bytes, _) => limited_pdf_string(bytes, MAX_REMOTE_FILE_SPEC_BYTES)?, + Object::Dictionary(spec) => { + if self.name(spec, b"FS").as_deref() == Some("URL") { + return None; + } + spec.get(b"UF") + .ok() + .and_then(|o| self.pdf_text(o, MAX_REMOTE_FILE_SPEC_BYTES)) + .or_else(|| spec.get(b"F").ok().and_then(|o| self.pdf_text(o, MAX_REMOTE_FILE_SPEC_BYTES)))? + } + _ => return None, + }; + if file.is_empty() { + return None; + } + let destination = match action.get(b"D") { + Err(_) => RemoteDestination::Default, + Ok(o) => match self.resolve(o) { + Object::Array(items) => { + let page = items.first().and_then(|o| self.resolve(o).as_i64().ok()).and_then(|n| usize::try_from(n).ok())?; + let kind = items.get(1).map(|o| self.resolve(o)).and_then(|o| o.as_name().ok()); + let operands: [Option; 4] = std::array::from_fn(|i| items.get(i + 2).and_then(|o| self.resolve(o).as_float().ok())); + RemoteDestination::Page { page, view: DestView::from_operands(kind, &operands) } + } + Object::Name(name) if name.len() <= MAX_REMOTE_DESTINATION_NAME_BYTES => { + let name = String::from_utf8_lossy(name).into_owned(); + if name.is_empty() || name.len() > MAX_REMOTE_DESTINATION_NAME_BYTES { + return None; + } + RemoteDestination::Named(name) + } + Object::String(bytes, _) if bytes.len() <= MAX_REMOTE_DESTINATION_NAME_BYTES => { + let name = text_string(bytes); + if name.is_empty() || name.len() > MAX_REMOTE_DESTINATION_NAME_BYTES { + return None; + } + RemoteDestination::Named(name) + } + _ => return None, + }, + }; + let new_window = match action.get(b"NewWindow").map(|o| self.resolve(o)) { + Ok(Object::Boolean(value)) => Some(*value), + _ => None, + }; + Some(LinkTarget::RemoteGoTo { file, destination, new_window }) + } + + fn pdf_text(&self, o: &Object, max_bytes: usize) -> Option { + match self.resolve(o) { + Object::String(bytes, _) => limited_pdf_string(bytes, max_bytes), + _ => None, + } + } + + /// A set-OCG-state action: `/State` is `ON`, `OFF` or `Toggle`, each followed by the groups + /// it applies to; `/PreserveRB` is true unless it is `false`. + fn layer_state(&self, a: &Dictionary) -> LinkTarget { + let mut changes = Vec::new(); + if let Ok(Object::Array(items)) = a.get(b"State").map(|o| self.resolve(o)) { + let mut op = None; + for item in items.iter().take(MAX_LAYER_STATE) { + match item { + Object::Name(n) => { + op = match n.as_slice() { + b"ON" => Some(LayerOp::On), + b"OFF" => Some(LayerOp::Off), + b"Toggle" => Some(LayerOp::Toggle), + _ => None, + } + } + Object::Reference(id) => changes.extend(op.map(|op| (op, *id))), + _ => {} + } + } + } + let preserve_rb = !matches!(a.get(b"PreserveRB").map(|o| self.resolve(o)), Ok(Object::Boolean(false))); + LinkTarget::SetLayers { changes, preserve_rb } + } + + // ── form fields ───────────────────────────────────────────────────────────────────────── + + fn field( + &self, + o: &'a Object, + parent: Option<(&str, Option, Option)>, + out: &mut Vec, + seen: &mut HashSet, + depth: u32, + ) { + if depth > 32 { + return; + } + if let Object::Reference(id) = o + && !seen.insert(*id) + { + return; + } + let Some(d) = self.dict(o) else { return }; + let (parent_name, parent_ft, parent_ff) = parent.unwrap_or(("", None, None)); + let partial = self.text(d, b"T"); + let full = match (&partial, parent_name.is_empty()) { + (Some(t), true) => t.clone(), + (Some(t), false) => format!("{parent_name}.{t}"), + (None, _) => parent_name.to_string(), + }; + let ft = self.name(d, b"FT").or(parent_ft); + let ff = d.get(b"Ff").ok().and_then(|x| x.as_i64().ok()).or(parent_ff); + // Non-terminal: has kids that are fields (they carry /T). Widgets-only kids are terminal. + let kids = match d.get(b"Kids").map(|k| self.resolve(k)) { + Ok(Object::Array(k)) => k.as_slice(), + _ => &[], + }; + let field_kids: Vec<_> = kids.iter().filter(|k| self.dict(k).is_some_and(|kd| kd.get(b"T").is_ok())).collect(); + if !field_kids.is_empty() { + for k in field_kids { + self.field(k, Some((&full, ft.clone(), ff)), out, seen, depth + 1); + } + return; + } + let flags = ff.unwrap_or(0); + let kind = match ft.as_deref() { + Some("Tx") => FieldKind::Text, + Some("Btn") if flags & (1 << 16) != 0 => FieldKind::PushButton, + Some("Btn") if flags & (1 << 15) != 0 => FieldKind::Radio, + Some("Btn") => FieldKind::CheckBox, + Some("Ch") if flags & (1 << 17) != 0 => FieldKind::Combo, + Some("Ch") => FieldKind::List, + Some("Sig") => FieldKind::Signature, + _ => FieldKind::Unknown, + }; + let value = match d.get(b"V").map(|v| self.resolve(v)) { + Ok(Object::Name(n)) => Some(String::from_utf8_lossy(n).into_owned()), + Ok(Object::String(bytes, _)) => Some(text_string(bytes)), + Ok(Object::Array(a)) => Some( + a.iter() + .filter_map(|x| match self.resolve(x) { + Object::String(bytes, _) => Some(text_string(bytes)), + _ => None, + }) + .collect::>() + .join(", "), + ), + Ok(Object::Dictionary(_)) if kind == FieldKind::Signature => Some("signed".into()), + _ => None, + }; + // The widget is either this dict (merged field/widget) or its first kid. + let widget = if d.get(b"Rect").is_ok() { Some(d) } else { kids.first().and_then(|k| self.dict(k)) }; + let page = widget.and_then(|w| w.get(b"P").ok()).and_then(|p| self.page_of(p)).or_else(|| self.find_widget_page(o, kids)); + let has_actions = d.get(b"AA").is_ok() || widget.is_some_and(|w| w.get(b"AA").is_ok() || w.get(b"A").is_ok()); + let rect = widget.and_then(|w| w.get(b"Rect").ok()).map(|r| rect4(self.resolve(r))); + out.push(Field { name: full, kind, value, page, tooltip: self.text(d, b"TU"), has_actions, rect }); + } + + /// The first page whose `/Annots` lists the field or one of its kids. + fn find_widget_page(&self, field: &Object, kids: &[Object]) -> Option { + let pages = self.annotation_pages.get_or_init(|| self.annotation_pages()); + std::iter::once(field).chain(kids).filter_map(|o| if let Object::Reference(id) = o { pages.get(id).copied() } else { None }).min() + } + + /// Each annotation listed in a page's `/Annots`, with the first page that lists it. + fn annotation_pages(&self) -> HashMap { + let mut pages = HashMap::new(); + for (idx, &pid) in self.page_ids.iter().enumerate() { + let Some(annots) = self.doc.get_dictionary(pid).ok().and_then(|d| d.get(b"Annots").ok()) else { continue }; + let Ok(annots) = self.resolve(annots).as_array() else { continue }; + for a in annots { + if let Object::Reference(id) = a { + pages.entry(*id).or_insert(idx); + } + } + } + pages + } + + /// The topmost field above widget `id` (itself without a `/Parent`), or `None` when it or an + /// ancestor is already reachable from the `/Fields` tree, or a `/Parent` loop hides the top. + fn unlisted_field(&self, id: ObjectId, seen: &HashSet) -> Option { + let mut top = id; + let mut visited = HashSet::from([id]); + loop { + let o = Object::Reference(top); + let d = self.dict(&o)?; + let parent = match d.get(b"Parent") { + Ok(Object::Reference(p)) => Some(*p), + _ => None, + }; + let Some(p) = parent else { + return if seen.contains(&top) { None } else { Some(top) }; + }; + if !visited.insert(p) || visited.len() > 64 { + return None; + } + if seen.contains(&p) { + return None; + } + top = p; + } + } + + // ── optional content ──────────────────────────────────────────────────────────────────── + + fn layers(&self, catalog: &Dictionary, out: &mut Vec) { + let Some(props) = catalog.get(b"OCProperties").ok().and_then(|o| self.dict(o)) else { return }; + let config = props.get(b"D").ok().and_then(|o| self.dict(o)); + let off: HashSet = config + .and_then(|c| c.get(b"OFF").ok()) + .and_then(|o| self.resolve(o).as_array().ok()) + .map(|a| a.iter().filter_map(|x| x.as_reference().ok()).collect()) + .unwrap_or_default(); + let base_off = config.and_then(|c| self.name(c, b"BaseState")).as_deref() == Some("OFF"); + let on: HashSet = config + .and_then(|c| c.get(b"ON").ok()) + .and_then(|o| self.resolve(o).as_array().ok()) + .map(|a| a.iter().filter_map(|x| x.as_reference().ok()).collect()) + .unwrap_or_default(); + let Ok(Object::Array(ocgs)) = props.get(b"OCGs").map(|o| self.resolve(o)) else { return }; + for g in ocgs { + let Some(d) = self.dict(g) else { continue }; + let id = g.as_reference().ok(); + let visible = match id { + Some(id) if off.contains(&id) => false, + Some(id) if on.contains(&id) => true, + _ => !base_off, + }; + let Some(id) = id else { continue }; + out.push(Layer { id, name: self.text(d, b"Name").unwrap_or_else(|| "Layer".into()), visible }); + } + } + + /// The default configuration's radio-button groups (`/RBGroups`) of two or more layers. + fn layer_groups(&self, catalog: &Dictionary) -> Vec> { + let groups = catalog + .get(b"OCProperties") + .ok() + .and_then(|o| self.dict(o)) + .and_then(|p| p.get(b"D").ok()) + .and_then(|o| self.dict(o)) + .and_then(|c| c.get(b"RBGroups").ok()) + .and_then(|o| self.resolve(o).as_array().ok()); + let mut left = MAX_LAYER_GROUP_ENTRIES; + let mut out = Vec::new(); + for g in groups.into_iter().flatten() { + let Ok(members) = self.resolve(g).as_array() else { continue }; + let group: Vec = members.iter().filter_map(|x| x.as_reference().ok()).take(left).collect(); + left = left.saturating_sub(group.len()); + if group.len() > 1 { + out.push(group); + } + if left == 0 { + break; + } + } + out + } + + fn fonts(&self, out: &mut Vec) { + let mut seen = HashSet::new(); + for &pid in &self.page_ids { + let Ok(fonts) = self.doc.get_page_fonts(pid) else { continue }; + for (_, f) in fonts { + let base = f + .get(b"BaseFont") + .ok() + .and_then(|o| o.as_name().ok()) + .map(|n| String::from_utf8_lossy(n).into_owned()) + .unwrap_or_else(|| "(unnamed)".into()); + let kind = self.name(f, b"Subtype").unwrap_or_default(); + let encoding = match f.get(b"Encoding").map(|o| self.resolve(o)) { + Ok(Object::Name(n)) => Some(String::from_utf8_lossy(n).into_owned()), + Ok(Object::Dictionary(_)) => Some("Custom".into()), + Ok(Object::Stream(_)) => Some("Embedded CMap".into()), + _ => None, + }; + // Embedded: FontFile/2/3 in the descriptor (for Type0, in the descendant font). + let descriptor_of = |d: &'a Dictionary| d.get(b"FontDescriptor").ok().and_then(|o| self.dict(o)); + let descendant = + f.get(b"DescendantFonts").ok().and_then(|o| self.resolve(o).as_array().ok()).and_then(|a| a.first()).and_then(|o| self.dict(o)); + let desc = descriptor_of(f).or_else(|| descendant.and_then(descriptor_of)); + let embedded = + kind == "Type3" || desc.is_some_and(|d| d.get(b"FontFile").is_ok() || d.get(b"FontFile2").is_ok() || d.get(b"FontFile3").is_ok()); + // What the substitution chain needs beyond the name: the descriptor flags, and for + // a composite font the script its descendant declares (ISO 32000-2 9.7.3). + let flags = desc + .and_then(|d| d.get(b"Flags").ok()) + .and_then(|o| self.resolve(o).as_i64().ok()) + .and_then(|n| u32::try_from(n).ok()) + .unwrap_or(0); + let ordering = + descendant.and_then(|d| d.get(b"CIDSystemInfo").ok()).and_then(|o| self.dict(o)).and_then(|c| self.text(c, b"Ordering")); + let subset = base.len() > 7 && base.as_bytes()[6] == b'+' && base[..6].bytes().all(|b| b.is_ascii_uppercase()); + let name = if subset { base[7..].to_string() } else { base }; + let chosen = pdfcraft_fonts::fallback::substitute(&pdfcraft_fonts::fallback::Request { + base_font: &name, + embedded, + flags, + ordering: ordering.as_deref(), + }); + let info = FontInfo { + name, + kind, + embedded, + subset, + encoding, + substitute: chosen.face_name(), + substitution: (!chosen.why.is_faithful()).then(|| chosen.why.note().to_owned()), + }; + if seen.insert((info.name.clone(), info.kind.clone(), info.embedded)) { + out.push(info); + } + } + } + out.sort_by(|a, b| a.name.cmp(&b.name)); + } + + fn attachment(&self, name: String, spec: &Object, source: AttachmentSource) -> Attachment { + let d = self.dict(spec); + let size = + d.and_then(|d| d.get(b"EF").ok()).and_then(|o| self.dict(o)).and_then(|ef| ef.get(b"F").ok().or_else(|| ef.get(b"UF").ok())).and_then( + |f| match self.resolve(f) { + Object::Stream(s) => s + .dict + .get(b"Params") + .ok() + .and_then(|p| self.dict(p)) + .and_then(|p| p.get(b"Size").ok()) + .and_then(|s| s.as_i64().ok()) + .map(|s| s as usize) + .or(Some(s.content.len())), + _ => None, + }, + ); + let display = d.and_then(|d| self.text(d, b"UF").or_else(|| self.text(d, b"F"))).unwrap_or(name); + Attachment { name: display, description: d.and_then(|d| self.text(d, b"Desc")), size, source } + } +} + +/// A text string's value, decoded by the same rules as the engine's object model +/// ([`pdfcraft_cos::PdfString::to_text`]) so the panels and outline editing agree. +fn text_string(bytes: &[u8]) -> String { + pdfcraft_cos::PdfString::literal(bytes).to_text() +} + +fn limited_pdf_string(bytes: &[u8], max_bytes: usize) -> Option { + if bytes.len() > max_bytes { + return None; + } + let text = text_string(bytes); + (text.len() <= max_bytes).then_some(text) +} + +/// Nests a pre-order list of `(depth, item)` into the outline tree. +fn nest_outline(listed: Vec<(usize, OutlineItem)>) -> Vec { + let mut roots = Vec::new(); + // The open ancestors, outermost first; each collects its children until it closes. + let mut open: Vec<(usize, OutlineItem)> = Vec::new(); + for (depth, item) in listed { + while open.last().is_some_and(|(d, _)| *d >= depth) { + close_outline(&mut open, &mut roots); + } + open.push((depth, item)); + } + while !open.is_empty() { + close_outline(&mut open, &mut roots); + } + roots +} + +/// Closes the innermost open bookmark: it joins its parent's children, or the top level. +fn close_outline(open: &mut Vec<(usize, OutlineItem)>, roots: &mut Vec) { + if let Some((_, item)) = open.pop() { + match open.last_mut() { + Some((_, parent)) => parent.children.push(item), + None => roots.push(item), + } + } +} + +fn rect4(o: &Object) -> [f32; 4] { + match o { + Object::Array(a) if a.len() == 4 => { + let v: Vec = a.iter().map(|x| x.as_float().unwrap_or(0.0)).collect(); + [v[0].min(v[2]), v[1].min(v[3]), v[0].max(v[2]), v[1].max(v[3])] + } + _ => [0.0; 4], + } +} + +fn label_work(left: &mut usize, count: usize) -> Result<(), &'static str> { + *left = left.checked_sub(count).ok_or("the number tree has too many entries")?; + Ok(()) +} + +/// `D:20260930104512-04'00'` → `2026-09-30 10:45`. +/// A PDF date (`D:20261001123000Z`) as "2026-10-01 12:30"; other strings unchanged. +pub fn pretty_date(s: &str) -> String { + let d = s.trim_start_matches("D:"); + if let Some(d) = d.get(..12).filter(|d| d.bytes().all(|b| b.is_ascii_digit())) { + // Twelve ASCII digits make every slice below a UTF-8 character boundary. + format!("{}-{}-{} {}:{}", &d[0..4], &d[4..6], &d[6..8], &d[8..10], &d[10..12]) + } else { + s.to_string() + } +} + +/// Fetch an attachment's bytes (decoded). Capped at 1 GiB to defuse decompression bombs. +pub fn attachment_data(bytes: &[u8], password: Option<&str>, att: &Attachment) -> Result, String> { + let run = || -> Result, String> { + let options = load_options(password); + let doc = Document::load_mem_with_options(bytes, options).map_err(|e| e.to_string())?; + let insp = Inspector::new(&doc); + let spec: &Object = match &att.source { + AttachmentSource::Document { key } => { + let names = doc.catalog().ok().and_then(|c| c.get(b"Names").ok()).and_then(|o| insp.dict(o)).ok_or("no /Names")?; + let ef = names.get(b"EmbeddedFiles").ok().and_then(|o| insp.dict(o)).ok_or("no /EmbeddedFiles")?; + let mut seen = HashSet::new(); + insp.name_tree(ef, &mut seen, 0).into_iter().find(|(k, _)| k == key).map(|(_, v)| v).ok_or("attachment not found")? + } + AttachmentSource::Annotation { page, index } => { + let (&pid, _) = insp.page_index.iter().find(|(_, i)| **i == *page).ok_or("page not found")?; + let annots = doc.get_dictionary(pid).map_err(|e| e.to_string())?.get(b"Annots").map_err(|e| e.to_string())?; + let a = insp.resolve(annots).as_array().map_err(|e| e.to_string())?.get(*index).ok_or("annotation not found")?; + insp.dict(a).and_then(|d| d.get(b"FS").ok()).ok_or("annotation has no file")? + } + }; + let fs = insp.dict(spec).ok_or("bad file specification")?; + let ef = fs.get(b"EF").ok().and_then(|o| insp.dict(o)).ok_or("the file is not embedded (external reference)")?; + let stream = ef.get(b"UF").ok().or_else(|| ef.get(b"F").ok()).map(|o| insp.resolve(o)).ok_or("no embedded stream")?; + let stream = stream.as_stream().map_err(|e| e.to_string())?; + if stream.dict.get(b"Filter").is_ok() { + stream.decompressed_content_with_limit(1 << 30).map_err(|e| e.to_string()) + } else { + Ok(stream.content.clone()) + } + }; + catch_unwind(AssertUnwindSafe(run)).unwrap_or_else(|p| Err(format!("reading the attachment crashed: {}", crate::raster::panic_message(&p)))) +} + +#[cfg(test)] +mod tests { + #[test] + fn png_predictor_rows_longer_than_the_data_are_refused_before_allocating() { + // Vendored lopdf patch: a fuzzed `/Columns 4294967295` allocated two 4 GiB rows before + // reading any data. A row that cannot fit in the data is refused up front. + let e = lopdf::filters::png::decode_frame(&[2, 0, 0, 0], 1, 64 << 20).unwrap_err(); + assert!(e.to_string().contains("longer than the data"), "{e}"); + // A real frame still decodes: two 3-byte rows, the second Up-filtered. + assert_eq!(lopdf::filters::png::decode_frame(&[0, 1, 2, 3, 2, 1, 1, 1], 1, 3).unwrap(), [1, 2, 3, 2, 3, 4]); + } + + #[test] + fn empty_png_predictor_frames_do_not_allocate_rows() { + // usize::MAX cannot be reserved, so the old code safely errors before allocating. + // An empty frame needs no rows regardless of the declared width. + assert!(lopdf::filters::png::decode_frame(&[], 1, usize::MAX).unwrap().is_empty()); + } + + fn tiff_predictor_stream(data: Vec, columns: i64, colors: i64, bits: i64) -> lopdf::Stream { + let mut params = lopdf::Dictionary::new(); + params.set("Predictor", 2i64); + params.set("Columns", columns); + params.set("Colors", colors); + params.set("BitsPerComponent", bits); + let mut dict = lopdf::Dictionary::new(); + dict.set("DecodeParms", params); + let mut stream = lopdf::Stream::new(dict, data); + stream.compress().unwrap(); + assert_eq!(stream.dict.get(b"Filter").unwrap().as_name().unwrap(), b"FlateDecode"); + stream + } + + #[test] + fn tiff_subbyte_predictor_row_width_overflow_is_refused() { + // Each multiplication overflows before any scratch allocation in the old debug build. + for bits in [1, 2, 4] { + let stream = tiff_predictor_stream(vec![0; 64], i64::MAX, 3, bits); + assert!( + matches!(stream.decompressed_content_with_limit(64), Err(lopdf::Error::Decompress(lopdf::DecompressError::Predictor(_)))), + "{bits}-bit row" + ); + } + } + + #[test] + fn tiff_subbyte_predictor_scratch_is_bounded_by_available_samples() { + // 1/2-bit row widths fit usize on 32- and 64-bit hosts; Colors previously made Vec + // reject the capacity before allocating. A partial row has no preceding pixel. + for bits in [1, 2] { + let data = vec![0b1010_0110; 64]; + let stream = tiff_predictor_stream(data.clone(), 1, i64::try_from(isize::MAX).unwrap(), bits); + assert_eq!(stream.decompressed_content_with_limit(64).unwrap(), data, "{bits}-bit row"); + } + } + + #[test] + fn tiff_subbyte_predictors_keep_components_rows_and_padding() { + // Repeated tiny rows compress through the public Stream API. Rows remain independent, + // differences wrap at each component depth, and trailing padding bits survive. + for (columns, colors, bits, encoded, decoded) in [ + (16, 1, 1, vec![255, 170, 8, 255], vec![170, 204, 15, 85]), + (6, 1, 2, vec![85, 179], vec![108, 147]), + (3, 1, 4, vec![25, 16], vec![26, 176]), + (2, 2, 4, vec![18, 34], vec![18, 52]), + ] { + let stream = tiff_predictor_stream(encoded.repeat(32), columns, colors, bits); + assert_eq!(stream.decompressed_content_with_limit(128).unwrap(), decoded.repeat(32), "{bits}-bit, {colors} colours"); + } + } + + // Xref-stream fixtures exercise indirect /Length lookup during document loading: stream 2's /Length is `3 0 R`, + // `containers` lists compressed (id, container, index) entries and `objects` the other objects in the file. + fn compressed_length_pdf(containers: &[(u32, u32, u16)], objects: &[(u32, &[u8])]) -> Vec { + let mut pdf = b"%PDF-1.7\n".to_vec(); + let ids = containers.iter().flat_map(|&(id, container, _)| [id, container]).chain(objects.iter().map(|&(id, _)| id)); + let xref_id = ids.max().unwrap_or(3) + 1; + let mut records = vec![[0u8; 7]; (xref_id + 1) as usize]; + records[0] = [0, 0, 0, 0, 0, 255, 255]; + let mut append = |id: u32, body: &[u8]| { + records[id as usize][0] = 1; + records[id as usize][1..5].copy_from_slice(&(pdf.len() as u32).to_be_bytes()); + pdf.extend_from_slice(format!("{id} 0 obj\n").as_bytes()); + pdf.extend_from_slice(body); + pdf.extend_from_slice(b"\nendobj\n"); + }; + append(1, b"<< /Type /Catalog >>"); + append(2, b"<< /Length 3 0 R >>\nstream\nhello\nendstream"); + for &(id, body) in objects { + append(id, body); + } + for &(id, container, index) in containers { + records[id as usize][0] = 2; + records[id as usize][1..5].copy_from_slice(&container.to_be_bytes()); + records[id as usize][5..7].copy_from_slice(&index.to_be_bytes()); + } + let xref_offset = pdf.len(); + records[xref_id as usize][0] = 1; + records[xref_id as usize][1..5].copy_from_slice(&(xref_offset as u32).to_be_bytes()); + pdf.extend_from_slice( + format!("{xref_id} 0 obj\n<< /Type /XRef /Size {} /Root 1 0 R /W [1 4 2] /Length {} >>\nstream\n", records.len(), records.len() * 7) + .as_bytes(), + ); + for record in records { + pdf.extend_from_slice(&record); + } + pdf.extend_from_slice(format!("\nendstream\nendobj\nstartxref\n{xref_offset}\n%%EOF\n").as_bytes()); + pdf + } + + // Stream 2's /Length starts a chain of `hops` indirect /Length lookups, each naming a new object; only the last + // link's /Length is direct. Compressed: link k is the integer 3 + 2k (the length it measures) alone in the object + // stream 4 + 2k, whose own /Length is link k + 1. Plain: link k is the stream 3 + k, whose /Length is link k + 1. + fn length_chain_pdf(hops: u32, compressed: bool) -> Vec { + let length = |hop: u32, next: u32, data: &str| if hop + 1 < hops { format!("{next} 0 R") } else { data.len().to_string() }; + let (mut containers, mut objects) = (Vec::new(), Vec::new()); + let mut value = 5; // the length of stream 2's data, "hello" + for hop in 0..hops { + if compressed { + let (id, container) = (3 + 2 * hop, 4 + 2 * hop); + let index = format!("{id} 0 "); + let data = format!("{index}{value}"); + let dict = format!("/Type /ObjStm /N 1 /First {} /Length {}", index.len(), length(hop, id + 2, &data)); + objects.push((container, format!("<< {dict} >>\nstream\n{data}\nendstream"))); + containers.push((id, container, 0)); + value = data.len(); + } else { + objects.push((3 + hop, format!("<< /Length {} >>\nstream\n\nendstream", length(hop, 4 + hop, "")))); + } + } + let objects: Vec<_> = objects.iter().map(|(id, body)| (*id, body.as_bytes())).collect(); + compressed_length_pdf(&containers, &objects) + } + + // Lenient loading keeps a document whose stream 2 has an unresolvable /Length: the catalog, and stream 2's + // dictionary without its data. + fn load_without_stream_2_data(pdf: &[u8]) -> Document { + let doc = Document::load_mem(pdf).unwrap(); + assert!(doc.catalog().unwrap().has_type(b"Catalog")); + assert!(doc.get_object((2, 0)).unwrap().as_stream().unwrap().content.is_empty()); + doc + } + + #[test] + fn compressed_object_self_container_does_not_recurse() { + let doc = load_without_stream_2_data(&compressed_length_pdf(&[(3, 3, 0)], &[])); + assert!(doc.get_object((3, 0)).is_err()); + } + + #[test] + fn compressed_object_mutual_containers_do_not_recurse() { + let doc = load_without_stream_2_data(&compressed_length_pdf(&[(3, 4, 0), (4, 3, 0)], &[])); + assert!(doc.get_object((3, 0)).is_err()); + assert!(doc.get_object((4, 0)).is_err()); + } + + #[test] + fn object_stream_length_cycle_does_not_recurse() { + let pdf = compressed_length_pdf(&[(3, 4, 0)], &[(4, b"<< /Type /ObjStm /N 1 /First 4 /Length 3 0 R >>\nstream\n3 0 5\nendstream")]); + let doc = load_without_stream_2_data(&pdf); + // The container's /Length is the object it holds: the container is kept without data, so 3 never loads. + assert!(doc.get_object((4, 0)).unwrap().as_stream().unwrap().content.is_empty()); + assert!(doc.get_object((3, 0)).is_err()); + } + + #[test] + fn compressed_object_container_chain_does_not_recurse() { + let containers: Vec<_> = (3..100_003).map(|id| (id, id + 1, 0)).collect(); + let pdf = compressed_length_pdf(&containers, &[(100_003, b"<< /Type /ObjStm /N 1 /First 4 /Length 5 >>\nstream\n3 0 5\nendstream")]); + let doc = load_without_stream_2_data(&pdf); + // The object stream in the file loads, but the xref puts 3 in the compressed container 4, which is refused. + assert_eq!(doc.get_object((100_003, 0)).unwrap().as_stream().unwrap().content, b"3 0 5"); + assert!(doc.get_object((3, 0)).is_err()); + } + + #[test] + fn compressed_objects_and_indirect_stream_length_still_load() { + let pdf = compressed_length_pdf( + &[(3, 4, 0), (5, 4, 1)], + &[(4, b"<< /Type /ObjStm /N 2 /First 8 /Length 17 >>\nstream\n3 0 5 2 5 (value)\nendstream")], + ); + let doc = Document::load_mem_with_options(&pdf, LoadOptions { strict: true, ..Default::default() }).unwrap(); + assert_eq!(doc.get_object((3, 0)).unwrap().as_i64().unwrap(), 5); + assert_eq!(doc.get_object((5, 0)).unwrap().as_str().unwrap(), b"value"); + assert_eq!(doc.get_object((2, 0)).unwrap().as_stream().unwrap().content, b"hello"); + } + + #[test] + fn compressed_length_in_a_container_with_an_indirect_length_still_loads() { + // Two hops, as in a valid file: stream 2's /Length (3) is compressed in container 4, whose own /Length (5) is + // a plain indirect integer. + let pdf = + compressed_length_pdf(&[(3, 4, 0)], &[(4, b"<< /Type /ObjStm /N 1 /First 4 /Length 5 0 R >>\nstream\n3 0 5\nendstream"), (5, b"5")]); + let doc = Document::load_mem_with_options(&pdf, LoadOptions { strict: true, ..Default::default() }).unwrap(); + assert_eq!(doc.get_object((3, 0)).unwrap().as_i64().unwrap(), 5); + assert_eq!(doc.get_object((5, 0)).unwrap().as_i64().unwrap(), 5); + assert_eq!(doc.get_object((2, 0)).unwrap().as_stream().unwrap().content, b"hello"); + } + + #[test] + fn compressed_length_chain_does_not_recurse() { + // Every container is an ordinary object in the file and every hop names a new object, so neither the + // container check nor the cycle history stops the chain; each hop recursed once more (a 512 KiB stack + // overflowed within 150 hops). Lookups past the cap fail: the first link never loads, while the last one, + // next to the direct /Length, does. + let hops = 5_000; + let doc = load_without_stream_2_data(&length_chain_pdf(hops, true)); + assert!(doc.get_object((3, 0)).is_err()); + assert!(doc.get_object((3 + 2 * (hops - 1), 0)).unwrap().as_i64().is_ok()); + // The cap itself: loading link j reads its container's /Length, a chain of 2 * (hops - 1 - j) lookups, and + // 16 are allowed, so the 9th link from the end loads and the 10th does not. + assert!(doc.get_object((3 + 2 * (hops - 9), 0)).is_ok()); + assert!(doc.get_object((3 + 2 * (hops - 10), 0)).is_err()); + } + + #[test] + fn plain_stream_length_chain_does_not_recurse() { + // The same recursion without object streams: each stream's /Length names the next stream (a 512 KiB stack + // overflowed within 200 hops). A stream is no length, so every link is kept as a stream without data. + let hops = 5_000; + let doc = load_without_stream_2_data(&length_chain_pdf(hops, false)); + assert!((3..3 + hops).all(|id| doc.get_object((id, 0)).is_ok_and(|link| link.as_stream().is_ok_and(|s| s.content.is_empty())))); + } + + fn indexed_object_stream(pairs: &[(u32, usize)], members: &[u8]) -> lopdf::Stream { + let mut content = Vec::new(); + for &(id, offset) in pairs { + content.extend_from_slice(format!("{id} {offset} ").as_bytes()); + } + let mut dict = Dictionary::new(); + dict.set("Type", "ObjStm"); + dict.set("N", pairs.len() as i64); + dict.set("First", content.len() as i64); + content.extend_from_slice(members); + lopdf::Stream::new(dict, content) + } + + #[test] + fn object_stream_member_extents_bound_owned_strings() { + let mut members = vec![b'('; 32]; + members.extend(vec![b'x'; 1 << 20]); + members.extend([b')'; 32]); + // Put the innermost (complete) string first in index order, then overlapping starts + // and duplicates. Only this first member at offset 31 may own the large string. + let mut pairs = vec![(100, 31)]; + pairs.extend((0..31).map(|offset| (offset as u32 + 1, offset))); + pairs.extend([(200, 31), (201, 31)]); + let stream = indexed_object_stream(&pairs, &members); + let parsed = lopdf::ObjectStream::new_with_limit(&stream, Some(2 << 20)).unwrap(); + assert_eq!(parsed.objects.get(&(100, 0)).unwrap().as_str().unwrap().len(), 1 << 20); + let total: usize = parsed.objects.values().filter_map(|object| object.as_str().ok()).map(<[u8]>::len).sum(); + assert!(total <= stream.content.len(), "owned {total} string bytes from {} decoded bytes", stream.content.len()); + assert!(!parsed.objects.contains_key(&(200, 0))); + assert!(!parsed.objects.contains_key(&(201, 0))); + } + + #[test] + fn object_stream_member_extents_preserve_index_order_and_whitespace() { + let members = b" \n42\n /Name \n[true 7]\n << /Key (value) >>\n (last)"; + // Unsorted offsets, and object 9 appears twice: its last index entry still wins. + let pairs = [(9, 0), (12, 21), (10, 5), (11, 12), (9, 41)]; + let parsed = lopdf::ObjectStream::new(&indexed_object_stream(&pairs, members)).unwrap(); + assert_eq!(parsed.objects.len(), 4); + assert_eq!(parsed.objects.get(&(9, 0)).unwrap().as_str().unwrap(), b"last"); + assert_eq!(parsed.objects.get(&(10, 0)).unwrap().as_name().unwrap(), b"Name"); + assert_eq!(parsed.objects.get(&(11, 0)).unwrap().as_array().unwrap(), &[Object::Boolean(true), Object::Integer(7)]); + assert_eq!(parsed.objects.get(&(12, 0)).unwrap().as_dict().unwrap().get(b"Key").unwrap().as_str().unwrap(), b"value"); + } + + #[test] + fn view_and_user_space_round_trip_for_every_rotation() { + for rotation in [0u16, 90, 180, 270] { + let (w, h) = if rotation % 180 == 0 { (200.0, 300.0) } else { (300.0, 200.0) }; + let p = super::PageInfo { width: w, height: h, label: String::new(), crop: [10.0, 20.0, 210.0, 320.0], rotation }; + for (x, y) in [(0.0, 0.0), (15.0, 40.0), (w, h)] { + let [ux, uy] = p.view_to_user(x, y); + let [vx, vy] = p.user_to_view(ux, uy); + assert!((vx - x).abs() < 1e-3 && (vy - y).abs() < 1e-3, "{rotation}: {x},{y} → {ux},{uy} → {vx},{vy}"); + } + } + // Unrotated: the view's top-left is the crop box's top-left. + let p = super::PageInfo { width: 200.0, height: 300.0, label: String::new(), crop: [10.0, 20.0, 210.0, 320.0], rotation: 0 }; + assert_eq!(p.view_to_user(0.0, 0.0), [10.0, 320.0]); + assert_eq!(p.view_rect_to_quad([0.0, 0.0, 10.0, 5.0]), [10.0, 320.0, 20.0, 320.0, 10.0, 315.0, 20.0, 315.0]); + } + + use super::*; + + #[test] + fn roman_and_alpha_labels() { + assert_eq!(roman(4, MAX_LABEL_BYTES).as_deref(), Some("iv")); + assert_eq!(roman(1994, MAX_LABEL_BYTES).as_deref(), Some("mcmxciv")); + assert_eq!(alpha(1, MAX_LABEL_BYTES).as_deref(), Some("a")); + assert_eq!(alpha(27, MAX_LABEL_BYTES).as_deref(), Some("aa")); + assert_eq!(alpha(53, MAX_LABEL_BYTES).as_deref(), Some("aaa")); + } + + // Synthetic label-only inspection fixtures: never construct an explosive branching tree. + fn label_info(nums: Vec, kids: Vec) -> DocInfo { + let mut doc = Document::with_version("1.7"); + let mut tree = Dictionary::new(); + tree.set("Nums", nums); + tree.set("Kids", kids); + doc.objects.insert((6, 0), Object::Dictionary(tree)); + let mut catalog = Dictionary::new(); + catalog.set("Type", Object::Name(b"Catalog".to_vec())); + catalog.set("PageLabels", Object::Reference((6, 0))); + doc.objects.insert((1, 0), Object::Dictionary(catalog)); + doc.trailer.set("Root", Object::Reference((1, 0))); + let mut info = DocInfo { + pages: (1..=3) + .map(|n| PageInfo { width: 200.0, height: 300.0, crop: [0.0, 0.0, 200.0, 300.0], rotation: 0, label: n.to_string() }) + .collect(), + ..Default::default() + }; + Inspector::new(&doc).fill(&mut info); + info + } + + fn label_spec(style: &str, prefix: Vec, first: i64) -> Object { + let mut d = Dictionary::new(); + d.set("S", Object::Name(style.as_bytes().to_vec())); + d.set("P", Object::String(prefix, lopdf::StringFormat::Hexadecimal)); + d.set("St", first); + Object::Dictionary(d) + } + + fn assert_label_fallback(info: &DocInfo) { + assert_eq!(info.pages.iter().map(|p| p.label.as_str()).collect::>(), ["1", "2", "3"]); + assert_eq!(info.warnings.len(), 1, "{:?}", info.warnings); + assert!(info.warnings[0].contains("Page labels"), "{:?}", info.warnings); + assert!(info.warnings[0].contains("physical page numbers"), "{:?}", info.warnings); + } + + // Contributor-original, blank three-page PDFs. The public importer reads both labels and metadata. + fn page_prefix_space_pdf(prefix: Vec, decimal: bool, indirect: bool) -> Vec { + use lopdf::dictionary; + let mut doc = Document::with_version("1.7"); + let pages_id = doc.new_object_id(); + let kids: Vec = (0..3) + .map(|_| { + doc.add_object(dictionary! { + "Type" => "Page", + "Parent" => pages_id, + "MediaBox" => vec![0.into(), 0.into(), 200.into(), 300.into()], + "Resources" => Dictionary::new(), + }) + .into() + }) + .collect(); + doc.objects.insert(pages_id, dictionary! { "Type" => "Pages", "Kids" => kids, "Count" => 3 }.into()); + let prefix = Object::String(prefix, lopdf::StringFormat::Hexadecimal); + let prefix = if indirect { Object::Reference(doc.add_object(prefix)) } else { prefix }; + let mut label = Dictionary::new(); + label.set("P", prefix); + if decimal { + label.set("S", Object::Name(b"D".to_vec())); + } + let labels = dictionary! { "Nums" => vec![Object::Integer(0), Object::Dictionary(label)] }; + let catalog_id = doc.add_object(dictionary! { "Type" => "Catalog", "Pages" => pages_id, "PageLabels" => labels }); + let info_id = doc.add_object(dictionary! { "Title" => Object::string_literal(" Metadata title ") }); + doc.trailer.set("Root", catalog_id); + doc.trailer.set("Info", info_id); + let mut bytes = Vec::new(); + doc.save_to(&mut bytes).expect("write original page-prefix fixture"); + bytes + } + + #[test] + fn page_label_prefix_spaces_survive_public_inspection() { + let mut utf16 = vec![0xfe, 0xff]; + utf16.extend(" Ω ".encode_utf16().flat_map(u16::to_be_bytes)); + for (raw, prefix, decimal, indirect) in [ + (b" leading".to_vec(), " leading", true, false), + (b"trailing ".to_vec(), "trailing ", true, false), + (b" both ".to_vec(), " both ", false, false), + (b" ".to_vec(), " ", false, false), + (b" Caf\xe9 ".to_vec(), " Café ", true, false), + (utf16.clone(), " Ω ", true, false), + (utf16, " Ω ", false, true), + (Vec::new(), "", true, false), + (Vec::new(), "", false, false), + ] { + let bytes = page_prefix_space_pdf(raw, decimal, indirect); + let info = inspect(Arc::new(bytes), None).expect("public import of original page-prefix fixture"); + let expected: Vec = (1..=3) + .map(|n| { + if decimal { + format!("{prefix}{n}") + } else if prefix.is_empty() { + n.to_string() + } else { + prefix.to_owned() + } + }) + .collect(); + assert_eq!( + info.pages.iter().map(|p| p.label.clone()).collect::>(), + expected, + "{prefix:?}, decimal={decimal}, indirect={indirect}" + ); + assert!(info.warnings.is_empty(), "{:?}", info.warnings); + assert_eq!(info.title.as_deref(), Some("Metadata title"), "page-prefix decoding must not change metadata normalization"); + } + } + + #[test] + fn page_label_limits_keep_normal_pdf_numbering() { + for (style, first, expected) in [ + ("r", 4, ["iv", "v", "vi"]), + ("R", 1994, ["MCMXCIV", "MCMXCV", "MCMXCVI"]), + ("a", 26, ["z", "aa", "bb"]), + ("A", 52, ["ZZ", "AAA", "BBB"]), + ] { + let info = label_info(vec![Object::Integer(0), label_spec(style, Vec::new(), first)], Vec::new()); + assert_eq!(info.pages.iter().map(|p| p.label.as_str()).collect::>(), expected); + assert!(info.warnings.is_empty(), "{:?}", info.warnings); + } + } + + #[test] + fn page_label_limits_reject_long_prefixes_without_partial_labels() { + for prefix in [vec![b'x'; 1025], vec![b'x'; 2051]] { + let nums = vec![Object::Integer(0), label_spec("D", b"ok-".to_vec(), 1), Object::Integer(1), label_spec("D", prefix, 1)]; + assert_label_fallback(&label_info(nums, Vec::new())); + } + } + + #[test] + fn page_label_limits_count_decoded_utf8_bytes() { + let mut prefix = vec![0xfe, 0xff]; + for _ in 0..342 { + prefix.extend_from_slice(&0x2022u16.to_be_bytes()); + } + assert_label_fallback(&label_info(vec![Object::Integer(0), label_spec("", prefix, 1)], Vec::new())); + } + + #[test] + fn page_label_limits_bound_roman_and_alpha_output() { + // Just 1025 output bytes on the old path, so RED does not consume excessive memory. + for (style, first) in [("r", 1_025_000), ("a", 26 * 1024 + 1)] { + assert_label_fallback(&label_info(vec![Object::Integer(0), label_spec(style, Vec::new(), first)], Vec::new())); + } + } + + #[test] + fn page_label_limits_detect_one_node_cycle() { + let nums = vec![Object::Integer(0), label_spec("D", b"custom-".to_vec(), 1)]; + assert_label_fallback(&label_info(nums, vec![Object::Reference((6, 0))])); + } + + #[test] + fn page_label_limits_accept_utf16_ascii_at_the_output_boundary() { + let mut prefix = vec![0xfe, 0xff]; + for _ in 0..1023 { + prefix.extend_from_slice(&u16::from(b'x').to_be_bytes()); + } + let info = label_info(vec![Object::Integer(0), label_spec("D", prefix, 1)], Vec::new()); + assert!(info.warnings.is_empty(), "{:?}", info.warnings); + assert_eq!(info.pages[0].label, format!("{}1", "x".repeat(1023))); + assert!(info.pages.iter().all(|p| p.label.len() == MAX_LABEL_BYTES)); + } + + #[test] + fn page_label_limits_refuse_hostile_numbers_before_allocating() { + for style in ["r", "R", "a", "A"] { + assert_label_fallback(&label_info(vec![Object::Integer(0), label_spec(style, Vec::new(), i64::MAX)], Vec::new())); + } + // Decimal labels remain small even when their first number exceeds 32-bit usize. + let info = label_info(vec![Object::Integer(0), label_spec("D", Vec::new(), i64::MAX)], Vec::new()); + assert_eq!(info.pages[0].label, i64::MAX.to_string()); + assert_eq!(info.pages[1].label, (i64::MAX as u64 + 1).to_string()); + assert!(info.warnings.is_empty()); + } + + #[test] + fn page_label_limits_bound_wide_number_trees() { + assert_label_fallback(&label_info(Vec::new(), vec![Object::Null; MAX_LABEL_TREE_WORK + 1])); + let pair = [Object::Integer(0), label_spec("D", Vec::new(), 1)]; + let nums = (0..MAX_LABEL_TREE_WORK).flat_map(|_| pair.clone()).collect(); + assert_label_fallback(&label_info(nums, Vec::new())); + } + + #[test] + fn page_label_limits_bound_total_custom_output_atomically() { + let doc = Document::with_version("1.7"); + let mut tree = Dictionary::new(); + tree.set("Nums", vec![Object::Integer(0), label_spec("", vec![b'x'; MAX_LABEL_BYTES], 1)]); + let mut catalog = Dictionary::new(); + catalog.set("PageLabels", Object::Dictionary(tree)); + let count = MAX_LABEL_TOTAL_BYTES / MAX_LABEL_BYTES + 1; + let mut pages: Vec<_> = (1..=count) + .map(|n| PageInfo { width: 200.0, height: 300.0, crop: [0.0, 0.0, 200.0, 300.0], rotation: 0, label: n.to_string() }) + .collect(); + let err = Inspector::new(&doc).page_labels(&catalog, &mut pages).unwrap_err(); + assert!(err.contains("4 MiB"), "{err}"); + assert!(pages.iter().enumerate().all(|(i, p)| p.label == (i + 1).to_string())); + // Exact aggregate boundary remains supported. + pages.pop(); + Inspector::new(&doc).page_labels(&catalog, &mut pages).unwrap(); + assert!(pages.iter().all(|p| p.label.len() == MAX_LABEL_BYTES)); + } + + #[test] + fn page_label_limits_bound_the_total_of_decoded_prefixes() { + // `ranges` ranges naming one shared prefix object of `prefix` bytes, without a style. + let shared = |ranges: i64, prefix: usize| { + let mut doc = Document::with_version("1.7"); + doc.objects.insert((7, 0), Object::String(vec![b'x'; prefix], lopdf::StringFormat::Literal)); + let mut spec = Dictionary::new(); + spec.set("P", Object::Reference((7, 0))); + doc.objects.insert((8, 0), Object::Dictionary(spec)); + let mut tree = Dictionary::new(); + tree.set("Nums", (0..ranges).flat_map(|i| [Object::Integer(i), Object::Reference((8, 0))]).collect::>()); + let mut catalog = Dictionary::new(); + catalog.set("PageLabels", Object::Dictionary(tree)); + (doc, catalog) + }; + let mut pages: Vec<_> = + (1..=3).map(|n| PageInfo { width: 200.0, height: 300.0, crop: [0.0, 0.0, 200.0, 300.0], rotation: 0, label: n.to_string() }).collect(); + // Each range decodes its prefix, so a small file sharing one prefix object asks for a + // copy per range: 5,000 ranges and a 1,000-byte prefix make 5 MB, past the 4 MiB total. + // Three pages make three labels, far below the label total: the prefixes alone refuse it. + let (doc, catalog) = shared(5000, 1000); + let err = Inspector::new(&doc).page_labels(&catalog, &mut pages).unwrap_err(); + assert!(err.contains("prefixes exceed 4 MiB"), "{err}"); + assert!(pages.iter().enumerate().all(|(i, p)| p.label == (i + 1).to_string())); + // Exactly 4 MiB of prefixes is still read. + let (doc, catalog) = shared((MAX_PREFIX_TOTAL_BYTES / MAX_LABEL_BYTES) as i64, MAX_LABEL_BYTES); + Inspector::new(&doc).page_labels(&catalog, &mut pages).unwrap(); + assert!(pages.iter().all(|p| p.label.len() == MAX_LABEL_BYTES)); + } + + #[test] + fn page_label_limits_handle_prefix_only_and_reference_cycles() { + let info = label_info(vec![Object::Integer(0), label_spec("", b"prefix".to_vec(), i64::MAX)], Vec::new()); + assert!(info.warnings.is_empty()); + assert!(info.pages.iter().all(|p| p.label == "prefix")); + let mut doc = Document::with_version("1.7"); + doc.objects.insert((6, 0), Object::Reference((6, 0))); + let mut catalog = Dictionary::new(); + catalog.set("PageLabels", Object::Reference((6, 0))); + assert!(Inspector::new(&doc).page_labels(&catalog, &mut []).unwrap_err().contains("cyclic")); + } + + #[test] + fn page_label_limits_bound_depth_and_count_combined_work() { + for depth in [MAX_LABEL_TREE_DEPTH, MAX_LABEL_TREE_DEPTH + 1] { + let mut child = Dictionary::new(); + child.set("Nums", vec![Object::Integer(0), label_spec("D", b"custom-".to_vec(), 1)]); + let mut child = Object::Dictionary(child); + for _ in 1..depth { + let mut parent = Dictionary::new(); + parent.set("Kids", vec![child]); + child = Object::Dictionary(parent); + } + let info = label_info(Vec::new(), vec![child]); + if depth == MAX_LABEL_TREE_DEPTH { + assert!(info.warnings.is_empty()); + assert_eq!(info.pages[0].label, "custom-1"); + } else { + assert_label_fallback(&info); + } + } + // Each array alone fits: their combined work still must be bounded. + let half = MAX_LABEL_TREE_WORK / 2; + let spec = label_spec("D", Vec::new(), 1); + let nums = (0..half).flat_map(|i| [Object::Integer(i as i64), spec.clone()]).collect(); + assert_label_fallback(&label_info(nums, vec![Object::Null; half])); + } + + #[test] + fn page_label_limits_keep_the_later_of_duplicate_ranges() { + let nums = vec![Object::Integer(0), label_spec("D", b"first-".to_vec(), 1), Object::Integer(0), label_spec("D", b"last-".to_vec(), 1)]; + let info = label_info(nums, Vec::new()); + assert_eq!(info.pages.iter().map(|p| p.label.as_str()).collect::>(), ["last-1", "last-2", "last-3"]); + assert_eq!(info.warnings.len(), 1, "{:?}", info.warnings); + assert!(info.warnings[0].contains("twice"), "{:?}", info.warnings); + } + + #[test] + fn page_label_limits_keep_other_ranges_when_one_is_too_long() { + // Page 1 is labelled normally; pages 2–3 would need over 1024 bytes of roman numerals. + let nums = vec![Object::Integer(0), label_spec("D", b"ok-".to_vec(), 1), Object::Integer(1), label_spec("r", Vec::new(), 1_025_000)]; + let info = label_info(nums, Vec::new()); + assert_eq!(info.pages.iter().map(|p| p.label.as_str()).collect::>(), ["ok-1", "2", "3"]); + assert_eq!(info.warnings.len(), 1, "{:?}", info.warnings); + assert!(info.warnings[0].contains("physical page numbers"), "{:?}", info.warnings); + } + + const ATTACHMENTS: &[u8] = b"%PDF-1.7 +1 0 obj << /Type /Catalog /Pages 2 0 R /Names << /EmbeddedFiles << /Names [(notes.txt) 6 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 200 200] /Annots [4 0 R] /Resources << /Font << /F1 9 0 R >> >> >> endobj +4 0 obj << /Type /Annot /Subtype /FileAttachment /Rect [10 10 30 30] /Contents (data.csv) /FS 8 0 R >> endobj +5 0 obj << /Type /EmbeddedFile /Length 11 >> stream +hello notes +endstream endobj +6 0 obj << /Type /Filespec /F (notes.txt) /UF (notes.txt) /EF << /F 5 0 R >> /Desc (Doc-level) >> endobj +7 0 obj << /Type /EmbeddedFile /Length 5 >> stream +a,b,c +endstream endobj +8 0 obj << /Type /Filespec /F (data.csv) /EF << /F 7 0 R >> >> endobj +9 0 obj << /Type /Font /Subtype /Type1 /BaseFont /ABCDEF+Helvetica >> endobj +trailer << /Root 1 0 R >> +%%EOF"; + + #[test] + fn attachments_of_both_kinds_are_listed_and_extracted() { + let bytes = Arc::new(ATTACHMENTS.to_vec()); + let info = inspect(bytes.clone(), None).expect("opens"); + let names: Vec<_> = info.attachments.iter().map(|a| a.name.as_str()).collect(); + assert!(names.contains(&"notes.txt") && names.contains(&"data.csv"), "{names:?}"); + for a in &info.attachments { + let data = attachment_data(&bytes, None, a).expect("extracts"); + let expected: &[u8] = if a.name == "notes.txt" { b"hello notes" } else { b"a,b,c" }; + assert_eq!(data, expected, "{}", a.name); + } + // A non-embedded /Helvetica is reported as standing in for itself: the standard-14 + // metrics are exact, so the substitution is named rather than left implicit. + assert_eq!( + info.fonts, + vec![FontInfo { + name: "Helvetica".into(), + kind: "Type1".into(), + embedded: false, + subset: true, + encoding: None, + substitute: Some("Helvetica".into()), + substitution: Some(pdfcraft_fonts::fallback::Why::NamedStandard14.note().into()), + }] + ); + } + + const LAYERS: &[u8] = b"%PDF-1.7 +1 0 obj << /Type /Catalog /Pages 2 0 R /OCProperties << /OCGs [5 0 R 6 0 R 7 0 R] /D << /OFF [6 0 R] /RBGroups [[5 0 R 6 0 R] [7 0 R] 8 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 200 200] /Annots [10 0 R 11 0 R] >> endobj +5 0 obj << /Type /OCG /Name (Red) >> endobj +6 0 obj << /Type /OCG /Name (Green) >> endobj +7 0 obj << /Type /OCG /Name (Blue) >> endobj +8 0 obj [6 0 R 7 0 R] endobj +9 0 obj [/OFF 5 0 R] endobj +10 0 obj << /Type /Annot /Subtype /Link /Rect [10 10 50 30] /A << /S /SetOCGState /State [7 0 R /ON 6 0 R /Bogus 5 0 R /Toggle 5 0 R 1 7 0 R] /PreserveRB false >> >> endobj +11 0 obj << /Type /Annot /Subtype /Link /Rect [60 10 100 30] /A << /S /SetOCGState /State 9 0 R >> >> endobj +trailer << /Root 1 0 R >> +%%EOF"; + + /// `/Trans` and `/Dur` (ISO 32000-2 §12.4.4): read with the spec's defaults for what a page + /// leaves out, lenient about what is out of range, and only for pages that have either. + const TRANSITIONS: &[u8] = b"%PDF-1.7 +1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj +2 0 obj << /Type /Pages /Kids [3 0 R 4 0 R 5 0 R 6 0 R 7 0 R] /Count 5 /MediaBox [0 0 200 200] >> endobj +3 0 obj << /Type /Page /Parent 2 0 R /Trans << /Type /Trans /S /Wipe /D 2.5 /Di 270 >> /Dur 5 >> endobj +4 0 obj << /Type /Page /Parent 2 0 R /Dur 3 >> endobj +5 0 obj << /Type /Page /Parent 2 0 R /Trans 8 0 R >> endobj +6 0 obj << /Type /Page /Parent 2 0 R /Trans << /S /Bogus /D (slow) /Di 45 /Dm /Q >> /Dur -1 >> endobj +7 0 obj << /Type /Page /Parent 2 0 R >> endobj +8 0 obj << /S /Fly /M /O /SS 0.5 /B true /Di /None /Dm /V >> endobj +trailer << /Root 1 0 R >> +%%EOF"; + + #[test] + fn page_transitions_and_display_times_are_read() { + use super::TransitionStyle as S; + let info = inspect(Arc::new(TRANSITIONS.to_vec()), None).expect("opens"); + let t = &info.transitions; + assert_eq!(t.iter().map(|t| t.page).collect::>(), [0, 1, 2, 3], "pages with /Trans or /Dur, in order; page 5 has neither"); + assert_eq!((t[0].style, t[0].duration, t[0].direction, t[0].display), (Some(S::Wipe), 2.5, Some(270), Some(5.0))); + assert!((t[0].horizontal, t[0].inward, t[0].scale, t[0].opaque) == (true, true, 1.0, false), "defaults: {:?}", t[0]); + assert_eq!((t[1].style, t[1].duration, t[1].display), (None, 1.0, Some(3.0)), "/Dur without /Trans"); + assert_eq!( + (t[2].style, t[2].inward, t[2].scale, t[2].opaque, t[2].direction, t[2].horizontal), + (Some(S::Fly), false, 0.5, true, None, false), + "an indirect /Trans: {:?}", + t[2] + ); + // Out of range: an unknown style is the spec's default R, a non-numeric /D the default + // second, a direction that is not one of the five falls back to 0, a bad /Dm to + // horizontal, and a negative /Dur is no display time. + assert_eq!( + (t[3].style, t[3].duration, t[3].direction, t[3].horizontal, t[3].display), + (Some(S::Replace), 1.0, Some(0), true, None), + "{:?}", + t[3] + ); + } + + #[test] + fn radio_button_layer_groups_are_read() { + let info = inspect(Arc::new(LAYERS.to_vec()), None).expect("opens"); + let layers: Vec<_> = info.layers.iter().map(|l| (l.id, l.name.as_str(), l.visible)).collect(); + assert_eq!(layers, [((5, 0), "Red", true), ((6, 0), "Green", false), ((7, 0), "Blue", true)]); + // A group of one constrains nothing; an indirect group is read. + assert_eq!(info.layer_groups, [vec![(5, 0), (6, 0)], vec![(6, 0), (7, 0)]]); + } + + const ORPHAN_FIELDS: &[u8] = b"%PDF-1.7 +1 0 obj << /Type /Catalog /Pages 2 0 R /AcroForm 4 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 200 200] /Annots [5 0 R 6 0 R] >> endobj +4 0 obj << /Fields [] >> endobj +5 0 obj << /Type /Annot /Subtype /Widget /FT /Tx /T (alpha) /V (one) /Rect [10 100 90 120] >> endobj +6 0 obj << /Type /Annot /Subtype /Widget /FT /Tx /T (beta) /Rect [10 60 90 80] >> endobj +trailer << /Root 1 0 R >> +%%EOF"; + + #[test] + fn fields_listed_only_on_the_pages_are_listed() { + let info = inspect(Arc::new(ORPHAN_FIELDS.to_vec()), None).expect("opens"); + let listed: Vec<_> = info.fields.iter().map(|f| (f.name.as_str(), f.value.as_deref(), f.page)).collect(); + assert_eq!(listed, [("alpha", Some("one"), Some(0)), ("beta", None, Some(0))], "{:?}", info.fields); + } + + /// A `Structure` that counts the objects read through it. + struct CountingReads<'a> { + doc: &'a Document, + reads: std::cell::Cell, + } + + impl Structure for CountingReads<'_> { + fn trailer(&self) -> &Dictionary { + &self.doc.trailer + } + fn get_object(&self, id: ObjectId) -> lopdf::Result<&Object> { + self.reads.set(self.reads.get() + 1); + self.doc.get_object(id) + } + fn encrypted(&self) -> bool { + false + } + } + + /// Widgets without `/P` are placed on the page that lists them with one pass over the pages' + /// `/Annots`, not a search of every page per field: that was O(fields × annotations), a + /// second to open a form of 8,000 fields. A widget two pages list is on the first of them. + #[test] + fn widgets_without_a_page_entry_are_placed_in_one_pass() { + const PAGES: usize = 100; + const PER_PAGE: usize = 10; + let field = |p: usize, k: usize| 4 + PAGES + p * PER_PAGE + k; + let mut pdf = String::from("%PDF-1.7\n1 0 obj << /Type /Catalog /Pages 2 0 R /AcroForm 3 0 R >> endobj\n"); + let kids: Vec = (0..PAGES).map(|p| format!("{} 0 R", 4 + p)).collect(); + pdf += &format!("2 0 obj << /Type /Pages /Kids [{}] /Count {PAGES} >> endobj\n", kids.join(" ")); + let fields: Vec = (0..PAGES).flat_map(|p| (0..PER_PAGE).map(move |k| format!("{} 0 R", field(p, k)))).collect(); + pdf += &format!("3 0 obj << /Fields [{}] >> endobj\n", fields.join(" ")); + for p in 0..PAGES { + let mut annots: Vec = (0..PER_PAGE).map(|k| format!("{} 0 R", field(p, k))).collect(); + // The last page lists the first page's first widget too. + if p == PAGES - 1 { + annots.push(format!("{} 0 R", field(0, 0))); + } + pdf += &format!("{} 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 200 200] /Annots [{}] >> endobj\n", 4 + p, annots.join(" ")); + } + for p in 0..PAGES { + for k in 0..PER_PAGE { + pdf += &format!("{} 0 obj << /Type /Annot /Subtype /Widget /FT /Tx /T (f{p}_{k}) /Rect [10 10 90 30] >> endobj\n", field(p, k)); + } + } + pdf += "trailer << /Root 1 0 R >>\n%%EOF"; + let doc = Document::load_mem(pdf.as_bytes()).expect("loads"); + let counting = CountingReads { doc: &doc, reads: std::cell::Cell::new(0) }; + let mut info = DocInfo { + pages: (0..PAGES) + .map(|_| PageInfo { width: 200.0, height: 200.0, crop: [0.0, 0.0, 200.0, 200.0], rotation: 0, label: String::new() }) + .collect(), + ..Default::default() + }; + Inspector::new(&counting).fill(&mut info); + let placed: Vec<_> = info.fields.iter().map(|f| (f.name.clone(), f.page)).collect(); + let want: Vec<_> = (0..PAGES).flat_map(|p| (0..PER_PAGE).map(move |k| (format!("f{p}_{k}"), Some(p)))).collect(); + assert_eq!(placed, want); + // A handful of reads per page and field; searching the pages for each field read the + // page dictionaries about 50,000 times. + let reads = counting.reads.get(); + assert!(reads < 20 * (PAGES + PAGES * PER_PAGE), "{reads} object reads"); + } + + #[test] + fn lazy_structure_matches_the_compatibility_inspector() { + // Each fixture as lopdf writes it, and with its objects in object streams. + let packed = |bytes: &[u8]| { + let doc = pdfcraft_cos::Document::open(Arc::new(bytes.to_vec())).unwrap(); + pdfcraft_cos::write_full(&doc, &pdfcraft_cos::SaveOptions { object_streams: true, ..Default::default() }).unwrap() + }; + let fixtures = [ATTACHMENTS, LAYERS, DESTS].into_iter().flat_map(|bytes| { + let mut original = Document::load_mem(bytes).unwrap(); + let mut plain = Vec::new(); + original.save_to(&mut plain).unwrap(); + let stm = packed(&plain); + [plain, stm] + }); + for bytes in fixtures { + let lazy = LazyStructure::new(Arc::new(bytes.clone()), None).unwrap(); + let eager = Document::load_mem(&bytes).unwrap(); + let mut a = inspect(Arc::new(bytes), None).unwrap(); + let mut b = DocInfo { pages: a.pages.clone(), ..Default::default() }; + let mut c = DocInfo { pages: a.pages.clone(), ..Default::default() }; + Inspector::new(&lazy).fill(&mut b); + Inspector::new(&eager).fill(&mut c); + assert!(!lazy.failed()); + assert_eq!(format!("{b:?}"), format!("{c:?}")); + a.file_size = 0; + a.pdf_version.clear(); + assert_eq!(format!("{a:?}"), format!("{c:?}")); + } + } + + #[test] + fn links_read_set_layer_actions() { + let info = inspect(Arc::new(LAYERS.to_vec()), None).expect("opens"); + let targets: Vec<_> = info.links.iter().map(|l| l.target.clone()).collect(); + use LayerOp::{Off, On, Toggle}; + assert_eq!( + targets, + [ + // Groups before the first name or after an unknown one are skipped, and so is + // anything that isn't a group. + LinkTarget::SetLayers { changes: vec![(On, (6, 0)), (Toggle, (5, 0)), (Toggle, (7, 0))], preserve_rb: false }, + // An indirect /State; /PreserveRB defaults to true. + LinkTarget::SetLayers { changes: vec![(Off, (5, 0))], preserve_rb: true }, + ] + ); + } + + #[test] + fn links_read_bounded_local_remote_go_to_actions() { + use DestView::{Fit, Xyz}; + let too_long = "a".repeat(MAX_REMOTE_FILE_SPEC_BYTES + 1); + let wide_file = format!("", "4E00".repeat(1400)); + let wide_name = format!("", "4E00".repeat(342)); + let pdf = format!( + "%PDF-1.7\n1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj\n\ + 2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 300 400] >> endobj\n\ + 3 0 obj << /Type /Page /Parent 2 0 R /Annots [99 0 R 30 0 R 31 0 R 32 0 R 33 0 R 34 0 R 35 0 R 36 0 R 37 0 R 38 0 R 39 0 R 40 0 R 41 0 R] >> endobj\n\ + 30 0 obj << /Type /Annot /Subtype /Link /Rect [0 0 10 10] /A << /S /GoToR /F (chapters/remote.pdf) /D [2 /XYZ 10 20 1.5] /NewWindow true >> >> endobj\n\ + 31 0 obj << /Type /Annot /Subtype /Link /Rect [0 20 10 30] /A << /S /GoToR /F << /Type /Filespec /F (fallback.pdf) /UF >> /D [0 /Fit] /NewWindow false >> >> endobj\n\ + 32 0 obj << /Type /Annot /Subtype /Link /Rect [0 40 10 50] /A << /S /GoToR /F (remote.pdf) >> >> endobj\n\ + 33 0 obj << /Type /Annot /Subtype /Link /Rect [0 60 10 70] /A << /S /GoToR /F (chapter.pdf) /D (chapter) >> >> endobj\n\ + 34 0 obj << /Type /Annot /Subtype /Link /Rect [0 80 10 90] /A << /S /GoToR /FS /URL /F (https://example.com) /D [0 /Fit] >> >> endobj\n\ + 35 0 obj << /Type /Annot /Subtype /Link /Rect [0 100 10 110] /A << /S /GoToR /F ({too_long}) /D [0 /Fit] >> >> endobj\n\ + 36 0 obj << /Type /Annot /Subtype /Link /Rect [0 120 10 130] /A << /S /GoToR /F (chapter.pdf) /D /chapter >> >> endobj\n\ + 37 0 obj << /Type /Annot /Subtype /Link /Rect [0 140 10 150] /A << /S /GoToR /F (chapter.pdf) /D [-1 /Fit] >> >> endobj\n\ + 38 0 obj << /Type /Annot /Subtype /Link /Rect [0 160 10 170] /A << /S /GoToR /F (chapter.pdf) /D [1.5 /Fit] >> >> endobj\n\ + 39 0 obj << /Type /Annot /Subtype /Link /Rect [0 180 10 190] /A << /S /GoToR /F (chapter.pdf) /D [9223372036854775807 /Fit] >> >> endobj\n\ + 40 0 obj << /Type /Annot /Subtype /Link /Rect [0 200 10 210] /A << /S /GoToR /F << /Type /Filespec /UF {wide_file} >> /D [0 /Fit] >> >> endobj\n\ + 41 0 obj << /Type /Annot /Subtype /Link /Rect [0 220 10 230] /A << /S /GoToR /F (chapter.pdf) /D {wide_name} >> >> endobj\n\ + trailer << /Root 1 0 R >>\n%%EOF" + ); + let info = inspect(Arc::new(pdf.into_bytes()), None).expect("opens"); + let targets: Vec<_> = info.links.iter().map(|l| (l.index, l.target.clone())).collect(); + let huge_page_target = usize::try_from(i64::MAX) + .map(|page| LinkTarget::RemoteGoTo { + file: "chapter.pdf".into(), + destination: RemoteDestination::Page { page, view: Fit }, + new_window: None, + }) + .unwrap_or_else(|_| LinkTarget::Other("GoToR".into())); + assert_eq!( + targets, + [ + ( + 1, + LinkTarget::RemoteGoTo { + file: "chapters/remote.pdf".into(), + destination: RemoteDestination::Page { page: 2, view: Xyz { left: Some(10.0), top: Some(20.0), zoom: Some(1.5) } }, + new_window: Some(true), + } + ), + ( + 2, + LinkTarget::RemoteGoTo { + file: "foo.pdf".into(), + destination: RemoteDestination::Page { page: 0, view: Fit }, + new_window: Some(false), + } + ), + (3, LinkTarget::RemoteGoTo { file: "remote.pdf".into(), destination: RemoteDestination::Default, new_window: None }), + (4, LinkTarget::RemoteGoTo { file: "chapter.pdf".into(), destination: RemoteDestination::Named("chapter".into()), new_window: None }), + (5, LinkTarget::Other("GoToR".into())), + (6, LinkTarget::Other("GoToR".into())), + (7, LinkTarget::RemoteGoTo { file: "chapter.pdf".into(), destination: RemoteDestination::Named("chapter".into()), new_window: None }), + (8, LinkTarget::Other("GoToR".into())), + (9, LinkTarget::Other("GoToR".into())), + (10, huge_page_target), + (11, LinkTarget::Other("GoToR".into())), + (12, LinkTarget::Other("GoToR".into())), + ] + ); + } + + #[test] + fn destination_operands_become_views() { + use DestView::*; + let v = |kind: &[u8], ops: &[Option]| DestView::from_operands(Some(kind), ops); + // ISO 32000-2 Table 149, each type. + assert_eq!(v(b"XYZ", &[Some(0.0), Some(420.0), Some(0.0)]), Xyz { left: Some(0.0), top: Some(420.0), zoom: None }); + assert_eq!(v(b"XYZ", &[Some(72.0), Some(500.0), Some(1.5)]), Xyz { left: Some(72.0), top: Some(500.0), zoom: Some(1.5) }); + assert_eq!(v(b"Fit", &[]), Fit); + assert_eq!(v(b"FitB", &[]), Fit); + assert_eq!(v(b"FitH", &[Some(250.0)]), FitH { top: Some(250.0) }); + assert_eq!(v(b"FitBH", &[None]), FitH { top: None }); + assert_eq!(v(b"FitV", &[Some(120.0)]), FitV { left: Some(120.0) }); + assert_eq!(v(b"FitBV", &[Some(5.0)]), FitV { left: Some(5.0) }); + // FitR's corners are normalized. + assert_eq!(v(b"FitR", &[Some(110.0), Some(220.0), Some(10.0), Some(20.0)]), FitR { rect: [10.0, 20.0, 110.0, 220.0] }); + // Missing operands are null; so are non-finite ones, and a zoom that isn't positive. + assert_eq!(v(b"XYZ", &[]), Xyz { left: None, top: None, zoom: None }); + assert_eq!(v(b"XYZ", &[Some(f32::NAN), Some(f32::INFINITY), Some(f32::NEG_INFINITY)]), Xyz { left: None, top: None, zoom: None }); + assert_eq!(v(b"XYZ", &[None, Some(-5.0), Some(-1.0)]), Xyz { left: None, top: Some(-5.0), zoom: None }); + assert_eq!(v(b"XYZ", &[None, None, Some(f32::NAN)]), Xyz { left: None, top: None, zoom: None }); + assert_eq!(v(b"FitH", &[Some(f32::NAN)]), FitH { top: None }); + // A FitR without four usable numbers, an unknown type or no type: the top of the page. + assert_eq!(v(b"FitR", &[Some(1.0), Some(2.0)]), Top); + assert_eq!(v(b"FitR", &[Some(1.0), Some(2.0), Some(f32::INFINITY), Some(4.0)]), Top); + assert_eq!(v(b"Zoom", &[Some(3.0)]), Top); + assert_eq!(v(b"xyz", &[Some(3.0)]), Top); + assert_eq!(DestView::from_operands(None, &[Some(1.0)]), Top); + } + + /// Two 300×400 pages. Bookmarks and links on page 1 go to page 2 in every destination form, + /// named (`/Names /Dests` and the PDF 1.1 `/Dests`) and malformed. + const DESTS: &[u8] = b"%PDF-1.7 +1 0 obj << /Type /Catalog /Pages 2 0 R /Outlines 10 0 R /Names << /Dests 40 0 R >> /Dests << /old << /D [4 0 R /FitV 120] >> >> >> endobj +2 0 obj << /Type /Pages /Kids [3 0 R 4 0 R] /Count 2 /MediaBox [0 0 300 400] >> endobj +3 0 obj << /Type /Page /Parent 2 0 R /Annots [30 0 R 31 0 R 32 0 R 33 0 R] >> endobj +4 0 obj << /Type /Page /Parent 2 0 R >> endobj +10 0 obj << /Type /Outlines /First 11 0 R /Last 20 0 R /Count 10 >> endobj +11 0 obj << /Title (xyz) /Parent 10 0 R /Next 12 0 R /Dest [4 0 R /XYZ 0 420 0] >> endobj +12 0 obj << /Title (fith) /Parent 10 0 R /Next 13 0 R /Dest [4 0 R /FitH 250] >> endobj +13 0 obj << /Title (named) /Parent 10 0 R /Next 14 0 R /Dest (sec) >> endobj +14 0 obj << /Title (old named) /Parent 10 0 R /Next 15 0 R /Dest /old >> endobj +15 0 obj << /Title (action) /Parent 10 0 R /Next 16 0 R /A << /S /GoTo /D [4 0 R /FitR 110 220 10 20] >> >> endobj +16 0 obj << /Title (wrong types) /Parent 10 0 R /Next 17 0 R /Dest [4 0 R /XYZ (left) /Top true] >> endobj +17 0 obj << /Title (short) /Parent 10 0 R /Next 18 0 R /Dest [4 0 R /FitR 1 2] >> endobj +18 0 obj << /Title (no type) /Parent 10 0 R /Next 19 0 R /Dest [4 0 R] >> endobj +19 0 obj << /Title (huge) /Parent 10 0 R /Next 20 0 R /Dest [4 0 R /XYZ 1000000000000 -5 -1] >> endobj +20 0 obj << /Title (no page) /Parent 10 0 R /Dest [/XYZ 0 420 0] >> endobj +30 0 obj << /Type /Annot /Subtype /Link /Rect [0 0 10 10] /Dest [4 0 R /XYZ 72 500 1.5] >> endobj +31 0 obj << /Type /Annot /Subtype /Link /Rect [0 20 10 30] /A << /S /GoTo /D (sec) >> >> endobj +32 0 obj << /Type /Annot /Subtype /Link /Rect [0 40 10 50] /Dest [4 0 R /FitBH 100] >> endobj +33 0 obj << /Type /Annot /Subtype /Link /Rect [0 60 10 70] /Dest [4 0 R /Fit] >> endobj +40 0 obj << /Names [(sec) << /D [4 0 R /XYZ null 300 2] >>] >> endobj +trailer << /Root 1 0 R >> +%%EOF"; + + #[test] + fn bookmarks_and_links_keep_their_destination_view() { + use DestView::*; + let info = inspect(Arc::new(DESTS.to_vec()), None).expect("opens"); + let outline: Vec<_> = info.outline.iter().map(|o| (o.title.as_str(), o.page, o.view)).collect(); + assert_eq!( + outline, + [ + ("xyz", Some(1), Xyz { left: Some(0.0), top: Some(420.0), zoom: None }), + ("fith", Some(1), FitH { top: Some(250.0) }), + ("named", Some(1), Xyz { left: None, top: Some(300.0), zoom: Some(2.0) }), + ("old named", Some(1), FitV { left: Some(120.0) }), + ("action", Some(1), FitR { rect: [10.0, 20.0, 110.0, 220.0] }), + // Hostile operands fall back without failing the bookmark. + ("wrong types", Some(1), Xyz { left: None, top: None, zoom: None }), + ("short", Some(1), Top), + ("no type", Some(1), Top), + ("huge", Some(1), Xyz { left: Some(1e12), top: Some(-5.0), zoom: None }), + ("no page", None, Top), + ] + ); + let links: Vec<_> = info.links.iter().map(|l| l.target.clone()).collect(); + assert_eq!( + links, + [ + LinkTarget::Page(1, Xyz { left: Some(72.0), top: Some(500.0), zoom: Some(1.5) }), + LinkTarget::Page(1, Xyz { left: None, top: Some(300.0), zoom: Some(2.0) }), + LinkTarget::Page(1, FitH { top: Some(100.0) }), + LinkTarget::Page(1, Fit), + ] + ); + } + + #[test] + fn outline_titles_decode_every_text_string_encoding() { + // Issue #142: CJK bookmark titles showed as mojibake or with a stray BOM. + let pdf = b"%PDF-1.7 +1 0 obj << /Type /Catalog /Pages 2 0 R /Outlines 10 0 R >> endobj +2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 300 400] >> endobj +3 0 obj << /Type /Page /Parent 2 0 R >> endobj +10 0 obj << /Type /Outlines /First 11 0 R /Last 16 0 R /Count 6 >> endobj +11 0 obj << /Title /Parent 10 0 R /Next 12 0 R /Dest [3 0 R /Fit] >> endobj +12 0 obj << /Title /Parent 10 0 R /Next 13 0 R /Dest [3 0 R /Fit] >> endobj +13 0 obj << /Title /Parent 10 0 R /Next 14 0 R /Dest [3 0 R /Fit] >> endobj +14 0 obj << /Title /Parent 10 0 R /Next 15 0 R /Dest [3 0 R /Fit] >> endobj +15 0 obj << /Title <436166E9> /Parent 10 0 R /Next 16 0 R /Dest [3 0 R /Fit] >> endobj +16 0 obj << /Title /Parent 10 0 R /Dest [3 0 R /Fit] >> endobj +trailer << /Root 1 0 R >> +%%EOF"; + let info = inspect(Arc::new(pdf.to_vec()), None).expect("opens"); + let titles: Vec<_> = info.outline.iter().map(|o| o.title.as_str()).collect(); + // UTF-16BE, UTF-8 with BOM, invalid UTF-8 after a BOM (lossy, not dropped), BOM-less + // raw UTF-8, PDFDocEncoding Latin-1, and a doubled BOM. + assert_eq!(titles, ["第一章", "目录", "A\u{FFFD}B", "概述", "Café", "A"]); + } + + /// A file whose objects are `bodies` in order (object 1 first), with a cross-reference table. + fn pdf_of(bodies: &[String]) -> Vec { + let mut out = b"%PDF-1.7\n".to_vec(); + let mut offsets = Vec::new(); + for (i, body) in bodies.iter().enumerate() { + offsets.push(out.len()); + out.extend_from_slice(format!("{} 0 obj\n{body}\nendobj\n", i + 1).as_bytes()); + } + let xref = out.len(); + out.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", bodies.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", bodies.len() + 1).as_bytes()); + out + } + + /// A one-page file whose outline is `items` (objects 5, 6, … in order). + fn outline_file(items: &[String]) -> Vec { + let mut bodies = vec![ + "<< /Type /Catalog /Pages 2 0 R /Outlines 4 0 R >>".to_string(), + "<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 300 400] >>".to_string(), + "<< /Type /Page /Parent 2 0 R >>".to_string(), + format!("<< /Type /Outlines /First 5 0 R /Count {} >>", items.len()), + ]; + bodies.extend(items.iter().cloned()); + pdf_of(&bodies) + } + + /// The titles of `items`, with children in brackets (`Parent[Child]`). + fn shape(items: &[OutlineItem]) -> Vec { + items.iter().map(|o| if o.children.is_empty() { o.title.clone() } else { format!("{}[{}]", o.title, shape(&o.children).join(",")) }).collect() + } + + #[test] + fn outline_window_keeps_the_first_bookmarks_and_says_when_more_follow() { + const COUNT: usize = OUTLINE_WINDOW + 5; + let items: Vec = (0..COUNT) + .map(|k| { + let prev = if k == 0 { String::new() } else { format!(" /Prev {} 0 R", 4 + k) }; + let next = if k + 1 == COUNT { String::new() } else { format!(" /Next {} 0 R", 6 + k) }; + format!("<< /Title (Item {k}) /Parent 4 0 R{prev}{next} /Dest [3 0 R /Fit] >>") + }) + .collect(); + let info = inspect(Arc::new(outline_file(&items)), None).expect("opens"); + assert_eq!(info.outline.len(), OUTLINE_WINDOW); + assert_eq!(info.outline.last().map(|o| o.title.as_str()), Some("Item 9999")); + assert!(info.outline_more); + + let one = inspect(Arc::new(outline_file(&["<< /Title (Only) /Parent 4 0 R /Dest [3 0 R /Fit] >>".into()])), None).expect("opens"); + assert_eq!(shape(&one.outline), ["Only"]); + assert!(!one.outline_more); + } + + #[test] + fn outline_window_nests_no_deeper_than_its_depth_limit() { + // A chain of 40 bookmarks, each the first child of the one before. + let items: Vec = (0..40) + .map(|k| { + let parent = if k == 0 { 4 } else { 4 + k }; + let first = if k + 1 == 40 { String::new() } else { format!(" /First {} 0 R", 6 + k) }; + format!("<< /Title (Deep {k}) /Parent {parent} 0 R{first} /Dest [3 0 R /Fit] >>") + }) + .collect(); + let info = inspect(Arc::new(outline_file(&items)), None).expect("opens"); + let mut levels = 0; + let mut level = info.outline.first(); + while let Some(item) = level { + levels += 1; + level = item.children.first(); + } + assert_eq!(levels, OUTLINE_DEPTH + 1); + assert!(info.outline_more); + } + + #[test] + fn outline_window_ends_cycles_and_broken_links_without_looping() { + let listed = |items: &[&str]| { + let items: Vec = items.iter().map(|s| s.to_string()).collect(); + shape(&inspect(Arc::new(outline_file(&items)), None).expect("opens").outline) + }; + assert_eq!(listed(&["<< /Title (Self) /Parent 4 0 R /Next 5 0 R >>"]), ["Self"]); + assert_eq!(listed(&["<< /Title (Self) /Parent 4 0 R /First 5 0 R >>"]), ["Self"]); + assert_eq!(listed(&["<< /Title (A) /Parent 4 0 R /Next 6 0 R >>", "<< /Title (B) /Parent 4 0 R /Next 5 0 R >>"]), ["A", "B"]); + assert_eq!(listed(&["<< /Title (A) /Parent 4 0 R /First 6 0 R >>", "<< /Title (B) /Parent 5 0 R /First 5 0 R >>"]), ["A[B]"]); + assert_eq!(listed(&["<< /Title (A) /Parent 4 0 R /Next 99 0 R >>"]), ["A"]); + assert_eq!(listed(&["<< /Title (A) /Parent 4 0 R /Next 6 0 R >>", "42"]), ["A"]); + } + + #[test] + fn destination_points_map_to_the_displayed_page() { + // A crop box away from the origin: x 100..400, y 50..450. + let mut p = PageInfo { width: 300.0, height: 400.0, label: "1".into(), crop: [100.0, 50.0, 400.0, 450.0], rotation: 0 }; + assert_eq!(p.dest_fraction(Some(100.0), Some(450.0), 0), [Some(0.0), Some(0.0)]); + assert_eq!(p.dest_fraction(Some(250.0), Some(350.0), 0), [Some(0.5), Some(0.25)]); + // Unspecified stays unspecified; off-page and huge values are clamped to the page. + assert_eq!(p.dest_fraction(None, Some(350.0), 0), [None, Some(0.25)]); + assert_eq!(p.dest_fraction(Some(-1e30), Some(1e30), 0), [Some(0.0), Some(0.0)]); + assert_eq!(p.dest_fraction(Some(f32::INFINITY), Some(f32::NAN), 0), [None, None]); + // /Rotate 90 (clockwise): user y runs left to right across the displayed page, user x + // top to bottom. A view rotation adds to it. + p.rotation = 90; + assert_eq!(p.dest_fraction(Some(250.0), Some(350.0), 0), [Some(0.75), Some(0.5)]); + assert_eq!(p.dest_fraction(None, Some(350.0), 0), [Some(0.75), None]); + assert_eq!(p.dest_fraction(Some(250.0), Some(350.0), 90), [Some(0.5), Some(0.75)]); + p.rotation = 180; + assert_eq!(p.dest_fraction(Some(250.0), Some(350.0), 0), [Some(0.5), Some(0.75)]); + p.rotation = 270; + assert_eq!(p.dest_fraction(Some(250.0), Some(350.0), 0), [Some(0.25), Some(0.5)]); + assert_eq!(p.dest_fraction(Some(250.0), Some(350.0), 90), [Some(0.5), Some(0.25)]); + // A degenerate crop box never divides by zero. + let flat = PageInfo { crop: [0.0, 0.0, 0.0, 0.0], rotation: 0, ..p }; + assert!(flat.dest_fraction(Some(5.0), Some(-5.0), 0).iter().all(|f| f.is_some_and(|f| (0.0..=1.0).contains(&f)))); + } + + #[test] + fn structure_failure_preserves_renderer_pages() { + for crash in [true, false] { + let mut info = DocInfo { + file_size: 321, + pdf_version: "1.7".into(), + pages: vec![PageInfo { width: 300.0, height: 200.0, label: "1".into(), crop: [10.0, 20.0, 210.0, 320.0], rotation: 90 }], + ..Default::default() + }; + inspect_structure(&mut info, |tmp| { + tmp.pages.clear(); + tmp.title = Some("partially inspected".into()); + if crash { + panic!("synthetic structure inspection failure"); + } + Err("synthetic structure inspection failure".into()) + }); + + assert_eq!(info.pages.len(), 1); + let page = &info.pages[0]; + assert_eq!((page.width, page.height, page.label.as_str(), page.crop, page.rotation), (300.0, 200.0, "1", [10.0, 20.0, 210.0, 320.0], 90)); + assert_eq!(info.file_size, 321); + assert_eq!(info.pdf_version, "1.7"); + assert!(info.title.is_none()); + assert_eq!(info.warnings.len(), 1); + assert!(info.warnings[0].contains("synthetic structure inspection failure")); + assert_eq!(info.warnings[0].contains("crashed"), crash); + } + } + + #[test] + fn invalid_dates_with_unicode_are_unchanged() { + for input in ["", "D:", "D:20260930104", "D:202609x01045", "yesterday"] { + assert_eq!(pretty_date(input), input); + } + // Cover every position before the 12-byte prefix, including characters that + // straddle its end. None of these strings is an ASCII PDF date. + for character in ['é', '€', '😀'] { + for prefix_len in 0..12 { + let input = format!("D:{}{character}123456789012", "1".repeat(prefix_len)); + assert_eq!(pretty_date(&input), input, "{character} after {prefix_len} digits"); + } + } + } + + #[test] + fn unicode_annotation_date_does_not_prevent_opening() { + use lopdf::dictionary; + + let date = "D:12345678901éX"; + let mut encoded_date = vec![0xFE, 0xFF]; + encoded_date.extend(date.encode_utf16().flat_map(u16::to_be_bytes)); + let mut doc = Document::with_version("1.7"); + let pages_id = doc.new_object_id(); + let annot_id = doc.add_object(dictionary! { + "Type" => "Annot", + "Subtype" => "Text", + "Rect" => vec![10.into(), 10.into(), 30.into(), 30.into()], + "M" => Object::String(encoded_date, lopdf::StringFormat::Hexadecimal), + }); + let page_id = doc.add_object(dictionary! { + "Type" => "Page", + "Parent" => pages_id, + "MediaBox" => vec![0.into(), 0.into(), 200.into(), 300.into()], + "Annots" => vec![annot_id.into()], + }); + doc.objects.insert( + pages_id, + dictionary! { + "Type" => "Pages", + "Kids" => vec![page_id.into()], + "Count" => 1, + } + .into(), + ); + let catalog_id = doc.add_object(dictionary! { "Type" => "Catalog", "Pages" => pages_id }); + doc.trailer.set("Root", catalog_id); + let mut bytes = Vec::new(); + doc.save_to(&mut bytes).expect("write synthetic fixture"); + + let info = inspect(Arc::new(bytes), None).expect("opens despite a non-date /M string"); + assert_eq!(info.pages.len(), 1); + assert_eq!((info.pages[0].width, info.pages[0].height), (200.0, 300.0)); + assert_eq!(info.annotations.len(), 1); + assert_eq!(info.annotations[0].modified.as_deref(), Some(date)); + assert!(info.warnings.is_empty(), "{:?}", info.warnings); + } + + #[test] + fn carriage_returns_in_comment_text_become_line_breaks() { + use lopdf::dictionary; + + let mut doc = Document::with_version("1.7"); + let pages_id = doc.new_object_id(); + let annot_id = doc.add_object(dictionary! { + "Type" => "Annot", + "Subtype" => "Text", + "Rect" => vec![10.into(), 10.into(), 30.into(), 30.into()], + // Acrobat ends lines in /Contents with CR; the interface only breaks lines at LF. + "Contents" => Object::string_literal("one\r\rtwo\r\nthree"), + }); + let page_id = doc.add_object(dictionary! { + "Type" => "Page", + "Parent" => pages_id, + "MediaBox" => vec![0.into(), 0.into(), 200.into(), 300.into()], + "Annots" => vec![annot_id.into()], + }); + doc.objects.insert(pages_id, dictionary! { "Type" => "Pages", "Kids" => vec![page_id.into()], "Count" => 1 }.into()); + let catalog_id = doc.add_object(dictionary! { "Type" => "Catalog", "Pages" => pages_id }); + doc.trailer.set("Root", catalog_id); + let mut bytes = Vec::new(); + doc.save_to(&mut bytes).expect("write synthetic fixture"); + + let info = inspect(Arc::new(bytes), None).expect("opens"); + assert_eq!(info.annotations[0].contents.as_deref(), Some("one\n\ntwo\nthree")); + } + + #[test] + fn dates_are_prettified() { + assert_eq!(pretty_date("D:20260930104512-04'00'"), "2026-09-30 10:45"); + assert_eq!(pretty_date("yesterday"), "yesterday"); + } + + #[test] + fn jspdf_high_precision_mediabox_does_not_overflow_dimensions() { + let pdf = b"%PDF-1.3 +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 3874.9606299212600788 5493.5433070866147318] >> endobj +trailer << /Root 1 0 R >> +%%EOF"; + let info = inspect(std::sync::Arc::new(pdf.to_vec()), None).expect("opens"); + assert_eq!(info.pages.len(), 1); + let p = &info.pages[0]; + assert!((p.width - 3874.96).abs() < 0.1, "width expected ~3874.96, got {}", p.width); + assert!((p.height - 5493.54).abs() < 0.1, "height expected ~5493.54, got {}", p.height); + } +} + +/// "1.7" from the parser's version name ("Pdf17", "V1_7", …). +fn version_label(name: &str) -> String { + let digits: Vec = name.chars().filter(char::is_ascii_digit).collect(); + match digits[..] { + [a, b] => format!("{a}.{b}"), + _ => name.to_owned(), + } +} + +#[cfg(test)] +mod version_tests { + #[test] + fn version_labels_read_as_numbers() { + assert_eq!(super::version_label("Pdf17"), "1.7"); + assert_eq!(super::version_label("V2_0"), "2.0"); + assert_eq!(super::version_label("Unknown"), "Unknown"); + } +} + +#[cfg(test)] +mod substitution_tests { + use std::sync::Arc; + + use super::{FontInfo, inspect}; + + /// One page naming four fonts the document does not embed: a Chinese CID font (Adobe-GB1), a + /// Japanese one (Adobe-Japan1), an unknown serif-flagged simple font, and an aliased + /// standard-14 name. + const NOT_EMBEDDED: &[u8] = b"%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 200 200] /Resources << /Font << /F1 4 0 R /F2 6 0 R /F3 8 0 R /F4 9 0 R /F5 11 0 R >> >> >> endobj +4 0 obj << /Type /Font /Subtype /Type0 /BaseFont /STSong-Light /Encoding /UniGB-UCS2-H /DescendantFonts [5 0 R] >> endobj +5 0 obj << /Type /Font /Subtype /CIDFontType0 /BaseFont /STSong-Light /CIDSystemInfo << /Registry (Adobe) /Ordering (GB1) /Supplement 2 >> >> endobj +6 0 obj << /Type /Font /Subtype /Type0 /BaseFont /KozMinPro-Regular /Encoding /UniJIS-UCS2-H /DescendantFonts [7 0 R] >> endobj +7 0 obj << /Type /Font /Subtype /CIDFontType0 /BaseFont /KozMinPro-Regular /CIDSystemInfo << /Registry (Adobe) /Ordering (Japan1) /Supplement 4 >> >> endobj +8 0 obj << /Type /Font /Subtype /TrueType /BaseFont /SomeHouseSerif /FontDescriptor 10 0 R >> endobj +9 0 obj << /Type /Font /Subtype /TrueType /BaseFont /ArialMT >> endobj +10 0 obj << /Type /FontDescriptor /FontName /SomeHouseSerif /Flags 34 >> endobj +11 0 obj << /Type /Font /Subtype /TrueType /BaseFont /HouseGrotesk /FontDescriptor 12 0 R >> endobj +12 0 obj << /Type /FontDescriptor /FontName /HouseGrotesk /Flags 33 >> endobj +trailer << /Size 13 /Root 1 0 R >> +%%EOF"; + + fn font<'a>(fonts: &'a [FontInfo], name: &str) -> &'a FontInfo { + fonts.iter().find(|f| f.name == name).unwrap_or_else(|| panic!("{name} listed; got {:?}", fonts.iter().map(|f| &f.name).collect::>())) + } + + #[test] + fn doc_info_says_which_face_stands_in_and_why() { + let info = inspect(Arc::new(NOT_EMBEDDED.to_vec()), None).expect("opens"); + + // The one that matters most to a user: a Chinese PDF that draws nothing. Before this, + // doc_info said only `embedded: false` and left them to guess. + let cjk = font(&info.fonts, "STSong-Light"); + assert!(!cjk.embedded); + assert_eq!(cjk.substitute, None, "no licensed Chinese face, so nothing stands in"); + let why = cjk.substitution.as_deref().unwrap_or_default(); + assert!(why.contains("no licensed face") && why.contains("rather than the wrong glyphs"), "{why}"); + + // An aliased standard-14 name resolves to the real face, whose metrics are exact. + let arial = font(&info.fonts, "ArialMT"); + assert_eq!(arial.substitute.as_deref(), Some("Helvetica")); + assert!(arial.substitution.as_deref().unwrap_or_default().contains("standard 14")); + + // A house font whose name merely contains "Serif" gets Times -- reported as the guess it + // is, not as a standard-14 name, which would be untrue of a font nobody has heard of. + let serif = font(&info.fonts, "SomeHouseSerif"); + assert_eq!(serif.substitute.as_deref(), Some("Times-Roman")); + assert!(serif.substitution.as_deref().unwrap_or_default().contains("hints at one"), "{serif:?}"); + + // No hint in the name at all, so the descriptor flags decide: 33 = fixed pitch + nonsymbolic. + let grotesk = font(&info.fonts, "HouseGrotesk"); + assert_eq!(grotesk.substitute.as_deref(), Some("Courier")); + assert!(grotesk.substitution.as_deref().unwrap_or_default().contains("descriptor flags"), "{grotesk:?}"); + + // Japanese depends on the optional craft-fonts build input; either way it is reported, + // and never as a Latin face. + let jpan = font(&info.fonts, "KozMinPro-Regular"); + assert!(jpan.substitution.is_some(), "a substitution is always explained"); + match &jpan.substitute { + Some(face) => { + assert!(!face.starts_with("Helvetica") && !face.starts_with("Times"), "a Latin face is never a Japanese substitute: {face}") + } + None => assert!(jpan.substitution.as_deref().unwrap_or_default().contains("no licensed face")), + } + + // Nothing claims to be embedded, so every font here carries a reason. + assert!(info.fonts.iter().all(|f| !f.embedded && f.substitution.is_some()), "{:?}", info.fonts); + } + + /// An embedded font is reported as faithful: nothing stands in, and there is nothing to explain. + const EMBEDDED: &[u8] = b"%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 200 200] /Resources << /Font << /F1 4 0 R >> >> >> endobj +4 0 obj << /Type /Font /Subtype /TrueType /BaseFont /ABCDEF+SomeHouseSans /FontDescriptor 5 0 R >> endobj +5 0 obj << /Type /FontDescriptor /FontName /ABCDEF+SomeHouseSans /Flags 32 /FontFile2 6 0 R >> endobj +6 0 obj << /Length 4 >> stream +test +endstream endobj +trailer << /Size 7 /Root 1 0 R >> +%%EOF"; + + #[test] + fn an_embedded_font_reports_no_substitution_at_all() { + let info = inspect(Arc::new(EMBEDDED.to_vec()), None).expect("opens"); + let f = font(&info.fonts, "SomeHouseSans"); + assert!(f.embedded && f.subset, "{f:?}"); + assert_eq!((f.substitute.as_deref(), f.substitution.as_deref()), (None, None), "the document's own glyphs are shown"); + } +} + +#[cfg(test)] +mod open_action_javascript_tests { + use std::sync::Arc; + + use super::inspect; + + /// One-page PDF whose catalog points its /OpenAction at object 4 (written by the caller). + fn pdf_with_open_action(action: &[u8]) -> Vec { + let objs: Vec<&[u8]> = vec![ + b"<< /Type /Catalog /Pages 2 0 R /OpenAction 4 0 R >>", + b"<< /Type /Pages /Kids [3 0 R] /Count 1 >>", + b"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] >>", + action, + ]; + let mut out = b"%PDF-1.7\n".to_vec(); + let mut offsets = Vec::new(); + for (i, o) in objs.iter().enumerate() { + offsets.push(out.len()); + out.extend_from_slice(format!("{} 0 obj\n", i + 1).as_bytes()); + out.extend_from_slice(o); + out.extend_from_slice(b"\nendobj\n"); + } + let xref = out.len(); + out.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objs.len() + 1).as_bytes()); + for o in offsets { + out.extend_from_slice(format!("{o:010} 00000 n \n").as_bytes()); + } + out.extend_from_slice(format!("trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n", objs.len() + 1).as_bytes()); + out + } + + #[test] + fn open_action_javascript_sets_has_javascript() { + let bytes = pdf_with_open_action(b"<< /S /JavaScript /JS (app.alert('hi')) >>"); + let info = inspect(Arc::new(bytes), None).expect("inspects"); + assert!(info.has_javascript, "an /OpenAction /JavaScript is JavaScript"); + } + + #[test] + fn open_action_goto_does_not_set_has_javascript() { + let bytes = pdf_with_open_action(b"<< /S /GoTo /D [3 0 R /Fit] >>"); + let info = inspect(Arc::new(bytes), None).expect("inspects"); + assert!(!info.has_javascript, "a /GoTo /OpenAction is not JavaScript"); + } +} From c30fce5d3e2256bad0cb9793a68fadb5093d5c6f Mon Sep 17 00:00:00 2001 From: Mex <44814932+MMexicann@users.noreply.github.com> Date: Thu, 15 Oct 2026 23:13:27 +0200 Subject: [PATCH 023/105] M6: distinguish CSV header tabs from TSV delimiters --- ROADMAP.md | 4 +++- crates/xfdf/README.md | 4 ++++ crates/xfdf/src/lib.rs | 31 +++++++++++++++++++++--- crates/xfdf/src/tests.rs | 46 ++++++++++++++++++++++++++++++++++++ docs/roadmap.md | 5 ++-- parity/acrobat-features.toml | 8 +++---- 6 files changed, 88 insertions(+), 10 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 66b9677569..c0bd303bbf 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -2,7 +2,7 @@ **Stage: alpha** · next: beta, ~25% (ready for real work 50% → 75%) and ~400–700 h away -> **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 (M6: CSV tab-containing headers; estimates unchanged) > **Last reviewed:** 2026-10-13 · **Last updated:** 2026-10-14 · **Change:** minor (M14: the Italian catalog has every entry the other catalogs had and the code looks up; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS) > **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-14 · **Change:** minor (M3 #2080 candidate: explicit search jumps join per-tab history; rail and control-channel Back/Forward; estimates unchanged) > **Last reviewed:** 2026-10-13 · **Last updated:** 2026-10-13 · **Change:** minor (M4 #1910: Organize Pages opens on the current page; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS) @@ -157,6 +157,7 @@ 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-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 (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-13 (community, M3, #1569 review):** Pixel-only Wayland wheel, wheel-tilt and continuous input use standalone moved events, so Ctrl zoom does not remain latched after the modifier is released (#991); vendored winit patch (13), with a policy test. Estimates unchanged. @@ -493,6 +494,7 @@ Newest first: the date, what moved, and the new overall estimate. | Date | Change | Summary | |---|---|---| +| 2026-10-15 | minor | M6: CSV export and spreadsheet merging quote tabs, and import distinguishes quoted tabs from TSV delimiters without changing field identities. Estimates unchanged. | | 2026-10-12 | minor | M6: valid AcroForm XML element names with original PDF field names preserved; saved-PDF collision and Unicode regressions. Estimates unchanged. | | 2026-10-12 | minor | M6: preserve carriage returns in exported CSV field names and values; saved-PDF round-trip coverage. Estimates unchanged. | | 2026-10-13 | minor | M4: Insert-from-file preserves source bookmarks; integration tests retained with current main. Estimates unchanged. | diff --git a/crates/xfdf/README.md b/crates/xfdf/README.md index 2b179e2007..c14f65c293 100644 --- a/crates/xfdf/README.md +++ b/crates/xfdf/README.md @@ -18,6 +18,10 @@ let report = import(&mut doc, &bytes)?; // XFDF, FDF, XML, CSV o - **Form data**: Acrobat's XML (`xfdf:original` keeps names that aren't XML names), CSV and tab-delimited text (a row of names, a row of values). +CSV export and spreadsheet merging quote tabs, and import distinguishes quoted tabs from TSV +delimiters without changing field identities. Automatic detection of externally authored, ambiguous +tables remains heuristic. Formula-cell policy and tab-delimited export are unchanged. + Import replaces comments with the same `/NM` (and drops the replaced comment's pop-up), draws appearances PdfCraft can draw, and sets values through the form's own checks (formats, validation, recalculation); read-only fields take imported values, as in Acrobat. XML is read diff --git a/crates/xfdf/src/lib.rs b/crates/xfdf/src/lib.rs index 50326c4e71..680bf04fa1 100644 --- a/crates/xfdf/src/lib.rs +++ b/crates/xfdf/src/lib.rs @@ -612,7 +612,7 @@ pub fn export_data(doc: &Document, format: Format) -> String { out } Format::Csv => { - let q = |s: &str| if s.contains([',', '"', '\n', '\r']) { format!("\"{}\"", s.replace('"', "\"\"")) } else { s.to_string() }; + let q = |s: &str| if s.contains([',', '"', '\n', '\r', '\t']) { format!("\"{}\"", s.replace('"', "\"\"")) } else { s.to_string() }; format!( "{}\n{}\n", fields.iter().map(|f| q(&f.0)).collect::>().join(","), @@ -1041,7 +1041,7 @@ pub fn merge_csv(files: &[Vec<(String, Vec)>]) -> String { } } } - let q = |s: &str| if s.contains([',', '"', '\n', '\r']) { format!("\"{}\"", s.replace('"', "\"\"")) } else { s.to_string() }; + let q = |s: &str| if s.contains([',', '"', '\n', '\r', '\t']) { format!("\"{}\"", s.replace('"', "\"\"")) } else { s.to_string() }; let mut out = columns.iter().map(|c| q(c)).collect::>().join(","); out.push('\n'); for f in files { @@ -1101,6 +1101,31 @@ fn import_fdf(doc: &mut Document, bytes: &[u8]) -> Result { Ok(report) } +// Inspect the first logical record: tabs within quoted CSV cells are data. A quote +// in the middle of an unquoted TSV name stays literal, as in the TSV reader. +fn has_tab_delimiter(text: &str) -> bool { + let mut quoted = false; + let mut cell_start = true; + let mut chars = text.chars().peekable(); + while let Some(c) = chars.next() { + match c { + '"' if quoted && chars.peek() == Some(&'"') => { + chars.next(); + } + '"' if quoted || cell_start => { + quoted = !quoted; + cell_start = false; + } + '\t' if !quoted => return true, + '\r' | '\n' if !quoted => return false, + ',' if !quoted => cell_start = true, + _ => cell_start = false, + } + } + // An unclosed quote can be a literal TSV field-name character. + quoted && text.lines().next().is_some_and(|line| line.contains('\t')) +} + /// Form data from XML, CSV or tab-delimited text. fn import_table(doc: &mut Document, text: &str) -> Result { let mut report = Report::default(); @@ -1117,7 +1142,7 @@ fn import_table(doc: &mut Document, text: &str) -> Result { (name, vec![c.text().unwrap_or("").to_string()]) }) .collect() - } else if t.lines().next().is_some_and(|l| l.contains('\t')) { + } else if has_tab_delimiter(t) { tab_delimited(t)? } else { // CSV with quotes: a quoted value may hold commas, doubled quotes and line breaks. diff --git a/crates/xfdf/src/tests.rs b/crates/xfdf/src/tests.rs index d244df1adf..7282c5ef64 100644 --- a/crates/xfdf/src/tests.rs +++ b/crates/xfdf/src/tests.rs @@ -1011,3 +1011,49 @@ fn xfdf_import_keeps_text_around_xml_comments_and_instructions() { assert_eq!(values(&reopened)[0].1, want, "{xml_text}"); } } + +#[test] +fn csv_headers_with_tabs_survive_export_import_and_save() { + for name in ["First\tName", "\tName", "Name\t", "Name\t\"quoted\"", "Name\nwith\ta tab"] { + let mut source = one_field(); + add_field(&mut source, 0, [50.0, 650.0, 250.0, 670.0], &NewField::Text { multiline: false }, Some("Other")).unwrap(); + set_value(&mut source, "Other", &FieldValue::Text("other field".into())).unwrap(); + let field = fields(&source).into_iter().find(|f| f.name == "Name").unwrap().obj; + source.update_dict(field, |dict| dict.set(b"T".to_vec(), PdfString::text(name))).unwrap(); + set_value(&mut source, name, &FieldValue::Text("original, \"value\"".into())).unwrap(); + let bytes = write_full(&source, &SaveOptions::default()).unwrap(); + let source = Document::open(Arc::new(bytes.clone())).unwrap(); + let row = data_values(&bytes).unwrap(); + for csv in [export_data(&source, Format::Csv), merge_csv(&[row])] { + let mut doc = Document::open(Arc::new(bytes.clone())).unwrap(); + set_value(&mut doc, name, &FieldValue::Text("replacement".into())).unwrap(); + set_value(&mut doc, "Other", &FieldValue::Text("other replacement".into())).unwrap(); + let report = import(&mut doc, csv.as_bytes()).unwrap(); + assert_eq!(report.fields, 2); + assert!(report.rejected.is_empty(), "{:?}", report.rejected); + for saved in [write_full(&doc, &SaveOptions::default()).unwrap(), write_incremental(&doc, &SaveOptions::default()).unwrap()] { + let reopened = Document::open(Arc::new(saved)).unwrap(); + assert_eq!(fields(&reopened).into_iter().find(|f| f.name == name).unwrap().value, vec!["original, \"value\""], "{csv:?}"); + assert_eq!(values(&reopened), values(&source), "{csv:?}"); + } + } + } +} + +#[test] +fn tabular_import_keeps_tsv_quotes_literal_and_csv_value_tabs() { + for name in ["Name", "Na\"me", "\"Name\"", "\"Name"] { + let mut doc = one_field(); + add_field(&mut doc, 0, [50.0, 650.0, 250.0, 670.0], &NewField::Text { multiline: false }, Some("Other")).unwrap(); + let field = fields(&doc).into_iter().find(|f| f.name == "Name").unwrap().obj; + doc.update_dict(field, |dict| dict.set(b"T".to_vec(), PdfString::text(name))).unwrap(); + let text = format!("{name}\tOther\r\nAda\tother field\r\n"); + let report = import(&mut doc, text.as_bytes()).unwrap(); + assert_eq!(report.fields, 2, "{text:?}"); + assert_eq!(fields(&doc).into_iter().find(|f| f.name == name).unwrap().value, vec!["Ada"]); + } + let mut doc = one_field(); + add_field(&mut doc, 0, [50.0, 650.0, 250.0, 670.0], &NewField::Text { multiline: false }, Some("Other")).unwrap(); + import(&mut doc, b"Name,Other\r\n\"Ada\tLovelace\",other field\r\n").unwrap(); + assert_eq!(fields(&doc).into_iter().find(|f| f.name == "Name").unwrap().value, vec!["Ada\tLovelace"]); +} diff --git a/docs/roadmap.md b/docs/roadmap.md index 3148823ea5..bc244a10e3 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -1,6 +1,6 @@ # PdfCraft roadmap: milestones and what's next -> **Last reviewed:** 2026-10-13 · **Last updated:** 2026-10-14 · **Change:** minor (M14: the Italian catalog has every entry the other catalogs had and the code looks up, 2,528 entries; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS) +> **Last reviewed:** 2026-10-15 · **Last updated:** 2026-10-15 · **Change:** minor (M6: CSV tab-containing headers; estimates unchanged) > **Last reviewed:** 2026-10-12 · **Last updated:** 2026-10-13 · **Change:** minor (M2/M6: spot-colour images convert once per pixel value; widget placement and the Fields panel scale with the field count; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS) > **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-14 · **Change:** minor (M3 candidate: coordinate control clicks on slow frames and coalesced text selection; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS) > **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-14 · **Change:** minor (M3 #2080 candidate: explicit search jumps join per-tab history; rail and control-channel Back/Forward; estimates unchanged) @@ -116,7 +116,7 @@ total matches the parity estimate. | M4 | Engine, history, save, organize | 100–180 | 95% | 8–15 | Done:
- command registry (menus, shortcuts and palette all use it);
- undo/redo; incremental, atomic and encrypted saves;
- autosave and crash recovery;
- organize, combine, extract, split and insert-from-file, with identical fonts and images stored once;
- CLI `edit/combine/extract/split`.
Done since: Combine files in its own tab (sortable, resizable columns; folders; early warnings; unlocking protected files), bookmark editing, page labels (Number pages), CLI `run`, Set Page Boxes, Crop tool, Duplicate pages, Ctrl+A / Cmd+A to select all pages in Organize, Replace pages, bookmarks from tagged headings. Missing: page transitions, recovery on the web M4: Insert-from-file preserves source bookmarks; integration tests retained with current main. | | M5 | Comments (all annotation types, XFDF) | 120–200 | 88% | 12–25 | Done: notes, highlight/underline/strikeout/squiggly, text boxes, ink, lines, arrows, rectangles, ovals, stamps (dynamic, Sign Here, Standard Business), with appearance streams; replies, status, checkmarks, locking, move/resize/restyle/delete; properties dialog; panel filter and sort; hide all; summaries (comments only); XFDF/FDF import and export (partial imports preserve existing reply and review-status links); flatten; Fill & Sign with adjustable text/date size; agent tools.
Done since: stamps placed on a rotated page read upright (#1398); polygons, connected lines, clouds, callouts, inserted text, colour and checkmark filters; image signature pointer previews, selection handles and live image movement/resizing with proportional corners and independent edge handles. Missing: custom stamps, replace-text proposals, summary layouts with the page | | M5 | Comments (all annotation types, XFDF) | 120–200 | 88% | 12–25 | Done: notes, highlight/underline/strikeout/squiggly, text boxes, ink, lines, arrows, rectangles, ovals, stamps (dynamic, Sign Here, Standard Business), with appearance streams; replies, status, checkmarks, locking, move/resize/restyle/delete; properties dialog; panel filter and sort; hide all; summaries (comments only); XFDF/FDF import and export (partial imports preserve existing reply and review-status links); flatten; Fill & Sign with adjustable text/date size; agent tools.
Done since: polygons, connected lines, clouds, callouts, inserted text, colour and checkmark filters; image signature pointer previews, selection handles and live image movement/resizing with proportional corners and independent edge handles. A refused restyle or resize of a text box another program drew leaves that comment unchanged (#1327). Make Current Properties Default on an arrow updates arrows, not plain lines (#1302). Flattening keeps a visible comment it cannot draw (#1317). Missing: custom stamps, replace-text proposals, summary layouts with the page | -| M6 | Forms + JavaScript | 160–320 | 50% | 40–75 | Done: filling all field types with regenerated appearances; Prepare a form (every field type, multi-select/shared properties, move/resize/delete, Field Properties General/Appearance/Position/Options/Format/Validate/Calculate); Acrobat's AF format/keystroke/validate/calculate functions and simplified field notation run natively in Acrobat's event order; tab order; push-button actions; flatten; agent tools. Form data exchange (FDF, XFDF, XML, CSV, text) done; XFDF values retain text across XML comments and processing instructions.
Done since: JavaScript engine (boa, sandboxed) running custom keystroke/validate/calculate/format and button scripts, document JavaScripts, console. Missing: Actions tab, wider object model (annotations, layers, dialogs), XFA
Done since: Multi-select choice fields write ascending `/I` option indices while preserving `/V` selection order; synthetic forms/engine regressions cover reverse selection and save/reopen. CSV export preserves carriage returns in names and values. | +| M6 | Forms + JavaScript | 160–320 | 50% | 40–75 | Done: filling all field types with regenerated appearances; Prepare a form (every field type, multi-select/shared properties, move/resize/delete, Field Properties General/Appearance/Position/Options/Format/Validate/Calculate); Acrobat's AF format/keystroke/validate/calculate functions and simplified field notation run natively in Acrobat's event order; tab order; push-button actions; flatten; agent tools. Form data exchange (FDF, XFDF, XML, CSV, text) done; XFDF values retain text across XML comments and processing instructions. CSV export and spreadsheet merging quote tabs, and import distinguishes quoted tabs from TSV delimiters without changing field identities.
Done since: JavaScript engine (boa, sandboxed) running custom keystroke/validate/calculate/format and button scripts, document JavaScripts, console. Missing: Actions tab, wider object model (annotations, layers, dialogs), XFA
Done since: Multi-select choice fields write ascending `/I` option indices while preserving `/V` selection order; synthetic forms/engine regressions cover reverse selection and save/reopen. CSV export preserves carriage returns in names and values. | | M7 | Content editing (text, images, header/footer, watermark) | 250–500 | 20% | 100–180 | Done: header & footer, watermarks, backgrounds, Bates; added text and images that stay editable (move, resize, format, rotate, flip, crop, replace); links (Link tool, Link Properties, create from URLs, remove all). Done since: Edit text preserves Arabic/Hebrew `/ActualText` typing order, with font-independent regressions; single-glyph RTL ligature spans (including lam-alef) join their surrounding word. Done since (#1116): editable text regions grouped across compatible words and subset fonts, one rich-text editor over a background with the source text removed, embedded sfnt faces reused as editor faces, and visible (clipped) bounds for cropped-image selection and resize. Exclusive crops now resize across nested states and content streams, with shared clips fixed.
Done since: #594 ordinary paragraph drags preserve overlapping source text; UI/engine regressions, native save/reopen, independent-reader checks and full CI passed in the Linux test environment. #1200 candidate: local GoToR links open in desktop tabs and through root-confined automation; appearance-only edits preserve existing actions. Named destinations remain unapplied and `/NewWindow` is a tab hint. Missing: editing existing text and images in place (the longest pole), image/PDF watermarks | | M7 | Content editing (text, images, header/footer, watermark) | 250–500 | 20% | 100–180 | Done: header & footer, watermarks, backgrounds, Bates; added text and images that stay editable (move, resize, format, rotate, flip, crop, replace); links (Link tool, Link Properties, create from URLs, remove all). Done since: Edit text preserves Arabic/Hebrew `/ActualText` typing order, with font-independent regressions. Done since (#1116): editable text regions grouped across compatible words and subset fonts, one rich-text editor over a background with the source text removed, embedded sfnt faces reused as editor faces, and visible (clipped) bounds for cropped-image selection and resize. Exclusive crops now resize across nested states and content streams, with shared clips fixed.
Done since: #594 ordinary paragraph drags preserve overlapping source text; UI/engine regressions, native save/reopen, independent-reader checks and full CI passed in the Linux test environment. #1200 candidate: local GoToR links open in desktop tabs and through root-confined automation; appearance-only edits preserve existing actions. Named destinations remain unapplied and `/NewWindow` is a tab hint. #1316: replacing a line or paragraph keeps a neighbouring line whose glyph box overlaps when the baselines differ; a second copy on the same baseline is still replaced. Missing: editing existing text and images in place (the longest pole), image/PDF watermarks | | M7 | Content editing (text, images, header/footer, watermark) | 250–500 | 20% | 100–180 | Done: header & footer, watermarks, backgrounds, Bates; added text and images that stay editable (move, resize, format, rotate, flip, crop, replace); links (Link tool, Link Properties, create from URLs, remove all). Missing: editing existing text and images in place (the longest pole), watermark print/screen visibility and repeat | @@ -179,6 +179,7 @@ M2 (renderer), M7 (content editing), M10 (OCR and Office), M11 (Preflight and st | Date | Change | Summary | |---|---|---| +| 2026-10-15 | minor | M6: CSV export and spreadsheet merging quote tabs, and import distinguishes quoted tabs from TSV delimiters without changing field identities. Estimates unchanged. | | 2026-10-12 | minor | M6: valid AcroForm XML element names with original PDF field names preserved; saved-PDF collision and Unicode regressions. Estimates unchanged. | | 2026-10-12 | minor | M6: preserve carriage returns in exported CSV field names and values; saved-PDF round-trip coverage. Estimates unchanged. | | 2026-10-12 | minor | M7 local candidate: text drafts grow and save their width; single visual rows grow across style boundaries; both text tools retype added items and Add Text edits source text. Added-item textless previews are read-only and covered by UI/tool regressions. Estimates unchanged. | diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index 533013b547..64d7019728 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -4704,8 +4704,8 @@ milestone = "M6" status = "shipped" commands = ["form.export_data", "form.import_data"] tools = ["doc_export_data", "doc_import_data"] -evidence = ["crates/xfdf/src/tests.rs::form_data_as_xml_csv_and_text", "crates/automation/tests/automation.rs::comments_and_form_data_travel_as_xfdf_fdf_and_text"] -notes = "Acrobat's XML (xfdf:original), CSV, tab-delimited" +evidence = ["crates/xfdf/src/tests.rs::form_data_as_xml_csv_and_text", "crates/automation/tests/automation.rs::comments_and_form_data_travel_as_xfdf_fdf_and_text", "crates/xfdf/src/tests.rs::csv_headers_with_tabs_survive_export_import_and_save", "crates/xfdf/src/tests.rs::tabular_import_keeps_tsv_quotes_literal_and_csv_value_tabs"] +notes = "Acrobat's XML (xfdf:original), CSV, tab-delimited; CSV export and spreadsheet merging quote tabs, and import distinguishes quoted tabs from TSV delimiters without changing field identities. Ambiguous externally authored tables remain heuristic; TSV export and formula-cell policy are unchanged." [[feature]] id = "form.merge-to-spreadsheet" @@ -4716,8 +4716,8 @@ milestone = "M6" status = "shipped" commands = ["form.merge_data"] tools = ["form_merge_data"] -evidence = ["crates/xfdf/src/tests.rs::data_files_merge_into_a_spreadsheet", "crates/automation/tests/automation.rs::merging_form_data_into_a_spreadsheet", "crates/ui-egui/tests/javascript.rs::merge_data_files_into_a_spreadsheet"] -notes = "FDF, XFDF or filled-in PDF forms \u2192 CSV, a column per field and a row per file." +evidence = ["crates/xfdf/src/tests.rs::data_files_merge_into_a_spreadsheet", "crates/automation/tests/automation.rs::merging_form_data_into_a_spreadsheet", "crates/ui-egui/tests/javascript.rs::merge_data_files_into_a_spreadsheet", "crates/xfdf/src/tests.rs::csv_headers_with_tabs_survive_export_import_and_save"] +notes = "FDF, XFDF or filled-in PDF forms \u2192 CSV, a column per field and a row per file. CSV header tabs, quotes and line breaks retain field identities through import and save/reopen; ambiguous external table detection remains heuristic." [[feature]] id = "form.flatten-fields" From f0576408eed7da76abc694ad5fc0defe7922e7ec Mon Sep 17 00:00:00 2001 From: mtlcl410 Date: Fri, 16 Oct 2026 06:30:57 +0800 Subject: [PATCH 024/105] M1: saving refuses to replace a read-only file (#2658) Saves write a staging file and rename it over the target. On Unix the rename ignores the target's permissions, so a read-only file was overwritten without a word and came out writable. The shared atomic writer now refuses a read-only target before staging anything, with an error that points to Save As; the edits stay unsaved in the document. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01RUNQSPVXx3dDBabRZHAnjz --- crates/platform/src/staging.rs | 35 +++++++++++++++++++++++++++++++++- 1 file changed, 34 insertions(+), 1 deletion(-) diff --git a/crates/platform/src/staging.rs b/crates/platform/src/staging.rs index f7200efb9e..1319e468e1 100644 --- a/crates/platform/src/staging.rs +++ b/crates/platform/src/staging.rs @@ -76,7 +76,7 @@ pub fn write_atomically(path: &Path, bytes: &[u8], form: StagingName) -> std::io /// [`write_atomically`] with the staging-name suffixes and the flush given, so tests can plant /// names and refuse flushes. A failure at any step removes the staging file and leaves the -/// target as it was. +/// target as it was. A read-only target is refused before anything is written. pub fn write_atomically_with( path: &Path, bytes: &[u8], @@ -85,6 +85,12 @@ pub fn write_atomically_with( sync_all: impl FnOnce(&std::fs::File) -> std::io::Result<()>, ) -> std::io::Result<()> { use std::io::Write as _; + // A read-only file is not replaced. Windows refuses the rename anyway, but on Unix a rename + // ignores the target's permissions: the file would be overwritten without a word, and come + // out writable (#2658). + if std::fs::metadata(path).is_ok_and(|m| m.is_file() && m.permissions().readonly()) { + return Err(std::io::Error::new(std::io::ErrorKind::PermissionDenied, "the file is read-only; save a copy with Save As")); + } let dir = path.parent().filter(|d| !d.as_os_str().is_empty()).unwrap_or(Path::new(".")); let name = path.file_name().and_then(|n| n.to_str()).unwrap_or("save"); let (tmp, file) = create_staging(dir, name, form, suffixes)?; @@ -162,6 +168,33 @@ mod tests { let _ = std::fs::remove_dir_all(&dir); } + /// #2658: a read-only file is refused before anything is staged, on Unix (where a rename would + /// replace it) as on Windows (where the rename fails). + #[test] + fn a_read_only_target_is_refused_and_kept() { + let dir = test_dir("readonly"); + let target = dir.join("out.pdf"); + std::fs::write(&target, "OLD").unwrap(); + let mut perms = std::fs::metadata(&target).unwrap().permissions(); + perms.set_readonly(true); + std::fs::set_permissions(&target, perms.clone()).unwrap(); + let staged = std::cell::Cell::new(false); + let e = write_atomically_with(&target, b"NEW", StagingName::TagThenSuffix, [7], |_: &std::fs::File| { + staged.set(true); + Ok(()) + }) + .unwrap_err(); + assert_eq!(e.kind(), std::io::ErrorKind::PermissionDenied, "{e}"); + assert!(!staged.get(), "nothing was staged"); + assert_eq!(std::fs::read(&target).unwrap(), b"OLD"); + assert!(std::fs::metadata(&target).unwrap().permissions().readonly(), "still read-only"); + assert_eq!(std::fs::read_dir(&dir).unwrap().count(), 1, "no staging file is left"); + #[allow(clippy::permissions_set_readonly_false)] + perms.set_readonly(false); + std::fs::set_permissions(&target, perms).unwrap(); + let _ = std::fs::remove_dir_all(&dir); + } + #[test] fn running_out_of_suffixes_refuses_and_creates_nothing() { let dir = test_dir("empty"); From bb651792a9e27cef3530b9fe4527702035d3e693 Mon Sep 17 00:00:00 2001 From: Marco Arnulfo Date: Fri, 16 Oct 2026 00:33:39 +0200 Subject: [PATCH 025/105] Remove security also from files with /Encrypt inline in the trailer (fixes #2661) --- crates/cos/src/document.rs | 5 +++-- crates/cos/tests/encryption.rs | 38 ++++++++++++++++++++++++++++++++++ crates/engine/src/tests.rs | 16 ++++++++++++++ 3 files changed, 57 insertions(+), 2 deletions(-) diff --git a/crates/cos/src/document.rs b/crates/cos/src/document.rs index a0b1f56eeb..3ac19869ba 100644 --- a/crates/cos/src/document.rs +++ b/crates/cos/src/document.rs @@ -851,9 +851,10 @@ impl Document { self.repair_log = Arc::new(log); } - /// `true` when there are unsaved edits. + /// `true` when there are unsaved edits. Removing an `/Encrypt` dictionary written inline in + /// the trailer (as MuPDF writes it) changes no object, and is an edit all the same. pub fn is_modified(&self) -> bool { - !self.overlay.is_empty() + !self.overlay.is_empty() || self.encryption_changed } /// Object numbers changed since the document was opened (or last saved). diff --git a/crates/cos/tests/encryption.rs b/crates/cos/tests/encryption.rs index af92cf67c7..e05161558d 100644 --- a/crates/cos/tests/encryption.rs +++ b/crates/cos/tests/encryption.rs @@ -336,3 +336,41 @@ fn an_earlier_revision_opens_with_the_documents_own_credentials() { assert_eq!(content(¤t.open_with_same_credentials(Arc::new(fixture())).unwrap()), SECRET, "{alg:?}"); } } + +/// `protect`, with the `/Encrypt` dictionary written inline in the trailer, as MuPDF writes it, +/// instead of as an indirect object. +fn protect_inline(alg: Algorithm, user: &str, owner: &str) -> Vec { + let mut doc = Document::open(Arc::new(fixture())).unwrap(); + doc.set_encryption(&NewEncryption { + algorithm: alg, + user_password: user, + owner_password: owner, + permissions: -1, + encrypt_metadata: true, + seed: [9; 32], + }) + .unwrap(); + let dict = doc.get(doc.trailer().reference(b"Encrypt").unwrap()).as_dict().cloned().unwrap(); + doc.trailer_mut().set(b"Encrypt".to_vec(), Object::Dict(dict)); + write_full(&doc, &SaveOptions::default()).unwrap() +} + +/// MuPDF writes `/Encrypt` inline in the trailer, so removing it changes no object. It is still +/// an unsaved change, and saving must write a file that opens without a password (#2661). +#[test] +fn removing_an_inline_encrypt_dictionary_leaves_an_unencrypted_file() { + for alg in ALL { + let bytes = Arc::new(protect_inline(alg, "user", "owner")); + assert!(matches!(Document::open(bytes.clone()), Err(CosError::NeedsPassword)), "{alg:?}"); + let mut doc = Document::open_with_password(bytes, Some("owner")).unwrap(); + assert!(matches!(doc.trailer().get(b"Encrypt"), Some(Object::Dict(_))), "{alg:?}: inline"); + assert!(!doc.is_modified(), "{alg:?}"); + doc.remove_encryption(); + assert!(doc.is_modified(), "{alg:?}: removing security is an unsaved change"); + let plain = write_incremental(&doc, &SaveOptions::default()).unwrap(); + let again = Document::open(Arc::new(plain.clone())).unwrap_or_else(|e| panic!("{alg:?}: {e:?}")); + assert!(again.security().is_none() && !again.trailer().contains(b"Encrypt"), "{alg:?}"); + assert_eq!(content(&again), SECRET, "{alg:?}"); + assert!(hayro_syntax::Pdf::new(plain).is_ok(), "{alg:?}"); + } +} diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index ed90eea411..b24d25c813 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -1186,6 +1186,22 @@ fn the_permissions_password_unlocks_an_open_document() { assert!(pdfcraft_cos::Document::open(plain).unwrap().security().is_none(), "security removed"); } +#[test] +fn remove_security_from_a_file_with_its_encrypt_dictionary_in_the_trailer() { + // MuPDF writes /Encrypt inline in the trailer rather than as an object (#2661). + let mut doc = pdfcraft_cos::Document::open_with_password(protected("user", "owner", -1), Some("owner")).unwrap(); + let r = doc.trailer().reference(b"Encrypt").unwrap(); + let dict = doc.get(r).as_dict().cloned().unwrap(); + doc.trailer_mut().set(b"Encrypt".to_vec(), pdfcraft_cos::Object::Dict(dict)); + let bytes = Arc::new(pdfcraft_cos::write_full(&doc, &Default::default()).unwrap()); + let mut s = Session::new(); + let id = s.open("m.pdf", None, bytes, Some("owner")).unwrap(); + s.apply(id, Edit::RemoveProtection).unwrap(); + assert!(!s.get(id).unwrap().info.encrypted); + let plain = s.save_bytes(id).unwrap(); + assert!(pdfcraft_cos::Document::open(plain).unwrap().security().is_none(), "security removed"); +} + #[test] fn unlocking_needs_the_permissions_password_not_the_open_password() { let mut s = Session::new(); From 2dd976b90187e8e676da2aa0bce60c6cf3fb4809 Mon Sep 17 00:00:00 2001 From: Marco Arnulfo Date: Fri, 16 Oct 2026 00:36:24 +0200 Subject: [PATCH 026/105] Go to page brings the page into view horizontally too (part of #2665) --- crates/ui-egui/src/canvas.rs | 13 +++++++++++++ crates/ui-egui/tests/viewer.rs | 24 ++++++++++++++++++++++++ 2 files changed, 37 insertions(+) diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 989b731b7e..136619a353 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -2468,6 +2468,19 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) { let page = page.min(rects.len() - 1); let r = rects[page]; scroll = scroll.vertical_scroll_offset((r.top() - y_shift - GAP + frac * r.height()).max(0.0)); + // Horizontally too, as Acrobat does (#2665): beside a much wider page the others sit off + // to one side. A page already shown whole, or one filling the window's width, keeps the + // scroll, so paging through a zoomed-in document stays on the same column. Otherwise a + // page that fits is centred, and a wider one scrolled just far enough to fill the window. + let (x, w) = (view.last_scroll.x, avail.width()); + let target = if r.width() <= w { + if r.left() >= x && r.right() <= x + w { x } else { r.center().x - w / 2.0 } + } else { + x.clamp(r.left(), r.right() - w) + }; + if (target - x).abs() > 0.5 { + scroll = scroll.horizontal_scroll_offset(target.clamp(0.0, (content_w - w).max(0.0))); + } } let ppp = ui.ctx().pixels_per_point(); let scale = view.render_scale(ppp); diff --git a/crates/ui-egui/tests/viewer.rs b/crates/ui-egui/tests/viewer.rs index 077553c71d..973352ade6 100644 --- a/crates/ui-egui/tests/viewer.rs +++ b/crates/ui-egui/tests/viewer.rs @@ -1738,3 +1738,27 @@ fn arrow_keys_follow_the_scroll_speed_preference() { let (slow, normal, fast) = (lines_to_page_2("slow"), lines_to_page_2("normal"), lines_to_page_2("fast")); assert!(fast * 3 < normal * 2 && normal * 3 < slow * 2, "presses to page 2: slow {slow}, normal {normal}, fast {fast}"); } + +/// #2665: next to a very wide page the normal pages sit far to its right, outside the window. +/// Going to one changed the page counter but kept the horizontal scroll, so it stayed out of +/// sight; as in Acrobat, it is brought into view. +#[test] +fn go_to_page_brings_the_page_into_view_horizontally() { + use pdfcraft_ui_egui::canvas::Fit; + let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).build_eframe(|_cc| { + let mut app = PdfCraftApp::new(); + app.set_option("language", "en").unwrap(); + app.open_bytes("wide.pdf", None, sized_fixture(&[(595, 842), (595, 842), (595, 842), (6000, 2000)])).unwrap(); + app.views[0].fit = Fit::None; + app.views[0].zoom = 1.0; + app + }); + h.run_steps(4); + assert!(h.state().views[0].page_screen_rect(0).is_none(), "page 1 starts off-screen, right of the window"); + h.state_mut().views[0].go_to_page(2); + h.run_steps(4); + let v = &h.state().views[0]; + assert_eq!(v.current, 2); + let (r, vp) = (v.page_screen_rect(2).expect("page 3 on screen"), v.viewport_rect()); + assert!(r.left() >= vp.left() && r.right() <= vp.right(), "page 3 at {r:?}, viewport {vp:?}"); +} From b78724be05ea321a32c03a4811b5d2047333329b Mon Sep 17 00:00:00 2001 From: Marco Arnulfo Date: Fri, 16 Oct 2026 00:57:01 +0200 Subject: [PATCH 027/105] Go to page keeps the side gutter clear of the quick-action bar (part of #2665) --- crates/ui-egui/src/canvas.rs | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 136619a353..7747fdf006 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -2472,12 +2472,11 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) { // to one side. A page already shown whole, or one filling the window's width, keeps the // scroll, so paging through a zoomed-in document stays on the same column. Otherwise a // page that fits is centred, and a wider one scrolled just far enough to fill the window. + // The page counts with its side gutters, which keep its edge clear of the floating + // quick-action bar as the layout does. let (x, w) = (view.last_scroll.x, avail.width()); - let target = if r.width() <= w { - if r.left() >= x && r.right() <= x + w { x } else { r.center().x - w / 2.0 } - } else { - x.clamp(r.left(), r.right() - w) - }; + let (left, right) = (r.left() - SIDE, r.right() + SIDE); + let target = if right - left <= w { if left >= x && right <= x + w { x } else { r.center().x - w / 2.0 } } else { x.clamp(left, right - w) }; if (target - x).abs() > 0.5 { scroll = scroll.horizontal_scroll_offset(target.clamp(0.0, (content_w - w).max(0.0))); } From 2e2b5e7bf480a72d00620a40a100effd2c253cde Mon Sep 17 00:00:00 2001 From: mtlcl410 Date: Thu, 15 Oct 2026 21:10:30 +0800 Subject: [PATCH 028/105] fix(redact): removed links and comments leave the structure tree too (#2447) Remove Hidden Information and Sanitize took a link or comment off its page's /Annots, but a tagged PDF's structure tree still referenced it through an /OBJR, so the full rewrite kept the annotation, its URL or text included, while link_list reported none. The annotations removed from every page are now collected, and tags::drop_object_refs drops the structure elements' object references to them (direct or indirect, array or single kid), with the same seen-set bound as tags::clean. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01RUNQSPVXx3dDBabRZHAnjz --- ROADMAP.md | 1 + crates/redact/src/sanitize.rs | 6 +++ crates/redact/src/tags.rs | 82 +++++++++++++++++++++++++++++++++++ crates/redact/src/tests.rs | 51 ++++++++++++++++++++++ parity/acrobat-features.toml | 4 +- 5 files changed, 142 insertions(+), 2 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 66b9677569..36de00c77c 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -159,6 +159,7 @@ Ranked; detail and the full milestone table in [docs/roadmap.md](docs/roadmap.md ## Progress log - **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 (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-13 (community, M3, #1569 review):** Pixel-only Wayland wheel, wheel-tilt and continuous input use standalone moved events, so Ctrl zoom does not remain latched after the modifier is released (#991); vendored winit patch (13), with a policy test. Estimates unchanged. - **2026-10-12 (M6):** XFDF field import and data-file extraction join all text nodes in a value, preserving text after XML comments and processing instructions. Synthetic regressions cover Unicode, CDATA, empty values and PDF save/reopen. Estimates unchanged. - **2026-10-12 (M7, picture right-click menu):** The Edit text & images menu of a picture gains Cut Image, Redact Image (a redaction mark over the picture, applied from Redact a PDF), Create Link… and Recognize Text… (for that page), in all 21 catalogs. The menu was drawn only while the pointer was over the picture, so its items outside the picture (most of a logo's menu, and Delete below a page) could not be clicked; an open menu now stays drawn. Still missing from Acrobat's menu: Arrange (bring to front, send backward), Remove crop, Draw freehand and Edit image using… (opening an external editor). diff --git a/crates/redact/src/sanitize.rs b/crates/redact/src/sanitize.rs index 64d6ceefce..75b8c89bea 100644 --- a/crates/redact/src/sanitize.rs +++ b/crates/redact/src/sanitize.rs @@ -286,6 +286,8 @@ pub fn remove_hidden(doc: &mut Document, which: &[Hidden]) -> Result = std::collections::HashSet::new(); for p in pdfcraft_model::pages(doc) { let list = annots_of(doc, &p.dict); let mut removed: Vec = Vec::new(); @@ -305,6 +307,7 @@ pub fn remove_hidden(doc: &mut Document, which: &[Hidden]) -> Result Result) -> bool { + doc.resolve(o) + .as_dict() + .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))) +} + +/// 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 { + if gone.is_empty() { + return Ok(0); + } + let Some(root) = doc + .root() + .and_then(|r| doc.get(r).as_dict().cloned()) + .and_then(|c| c.get(b"StructTreeRoot").map(|s| doc.resolve(s)).and_then(|s| s.as_dict().cloned())) + else { + return Ok(0); + }; + let mut todo: Vec = root.get(b"K").cloned().into_iter().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), + Object::Ref(r) => { + if !seen.insert(r) || seen.len() > 2_000_000 { + continue; + } + let object = doc.get(r); + if let Object::Array(a) = &*object { + todo.extend(a.iter().cloned()); + continue; + } + let Some(d) = object.as_dict() else { continue }; + if !d.contains(b"S") { + 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(), + }; + if kids.iter().any(|x| refers_to(doc, x, gone)) { + hits.push(r); + } + todo.extend(kids); + } + _ => {} + } + } + let mut n = 0; + for r in hits { + let Some(d) = doc.get(r).as_dict().cloned() else { continue }; + let kids = d.get(b"K").map(|k| doc.resolve(k)); + let array = kids.as_ref().is_some_and(|k| matches!(&**k, Object::Array(_))); + let list: Vec = match kids.as_deref() { + Some(Object::Array(a)) => a.clone(), + Some(_) => d.get(b"K").cloned().into_iter().collect(), + None => Vec::new(), + }; + let before = list.len(); + let keep: Vec = list.into_iter().filter(|x| !refers_to(doc, x, gone)).collect(); + n += before - keep.len(); + doc.update_dict(r, |e| { + if array { + e.set(b"K".to_vec(), Object::Array(keep)); + } else if let Some(k) = keep.into_iter().next() { + e.set(b"K".to_vec(), k); + } else { + e.remove(b"K"); + } + })?; + } + Ok(n) +} diff --git a/crates/redact/src/tests.rs b/crates/redact/src/tests.rs index b9a230fc41..f9e08d35d0 100644 --- a/crates/redact/src/tests.rs +++ b/crates/redact/src/tests.rs @@ -631,6 +631,57 @@ fn hidden_information_is_counted_and_removed() { assert!(shown, "the field's value stays visible as page content"); } +/// Issue #2447: in a tagged PDF the structure tree points at the link and the comment it tags +/// (`/OBJR`). Removing them must drop those references too, or the full rewrite keeps the +/// annotations, the URL and the comment text included, while the page no longer lists them. +#[test] +fn removed_links_and_comments_leave_the_structure_tree_and_the_saved_file() { + use crate::sanitize::{Hidden, remove_hidden}; + let objs: Vec> = vec![ + // 1 catalog, with a structure tree + b"<< /Type /Catalog /Pages 2 0 R /MarkInfo << /Marked true >> /StructTreeRoot 8 0 R >>".to_vec(), + b"<< /Type /Pages /Kids [3 0 R] /Count 1 >>".to_vec(), + // 3 page: a tagged paragraph (MCID 0), a link and a comment, both tagged + b"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 300 300] /Contents 4 0 R /Resources << /Font << /F1 5 0 R >> >> /Annots [6 0 R 7 0 R] /StructParents 0 >>".to_vec(), + stream("", b"/P << /MCID 0 >> BDC BT /F1 10 Tf 10 230 Td (See the site) Tj ET EMC"), + FONT.replace("95 0 R", "13 0 R").into_bytes(), + // 6 link, 7 comment + b"<< /Type /Annot /Subtype /Link /Rect [10 228 80 240] /StructParent 1 /A << /S /URI /URI (https://secret.example/token=ZX81) >> >>".to_vec(), + b"<< /Type /Annot /Subtype /Text /Rect [200 200 220 220] /StructParent 2 /Contents (COMMENTSECRETNOTE) >>".to_vec(), + // 8 struct tree root, 9 document element, 10 paragraph, 11 link element (direct OBJR), + // 12 annotation element (OBJR by reference, as K's only kid) + b"<< /Type /StructTreeRoot /K 9 0 R /ParentTree << /Nums [0 [10 0 R] 1 11 0 R 2 12 0 R] >> /ParentTreeNextKey 3 >>".to_vec(), + b"<< /Type /StructElem /S /Document /P 8 0 R /K [10 0 R 11 0 R 12 0 R] >>".to_vec(), + b"<< /Type /StructElem /S /P /P 9 0 R /Pg 3 0 R /K 0 >>".to_vec(), + b"<< /Type /StructElem /S /Link /P 9 0 R /Pg 3 0 R /K [<< /Type /OBJR /Obj 6 0 R /Pg 3 0 R >>] >>".to_vec(), + b"<< /Type /StructElem /S /Annot /P 9 0 R /Pg 3 0 R /K 14 0 R >>".to_vec(), + widths(), + b"<< /Type /OBJR /Obj 7 0 R /Pg 3 0 R >>".to_vec(), + ]; + let mut doc = pdf(objs); + let done = remove_hidden(&mut doc, &[Hidden::LinksActionsScripts, Hidden::Comments]).unwrap(); + assert!(done.contains(&(Hidden::LinksActionsScripts, 1)) && done.contains(&(Hidden::Comments, 1)), "{done:?}"); + let bytes = write_full(&doc, &SaveOptions::default()).unwrap(); + let has = |needle: &[u8]| bytes.windows(needle.len()).any(|w| w == needle); + assert!(!has(b"secret.example"), "the removed link's URL survived the save"); + assert!(!has(b"COMMENTSECRETNOTE"), "the removed comment's text survived the save"); + // The tagged paragraph and its content stay; the elements lose only the references. + let saved = Document::open(Arc::new(bytes)).unwrap(); + assert!(content(&saved, 0).contains("(See the site)")); + let objrs = saved.object_numbers().into_iter().filter(|&n| { + let o = saved.get(ObjRef::new(n, saved.generation(n))); + let direct = o.as_dict().is_some_and(|d| d.name(b"Type") == Some(b"OBJR")); + let nested = o.as_dict().and_then(|d| d.get(b"K")).is_some_and(|k| match &*saved.resolve(k) { + Object::Array(a) => a.iter().any(|x| saved.resolve(x).as_dict().is_some_and(|d| d.name(b"Type") == Some(b"OBJR"))), + _ => false, + }); + direct || nested + }); + assert_eq!(objrs.count(), 0, "no object reference to a removed annotation is left"); + let root = saved.get(saved.root().unwrap()).as_dict().cloned().unwrap(); + assert!(root.contains(b"StructTreeRoot"), "the structure tree itself stays"); +} + /// Issue #1325: removing comments keeps the annotations that are not comments: the multimedia /// and print-production ones (Watermark, PrinterMark, TrapNet, Screen, Movie, RichMedia, 3D). #[test] diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index 533013b547..65e7afcc4d 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -5788,8 +5788,8 @@ milestone = "M8" status = "shipped" commands = ["protect.remove_hidden"] tools = ["doc_hidden_info", "doc_remove_hidden"] -evidence = ["crates/redact/src/tests.rs::hidden_information_is_counted_and_removed", "crates/ui-egui/tests/ui_group/redact.rs::removing_hidden_information_and_sanitizing", "crates/automation/tests/automation.rs::removing_hidden_information_through_tools"] -notes = "metadata, attachments, comments, form fields (flattened), hidden text, hidden layers, bookmarks, links/actions/scripts, private data; overlapping objects and cropped content pending" +evidence = ["crates/redact/src/tests.rs::hidden_information_is_counted_and_removed", "crates/ui-egui/tests/ui_group/redact.rs::removing_hidden_information_and_sanitizing", "crates/automation/tests/automation.rs::removing_hidden_information_through_tools", "crates/redact/src/tests.rs::removed_links_and_comments_leave_the_structure_tree_and_the_saved_file"] +notes = "metadata, attachments, comments, form fields (flattened), hidden text, hidden layers, bookmarks, links/actions/scripts (with the structure tree's references to removed links and comments, #2447), private data; overlapping objects and cropped content pending" [[feature]] id = "protect.sanitize" From bf1ce2a07cf78d0df29a7b39b5607c574839ab0b Mon Sep 17 00:00:00 2001 From: mtlcl410 Date: Fri, 16 Oct 2026 07:18:35 +0800 Subject: [PATCH 029/105] =?UTF-8?q?M3:=20=E2=8C=98G=20and=20=E2=87=A7?= =?UTF-8?q?=E2=8C=98G=20step=20through=20matches=20from=20the=20find=20fie?= =?UTF-8?q?ld=20(#2669)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The find-step shortcuts sat after the canvas's "egui wants keyboard input" check, so they did nothing while the find field had the focus (Enter worked). They now run before it, while a search is open, as in Acrobat and Preview. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01RUNQSPVXx3dDBabRZHAnjz --- crates/ui-egui/src/canvas.rs | 18 +++++++++++------- crates/ui-egui/tests/viewer.rs | 30 ++++++++++++++++++++++++++++++ 2 files changed, 41 insertions(+), 7 deletions(-) diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 989b731b7e..360d317bd9 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -2072,11 +2072,20 @@ pub fn shortcuts(view: &mut DocView, ctx: &egui::Context, speed: crate::scroll_s if ctx.input_mut(|i| i.consume_shortcut(&KeyboardShortcut::new(Modifiers::COMMAND, Key::F))) { view.open_find(); } + let cmd = |k| KeyboardShortcut::new(Modifiers::COMMAND, k); + let pressed = |s: KeyboardShortcut| ctx.input_mut(|i| i.consume_shortcut(&s)); + // ⌘G / ⇧⌘G step through the matches from the find field too, as in Acrobat and Preview + // (#2669): ahead of the text-input check, but only while there is a search to step. + if view.find.is_some() { + if pressed(KeyboardShortcut::new(Modifiers::COMMAND | Modifiers::SHIFT, Key::G)) { + view.find_step(false); + } else if pressed(cmd(Key::G)) { + view.find_step(true); + } + } if ctx.egui_wants_keyboard_input() { return; } - let cmd = |k| KeyboardShortcut::new(Modifiers::COMMAND, k); - let pressed = |s: KeyboardShortcut| ctx.input_mut(|i| i.consume_shortcut(&s)); // Rotation first: egui matches ⌘+ loosely with respect to Shift. if pressed(KeyboardShortcut::new(Modifiers::COMMAND | Modifiers::SHIFT, Key::Plus)) || pressed(KeyboardShortcut::new(Modifiers::COMMAND | Modifiers::SHIFT, Key::Equals)) @@ -2117,11 +2126,6 @@ pub fn shortcuts(view: &mut DocView, ctx: &egui::Context, speed: crate::scroll_s view.fit = Fit::Width; view.goto = Some((view.current, 0.0)); } - if pressed(KeyboardShortcut::new(Modifiers::COMMAND | Modifiers::SHIFT, Key::G)) { - view.find_step(false); - } else if pressed(cmd(Key::G)) { - view.find_step(true); - } if pressed(cmd(Key::OpenBracket)) { view.view_history(false); } diff --git a/crates/ui-egui/tests/viewer.rs b/crates/ui-egui/tests/viewer.rs index 077553c71d..36c1f07db3 100644 --- a/crates/ui-egui/tests/viewer.rs +++ b/crates/ui-egui/tests/viewer.rs @@ -175,6 +175,36 @@ fn find_shortcuts_advance_and_retreat_matches() { assert_eq!(h.state().views[0].find.as_ref().unwrap().current, Some((start + n - 1) % n)); } +/// #2669: ⌘G and ⇧⌘G step through the matches while the find field still has the focus. +#[test] +fn find_shortcuts_work_from_the_find_field() { + let mut h = harness(); + h.state_mut().active = Some(0); + h.run_steps(2); + h.state_mut().views[0].open_find(); + h.state_mut().views[0].find.as_mut().unwrap().query = "page".into(); + h.state_mut().views[0].rerun_find(); + let deadline = std::time::Instant::now() + std::time::Duration::from_secs(20); + while std::time::Instant::now() < deadline { + h.run_steps(2); + std::thread::sleep(std::time::Duration::from_millis(5)); + if h.state().views[0].find.as_ref().unwrap().matches.len() >= 15 { + break; + } + } + let n = h.state().views[0].find.as_ref().unwrap().matches.len(); + assert_eq!(n, 15); + assert!(h.ctx.egui_wants_keyboard_input(), "the find field has the focus"); + let start = h.state().views[0].find.as_ref().unwrap().current.unwrap(); + h.key_press_modifiers(egui::Modifiers::COMMAND, egui::Key::G); + h.run_steps(2); + assert_eq!(h.state().views[0].find.as_ref().unwrap().current, Some((start + 1) % n)); + h.key_press_modifiers(egui::Modifiers::COMMAND | egui::Modifiers::SHIFT, egui::Key::G); + h.run_steps(2); + assert_eq!(h.state().views[0].find.as_ref().unwrap().current, Some(start)); + assert!(h.ctx.egui_wants_keyboard_input(), "and keeps it"); +} + #[test] fn marking_while_searching_does_not_jitter_or_reset_search_state() { let mut h = harness(); From 6f348589bfe134cc2377369fa97e496c31c77194 Mon Sep 17 00:00:00 2001 From: jonathanwxh-cell Date: Thu, 15 Oct 2026 22:02:59 +0800 Subject: [PATCH 030/105] fix(edit): emit rewritten paragraph after backgrounds between its rows (#2377) --- crates/edit/src/tests.rs | 100 ++++++++++++++++++++++++++++++++ crates/edit/src/text.rs | 32 ++++++---- crates/edit/src/text_regions.rs | 28 ++++----- 3 files changed, 134 insertions(+), 26 deletions(-) diff --git a/crates/edit/src/tests.rs b/crates/edit/src/tests.rs index b542142df8..1a09406175 100644 --- a/crates/edit/src/tests.rs +++ b/crates/edit/src/tests.rs @@ -4249,3 +4249,103 @@ fn japanese_paragraphs_wrap_between_characters_without_closing_marks_at_a_row_st } } } + +#[test] +fn rewritten_paragraph_is_drawn_after_backgrounds_between_its_rows() { + // Each row has its own background box drawn immediately before its BT..ET, as cards and + // highlighted headings exported from LibreOffice or browsers do (#2377). + let card = "0 0.45 0.25 rg 60 696 240 16 re f BT /F1 10 Tf 1 1 1 rg 64 700 Td (SEMINARIO ONLINE) Tj ET \ + 0 0.45 0.25 rg 60 668 240 24 re f BT /F1 16 Tf 1 1 1 rg 64 674 Td (Dalla teoria alla pratica:) Tj ET \ + 0 0.45 0.25 rg 60 632 240 32 re f BT /F1 24 Tf 1 1 1 rg 64 640 Td (gestire con metodo) Tj ET \ + 0 0.45 0.25 rg 60 596 240 32 re f BT /F1 24 Tf 1 1 1 rg 64 604 Td (un progetto complesso) Tj ET"; + let shown_at = |ops: &[pdfcraft_content::Op], needle: &str| { + ops.iter() + .position(|op| { + matches!(op.op.as_slice(), b"Tj" | b"TJ") + && op.operands.iter().any(|o| matches!(o, Object::String(s) if String::from_utf8_lossy(&s.bytes).contains(needle))) + }) + .unwrap() + }; + + // 1. Editing the title rows emits all rewritten rows after all four background fills. + let mut doc = text_page(card); + assert_eq!(text::text_blocks(&doc, 0).unwrap().len(), 1); + let edited = "SEMINARIO ONLINE\nDalla teoria alla pratica:\ngestire con criterio\nun progetto nuovo"; + text::rewrite_block(&mut doc, 0, 0, Some(edited), &text::BlockStyle::default()).unwrap(); + let doc = reopen(&doc); + assert_eq!(text::text_blocks(&doc, 0).unwrap()[0].editor_text, edited); + let ops = pdfcraft_content::parse(&page_content_bytes(&doc, 0)).ops; + let fills: Vec = ops.iter().enumerate().filter_map(|(i, op)| op.is("f").then_some(i)).collect(); + assert_eq!(fills.len(), 4); + assert!( + shown_at(&ops, "criterio") > fills[3] && shown_at(&ops, "nuovo") > fills[3], + "rewritten title rows must be drawn after their row background fills (fills at {fills:?}): {ops:?}" + ); + + // 2. Partial edit with a kept suffix row: rewritten rows 0..=2 are drawn after fills[0..=2] + // and still before kept row 3, preserving reading order. + let mut doc = text_page(card); + let partial = "WORKSHOP ONLINE\nDalla teoria alla pratica:\ngestire con criterio\nun progetto complesso"; + text::rewrite_block(&mut doc, 0, 0, Some(partial), &text::BlockStyle::default()).unwrap(); + let doc = reopen(&doc); + assert_eq!(text::text_blocks(&doc, 0).unwrap()[0].editor_text, partial); + let ops = pdfcraft_content::parse(&page_content_bytes(&doc, 0)).ops; + let fills: Vec = ops.iter().enumerate().filter_map(|(i, op)| op.is("f").then_some(i)).collect(); + assert!( + shown_at(&ops, "WORKSHOP") > fills[2] + && shown_at(&ops, "pratica:") > fills[2] + && shown_at(&ops, "criterio") > fills[2] + && shown_at(&ops, "criterio") < shown_at(&ops, "complesso") + && shown_at(&ops, "complesso") > fills[3], + "rewritten prefix rows must follow their fills and precede the kept final row" + ); + + // 3. Editing row 2 so that it wraps pushes row 3 down; all rewritten rows must follow fills[3]. + let mut doc = text_page(card); + let wrapped_edit = "SEMINARIO ONLINE\nDalla teoria alla pratica:\ngestire con criterio e metodo approfondito\nun progetto complesso"; + text::rewrite_block(&mut doc, 0, 0, Some(wrapped_edit), &text::BlockStyle::default()).unwrap(); + let doc = reopen(&doc); + let ops = pdfcraft_content::parse(&page_content_bytes(&doc, 0)).ops; + let last_fill = ops.iter().rposition(|op| op.is("f")).unwrap(); + assert!( + shown_at(&ops, "approfondito") > last_fill && shown_at(&ops, "complesso") > last_fill, + "wrapped rows and pushed-down suffix row must follow all row background fills" + ); + + // 4. Single-style paragraph path across a CTM change between rows, with movement, underline, + // and a trailing unrelated line sharing the second row's BT..ET. + let cross_ctm = "0.9 0.9 0.2 rg 60 696 240 18 re f BT /F1 12 Tf 0 0 0 rg 64 700 Td (First highlighted row) Tj ET \ + q 1 0 0 1 10 12 cm 0.9 0.9 0.2 rg 50 668 240 18 re f \ + BT /F1 12 Tf 0 0 0 rg 54 672 Td (Second highlighted row) Tj 246 -40 Td (Side footer) Tj ET Q"; + let mut doc = text_page(cross_ctm); + let para = text::text_blocks(&doc, 0).unwrap().into_iter().position(|b| b.text.starts_with("First highlighted")).unwrap(); + assert_eq!(text::text_blocks(&doc, 0).unwrap()[para].rows.len(), 2); + text::rewrite_block( + &mut doc, + 0, + para, + Some("Updated first highlighted row and updated second highlighted row"), + &text::BlockStyle { offset: Some([6.0, -4.0]), underline: Some(true), ..Default::default() }, + ) + .unwrap(); + let doc = reopen(&doc); + let ops = pdfcraft_content::parse(&page_content_bytes(&doc, 0)).ops; + let last_fill = ops.iter().rposition(|op| op.is("f")).unwrap(); + assert!( + shown_at(&ops, "Updated first") > last_fill && shown_at(&ops, "Side footer") > shown_at(&ops, "Updated first"), + "cross-CTM single-style rewrite must follow both background fills and precede the shared BT's trailing line" + ); + let lines = text::text_lines(&doc, 0).unwrap(); + let updated_first = lines.iter().find(|l| l.text.starts_with("Updated first")).unwrap(); + let side_footer = lines.iter().find(|l| l.text == "Side footer").unwrap(); + assert!( + (updated_first.baseline()[0] - 70.0).abs() < 0.01 && (updated_first.baseline()[1] - 696.0).abs() < 0.01, + "offset baseline in user space across CTM: {:?}", + updated_first.baseline() + ); + assert!( + (side_footer.baseline()[0] - 310.0).abs() < 0.01 && (side_footer.baseline()[1] - 644.0).abs() < 0.01, + "trailing line in shared BT keeps its tlm across CTM: {:?}", + side_footer.baseline() + ); +} diff --git a/crates/edit/src/text.rs b/crates/edit/src/text.rs index 9c83cf9a39..f9e27104ac 100644 --- a/crates/edit/src/text.rs +++ b/crates/edit/src/text.rs @@ -2060,6 +2060,8 @@ fn rewrite_block_from( // The characters to show, for choosing fonts: the breaks are moves, not glyphs. let flat = text.replace('\n', " "); let o = &first.origin; + let last = members.last().copied().unwrap_or(first); + let emit = &last.origin; let (font_name, old_size) = o.state.font.clone().ok_or_else(|| EditError::Invalid("the paragraph has no font".into()))?; let k = o.k.max(1e-6); // The size in text space: points ÷ the text-to-user scale. @@ -2120,7 +2122,7 @@ fn rewrite_block_from( // paragraph leaves the document as it was. The move is in page space, so it is taken back through // the CTM's linear part into the space the paragraph is drawn in. let movement = if dx != 0.0 || dy != 0.0 { - let c = o.ctm; + let c = emit.ctm; let back = Matrix([c[0], c[1], c[2], c[3], 0.0, 0.0]) .invert() .ok_or_else(|| EditError::Invalid("the paragraph is drawn in a space it can't be moved in".into()))?; @@ -2128,6 +2130,12 @@ fn rewrite_block_from( } else { None }; + let tm = if o.ctm == emit.ctm { + o.tm + } else { + let emit_inv = Matrix(emit.ctm).invert().ok_or_else(|| EditError::Invalid("the paragraph has a singular transform".into()))?; + Matrix(o.tm).then(&Matrix(o.ctm)).then(&emit_inv).0 + }; let type3 = if !reuse && needs_type3(&flat) { Some(type3_font(doc, &mut fonts_res, &flat, family, bold, max_glyphs)?) } else { None }; let advance = |s: &str| -> f64 { let t = match (&metrics, reuse) { @@ -2205,7 +2213,7 @@ fn rewrite_block_from( let n = pdfcraft_content::num; // In its own graphics state, so a new colour (or anything else) stops at the paragraph. let mut block_ops = vec![Op::new("q", vec![])]; - block_ops.extend(reveal_ops(&b, &lines, style, o.ctm)?); + block_ops.extend(reveal_ops(&b, &lines, style, emit.ctm)?); // Moved: a translation inside that state. if let Some((ax, ay)) = movement { block_ops.push(Op::new("cm", vec![n(1.0), n(0.0), n(0.0), n(1.0), n(ax), n(ay)])); @@ -2220,7 +2228,7 @@ fn rewrite_block_from( state.fill = vec![Op::new("rg", vec![n(r), n(g), n(bl)])]; } block_ops.extend(state.ops()); - block_ops.push(Op::new("Tm", o.tm.iter().map(|v| n(*v)).collect())); + block_ops.push(Op::new("Tm", tm.iter().map(|v| n(*v)).collect())); // Alignment: each line's offset from the left edge, in text space. let offset = |line: &str| -> f64 { let free = (width - advance(line)).max(0.0) / k; @@ -2303,7 +2311,7 @@ fn rewrite_block_from( }) .collect(); block_ops.push(Op::new("q", vec![])); - block_ops.push(Op::new("cm", o.tm.iter().map(|v| n(*v)).collect())); + block_ops.push(Op::new("cm", tm.iter().map(|v| n(*v)).collect())); block_ops.extend(colour); block_ops.push(Op::new("w", vec![n(size * 0.06)])); for (x0, x1, y) in underlines { @@ -2318,24 +2326,24 @@ fn rewrite_block_from( // second copy, an invisible text layer): the new text is all that may show. let mut drop = coincident_ops(&lines, &members); drop.extend(members.iter().flat_map(|l| l.ops.iter().copied())); - // Text shown earlier in the same text object stays first: the paragraph then goes where it - // was, between two halves of the text object, so it keeps its place in reading order. - let start = first.ops.first().copied().unwrap_or(o.bt_op); + // Text shown earlier in the same text object stays first: the paragraph then goes where its + // last row was, after any backgrounds between the rows, or between two halves of the text object. + let start = last.ops.first().copied().unwrap_or(emit.bt_op); let split = ops - .get(o.bt_op..start) + .get(emit.bt_op..start) .unwrap_or_default() .iter() .enumerate() - .any(|(i, op)| matches!(op.op.as_slice(), b"Tj" | b"TJ" | b"'" | b"\"") && !drop.contains(&(o.bt_op + i))); + .any(|(i, op)| matches!(op.op.as_slice(), b"Tj" | b"TJ" | b"'" | b"\"") && !drop.contains(&(emit.bt_op + i))); // What the text after the paragraph expects: the state at its BT (or where it was split). - let after = if split { &o.state } else { &o.bt_state }; + let after = if split { &emit.state } else { &emit.bt_state }; block_ops.extend(after.ops().into_iter().filter(|op| !op.is("Tf") || after.font.is_some())); if split { block_ops.insert(0, Op::new("ET", vec![])); block_ops.push(Op::new("BT", vec![])); - block_ops.push(Op::new("Tm", o.tlm.iter().map(|v| n(*v)).collect())); + block_ops.push(Op::new("Tm", emit.tlm.iter().map(|v| n(*v)).collect())); } - let at = if split { start } else { o.bt_op }; + let at = if split { start } else { emit.bt_op }; let new_data = splice(&pieces, &ops, |i| { let insert = if i == at { std::mem::take(&mut block_ops) } else { Vec::new() }; (insert, !drop.contains(&i)) diff --git a/crates/edit/src/text_regions.rs b/crates/edit/src/text_regions.rs index 2d7baf519f..9e0ba2802e 100644 --- a/crates/edit/src/text_regions.rs +++ b/crates/edit/src/text_regions.rs @@ -620,19 +620,20 @@ pub(super) fn rewrite_styled_block( let kept_lines: std::collections::HashSet = kept.iter().enumerate().filter(|(_, k)| **k).filter_map(|(r, _)| block.rows.get(r)).flatten().copied().collect(); let rewritten: Vec<&TextLine> = block.lines.iter().filter(|i| !kept_lines.contains(i)).filter_map(|i| lines.get(*i)).collect(); - // The rewrite goes where the first rewritten row was drawn, so it must share the paragraph's - // space. A dropped `'` or `"` also moved to the next line, which a kept row relies on. - let lead = rewritten.first().copied().filter(|lead| { - lead.origin.ctm.iter().zip(first.origin.ctm).all(|(a, b)| (a - b).abs() < 1e-9) - && !rewritten.iter().flat_map(|l| &l.ops).any(|i| ops.get(*i).is_some_and(|op| op.is("'") || op.is("\""))) - }); + // The rewrite goes where the last rewritten row was drawn, after any backgrounds painted + // between the rows. A dropped `'` or `"` also moved to the next line, which a kept row relies on. + let lead = + rewritten.last().copied().filter(|_| !rewritten.iter().flat_map(|l| &l.ops).any(|i| ops.get(*i).is_some_and(|op| op.is("'") || op.is("\"")))); let (members, lead) = match lead { Some(lead) if !kept_lines.is_empty() => (rewritten, lead), _ => { kept.iter_mut().for_each(|k| *k = false); - (block.lines.iter().filter_map(|i| lines.get(*i)).collect(), first) + let all: Vec<&TextLine> = block.lines.iter().filter_map(|i| lines.get(*i)).collect(); + let last = all.last().copied().unwrap_or(first); + (all, last) } }; + let o = &lead.origin; for (row, _) in rows.iter().zip(&kept).filter(|(_, k)| !**k) { let text: String = row.iter().map(|v| v.0).collect(); if pdfcraft_fonts::source_shaping::required(&text) && row.first().is_some_and(|first| row.iter().any(|v| v.1 != first.1 || v.2 != first.2)) { @@ -668,8 +669,8 @@ pub(super) fn rewrite_styled_block( } let n = pdfcraft_content::num; let mut block_ops = vec![Op::new("q", vec![])]; - block_ops.extend(reveal_ops(block, lines, style, first.origin.ctm)?); - let back = Matrix([first.origin.ctm[0], first.origin.ctm[1], first.origin.ctm[2], first.origin.ctm[3], 0.0, 0.0]) + block_ops.extend(reveal_ops(block, lines, style, o.ctm)?); + let back = Matrix([o.ctm[0], o.ctm[1], o.ctm[2], o.ctm[3], 0.0, 0.0]) .invert() .ok_or_else(|| EditError::Invalid("the paragraph is drawn in a space it cannot be edited in".into()))?; let (tx, ty) = back.apply(dx, dy); @@ -741,7 +742,7 @@ pub(super) fn rewrite_styled_block( if let Some([r, g, b]) = overrides.get(&item.1).and_then(|r| r.color).or(style.color) { state.fill = vec![Op::new("rg", vec![n(r), n(g), n(b)])]; } - let inverse = Matrix(first.origin.ctm).invert().ok_or_else(|| EditError::Invalid("the paragraph has a singular transform".into()))?; + let inverse = Matrix(o.ctm).invert().ok_or_else(|| EditError::Invalid("the paragraph has a singular transform".into()))?; let mut tm = Matrix(source.origin.tm).then(&Matrix(source.origin.ctm)).then(&inverse).0; let (ax, ay) = back.apply(x - source.origin.x, baseline - source.origin.baseline); tm[4] += ax; @@ -790,10 +791,10 @@ pub(super) fn rewrite_styled_block( if underlines.get(&item.1).copied().or(style.underline) == Some(true) { let [r, g, b] = overrides.get(&item.1).and_then(|r| r.color).or(style.color).unwrap_or(source.color); let underline_size = size_of(source, item.1) * source.origin.k; - let (x0, y0) = back.apply(x - first.origin.ctm[4], baseline - first.origin.ctm[5] - underline_size * 0.12); - let (x1, y1) = back.apply(x + segment_width - first.origin.ctm[4], baseline - first.origin.ctm[5] - underline_size * 0.12); + let (x0, y0) = back.apply(x - o.ctm[4], baseline - o.ctm[5] - underline_size * 0.12); + let (x1, y1) = back.apply(x + segment_width - o.ctm[4], baseline - o.ctm[5] - underline_size * 0.12); block_ops.push(Op::new("RG", vec![n(r), n(g), n(b)])); - block_ops.push(Op::new("w", vec![n(underline_size * 0.06 / first.origin.ctm[2].hypot(first.origin.ctm[3]).max(1e-6))])); + block_ops.push(Op::new("w", vec![n(underline_size * 0.06 / o.ctm[2].hypot(o.ctm[3]).max(1e-6))])); block_ops.push(Op::new("m", vec![n(x0), n(y0)])); block_ops.push(Op::new("l", vec![n(x1), n(y1)])); block_ops.push(Op::new("S", vec![])); @@ -810,7 +811,6 @@ pub(super) fn rewrite_styled_block( block_ops.push(Op::new("Q", vec![])); let mut drop = coincident_ops(lines, &members); drop.extend(members.iter().flat_map(|l| l.ops.iter().copied())); - let o = &lead.origin; let start = lead.ops.first().copied().unwrap_or(o.bt_op); let split = ops .get(o.bt_op..start) From 33fe4880611288f659bbfae75e14865002a04d97 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 15 Oct 2026 04:20:41 +0000 Subject: [PATCH 031/105] fix(cos): an incremental save of a file with bytes before its header counts from the header Some files carry bytes before `%PDF-`, and their offsets count from the header rather than from the first byte. We read those, retrying a section relative to the header when the absolute offset misses, but the incremental writer then appended a section whose offsets and `startxref` were absolute while its `/Prev` kept the header-relative value it was given. qpdf reported "xref not found" and reconstructed the file, so a save that reported success left a damaged-looking update behind (#2364, found with KillerPDF's reserve3.pdf and a Subject edit). The reader now records on each `Revision` whether it was found counting from the header, and the writer counts the new section the same way as the one its `/Prev` points to. A file whose offsets already include the prefix keeps absolute offsets, which the second test pins so the fix cannot overcorrect. Seen red with the writer change reverted: the appended section's `startxref` missed its `xref`. Fixes storytold/pdfcraft#2364 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01DsT1y8xBvkXeBxEUQqdDtj --- crates/cos/src/document.rs | 25 ++++++++++----- crates/cos/src/writer.rs | 6 +++- crates/cos/tests/writer.rs | 63 ++++++++++++++++++++++++++++++++++++++ 3 files changed, 85 insertions(+), 9 deletions(-) diff --git a/crates/cos/src/document.rs b/crates/cos/src/document.rs index a0b1f56eeb..1c82e500dc 100644 --- a/crates/cos/src/document.rs +++ b/crates/cos/src/document.rs @@ -307,6 +307,10 @@ pub struct Revision { pub xref_offset: u64, /// `true` if the section is a cross-reference stream. pub is_stream: bool, + /// `true` if `xref_offset` counts from the `%PDF-` header rather than from the start of the + /// file. The two differ only in a file with bytes before its header, and an update appended + /// to such a file must count the same way as the section it points back to. + pub header_relative: bool, } #[derive(Clone, Debug)] @@ -817,6 +821,11 @@ impl Document { &self.revisions } + /// Position of the `%PDF-` header in the file: 0 unless bytes come before it. + pub fn header_offset(&self) -> usize { + self.header_offset + } + /// Where each revision of the file ends (oldest first): the byte after the `%%EOF` (and its /// end-of-line) that closes its cross-reference section. `data[..end]` is that revision as it /// was saved. A linearized file's first-page section belongs to the revision it precedes, so @@ -1345,13 +1354,13 @@ impl Document { log.push(format!("cross-reference chain loops at offset {off}; stopped")); break; } - let (primary, sect_trailer, is_stream) = match self.read_compact_section(off as usize) { - Ok(Some(v)) => v, + let ((primary, sect_trailer, is_stream), header_relative) = match self.read_compact_section(off as usize) { + Ok(Some(v)) => (v, false), Ok(None) => return Ok(None), Err(e) if self.header_offset > 0 => { log.push(format!("xref at {off} unreadable ({e}); retrying relative to the header")); match self.read_compact_section(off as usize + self.header_offset)? { - Some(v) => v, + Some(v) => (v, true), None => return Ok(None), } } @@ -1359,7 +1368,7 @@ impl Document { // materialized reader logs it and the file is rebuilt from object headers (#1656). Err(e) => return Err(e), }; - revisions.push(Revision { xref_offset: off, is_stream }); + revisions.push(Revision { xref_offset: off, is_stream, header_relative }); let Some(rows) = retained_rows.checked_add(primary.row_count).filter(|rows| *rows <= MAX_COMPACT_XREF_ROWS) else { return Ok(None) }; let Some(bytes) = retained_bytes.checked_add(primary.retained_bytes()).filter(|bytes| *bytes <= MAX_COMPACT_XREF_BYTES) else { return Ok(None); @@ -1636,13 +1645,13 @@ impl Document { let mut sect = BTreeMap::new(); // Object numbers whose entry in this section was set aside ([`Self::xref_oddities`]). let mut odd = Vec::new(); - let (sect_trailer, is_stream) = match self.read_section(off as usize, &mut sect, &mut odd) { - Ok(v) => v, + let ((sect_trailer, is_stream), header_relative) = match self.read_section(off as usize, &mut sect, &mut odd) { + Ok(v) => (v, false), Err(e) if self.header_offset > 0 => { log.push(format!("xref at {off} unreadable ({e}); retrying relative to the header")); sect.clear(); odd.clear(); - self.read_section(off as usize + self.header_offset, &mut sect, &mut odd)? + (self.read_section(off as usize + self.header_offset, &mut sect, &mut odd)?, true) } Err(e) => { // An unreadable older section loses every object only it locates (the page @@ -1653,7 +1662,7 @@ impl Document { return Err(e); } }; - revisions.push(Revision { xref_offset: off, is_stream }); + revisions.push(Revision { xref_offset: off, is_stream, header_relative }); // Hybrid files (§7.5.8.4): the /XRefStm entries supplement the table and replace the // free entries it gives objects only a cross-reference stream can locate. if let Some(x) = sect_trailer.int(b"XRefStm").filter(|x| *x >= 0) { diff --git a/crates/cos/src/writer.rs b/crates/cos/src/writer.rs index f8d8fd3d80..69442cceae 100644 --- a/crates/cos/src/writer.rs +++ b/crates/cos/src/writer.rs @@ -193,7 +193,11 @@ pub fn write_incremental(doc: &Document, opts: &SaveOptions) -> Result, // allocated once at its exact size: growing a buffer that holds a large original past a // guessed capacity would copy the whole file again and keep up to as much again unused. let newline = !matches!(original.last(), Some(b'\n' | b'\r')); - let base = (original.len() + usize::from(newline)) as u64; + // A file with bytes before its `%PDF-` header may count its offsets from the header. The new + // section then has to as well: readers take `/Prev` and `startxref` in one convention, and a + // mix sends them into reconstruction (#2364). + let shift = if doc.revisions().last().is_some_and(|r| r.header_relative) { doc.header_offset() } else { 0 }; + let base = (original.len() + usize::from(newline)).saturating_sub(shift) as u64; let mut out = Vec::new(); let mut offsets: BTreeMap = BTreeMap::new(); // num → (offset, gen, in use) for (num, generation, obj) in doc.overlay_entries() { diff --git a/crates/cos/tests/writer.rs b/crates/cos/tests/writer.rs index ec18b13f36..b7a54aa168 100644 --- a/crates/cos/tests/writer.rs +++ b/crates/cos/tests/writer.rs @@ -392,3 +392,66 @@ fn full_save_preserves_unknown_trailer_data() { assert_eq!(trailer_note(&back), trailer_note(&doc)); assert_eq!(back.trailer().get(b"Version"), doc.trailer().get(b"Version")); } + +/// The `startxref` value at the end of `bytes`. +fn last_startxref(bytes: &[u8]) -> usize { + let at = bytes.windows(9).rposition(|w| w == b"startxref").unwrap(); + String::from_utf8_lossy(&bytes[at + 9..]).split_whitespace().next().unwrap().parse().unwrap() +} + +#[test] +fn incremental_saves_of_a_file_with_bytes_before_its_header_count_from_the_header() { + // Offsets written before the prefix was added count from `%PDF-`, as in the KillerPDF file + // of #2364; the appended section must count the same way as the `/Prev` it points back to. + let prefix = vec![b'#'; 128]; + let as_stream = write_full(&Document::open(Arc::new(fixture())).unwrap(), &SaveOptions::default()).unwrap(); + for (name, body, marker) in [("table", classic_table(), &b"xref"[..]), ("stream", as_stream, &b" 0 obj"[..])] { + let input = [prefix.as_slice(), &body].concat(); + let mut doc = Document::open(Arc::new(input.clone())).unwrap(); + assert!(doc.revisions().last().unwrap().header_relative, "{name}"); + doc.update_dict(ObjRef::new(1, 0), |d| d.set(b"Lang".to_vec(), Object::String(pdfcraft_cos::PdfString::literal(b"en".to_vec())))).unwrap(); + let saved = write_incremental(&doc, &SaveOptions::default()).unwrap(); + assert!(saved.starts_with(&input), "{name}"); + + let xref = last_startxref(&saved) + prefix.len(); + let section = saved.get(xref..).unwrap_or_default(); + assert!(section.windows(marker.len()).position(|w| w == marker).is_some_and(|p| p < 16), "{name}: startxref misses its section"); + let back = Document::open(Arc::new(saved.clone())).unwrap(); + assert_eq!(back.revisions().len(), doc.revisions().len() + 1, "{name}"); + assert!(back.revisions().iter().all(|r| r.header_relative), "{name}: one convention across the chain"); + let edited = back.get(ObjRef::new(1, 0)); + assert_eq!(edited.as_dict().unwrap().get(b"Lang").and_then(|o| o.as_string()).map(|s| s.bytes.as_slice()), Some(b"en".as_slice())); + assert!(back.xref_entry_is_exact(1), "{name}"); + assert_qpdf_clean(&saved); + } +} + +#[test] +fn incremental_saves_of_a_file_whose_offsets_count_from_its_first_byte_keep_counting_so() { + // The other convention: the same file, but its offsets include the bytes before the header. + let prefix = vec![b'#'; 128]; + let mut input = [prefix.as_slice(), &classic_table()].concat(); + let xref = input.windows(6).rposition(|w| w == b"\nxref\n").unwrap() + 1; + let table: String = String::from_utf8_lossy(&input[xref..]) + .lines() + .map(|line| match line.strip_suffix(" 00000 n ").and_then(|o| o.parse::().ok()) { + Some(o) => format!("{:010} 00000 n \n", o + prefix.len()), + None if line.parse::().is_ok() => format!("{xref}\n"), + None => format!("{line}\n"), + }) + .collect(); + input.truncate(xref); + input.extend_from_slice(table.as_bytes()); + + let mut doc = Document::open(Arc::new(input.clone())).unwrap(); + assert!(doc.repair_log().is_empty(), "{:?}", doc.repair_log()); + assert!(!doc.revisions().last().unwrap().header_relative); + doc.update_dict(ObjRef::new(1, 0), |d| d.set(b"Lang".to_vec(), Object::String(pdfcraft_cos::PdfString::literal(b"en".to_vec())))).unwrap(); + let saved = write_incremental(&doc, &SaveOptions::default()).unwrap(); + assert!(saved.get(last_startxref(&saved)..).unwrap_or_default().starts_with(b"xref")); + let back = Document::open(Arc::new(saved.clone())).unwrap(); + assert!(back.repair_log().is_empty(), "{:?}", back.repair_log()); + assert_eq!(back.revisions().len(), 2); + assert!(back.xref_entry_is_exact(1)); + assert_qpdf_clean(&saved); +} From 9229e7af3612e5e2056b8f8c0a2b85606dfe50e6 Mon Sep 17 00:00:00 2001 From: jonathanwxh-cell Date: Fri, 16 Oct 2026 08:13:00 +0800 Subject: [PATCH 032/105] fix(edit): wrap single turned rows at the page margin (#2578) --- crates/edit/src/tests.rs | 70 ++++++++++++++++++++++++++++++++++++++++ crates/edit/src/text.rs | 22 +++++++++++-- 2 files changed, 90 insertions(+), 2 deletions(-) diff --git a/crates/edit/src/tests.rs b/crates/edit/src/tests.rs index b542142df8..ca25a7acb8 100644 --- a/crates/edit/src/tests.rs +++ b/crates/edit/src/tests.rs @@ -3960,6 +3960,76 @@ fn default_width_subset_paragraph_keeps_its_rows_when_a_word_is_added() { } } +/// #2578: a single turned row grows along its baseline only up to the page's 36 pt margin and wraps +/// onto further rows from there, instead of running off the page. +#[test] +fn turned_single_row_wraps_at_the_page_margin() { + let draft = "A single turned row that keeps growing with many more words added until it is far longer than the page is tall, so it has to stop or wrap somewhere"; + // Reading upwards (90° CCW) from y = 600 on the 600 × 800 test page: top margin is at y = 764. + let before = text_page("BT /F1 12 Tf 0 1 -1 0 300 600 Tm (A single turned row) Tj ET"); + let layout = text::text_edit_layout(&before, 0, 0, draft, &text::BlockStyle::default()).unwrap(); + let mut doc = before; + text::rewrite_block(&mut doc, 0, 0, Some(draft), &text::BlockStyle::default()).unwrap(); + let reopened = reopen(&doc); + let lines = text::text_lines(&reopened, 0).unwrap(); + assert!(lines.len() > 1, "expected the turned row to wrap at the top margin: {lines:?}"); + for line in &lines { + assert!((line.rect[1] - 600.0).abs() < 0.5, "row starts at y = 600: {:?}", line.rect); + assert!(line.rect[3] <= 800.0 - 36.0 + 0.5, "row stays within the top margin: {:?}", line.rect); + } + let blocks = text::text_blocks(&reopened, 0).unwrap(); + assert_eq!(blocks.len(), 1, "wrapped turned rows stay one paragraph: {blocks:?}"); + assert_eq!(blocks[0].text, draft); + assert_eq!(layout.draft_rect, Some(blocks[0].rect)); + + // Reading downwards (90° CW) from y = 200: bottom margin is at y = 36. + let mut doc = text_page("BT /F1 12 Tf 0 -1 1 0 300 200 Tm (A single turned row) Tj ET"); + text::rewrite_block(&mut doc, 0, 0, Some(draft), &text::BlockStyle::default()).unwrap(); + let lines = text::text_lines(&reopen(&doc), 0).unwrap(); + assert!(lines.len() > 1, "expected 90° CW row to wrap at the bottom margin: {lines:?}"); + for line in &lines { + assert!((line.rect[3] - 200.0).abs() < 0.5, "row starts at y = 200: {:?}", line.rect); + assert!(line.rect[1] >= 36.0 - 0.5, "row stays within the bottom margin: {:?}", line.rect); + } + + // Reading upside down (180°) from x = 200: left margin is at x = 36. + let mut doc = text_page("BT /F1 12 Tf -1 0 0 -1 200 600 Tm (A single turned row) Tj ET"); + text::rewrite_block(&mut doc, 0, 0, Some(draft), &text::BlockStyle::default()).unwrap(); + let lines = text::text_lines(&reopen(&doc), 0).unwrap(); + assert!(lines.len() > 1, "expected 180° row to wrap at the left margin: {lines:?}"); + for line in &lines { + assert!((line.rect[2] - 200.0).abs() < 0.5, "row starts at x = 200: {:?}", line.rect); + assert!(line.rect[0] >= 36.0 - 0.5, "row stays within the left margin: {:?}", line.rect); + } + + // Slanted (45°) from (400, 600): baseline stops where it first reaches a 36 pt margin. + let (sin, cos) = 45.0_f64.to_radians().sin_cos(); + let mut doc = text_page(&format!("BT /F1 12 Tf {cos:.6} {sin:.6} {:.6} {cos:.6} 400 600 Tm (A single turned row) Tj ET", -sin)); + text::rewrite_block(&mut doc, 0, 0, Some(draft), &text::BlockStyle::default()).unwrap(); + let lines = text::text_lines(&reopen(&doc), 0).unwrap(); + assert!(lines.len() > 1, "expected 45° slanted row to wrap at the margin: {lines:?}"); + for line in &lines { + assert!(line.rect[2] <= 600.0 && line.rect[3] <= 800.0, "slanted row stays on the page: {:?}", line.rect); + } + + // Moving a turned single row with `offset` reduces the room left before the margin. + let mut doc = text_page("BT /F1 12 Tf 0 1 -1 0 300 500 Tm (A single turned row) Tj ET"); + let moved = text::BlockStyle { offset: Some([0.0, 150.0]), ..Default::default() }; + text::rewrite_block(&mut doc, 0, 0, Some(draft), &moved).unwrap(); + let lines = text::text_lines(&reopen(&doc), 0).unwrap(); + assert!(lines.len() > 1, "{lines:?}"); + for line in &lines { + assert!((line.rect[1] - 650.0).abs() < 0.5, "moved row starts at y = 650: {:?}", line.rect); + assert!(line.rect[3] <= 800.0 - 36.0 + 0.5, "moved row stops at the top margin: {:?}", line.rect); + } + + // A turned row that already starts inside the 36 pt margin keeps its own width instead of collapsing. + let mut doc = text_page("BT /F1 12 Tf 0 1 -1 0 300 770 Tm (Edge label) Tj ET"); + text::rewrite_block(&mut doc, 0, 0, Some("Edge label updated"), &text::BlockStyle::default()).unwrap(); + let lines = text::text_lines(&reopen(&doc), 0).unwrap(); + assert!(lines.len() >= 2, "{lines:?}"); +} + /// An added item is written out as a specific standard-14 face (`/Times-Italic`, `/Helvetica-Bold` /// …), so it has to be *measured* with that same face. Measuring the upright face while writing /// the italic one mis-sizes every box: Times-Italic `A` is 611 against Times-Roman's 722. diff --git a/crates/edit/src/text.rs b/crates/edit/src/text.rs index 9c83cf9a39..4a146d59d2 100644 --- a/crates/edit/src/text.rs +++ b/crates/edit/src/text.rs @@ -2149,9 +2149,27 @@ fn rewrite_block_from( }; t * o_state.scale * k }; - // A turned single line grows to fit, as far as the largest page PDF allows. + // A turned single line grows to fit, up to where its baseline reaches the page's margin. let width = if !upright && style.width.is_none() && members.len() == 1 { - text.split('\n').map(&advance).fold(width, f64::max).min(14_400.0) + let crop = p.crop(doc); + let (x0, y0) = (first.origin.x + dx, first.origin.baseline + dy); + let (ux, uy) = first.direction(); + let tx = if ux > 1e-6 { + (crop[2] - 36.0 - x0) / ux + } else if ux < -1e-6 { + (crop[0] + 36.0 - x0) / ux + } else { + f64::INFINITY + }; + let ty = if uy > 1e-6 { + (crop[3] - 36.0 - y0) / uy + } else if uy < -1e-6 { + (crop[1] + 36.0 - y0) / uy + } else { + f64::INFINITY + }; + let cap = width.max(tx.min(ty)).min(14_400.0); + text.split('\n').map(&advance).fold(width, f64::max).min(cap) } else { width }; From 271cb6161fb3ca697f013a1e3daf2d83e2cfd42f Mon Sep 17 00:00:00 2001 From: mtlcl410 Date: Fri, 16 Oct 2026 08:35:35 +0800 Subject: [PATCH 033/105] M1: read an xref table numbered from 1 that begins with object 0 (#2676) Some producers head a classic xref table "1 n" and then write object 0's free entry first, so every row is one number off and object 1 reads as free. The renderer coped, but cos did not: printing a scanned invoice built from cos dropped object 1 (page 1's image) and printed the page blank. Such a subsection is now read from 0 when its second row locates an object that is numbered 1, in both xref readers, and the repair is logged. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01RUNQSPVXx3dDBabRZHAnjz --- crates/cos/src/document.rs | 180 +++++++++++++++++++++++++++++++++-- parity/acrobat-features.toml | 4 +- 2 files changed, 176 insertions(+), 8 deletions(-) diff --git a/crates/cos/src/document.rs b/crates/cos/src/document.rs index a0b1f56eeb..e47d035363 100644 --- a/crates/cos/src/document.rs +++ b/crates/cos/src/document.rs @@ -82,6 +82,8 @@ struct XrefSection { row_count: usize, max_num: u32, has_entries: bool, + /// A table subsection was read one number lower than it says ([`subsection_start`]). + renumbered: bool, } #[derive(Clone, Debug)] @@ -1360,6 +1362,9 @@ impl Document { Err(e) => return Err(e), }; revisions.push(Revision { xref_offset: off, is_stream }); + if primary.renumbered { + log.push(format!("xref subsection at {off} is numbered from 1 but begins with object 0's entry; read from 0")); + } let Some(rows) = retained_rows.checked_add(primary.row_count).filter(|rows| *rows <= MAX_COMPACT_XREF_ROWS) else { return Ok(None) }; let Some(bytes) = retained_bytes.checked_add(primary.retained_bytes()).filter(|bytes| *bytes <= MAX_COMPACT_XREF_BYTES) else { return Ok(None); @@ -1434,6 +1439,7 @@ impl Document { let mut has_entries = false; let mut previous_end = None; let mut row_count = 0usize; + let mut renumbered = false; loop { lx.skip_ws(); if lx.eat_keyword(b"trailer") { @@ -1442,7 +1448,7 @@ impl Document { if !layout_ok { return Ok(None); } - return Ok(Some((XrefSection { data: XrefSectionData::Table, ranges, row_count, max_num, has_entries }, d, false))); + return Ok(Some((XrefSection { data: XrefSectionData::Table, ranges, row_count, max_num, has_entries, renumbered }, d, false))); } let (Ok(Object::Int(start)), Ok(Object::Int(count))) = (lx.object(), lx.object()) else { return Err(CosError::Syntax { offset: lx.pos, detail: "bad xref subsection header".into() }); @@ -1450,6 +1456,9 @@ impl Document { if !valid_xref_range(start, count) || count > 10_000_000 { return Err(CosError::Syntax { offset: lx.pos, detail: "invalid xref object range".into() }); } + let shifted = subsection_start(data, lx.pos, start, count); + renumbered |= shifted != start; + let start = shifted; let Some(next_rows) = usize::try_from(count).ok().and_then(|count| row_count.checked_add(count)) else { return Ok(None) }; if next_rows > MAX_COMPACT_XREF_ROWS { return Ok(None); @@ -1602,7 +1611,14 @@ impl Document { } let max_num = ranges.iter().filter_map(|range| range.start.checked_add(range.count.saturating_sub(1))).max().unwrap_or(0); let row_count = row_start; - let section = XrefSection { data: XrefSectionData::Stream { bytes: Arc::new(raw), widths }, ranges, row_count, max_num, has_entries: false }; + let section = XrefSection { + data: XrefSectionData::Stream { bytes: Arc::new(raw), widths }, + ranges, + row_count, + max_num, + has_entries: false, + renumbered: false, + }; let has_entries = section.has_any_entry(); let section = XrefSection { has_entries, ..section }; Ok(Some((section, stream.dict.clone(), true))) @@ -1636,13 +1652,13 @@ impl Document { let mut sect = BTreeMap::new(); // Object numbers whose entry in this section was set aside ([`Self::xref_oddities`]). let mut odd = Vec::new(); - let (sect_trailer, is_stream) = match self.read_section(off as usize, &mut sect, &mut odd) { + let (sect_trailer, is_stream) = match self.read_section(off as usize, &mut sect, &mut odd, log) { Ok(v) => v, Err(e) if self.header_offset > 0 => { log.push(format!("xref at {off} unreadable ({e}); retrying relative to the header")); sect.clear(); odd.clear(); - self.read_section(off as usize + self.header_offset, &mut sect, &mut odd)? + self.read_section(off as usize + self.header_offset, &mut sect, &mut odd, log)? } Err(e) => { // An unreadable older section loses every object only it locates (the page @@ -1658,7 +1674,7 @@ impl Document { // free entries it gives objects only a cross-reference stream can locate. if let Some(x) = sect_trailer.int(b"XRefStm").filter(|x| *x >= 0) { let mut stm = BTreeMap::new(); - match self.read_section(x as usize, &mut stm, &mut odd) { + match self.read_section(x as usize, &mut stm, &mut odd, log) { Ok(_) => { for (num, e) in stm { match sect.get(&num) { @@ -1693,7 +1709,13 @@ impl Document { /// Read one section at `off`, adding entries not already known (newer sections win). `odd` /// collects the object numbers whose entry here is set aside ([`Self::xref_oddities`]). - fn read_section(&self, off: usize, entries: &mut BTreeMap, odd: &mut Vec) -> Result<(Dict, bool), CosError> { + fn read_section( + &self, + off: usize, + entries: &mut BTreeMap, + odd: &mut Vec, + log: &mut Vec, + ) -> Result<(Dict, bool), CosError> { let data = &self.data; let mut lx = Lexer::new(data, off); if lx.eat_keyword(b"xref") { @@ -1710,6 +1732,11 @@ impl Document { if !valid_xref_range(start, count) || count > 10_000_000 { return Err(CosError::Syntax { offset: lx.pos, detail: "invalid xref object range".into() }); } + let shifted = subsection_start(data, lx.pos, start, count); + if shifted != start { + log.push(format!("xref subsection at {off} is numbered from 1 but begins with object 0's entry; read from 0")); + } + let start = shifted; for i in 0..count { lx.skip_ws(); let o = lx.token(); @@ -1923,6 +1950,34 @@ fn generated_id(seed: &[u8; 32]) -> Vec { /// Object numbers must fit the representation used by references and the xref map. /// Validate the whole subsection before adding entries, so neither arithmetic overflow nor /// truncation can turn a damaged range into entries for unrelated objects. +/// The object number a classic xref subsection really starts at. A common producer bug heads the +/// table `1 n` and then writes object 0's free entry (`0000000000 65535 f`) as its first row, so +/// every row would be read one number too high and object 1 as free. Such a subsection is read +/// from 0 when its second row, read that way, locates an object that is indeed numbered 1. `rows` +/// is the position just after the subsection header. +fn subsection_start(data: &[u8], rows: usize, start: i64, count: i64) -> i64 { + if start != 1 || count < 2 { + return start; + } + let mut lx = Lexer::new(data, rows); + let mut row = || { + lx.skip_ws(); + let offset = std::str::from_utf8(lx.token()).ok().and_then(|s| s.parse::().ok()); + lx.skip_ws(); + let generation = std::str::from_utf8(lx.token()).ok().and_then(|s| s.parse::().ok()); + lx.skip_ws(); + (offset, generation, lx.token() == b"f") + }; + let (Some(0), Some(65535), true) = row() else { return start }; + let (Some(second), _, false) = row() else { return start }; + let Some(at) = usize::try_from(second).ok().filter(|at| *at < data.len()) else { return start }; + let mut obj = Lexer::new(data, at); + match (obj.object(), obj.object()) { + (Ok(Object::Int(1)), Ok(Object::Int(_))) if obj.eat_keyword(b"obj") => 0, + _ => start, + } +} + fn valid_xref_range(start: i64, count: i64) -> bool { u32::try_from(start).is_ok() && count >= 0 && start.checked_add(count).is_some_and(|end| end <= i64::from(u32::MAX) + 1) } @@ -2085,6 +2140,119 @@ mod tests { assert!(doc.xref_entry_is_exact(1) && doc.xref_entry_is_exact(2)); } + /// A classic table with `/Size` rows and object 0's free entry first, its subsection numbered + /// `1 n` instead of `0 n` (#2676): every row is one number off. + fn misnumbered_table(start: u32) -> Vec { + let bodies = ["<< /Marker (first object) >>", "<< /Type /Pages /Kids [] /Count 0 >>", "<< /Type /Catalog /Pages 2 0 R >>"]; + let mut out = b"%PDF-1.7 +" + .to_vec(); + let mut offsets = Vec::new(); + for (i, body) in bodies.iter().enumerate() { + offsets.push(out.len()); + out.extend_from_slice( + format!( + "{} 0 obj +{body} +endobj +", + i + 1 + ) + .as_bytes(), + ); + } + let xref = out.len(); + out.extend_from_slice( + format!( + "xref +{start} 4 +0000000000 65535 f +" + ) + .as_bytes(), + ); + for o in offsets { + out.extend_from_slice( + format!( + "{o:010} 00000 n +" + ) + .as_bytes(), + ); + } + out.extend_from_slice( + format!( + "trailer +<< /Size 4 /Root 3 0 R >> +startxref +{xref} +%%EOF +" + ) + .as_bytes(), + ); + out + } + + #[test] + fn a_table_numbered_from_1_that_begins_with_object_0_is_read_from_0() { + let doc = Document::open(Arc::new(misnumbered_table(1))).unwrap(); + let first = doc.get(ObjRef::new(1, 0)).as_dict().cloned().expect("object 1 is not free"); + assert!(first.contains(b"Marker")); + assert_eq!(doc.root(), Some(ObjRef::new(3, 0))); + assert!(doc.repair_log().iter().any(|l| l.contains("numbered from 1")), "{:?}", doc.repair_log()); + // A correct table reads the same and logs nothing. + let fine = Document::open(Arc::new(misnumbered_table(0))).unwrap(); + assert!(fine.get(ObjRef::new(1, 0)).as_dict().is_some_and(|d| d.contains(b"Marker"))); + assert!(!fine.repair_log().iter().any(|l| l.contains("numbered from 1"))); + } + + #[test] + fn a_table_that_really_starts_at_1_keeps_its_numbers() { + // Object 1 deleted for good (generation 65535) in a subsection that does start at 1: its + // second row locates object 2, so nothing is shifted. + let mut bytes = b"%PDF-1.7 +" + .to_vec(); + let pages = bytes.len(); + bytes.extend_from_slice( + b"2 0 obj +<< /Type /Pages /Kids [] /Count 0 >> +endobj +", + ); + let catalog = bytes.len(); + bytes.extend_from_slice( + b"3 0 obj +<< /Type /Catalog /Pages 2 0 R >> +endobj +", + ); + let xref = bytes.len(); + bytes.extend_from_slice( + format!( + "xref +0 1 +0000000000 65535 f +1 3 +0000000000 65535 f +{pages:010} 00000 n +{catalog:010} 00000 n +trailer +<< /Size 4 /Root 3 0 R >> +startxref +{xref} +%%EOF +" + ) + .as_bytes(), + ); + let doc = Document::open(Arc::new(bytes)).unwrap(); + assert!(doc.get(ObjRef::new(2, 0)).as_dict().is_some_and(|d| d.name(b"Type") == Some(b"Pages"))); + assert!(doc.get(ObjRef::new(1, 0)).as_dict().is_none(), "object 1 stays free"); + assert!(!doc.repair_log().iter().any(|l| l.contains("numbered from 1"))); + } + #[test] fn opening_an_exhausted_classic_xref_repairs_and_allocates() { // The extra in-use row need not be loaded to exhaust the next object number. diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index 533013b547..4f4a99d737 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -46,8 +46,8 @@ milestone = "M1" status = "shipped" commands = ["file.open"] tools = ["doc_open"] -evidence = ["crates/cos/src/document.rs::damaged_xref_is_reconstructed", "crates/cos/src/parser.rs::indirect_stream_with_wrong_length_recovers", "crates/cos/src/document.rs::self_referential_length_does_not_overflow"] -notes = "" +evidence = ["crates/cos/src/document.rs::damaged_xref_is_reconstructed", "crates/cos/src/parser.rs::indirect_stream_with_wrong_length_recovers", "crates/cos/src/document.rs::self_referential_length_does_not_overflow", "crates/cos/src/document.rs::a_table_numbered_from_1_that_begins_with_object_0_is_read_from_0"] +notes = "A classic table numbered 1 n that begins with object 0's free entry is read from 0 (#2676) and logged." [[feature]] id = "core.malformed-input-safety" From c0a8a1a10f9a3f469c472aff385c20951b70bf18 Mon Sep 17 00:00:00 2001 From: Christoph Ortenburg Date: Thu, 15 Oct 2026 21:46:45 -0400 Subject: [PATCH 034/105] test(cos): qpdf can't check a prefixed file whose offsets include the prefix qpdf counts every offset from the header in a file with bytes before it, so it already reports the input of `incremental_saves_of_a_file_whose_offsets_count_from_its_first_byte_keep_counting_so` as damaged ("xref not found") and reconstructs it, before anything is appended. The qpdf check there could never pass; the test's own reader checks stay. The header-relative test keeps its qpdf check, which passes. Co-Authored-By: Claude Opus 5.5 --- crates/cos/tests/writer.rs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/crates/cos/tests/writer.rs b/crates/cos/tests/writer.rs index b7a54aa168..ac04be2e94 100644 --- a/crates/cos/tests/writer.rs +++ b/crates/cos/tests/writer.rs @@ -453,5 +453,6 @@ fn incremental_saves_of_a_file_whose_offsets_count_from_its_first_byte_keep_coun assert!(back.repair_log().is_empty(), "{:?}", back.repair_log()); assert_eq!(back.revisions().len(), 2); assert!(back.xref_entry_is_exact(1)); - assert_qpdf_clean(&saved); + // No qpdf check here: qpdf counts every offset from the header in a file with bytes before + // it, so it already reconstructs this input before anything is appended. } From 31716e70589b964691e9d7391c0cbab10f8966d4 Mon Sep 17 00:00:00 2001 From: hcchung1 <131962610+hcchung1@users.noreply.github.com> Date: Thu, 15 Oct 2026 23:22:26 +0800 Subject: [PATCH 035/105] fix(xfa): a page area without has no limit (fixes #2574) A pageArea with no was given a subform's default (at most one use). Once the first body page was out, the layout fell back to the last page area, here one with occur max="1", and drew its content over every body page, with a copy of its fields per page. A page area with no now repeats as long as pages are needed. Co-Authored-By: Claude Sonnet 5.5 --- crates/xfa/src/parse.rs | 5 +++-- crates/xfa/src/tests.rs | 39 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 42 insertions(+), 2 deletions(-) diff --git a/crates/xfa/src/parse.rs b/crates/xfa/src/parse.rs index f6c69789b4..3384b19a67 100644 --- a/crates/xfa/src/parse.rs +++ b/crates/xfa/src/parse.rs @@ -529,8 +529,9 @@ fn parse_page_set(n: XmlNode, warnings: &mut Vec, depth: usize) -> PageS content.push(Rect::new(0.0, 0.0, width, height)); } let items = pa.children().filter(|c| c.is_element()).filter_map(|c| parse_node(c, warnings, depth + 1)).collect(); - let occur = parse_occur(child(pa, "occur")); - areas.push(PageArea { name: pa.attribute("name").map(str::to_string), width, height, content, items, occur_max: occur.max }); + // A page area with no `` has no limit (a subform's default of one would exhaust it). + let occur_max = child(pa, "occur").and_then(|o| parse_occur(Some(o)).max); + areas.push(PageArea { name: pa.attribute("name").map(str::to_string), width, height, content, items, occur_max }); } // Nested page sets: take their areas too. for ps in children(n, "pageSet") { diff --git a/crates/xfa/src/tests.rs b/crates/xfa/src/tests.rs index 1e8730586d..c661929b12 100644 --- a/crates/xfa/src/tests.rs +++ b/crates/xfa/src/tests.rs @@ -1191,6 +1191,45 @@ fn repeating_subforms_inside_positioned_content_get_their_instances() { assert!(((after.rect.y - rows[2].rect.y) - step).abs() < 0.01, "after: {:?}, last row: {:?}", after.rect, rows[2].rect); } +/// A page area without an `` repeats for as long as the body needs pages. Before, it was +/// treated like a subform (at most once), so once the first body page was out the layout fell +/// back to the last page area, here one limited to a single use, and drew its content over every +/// body page (and listed a copy of its fields per page). +#[test] +fn page_area_without_occur_serves_every_body_page() { + let section = |n: u32| { + format!( + r#""# + ) + }; + let xdp = format!( + r##" + + + +"##, + section(1), + section(2), + section(3), + section(4) + ); + let form = layout_xml(&xdp).unwrap(); + let soms: Vec> = form.pages.iter().map(|p| widgets(p).iter().map(|w| w.som.clone()).collect()).collect(); + assert_eq!(form.pages.len(), 4, "{soms:?}"); + let buttons = soms.iter().flatten().filter(|s| s.contains("PrintButton")).count(); + assert_eq!(buttons, 0, "the single-use page area is not drawn over body pages: {soms:?}"); +} + /// Areas are named in SOM expressions but are not data scopes; unnamed subforms and areas have /// no segment at all. What the template doesn't have is kept as it is. #[test] From a51380e869994ed184408e4c8d296679b9b136d2 Mon Sep 17 00:00:00 2001 From: hcchung1 <131962610+hcchung1@users.noreply.github.com> Date: Thu, 15 Oct 2026 23:33:39 +0800 Subject: [PATCH 036/105] docs: record the XFA page area occur fix in parity and the ROADMAP log (#2574) Co-Authored-By: Claude Sonnet 5.5 --- ROADMAP.md | 1 + parity/acrobat-features.toml | 4 ++-- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 259dbfec04..7b2d27f085 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -159,6 +159,7 @@ Ranked; detail and the full milestone table in [docs/roadmap.md](docs/roadmap.md ## Progress log - **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. Synthetic regression test; the page area's `occur min` (a once-only page of its own) is still not laid out, recorded in `form.xfa-dynamic`. Estimates unchanged. - **2026-10-13 (community, M3, #1569 review):** Pixel-only Wayland wheel, wheel-tilt and continuous input use standalone moved events, so Ctrl zoom does not remain latched after the modifier is released (#991); vendored winit patch (13), with a policy test. Estimates unchanged. - **2026-10-12 (M6):** XFDF field import and data-file extraction join all text nodes in a value, preserving text after XML comments and processing instructions. Synthetic regressions cover Unicode, CDATA, empty values and PDF save/reopen. Estimates unchanged. - **2026-10-12 (M7, picture right-click menu):** The Edit text & images menu of a picture gains Cut Image, Redact Image (a redaction mark over the picture, applied from Redact a PDF), Create Link… and Recognize Text… (for that page), in all 21 catalogs. The menu was drawn only while the pointer was over the picture, so its items outside the picture (most of a logo's menu, and Delete below a page) could not be clicked; an open menu now stays drawn. Still missing from Acrobat's menu: Arrange (bring to front, send backward), Remove crop, Draw freehand and Edit image using… (opening an external editor). diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index c3d319f94f..bb5006ea7d 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -5220,8 +5220,8 @@ milestone = "M12" status = "partial" commands = ["file.open"] tools = ["doc_open", "doc_info", "form_fields", "form_fill", "js_run"] -evidence = ["crates/xfa/src/tests.rs::layout_places_captions_fields_and_sizes_containers_to_their_content", "crates/xfa/src/tests.rs::tables_break_across_pages_and_repeat_their_header_row", "crates/xfa/src/tests.rs::rendering_into_the_pdf_replaces_the_placeholder_page_and_adds_fields", "crates/engine/src/tests.rs::dynamic_xfa_forms_are_laid_out_on_open_filled_and_saved_incrementally", "crates/automation/tests/automation.rs::dynamic_xfa_forms_open_render_fill_and_save_through_tools", "crates/ui-egui/tests/ui_group/xfa.rs::a_dynamic_xfa_form_is_laid_out_and_filled", "crates/xfa/src/tests.rs::nested_width_less_subforms_lay_out_in_polynomial_time", "crates/js/src/tests.rs::instance_managers_add_remove_and_count", "crates/xfa/src/tests.rs::the_form_tree_carries_values_presence_instances_and_the_events", "crates/engine/src/tests.rs::xfa_scripts_initialize_calculate_validate_toggle_and_add_rows", "crates/automation/tests/automation.rs::xfa_scripts_run_for_buttons_and_field_changes_through_tools", "crates/ui-egui/tests/ui_group/xfa.rs::xfa_buttons_run_their_scripts_in_the_app", "crates/engine/src/tests.rs::hostile_xfa_open_scripts_are_merged_capped_kept_from_printing_and_noted", "crates/engine/src/tests.rs::runaway_xfa_calculations_at_open_end_quickly_with_a_report", "crates/ui-egui/tests/ui_group/xfa.rs::a_form_whose_scripts_were_turned_off_at_open_says_so", "crates/ui-egui/tests/ui_group/xfa.rs::scripts_turned_off_by_a_click_bring_a_closed_notice_bar_back", "crates/automation/tests/automation.rs::doc_info_says_when_a_runaway_script_turned_the_forms_scripts_off", "crates/xfa/src/tests.rs::the_live_form_of_nested_repeating_subforms_stops_at_its_node_budget", "crates/xfa/src/tests.rs::data_ops_of_one_event_are_one_datasets_rewrite", "crates/xfa/src/tests.rs::choice_lists_signatures_passwords_and_pictures_become_fields_and_images", "crates/xfa/src/tests.rs::choice_values_come_from_the_data_as_saved_values_and_several_for_lists", "crates/xfa/src/tests.rs::pictures_are_sized_from_their_headers_and_hostile_ones_are_refused_unallocated", "crates/engine/src/tests.rs::xfa_choice_lists_signature_and_picture_fields_open_fill_script_and_save", "crates/xfa/src/tests.rs::repeating_subforms_inside_positioned_content_get_their_instances", "crates/engine/src/tests.rs::xfa_rows_in_areas_are_laid_out_read_calculated_and_written_where_adobe_binds_them"] -notes = "laid out from the template on open by the xfa crate (positioned, tb, lr-tb, table/row, page masters, breaks, repeated header rows, captions, rich text, JPEG/PNG/GIF images) into real pages and AcroForm fields (text, password, date with AFDate formats, check boxes, radio groups, drop-downs and list boxes with shown/saved item pairs and multi-select, signature fields, image fields showing their picture (pictures decoded once per document, at most 20 million pixels, and reused across re-layouts), buttons with reset/print/save/URL), filled from the datasets packet (including rows another viewer added), saved as an incremental revision with the values merged into the datasets so Adobe's viewers show them; doc_info reports xfa_layout. XFA JavaScript and FormCalc (form.xfa-formcalc) run in the js crate's XFA object model (xfa.form, SOM resolution, rawValue, presence, access, instance managers, xfa.host, xfa.layout, xfa.event): initialize and calculate on open, exit, validate and calculate after a field changes, click for buttons (through js_run and the app), with rows added and removed, subforms shown and hidden, and the form laid out again, all as one undo step and kept in the file. All the scripts of one event run on one thread, each in a fresh context on the form as the engine holds it then (nothing shared between scripts; the live form kept up to date in place for values, rebuilt only when the structure changed), so hundreds of calculations run within the open budget. Hostile scripts are bounded per event (merged and capped effects, messages and console lines, lower loop limits and a time budget on open and calculate, one datasets rewrite per event, print/save/links/focus from clicks only, a runaway script abandoned and the form's scripts turned off, which the app says in a toast and on the notice bar, with a button to the JavaScript console, and doc_info reports as xfa_scripts_off) and what open-time scripts changed is noted. Missing: loops inside nested calls are only stopped by abandoning their thread, and script memory is not capped; the change event per keystroke; xfa.template, connections and data descriptions; a hidden instance of a repeating subform hides them all; barcodes are drawn as their text, not bars; keep rules; metrics are approximate and Acrobat fidelity is eyeballed on real forms, not measured (#60)" +evidence = ["crates/xfa/src/tests.rs::layout_places_captions_fields_and_sizes_containers_to_their_content", "crates/xfa/src/tests.rs::tables_break_across_pages_and_repeat_their_header_row", "crates/xfa/src/tests.rs::rendering_into_the_pdf_replaces_the_placeholder_page_and_adds_fields", "crates/engine/src/tests.rs::dynamic_xfa_forms_are_laid_out_on_open_filled_and_saved_incrementally", "crates/automation/tests/automation.rs::dynamic_xfa_forms_open_render_fill_and_save_through_tools", "crates/ui-egui/tests/ui_group/xfa.rs::a_dynamic_xfa_form_is_laid_out_and_filled", "crates/xfa/src/tests.rs::nested_width_less_subforms_lay_out_in_polynomial_time", "crates/js/src/tests.rs::instance_managers_add_remove_and_count", "crates/xfa/src/tests.rs::the_form_tree_carries_values_presence_instances_and_the_events", "crates/engine/src/tests.rs::xfa_scripts_initialize_calculate_validate_toggle_and_add_rows", "crates/automation/tests/automation.rs::xfa_scripts_run_for_buttons_and_field_changes_through_tools", "crates/ui-egui/tests/ui_group/xfa.rs::xfa_buttons_run_their_scripts_in_the_app", "crates/engine/src/tests.rs::hostile_xfa_open_scripts_are_merged_capped_kept_from_printing_and_noted", "crates/engine/src/tests.rs::runaway_xfa_calculations_at_open_end_quickly_with_a_report", "crates/ui-egui/tests/ui_group/xfa.rs::a_form_whose_scripts_were_turned_off_at_open_says_so", "crates/ui-egui/tests/ui_group/xfa.rs::scripts_turned_off_by_a_click_bring_a_closed_notice_bar_back", "crates/automation/tests/automation.rs::doc_info_says_when_a_runaway_script_turned_the_forms_scripts_off", "crates/xfa/src/tests.rs::the_live_form_of_nested_repeating_subforms_stops_at_its_node_budget", "crates/xfa/src/tests.rs::data_ops_of_one_event_are_one_datasets_rewrite", "crates/xfa/src/tests.rs::choice_lists_signatures_passwords_and_pictures_become_fields_and_images", "crates/xfa/src/tests.rs::choice_values_come_from_the_data_as_saved_values_and_several_for_lists", "crates/xfa/src/tests.rs::pictures_are_sized_from_their_headers_and_hostile_ones_are_refused_unallocated", "crates/engine/src/tests.rs::xfa_choice_lists_signature_and_picture_fields_open_fill_script_and_save", "crates/xfa/src/tests.rs::repeating_subforms_inside_positioned_content_get_their_instances", "crates/engine/src/tests.rs::xfa_rows_in_areas_are_laid_out_read_calculated_and_written_where_adobe_binds_them", "crates/xfa/src/tests.rs::page_area_without_occur_serves_every_body_page"] +notes = "laid out from the template on open by the xfa crate (positioned, tb, lr-tb, table/row, page masters (a page area with no occur repeats without limit, one with occur max=1 is used once), breaks, repeated header rows, captions, rich text, JPEG/PNG/GIF images) into real pages and AcroForm fields (text, password, date with AFDate formats, check boxes, radio groups, drop-downs and list boxes with shown/saved item pairs and multi-select, signature fields, image fields showing their picture (pictures decoded once per document, at most 20 million pixels, and reused across re-layouts), buttons with reset/print/save/URL), filled from the datasets packet (including rows another viewer added), saved as an incremental revision with the values merged into the datasets so Adobe's viewers show them; doc_info reports xfa_layout. XFA JavaScript and FormCalc (form.xfa-formcalc) run in the js crate's XFA object model (xfa.form, SOM resolution, rawValue, presence, access, instance managers, xfa.host, xfa.layout, xfa.event): initialize and calculate on open, exit, validate and calculate after a field changes, click for buttons (through js_run and the app), with rows added and removed, subforms shown and hidden, and the form laid out again, all as one undo step and kept in the file. All the scripts of one event run on one thread, each in a fresh context on the form as the engine holds it then (nothing shared between scripts; the live form kept up to date in place for values, rebuilt only when the structure changed), so hundreds of calculations run within the open budget. Hostile scripts are bounded per event (merged and capped effects, messages and console lines, lower loop limits and a time budget on open and calculate, one datasets rewrite per event, print/save/links/focus from clicks only, a runaway script abandoned and the form's scripts turned off, which the app says in a toast and on the notice bar, with a button to the JavaScript console, and doc_info reports as xfa_scripts_off) and what open-time scripts changed is noted. Missing: a page area's occur min (a once-only page area such as a closing instructions page is not laid out as a page of its own, #2574); loops inside nested calls are only stopped by abandoning their thread, and script memory is not capped; the change event per keystroke; xfa.template, connections and data descriptions; a hidden instance of a repeating subform hides them all; barcodes are drawn as their text, not bars; keep rules; metrics are approximate and Acrobat fidelity is eyeballed on real forms, not measured (#60)" [[feature]] id = "form.xfa-formcalc" From fdc8099ac2b30a025b43c73c12b1f7b482df1d35 Mon Sep 17 00:00:00 2001 From: NetterSilver <133535268+Silvertree2010@users.noreply.github.com> Date: Fri, 16 Oct 2026 04:08:29 +0000 Subject: [PATCH 037/105] Keep leading spaces and tabs when wrapping plain text into a PDF Found while testing crates/fonts/src/lib.rs:79 (wrap_with), called from crates/create/src/lib.rs:set_text Co-Authored-By: Claude Haiku 5.5 Co-Authored-By: Claude Sonnet 5.5 --- crates/create/src/tests.rs | 12 ++++++++++++ crates/fonts/src/lib.rs | 27 ++++++++++++++++++++++++--- 2 files changed, 36 insertions(+), 3 deletions(-) diff --git a/crates/create/src/tests.rs b/crates/create/src/tests.rs index 993c38d9f3..476316f346 100644 --- a/crates/create/src/tests.rs +++ b/crates/create/src/tests.rs @@ -124,6 +124,18 @@ fn text_is_wrapped_and_paginated() { assert!(content(p.last().unwrap()).contains("(After a form feed) Tj"), "a form feed starts a page"); } +#[test] +fn text_keeps_leading_spaces_and_tabs() { + let doc = reopen(&from_text("t", " indented\n\ttabbed\nplain", LETTER, 11.0).unwrap()); + let p = pages(&doc); + let c = doc.resolve(p[0].get(b"Contents").unwrap()); + let Object::Stream(s) = &*c else { panic!() }; + let content = String::from_utf8_lossy(&s.decoded().unwrap()).into_owned(); + assert!(content.contains("( indented) Tj"), "{content}"); + assert!(content.contains("( tabbed) Tj"), "{content}"); + assert!(content.contains("(plain) Tj"), "{content}"); +} + /// Text Helvetica can't draw is refused with its line and character, never set as `?`; text /// PdfCraft generates writes such characters as [U+XXXX] and says so. #[test] diff --git a/crates/fonts/src/lib.rs b/crates/fonts/src/lib.rs index cd0d8358e5..a1f2ff2779 100644 --- a/crates/fonts/src/lib.rs +++ b/crates/fonts/src/lib.rs @@ -80,13 +80,19 @@ pub fn wrap_with(text: &str, size: f64, width: f64, measure: impl Fn(&str, f64) let mut lines = Vec::new(); for para in text.split(['\n', '\r']) { let mut line = String::new(); + // No word is on the line yet; an empty word (a leading space) still starts it. + let mut fresh = true; for word in para.split(' ') { - let candidate = if line.is_empty() { word.to_string() } else { format!("{line} {word}") }; - if measure(&candidate, size) <= width || line.is_empty() && measure(word, size) <= width { + let candidate = if fresh { word.to_string() } else { format!("{line} {word}") }; + if measure(&candidate, size) <= width || fresh && measure(word, size) <= width { line = candidate; + fresh = false; continue; } - if !line.is_empty() { + // Indentation alone is not a line: drop it before an over-long word. + if line.trim().is_empty() { + line.clear(); + } else { lines.push(std::mem::take(&mut line)); } for ch in word.chars() { @@ -95,6 +101,7 @@ pub fn wrap_with(text: &str, size: f64, width: f64, measure: impl Fn(&str, f64) } line.push(ch); } + fresh = line.is_empty(); } lines.push(line); } @@ -256,6 +263,20 @@ pub fn literal(bytes: &[u8]) -> Vec { mod tests { use super::*; + #[test] + fn wrap_keeps_leading_spaces() { + assert_eq!(wrap(" indented", 11.0, 500.0), [" indented"]); + assert_eq!(wrap(" a b\n c", 11.0, 500.0), [" a b", " c"]); + assert_eq!(wrap("", 11.0, 500.0), [""]); + // A single leading space before a word that must be broken leaves no blank first line. + let broken = wrap(" abcdefghijklmnopqrstuvwxyz", 11.0, 40.0); + assert!(broken.len() > 1 && broken.iter().all(|l| !l.is_empty()), "{broken:?}"); + // So does indentation of two or more spaces: no whitespace-only line, same as one space. + let broken = wrap(" abcdefghijklmnopqrstuvwxyz", 11.0, 40.0); + assert!(broken.len() > 1 && broken.iter().all(|l| !l.trim().is_empty()), "{broken:?}"); + assert_eq!(broken, wrap(" abcdefghijklmnopqrstuvwxyz", 11.0, 40.0)); + } + #[test] fn win_ansi_encodability_matches_the_encoder() { for c in ['a', 'é', 'ÿ', '€', '—', '™', '\t', '?'] { From c73669768065052c2b0e4ef1cba9282d04882201 Mon Sep 17 00:00:00 2001 From: Marco Arnulfo Date: Fri, 16 Oct 2026 06:13:19 +0200 Subject: [PATCH 038/105] fix(ui): Save asks where to keep an Apple Mail attachment (fixes #2751) --- crates/ui-egui/src/editing.rs | 18 ++++++++++++++++-- 1 file changed, 16 insertions(+), 2 deletions(-) diff --git a/crates/ui-egui/src/editing.rs b/crates/ui-egui/src/editing.rs index be1347d68d..3f5e35256e 100644 --- a/crates/ui-egui/src/editing.rs +++ b/crates/ui-egui/src/editing.rs @@ -972,13 +972,15 @@ fn is_form_edit(edit: &Edit) -> bool { /// classic Outlook (`Temporary Internet Files\Content.Outlook\` on older Windows), and the /// `Microsoft.OutlookForWindows` package folder for the new Outlook. Saving there either fails, /// because Outlook can still hold the file, or appears to work while the edits stay out of the -/// email and are deleted with the cache. Matched on either separator and in any case, so it can -/// be tested on every platform. +/// email and are deleted with the cache. Apple Mail opens one from `Mail Downloads//` in +/// its sandbox container, where other apps can't write, so saving there fails (#2751). Matched on +/// either separator and in any case, so it can be tested on every platform. pub(crate) fn is_mail_temp_copy(path: &str) -> bool { let p = path.replace('\\', "/").to_ascii_lowercase(); p.contains("/inetcache/content.outlook/") || p.contains("/temporary internet files/content.outlook/") || p.contains("/microsoft.outlookforwindows") + || p.contains("/library/containers/com.apple.mail/data/library/mail downloads/") } /// Write via a temporary file in the same directory and rename over the target, so a crash or @@ -1296,6 +1298,16 @@ mod tests { } } + #[test] + fn apple_mail_attachment_copies_are_recognised() { + for p in [ + "/Users/me/Library/Containers/com.apple.mail/Data/Library/Mail Downloads/6F1C2A9E-3B4D-4E5F-8A7B-9C0D1E2F3A4B/booking.pdf", + "/Users/me/library/containers/COM.APPLE.MAIL/data/library/mail downloads/6F1C2A9E/Booking (2).pdf", + ] { + assert!(is_mail_temp_copy(p), "{p}"); + } + } + #[test] fn ordinary_paths_are_not_mail_copies() { for p in [ @@ -1303,6 +1315,8 @@ mod tests { r"C:\Users\me\OneDrive - Contoso\Content.Outlook notes\Quote.pdf", r"C:\Users\me\AppData\Local\Microsoft\Windows\INetCache\IE\Quote.pdf", "/home/me/Documents/Quote.pdf", + "/Users/me/Downloads/Mail Downloads/booking.pdf", + "/Users/me/Library/Containers/com.apple.Notes/Data/Library/Mail Downloads/booking.pdf", "Quote.pdf", ] { assert!(!is_mail_temp_copy(p), "{p}"); From 9409265c358740a7e2d5613e808f6b3c72361525 Mon Sep 17 00:00:00 2001 From: Ahmad Mujahid Date: Fri, 16 Oct 2026 11:06:55 +0400 Subject: [PATCH 039/105] M10: apply component Decode during lossless image export --- ROADMAP.md | 4 +- crates/create/README.md | 6 ++ crates/create/src/extract.rs | 55 ++++++++-- crates/create/src/tests.rs | 188 +++++++++++++++++++++++++++++++++++ docs/roadmap.md | 5 +- parity/acrobat-features.toml | 8 +- 6 files changed, 250 insertions(+), 16 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 259dbfec04..fcc551309e 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -2,7 +2,7 @@ **Stage: alpha** · next: beta, ~25% (ready for real work 50% → 75%) and ~400–700 h away -> **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-16 | **Last updated:** 2026-10-16 | **Change:** minor (M10 #1201 partial: lossless image Decode; estimates unchanged) | **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS) > **Last reviewed:** 2026-10-13 · **Last updated:** 2026-10-14 · **Change:** minor (M14: the Italian catalog has every entry the other catalogs had and the code looks up; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS) > **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-14 · **Change:** minor (M3 #2080 candidate: explicit search jumps join per-tab history; rail and control-channel Back/Forward; estimates unchanged) > **Last reviewed:** 2026-10-13 · **Last updated:** 2026-10-13 · **Change:** minor (M4 #1910: Organize Pages opens on the current page; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS) @@ -157,6 +157,7 @@ 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-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-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-13 (community, M3, #1569 review):** Pixel-only Wayland wheel, wheel-tilt and continuous input use standalone moved events, so Ctrl zoom does not remain latched after the modifier is released (#991); vendored winit patch (13), with a policy test. Estimates unchanged. @@ -495,6 +496,7 @@ Newest first: the date, what moved, and the new overall estimate. | Date | Change | Summary | |---|---|---| +| 2026-10-16 | minor | M10 #1201 partial: lossless image export applies component Decode pairs and Indexed mapping. Estimates unchanged. | | 2026-10-13 | minor | M9: separate page validation overlays preserve signature artwork, and snapshot tabs reuse instead of multiplying; baked captions remain unchanged (#79 open); estimates unchanged | | 2026-10-12 | minor | M6: valid AcroForm XML element names with original PDF field names preserved; saved-PDF collision and Unicode regressions. Estimates unchanged. | | 2026-10-12 | minor | M6: preserve carriage returns in exported CSV field names and values; saved-PDF round-trip coverage. Estimates unchanged. | diff --git a/crates/create/README.md b/crates/create/README.md index 88c6da2ea5..273b574df5 100644 --- a/crates/create/README.md +++ b/crates/create/README.md @@ -23,3 +23,9 @@ Layer L4. Create a PDF (Acrobat's Create a PDF tool), execution plan M10.2: line says so (`ESCAPED_NOTE`). Every function returns a `pdfcraft_cos::Document`; the caller writes it. + +Image export and Save Image As apply each lossless image's Decode pair before converting its color components. +Indexed Decode values select palette entries after rounding and clipping. Color-key masks compare the original samples. +Malformed explicit Decode arrays return an export error. Missing or null arrays keep the default mapping. +The existing 16-bit path still uses each sample's high byte. JPEG and mask Decode handling remain limited. +ICC transforms and calibrated color conversion remain outside this export path. diff --git a/crates/create/src/extract.rs b/crates/create/src/extract.rs index 1034489458..e431e437e4 100644 --- a/crates/create/src/extract.rs +++ b/crates/create/src/extract.rs @@ -126,13 +126,13 @@ fn image(doc: &Document, s: &Stream) -> Result<(&'static str, Vec), String> // A stencil mask (`ImageMask true`) has no /SMask or /Mask of its own (8.9.6.2). let mut alpha = if mask { None } else { soft_mask(doc, s, w, h).or_else(|| stencil_mask(doc, s, w, h)) }; let key = if mask { None } else { colour_key(doc, s) }; - // Decode arrays: only inversion ([1 0] per component) is honoured, the common case. - let inverted = s - .dict - .get(b"Decode") - .map(|d| doc.resolve(d)) - .and_then(|d| d.as_array().map(|a| a.first().and_then(Object::as_f64) > a.get(1).and_then(Object::as_f64))) - .unwrap_or(false); + // Keep the existing JPEG and stencil-mask Decode handling separate from lossless colour images. + let inverted = (jpeg || mask) + && s.dict + .get(b"Decode") + .map(|d| doc.resolve(d)) + .and_then(|d| d.as_array().map(|a| a.first().and_then(Object::as_f64) > a.get(1).and_then(Object::as_f64))) + .unwrap_or(false); // In a PDF a CMYK JPEG's samples are ink amounts unless /Decode inverts them, while image // viewers read a CMYK JPEG file as inverted (Adobe's convention): as is, it would show as // a negative. @@ -161,6 +161,7 @@ fn image(doc: &Document, s: &Stream) -> Result<(&'static str, Vec), String> (unpack(&data, w, h, space.components(), bpc).ok_or("the image data is shorter than its size says")?, bpc) }; let n = space.components(); + let decode = if jpeg || mask { None } else { decode(doc, s, n)? }; if alpha.is_none() { alpha = key.and_then(|k| keyed_alpha(&samples, n, bpc, &k)); } @@ -168,10 +169,21 @@ fn image(doc: &Document, s: &Stream) -> Result<(&'static str, Vec), String> return as_is(); } let invert = inverted ^ mask; // a stencil mask paints its 0 samples + // `unpack` keeps only the high byte at 16 bits, so Decode uses an effective maximum of 255 too. let max = (1u32 << bpc.min(8)) - 1; let mut rgb = Vec::with_capacity(w * h * 3); for px in samples.chunks_exact(n) { + let decoded = |i: usize| { + decode.as_ref().and_then(|pairs| pairs.get(i)).map(|&[lo, hi]| { + let t = f64::from(px.get(i).copied().unwrap_or(0)) / f64::from(max); + // Avoid overflowing hi - lo for finite endpoints with opposite signs. + (1.0 - t) * lo + t * hi + }) + }; let v = |i: usize| { + if let Some(value) = decoded(i) { + return (value.clamp(0.0, 1.0) * 255.0).round() as u8; + } let x = (px.get(i).copied().unwrap_or(0) as u32 * 255 / max) as u8; if invert { 255 - x } else { x } }; @@ -180,8 +192,12 @@ fn image(doc: &Document, s: &Stream) -> Result<(&'static str, Vec), String> Space::Rgb => rgb.extend_from_slice(&[v(0), v(1), v(2)]), Space::Cmyk => rgb.extend_from_slice(&cmyk(v(0), v(1), v(2), v(3))), Space::Indexed(base, hival, table) => { - // Indexed samples outside the declared range use the nearest endpoint (ISO 32000-1, 8.6.6.3). - let i = px.first().copied().unwrap_or(0).min(*hival) as usize; + // Explicit Decode maps into index units, rounded then clipped (8.6.6.3). + // Without Decode, retain raw indices rather than normalizing to hival. + let i = match decoded(0) { + Some(value) => value.round().clamp(0.0, f64::from(*hival)) as usize, + None => px.first().copied().unwrap_or(0).min(*hival) as usize, + }; let k = base.components(); let e = table.get(i * k..i * k + k).unwrap_or(&[0, 0, 0, 0][..k]); rgb.extend_from_slice(&match **base { @@ -198,6 +214,27 @@ fn image(doc: &Document, s: &Stream) -> Result<(&'static str, Vec), String> png(w as u32, h as u32, &rgb, alpha.as_deref()).map(|p| ("png", p)) } +/// The explicit Decode intervals for a lossless colour image (8.9.5.2, Table 90). +fn decode(doc: &Document, s: &Stream, n: usize) -> Result>, String> { + let Some(value) = s.dict.get(b"Decode") else { return Ok(None) }; + let value = doc.resolve(value); + if matches!(&*value, Object::Null) { + return Ok(None); + } + let values = value.as_array().ok_or("the image Decode must be an array")?; + if values.len() != n * 2 { + return Err(format!("the image Decode must contain {} numbers", n * 2)); + } + let endpoint = |value: &Object| { + doc.resolve(value).as_f64().filter(|v| v.is_finite()).ok_or_else(|| "the image Decode must contain finite numbers".to_string()) + }; + let mut pairs = Vec::with_capacity(n); + for [lo, hi] in values.as_chunks::<2>().0 { + pairs.push([endpoint(lo)?, endpoint(hi)?]); + } + Ok(Some(pairs)) +} + /// Width and height as sizes, when both are positive and the image is at most `MAX_PIXELS`. fn size(w: i64, h: i64) -> Option<(usize, usize)> { let (w, h) = (usize::try_from(w).ok()?, usize::try_from(h).ok()?); diff --git a/crates/create/src/tests.rs b/crates/create/src/tests.rs index 993c38d9f3..7d93f17dac 100644 --- a/crates/create/src/tests.rs +++ b/crates/create/src/tests.rs @@ -875,6 +875,194 @@ fn indexed_hival_keeps_existing_short_palette_fallback() { assert_eq!(exported_png_pixels(&bytes), vec![255, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0]); } +/// Save and reopen a synthetic image before checking both export entry points. +fn decode_fixture(dict: Dict, samples: Vec) -> (Document, pdfcraft_cos::ObjRef) { + let mut doc = from_images(&[("flat.png".into(), png_bytes(false))]).unwrap(); + with_image(&mut doc, |_| Stream::from_raw(dict, samples)); + let doc = reopen(&doc); + let page = pdfcraft_model::pages(&doc)[0].clone(); + let res = doc.resolve(page.dict.get(b"Resources").unwrap()); + let xo = doc.resolve(res.as_dict().unwrap().get(b"XObject").unwrap()); + let image = xo.as_dict().unwrap().iter().next().unwrap().1.as_ref().unwrap(); + (doc, image) +} + +fn assert_decode_pixels(dict: Dict, samples: Vec, expected: &[u8]) { + let (doc, image) = decode_fixture(dict, samples); + let out = extract_images(&doc, &[0], 0); + assert!(out.skipped.is_empty(), "{:?}", out.skipped); + assert_eq!(out.images.len(), 1); + assert_eq!(exported_png_pixels(&out.images[0].data), expected); + let (ext, bytes) = image_file(&doc, image).unwrap(); + assert_eq!(ext, "png"); + assert_eq!(exported_png_pixels(&bytes), expected); +} + +fn decode_array(values: &[f64]) -> Object { + Object::Array(values.iter().copied().map(Object::Real).collect()) +} + +#[test] +fn lossless_decode_maps_each_component_before_colour_conversion() { + for (space, samples, decode, expected) in [ + ("DeviceRGB", vec![0, 64, 255], vec![1., 0., 0., 1., 0., 1.], [255, 64, 255]), + ("DeviceRGB", vec![0, 64, 255], vec![0., 1., 1., 0., 0., 1.], [0, 191, 255]), + ("DeviceGray", vec![0], vec![0.2, 0.8], [51, 51, 51]), + ("DeviceGray", vec![128], vec![-1., 2.], [129, 129, 129]), + ("DeviceCMYK", vec![0, 0, 0, 0], vec![0., 1., 1., 0., 0., 1., 0., 1.], [255, 0, 255]), + ] { + let mut dict = image_dict(1, 1, space, 8); + dict.set(b"Decode".to_vec(), decode_array(&decode)); + assert_decode_pixels(dict, samples, &expected); + } +} + +#[test] +fn lossless_decode_scales_packed_samples_by_their_bit_depth() { + let mut dict = image_dict(4, 1, "DeviceGray", 2); + dict.set(b"Decode".to_vec(), decode_array(&[0.2, 0.8])); + assert_decode_pixels(dict, vec![0x1b], &[51, 51, 51, 102, 102, 102, 153, 153, 153, 204, 204, 204]); +} + +#[test] +fn lossless_decode_identity_and_defaults_keep_depth_and_row_padding() { + for (bpc, samples, expected) in [ + (1, vec![0x7f, 0xff], vec![0, 0, 0, 255, 255, 255]), + (2, vec![0x7f, 0xbf], vec![85, 85, 85, 170, 170, 170]), + (4, vec![0x5f, 0xaf], vec![85, 85, 85, 170, 170, 170]), + (8, vec![85, 170], vec![85, 85, 85, 170, 170, 170]), + (16, vec![85, 255, 170, 0], vec![85, 85, 85, 170, 170, 170]), + ] { + for decode in [None, Some(Object::Null), Some(decode_array(&[0., 1.]))] { + let mut dict = image_dict(1, 2, "DeviceGray", bpc); + if let Some(decode) = decode { + dict.set(b"Decode".to_vec(), decode); + } + assert_decode_pixels(dict, samples.clone(), &expected); + } + } +} + +#[test] +fn lossless_indexed_decode_rounds_in_index_units_then_clamps() { + let palette = vec![10, 20, 30, 40, 50, 60, 70, 80, 90, 100, 110, 120]; + for (bpc, samples, decode, expected) in [ + (2, vec![0x1b], Some(vec![3., 0.]), vec![100, 110, 120, 70, 80, 90, 40, 50, 60, 10, 20, 30]), + (2, vec![0x1b], Some(vec![-1., 5.]), vec![10, 20, 30, 40, 50, 60, 100, 110, 120, 100, 110, 120]), + (2, vec![0x1b], Some(vec![0.5, 0.5]), vec![40, 50, 60, 40, 50, 60, 40, 50, 60, 40, 50, 60]), + (8, vec![0, 1, 2, 255], None, vec![10, 20, 30, 40, 50, 60, 70, 80, 90, 100, 110, 120]), + // Existing 16-bit policy retains the high byte, 42 / 255 * 3 < 0.5. + (16, vec![0x2a, 0xab, 0, 0, 255, 255, 128, 0], Some(vec![0., 3.]), vec![10, 20, 30, 10, 20, 30, 100, 110, 120, 70, 80, 90]), + ] { + let (mut doc, image) = indexed_export_fixture(Object::Int(3), palette.clone(), false, false, bpc, samples); + if let Some(decode) = decode { + let Object::Stream(mut stream) = (*doc.get(image)).clone() else { panic!() }; + stream.dict.set(b"Decode".to_vec(), decode_array(&decode)); + doc.set(image, Object::Stream(stream)); + } + let doc = reopen(&doc); + let out = extract_images(&doc, &[0], 0); + assert!(out.skipped.is_empty(), "{:?}", out.skipped); + assert_eq!(exported_png_pixels(&out.images[0].data), expected); + let (_, bytes) = image_file(&doc, out.images[0].object).unwrap(); + assert_eq!(exported_png_pixels(&bytes), expected); + } +} + +#[test] +fn lossless_decode_keeps_colour_key_comparisons_in_raw_samples() { + let mut dict = image_dict(2, 1, "DeviceGray", 8); + dict.set(b"Decode".to_vec(), decode_array(&[0.2, 0.8])); + dict.set(b"Mask".to_vec(), Object::Array(vec![Object::Int(0), Object::Int(0)])); + assert_decode_pixels(dict, vec![0, 255], &[51, 51, 51, 0, 204, 204, 204, 255]); + + let (mut doc, image) = indexed_export_fixture(Object::Int(1), vec![255, 0, 0, 0, 0, 255], false, false, 2, vec![0x1b]); + let Object::Stream(mut stream) = (*doc.get(image)).clone() else { panic!() }; + stream.dict.set(b"Decode".to_vec(), decode_array(&[3., 0.])); + stream.dict.set(b"Mask".to_vec(), Object::Array(vec![Object::Int(2), Object::Int(2)])); + doc.set(image, Object::Stream(stream)); + let doc = reopen(&doc); + let out = extract_images(&doc, &[0], 0); + let expected = [0, 0, 255, 255, 0, 0, 255, 255, 0, 0, 255, 0, 255, 0, 0, 255]; + assert_eq!(exported_png_pixels(&out.images[0].data), expected); + let (_, bytes) = image_file(&doc, out.images[0].object).unwrap(); + assert_eq!(exported_png_pixels(&bytes), expected); +} + +#[test] +fn lossless_decode_resolves_indirect_arrays_and_endpoints() { + let mut doc = from_images(&[("flat.png".into(), png_bytes(false))]).unwrap(); + with_image(&mut doc, |doc| { + let lo = doc.add(Object::Real(0.2)); + let hi = doc.add(Object::Real(0.8)); + let decode = doc.add(Object::Array(vec![Object::Ref(lo), Object::Ref(hi)])); + let mut dict = image_dict(1, 1, "DeviceGray", 8); + dict.set(b"Decode".to_vec(), Object::Ref(decode)); + Stream::from_raw(dict, vec![0]) + }); + let doc = reopen(&doc); + let out = extract_images(&doc, &[0], 0); + assert_eq!(exported_png_pixels(&out.images[0].data), [51, 51, 51]); + let (_, bytes) = image_file(&doc, out.images[0].object).unwrap(); + assert_eq!(exported_png_pixels(&bytes), [51, 51, 51]); + + let mut doc = from_images(&[("flat.png".into(), png_bytes(false))]).unwrap(); + with_image(&mut doc, |doc| { + let decode = doc.add(Object::Null); + let mut dict = image_dict(1, 1, "DeviceGray", 8); + dict.set(b"Decode".to_vec(), Object::Ref(decode)); + Stream::from_raw(dict, vec![64]) + }); + let doc = reopen(&doc); + let out = extract_images(&doc, &[0], 0); + assert_eq!(exported_png_pixels(&out.images[0].data), [64, 64, 64]); + let (_, bytes) = image_file(&doc, out.images[0].object).unwrap(); + assert_eq!(exported_png_pixels(&bytes), [64, 64, 64]); +} + +#[test] +fn lossless_decode_interpolation_avoids_endpoint_difference_overflow() { + let (mut doc, image) = decode_fixture(image_dict(3, 1, "DeviceGray", 8), vec![0, 64, 255]); + let Object::Stream(mut stream) = (*doc.get(image)).clone() else { panic!() }; + stream.dict.set(b"Decode".to_vec(), decode_array(&[-f64::MAX, f64::MAX])); + doc.set(image, Object::Stream(stream)); + let expected = [0, 0, 0, 0, 0, 0, 255, 255, 255]; + let out = extract_images(&doc, &[0], 0); + assert_eq!(exported_png_pixels(&out.images[0].data), expected); + let (_, bytes) = image_file(&doc, image).unwrap(); + assert_eq!(exported_png_pixels(&bytes), expected); +} + +#[test] +fn lossless_decode_rejects_malformed_explicit_values() { + for decode in [ + Object::name("bad"), + decode_array(&[]), + decode_array(&[0.]), + decode_array(&[0., 1., 0., 1.]), + Object::Array(vec![Object::Int(0), Object::name("bad")]), + Object::Array(vec![Object::Int(0), Object::Null]), + ] { + let mut dict = image_dict(1, 1, "DeviceGray", 8); + dict.set(b"Decode".to_vec(), decode); + let (doc, image) = decode_fixture(dict, vec![0]); + assert!(image_file(&doc, image).unwrap_err().contains("Decode")); + let out = extract_images(&doc, &[0], 0); + assert!(out.images.is_empty()); + assert_eq!(out.skipped.len(), 1); + assert!(out.skipped[0].2.contains("Decode")); + } + // Non-finite reals cannot be serialized as PDF numbers. Exercise the in-memory input. + for value in [f64::NAN, f64::INFINITY, f64::NEG_INFINITY] { + let mut dict = image_dict(1, 1, "DeviceGray", 8); + dict.set(b"Decode".to_vec(), decode_array(&[0., value])); + let (mut doc, image) = decode_fixture(image_dict(1, 1, "DeviceGray", 8), vec![0]); + doc.set(image, Object::Stream(Stream::from_raw(dict, vec![0]))); + assert!(image_file(&doc, image).unwrap_err().contains("Decode")); + assert_eq!(extract_images(&doc, &[0], 0).skipped.len(), 1); + } +} + /// An 8×8 baseline JPEG of one flat colour with four components, stored as `samples`, and an /// Adobe APP14 segment with `transform` (0: CMYK, 2: YCCK). fn flat_four_component_jpeg(samples: [u8; 4], transform: u8) -> Vec { diff --git a/docs/roadmap.md b/docs/roadmap.md index 3dfea674f4..d6dc3f023c 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -1,6 +1,6 @@ # PdfCraft roadmap: milestones and what's next -> **Last reviewed:** 2026-10-13 · **Last updated:** 2026-10-14 · **Change:** minor (M14: the Italian catalog has every entry the other catalogs had and the code looks up, 2,528 entries; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS) +> **Last reviewed:** 2026-10-16 | **Last updated:** 2026-10-16 | **Change:** minor (M10 #1201 partial: lossless image Decode; estimates unchanged) | **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS) > **Last reviewed:** 2026-10-12 · **Last updated:** 2026-10-13 · **Change:** minor (M2/M6: spot-colour images convert once per pixel value; widget placement and the Fields panel scale with the field count; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS) > **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-14 · **Change:** minor (M3 candidate: coordinate control clicks on slow frames and coalesced text selection; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS) > **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-15 · **Change:** minor (M2: sampled functions index a flat table, one image decode serves every zoom level, patch meshes copy less; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS) @@ -126,7 +126,7 @@ total matches the parity estimate. | M7 | Content editing (text, images, header/footer, watermark) | 250–500 | 20% | 100–180 | Done: header & footer, watermarks, backgrounds, Bates; added text and images that stay editable (move, resize, format, rotate, flip, crop, replace); links (Link tool, Link Properties, create from URLs, remove all). Missing: editing existing text and images in place (the longest pole), image/PDF watermarks | | M8 | Security + redaction | 100–180 | 68% | 15–30 | Done: opening protected documents, permissions, Protect Using Password, Remove security; redaction (mark text/areas/pages, Search & Redact with patterns, apply removing glyphs, image pixels, vectors, XObject content, annotations and fields, verification, full rewrite on save); Remove hidden information and Sanitize.
Done since: redaction codes (U.S. FOIA and Privacy Act sets as overlay text, in Redaction properties and `redact_mark`). Redaction cleans indirect and form structure references, preserving surviving form tags. Redaction retires inherited resource dictionaries, so a parent `/Pages` can't keep a removed XObject reachable; unredacted pages are left untouched. Image redaction removes alternate images and unsupported masks only on affected images. Missing: certificate security, custom redaction code sets and pattern locales, DCT re-encoding | | M9 | Signatures (PAdES, validation) | 160–280 | 60% | 40–70 | Done: new `sign` crate (DER, X.509, CMS, PKCS #12 on RustCrypto; aws-lc-rs for RSA private keys); PAdES B-B signing (visible, invisible, existing fields, certification with DocMDP); validation with trust store and changes-after-signing classification; self-signed digital IDs; Signatures panel, message bar, sign dialogs; agent tools. Checked with pdfsig and OpenSSL.
Done since: macOS Keychain and Windows Current User Personal store signing (software-backed CNG keys), certificate viewer, signed documents protected from rewrites, page validation overlays separate from original signature artwork (baked Not Verified captions remain; #79 open), snapshot tabs that reuse instead of multiplying. Missing: timestamps (B-T), LTV (DSS, OCSP, CRL), FieldMDP, smart cards, PKCS #11 | -| M10 | OCR, create, export, print | 200–350 | 33% | 120–215 | Done: create from blank/text/PNG/JPEG/TIFF (multi-page)/GIF/BMP; export PNG/JPEG/TIFF and text; Print (Acrobat's sizing, n-up, booklet, poster, comments & forms, preview, the CUPS and Windows spoolers, print-ready PDF).
Done since: embedded/72/custom DPI choices for image imports; export all images; OCR (searchable image for pages, ranges and multiple files); single-sided cut-and-stack imposition with cut marks, through Print and doc_print; architectural/ANSI/large ISO sheet presets, Match page at 100% and PDF-scale preview (#599 follow-up); Create PDF from multiple files (PDFs, images and text, edited as pages in the grid); OCR can also read pictures on pages that have text, without adding that text twice (#1028). Done since: Word invoice export uses borderless inferred tables, floating positioned text/artwork only where text overlaps an image or is rotated, and per-page sizes, covered by synthetic invoice automation regressions (#773); printing on the web through the browser's print dialog, sheets drawn by PdfCraft (gap 4). Missing: OCR languages beyond Latin, editable-text OCR output, full Office layout fidelity/import | +| M10 | OCR, create, export, print | 200–350 | 33% | 120–215 | Done: create from blank/text/PNG/JPEG/TIFF (multi-page)/GIF/BMP; export PNG/JPEG/TIFF and text; Print (Acrobat's sizing, n-up, booklet, poster, comments & forms, preview, the CUPS and Windows spoolers, print-ready PDF).
Done since: embedded/72/custom DPI choices for image imports; export all images; OCR (searchable image for pages, ranges and multiple files); single-sided cut-and-stack imposition with cut marks, through Print and doc_print; architectural/ANSI/large ISO sheet presets, Match page at 100% and PDF-scale preview (#599 follow-up); Create PDF from multiple files (PDFs, images and text, edited as pages in the grid); OCR can also read pictures on pages that have text, without adding that text twice (#1028). Done since: Word invoice export uses borderless inferred tables, floating positioned text/artwork only where text overlaps an image or is rotated, and per-page sizes, covered by synthetic invoice automation regressions (#773); printing on the web through the browser's print dialog, sheets drawn by PdfCraft (gap 4). Lossless image export applies per-component Decode, including Indexed mapping (#1201 partial). Existing 16-bit precision and color-management limits remain. Missing: OCR languages beyond Latin, editable-text OCR output, full Office layout fidelity/import | | M11 | Optimize, preflight, PDF/A/X/UA, print production | 200–350 | 18% | 80–140 | Done: new `optimize` crate: Reduce File Size and the PDF Optimizer (images measured where drawn, bicubic downsampling, JPEG/ZIP recompression only when smaller, discard objects and user data, Flate clean-up, resource merging, object streams). Missing: fonts and transparency panels, space audit, preflight, PDF/A/X/UA | | M12 | Accessibility, compare, measure, search, XFA | 200–380 | 30% | 90–160 | Done: new `a11y` crate with the Accessibility Checker (all 32 rules, report, Fix/Skip/Explain, options dialog and results panel, agent tools). Locatable figure, annotation and form findings highlight their page bounds.
Done since: 2D distance, perimeter and area measurements with persistent viewport calibration, snapping, live information and CSV export; a bounded, text-only auto-tag proposal tool for one page. Marking a figure decorative rewrites only that stream (#1324). An XFA radio item whose value contains a space is written back unchanged (#1319). Missing: writing proposed tags and parent tree, field autotag, visual Tags/Order/Content panels, Reading Order tool, compare, geospatial/3D measurement, search index, XFA | | M13 | Automation (MCP, Action Wizard, CLI) + AI providers | 60–120 | 45% | 25–45 | Done: MCP resources (document info, text, page images); headless tool table (123 tools incl. signing, optimizing, initial view, links, stamps, data exchange, comment review, forms authoring and scripts, redaction, sanitize, print, add content), opt-in MCP server over stdio, CLI `run`/`tools` (closed stdout pipes exit cleanly), UI control channel with drag. Missing: Action Wizard, AI providers
Done since: CLI run and MCP reject missing, empty, repeated and option-valued --root arguments before any tool/script execution or MCP initialization. A single-hyphen directory name remains valid; synthetic CLI regressions preserve a would-be overwritten file. `text_find` also caps the per-page match ranges it materializes at the remaining result limit plus one continuation match. CLI robustness scans visit each directory once through overlapping roots and symlink aliases. | @@ -179,6 +179,7 @@ M2 (renderer), M7 (content editing), M10 (OCR and Office), M11 (Preflight and st | Date | Change | Summary | |---|---|---| +| 2026-10-16 | minor | M10 #1201 partial: lossless image export applies component Decode pairs and Indexed mapping. Estimates unchanged. | | 2026-10-15 | minor | M9 #1895: generic appearance rewriting deferred; separate validation overlays preserve original artwork, exports and printing. Baked captions remain unchanged (#79 open). Estimates unchanged. | | 2026-10-12 | minor | M6: valid AcroForm XML element names with original PDF field names preserved; saved-PDF collision and Unicode regressions. Estimates unchanged. | | 2026-10-12 | minor | M6: preserve carriage returns in exported CSV field names and values; saved-PDF round-trip coverage. Estimates unchanged. | diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index c3d319f94f..e7b9c1be74 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -2130,8 +2130,8 @@ milestone = "M7" status = "shipped" commands = ["edit.edit_text"] tools = ["image_save"] -evidence = ["crates/edit/src/tests.rs::page_images_move_turn_replace_and_delete", "crates/ui-egui/tests/editing.rs::editing_existing_images_on_the_page", "crates/automation/tests/automation.rs::editing_page_images_through_tools", "crates/create/src/tests.rs::images_export_as_jpeg_unchanged_and_others_as_png"] -notes = "right-click an image ▸ Save Image As: JPEG unchanged, others PNG" +evidence = ["crates/edit/src/tests.rs::page_images_move_turn_replace_and_delete", "crates/ui-egui/tests/editing.rs::editing_existing_images_on_the_page", "crates/automation/tests/automation.rs::editing_page_images_through_tools", "crates/create/src/tests.rs::images_export_as_jpeg_unchanged_and_others_as_png", "crates/create/src/tests.rs::lossless_decode_maps_each_component_before_colour_conversion", "crates/create/src/tests.rs::lossless_indexed_decode_rounds_in_index_units_then_clamps"] +notes = "right-click an image ▸ Save Image As: JPEG unchanged, others PNG. Lossless images apply per-component Decode; Indexed values round and clip before palette lookup. Malformed Decode arrays return an error. Still limited: 16-bit samples use high bytes; general JPEG and mask Decode, ICC transforms, and calibrated colors remain unsupported." [[feature]] id = "edit.image-external-editor" @@ -7076,8 +7076,8 @@ milestone = "M10" status = "shipped" commands = ["export.all_images"] tools = ["doc_export_all_images"] -evidence = ["crates/create/src/tests.rs::images_export_as_jpeg_unchanged_and_others_as_png", "crates/create/src/tests.rs::indexed_and_one_bit_images_decode", "crates/create/src/tests.rs::jpeg_images_with_a_soft_mask_export_as_png_with_alpha", "crates/create/src/tests.rs::soft_masks_of_another_size_and_stencil_and_colour_key_masks_become_alpha", "crates/automation/tests/automation.rs::exporting_all_images_through_tools", "crates/ui-egui/tests/export.rs::export_dialogs_write_images_and_text"] -notes = "each image once (page resources and nested forms); JPEG unchanged unless it has transparency, others PNG (gray/RGB/CMYK/ICC/Indexed, 1–16 bpc, stencil masks); transparency as PNG alpha: /SMask (scaled to the image size, /Matte un-blended), stencil /Mask and colour-key /Mask, JPEG images included (#645); JPX/JBIG2/CCITT masks are left out (image exported opaque); JPX/JBIG2/CCITT/separations reported as skipped; minimum size filter" +evidence = ["crates/create/src/tests.rs::images_export_as_jpeg_unchanged_and_others_as_png", "crates/create/src/tests.rs::indexed_and_one_bit_images_decode", "crates/create/src/tests.rs::jpeg_images_with_a_soft_mask_export_as_png_with_alpha", "crates/create/src/tests.rs::soft_masks_of_another_size_and_stencil_and_colour_key_masks_become_alpha", "crates/automation/tests/automation.rs::exporting_all_images_through_tools", "crates/ui-egui/tests/export.rs::export_dialogs_write_images_and_text", "crates/create/src/tests.rs::lossless_decode_maps_each_component_before_colour_conversion", "crates/create/src/tests.rs::lossless_indexed_decode_rounds_in_index_units_then_clamps"] +notes = "each image once (page resources and nested forms); JPEG unchanged unless it has transparency, others PNG (gray/RGB/CMYK/ICC/Indexed, 1–16 bpc, stencil masks); transparency as PNG alpha: /SMask (scaled to the image size, /Matte un-blended), stencil /Mask and colour-key /Mask, JPEG images included (#645); JPX/JBIG2/CCITT masks are left out (image exported opaque); JPX/JBIG2/CCITT/separations reported as skipped; minimum size filter. Lossless images apply per-component Decode; Indexed values round and clip before palette lookup. Malformed Decode arrays return an error. Still limited: 16-bit samples use high bytes; general JPEG and mask Decode, ICC transforms, and calibrated colors remain unsupported." [[feature]] id = "create.export-html" From e447749109a0c98949515a4c35c21aae56fbca1a Mon Sep 17 00:00:00 2001 From: Ahmad Mujahid Date: Fri, 16 Oct 2026 11:22:54 +0400 Subject: [PATCH 040/105] Fix wrapped revision controls overlapping Binding (#2739) --- ROADMAP.md | 2 ++ crates/ui-egui/src/dialogs.rs | 23 ++++++------ crates/ui-egui/tests/viewer.rs | 64 ++++++++++++++++++++++++++++++++++ docs/roadmap.md | 3 +- 4 files changed, 81 insertions(+), 11 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 259dbfec04..ca3840ae52 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -2,6 +2,7 @@ **Stage: alpha** · next: beta, ~25% (ready for real work 50% → 75%) and ~400–700 h away +> **Last reviewed:** 2026-10-16 | **Last updated:** 2026-10-16 | **Change:** minor (M3, #2739: wrapped revision buttons stay above Binding; 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-13 · **Last updated:** 2026-10-14 · **Change:** minor (M14: the Italian catalog has every entry the other catalogs had and the code looks up; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS) > **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-14 · **Change:** minor (M3 #2080 candidate: explicit search jumps join per-tab history; rail and control-channel Back/Forward; estimates unchanged) @@ -157,6 +158,7 @@ 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-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-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-13 (community, M3, #1569 review):** Pixel-only Wayland wheel, wheel-tilt and continuous input use standalone moved events, so Ctrl zoom does not remain latched after the modifier is released (#991); vendored winit patch (13), with a policy test. Estimates unchanged. diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 2d2407629c..0e5134dd3b 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -354,17 +354,20 @@ fn dialogs(app: &mut PdfCraftApp, ctx: &egui::Context) { let ends = doc.revision_ends(); if ends.len() > 1 { ui.label(egui::RichText::new(tl!("Revisions")).color(t.text_muted)); - ui.horizontal_wrapped(|ui| { - ui.label(ends.len().to_string()); - for n in (1..ends.len()).rev().take(12) { - if ui - .small_button(crate::i18n::fmt(tl!("View revision {n}"), &[("n", &n.to_string())])) - .on_hover_text(tl!("Open the file as it was saved then")) - .clicked() - { - open_revision = Some(n); + // Reserve the full wrapped height before the grid starts the next row. + ui.vertical(|ui| { + ui.horizontal_wrapped(|ui| { + ui.label(ends.len().to_string()); + for n in (1..ends.len()).rev().take(12) { + if ui + .small_button(crate::i18n::fmt(tl!("View revision {n}"), &[("n", &n.to_string())])) + .on_hover_text(tl!("Open the file as it was saved then")) + .clicked() + { + open_revision = Some(n); + } } - } + }); }); ui.end_row(); } diff --git a/crates/ui-egui/tests/viewer.rs b/crates/ui-egui/tests/viewer.rs index 474360865b..0b1207d839 100644 --- a/crates/ui-egui/tests/viewer.rs +++ b/crates/ui-egui/tests/viewer.rs @@ -902,6 +902,70 @@ fn an_earlier_revision_opens_from_document_properties() { ); } +#[test] +fn wrapped_revision_buttons_stay_above_binding_and_open_the_saved_revision() { + use pdfcraft_engine::{Edit, Session}; + let mut bytes = std::sync::Arc::new(fixture(1)); + // Reopen each save so the next edit appends to the preceding saved revision. + for revision in 2..=13 { + let mut source = Session::new().with_clock(|| 1_700_000_000); + let id = source.open("revisions.pdf", None, bytes, None).unwrap(); + source.apply(id, Edit::SetInfo { key: "Title".into(), value: format!("Revision {revision}") }).unwrap(); + bytes = source.save_bytes(id).unwrap(); + } + for width in [1400.0, 720.0] { + let pdf = bytes.clone(); + let mut h = Harness::builder().with_size(egui::vec2(width, 900.0)).build_eframe(move |_cc| { + let mut app = PdfCraftApp::new(); + app.set_option("language", "en").unwrap(); + app.open_bytes("revisions.pdf", None, pdf.to_vec()).unwrap(); + app + }); + let s = h.state(); + let doc = s.session.get(s.views[s.active.unwrap()].id).unwrap(); + assert_eq!(doc.revision_ends().len(), 13, "the fixture contains thirteen saved revisions"); + h.state_mut().dialog = Some(Dialog::Properties(pdfcraft_ui_egui::PropsTab::Advanced)); + // Let the grid and the centered dialog settle before measuring or clicking. + h.run_steps(6); + let buttons: Vec<_> = (1..13).map(|n| (n, h.get_by_label(&format!("View revision {n}")).rect())).collect(); + let first_row = h.get_by_label("View revision 12").rect(); + let lower_revision = buttons + .iter() + .find(|(n, rect)| *n > 1 && rect.top() > first_row.bottom()) + .map(|(n, _)| *n) + .expect("revision buttons wrap to a lower row"); + let binding = h.get_by_label("Binding").rect(); + let left = h.get_by_label("Left Edge").rect(); + let right = h.get_by_label("Right Edge").rect(); + // Properties requests a fixed width. Measure its card in each viewport. + let card = h.ctx.data(|d| d.get_temp::(egui::Id::new("dialog").with("card"))).expect("Properties is shown"); + for (n, rect) in &buttons { + for (label, below) in [("Binding", binding), ("Left Edge", left), ("Right Edge", right)] { + assert!(rect.bottom() <= below.top(), "revision {n} overlaps {label} at viewport width {width}: {rect:?}, {below:?}"); + } + } + for (n, rect) in &buttons { + assert!( + rect.left() >= card.left() && rect.right() <= card.right(), + "revision {n} escapes Properties horizontally at viewport width {width}: {rect:?}, measured card {card:?}" + ); + } + h.get_by_label(&format!("View revision {lower_revision}")).scroll_to_me(); + h.run_steps(3); + h.get_by_label(&format!("View revision {lower_revision}")).click(); + h.run_steps(3); + let s = h.state(); + assert!(s.dialog.is_none(), "opening a revision closes Properties"); + let view = &s.views[s.active.unwrap()]; + let doc = s.session.get(view.id).unwrap(); + assert_eq!(doc.name, format!("revisions (revision {lower_revision}).pdf")); + assert_eq!(doc.info_value("Title"), Some(format!("Revision {lower_revision}"))); + let snapshot = view.snapshot.as_ref().expect("the saved revision opens as a snapshot"); + assert_eq!(snapshot.kind, pdfcraft_ui_egui::canvas::SnapshotKind::Revision); + assert_eq!(snapshot.revision, lower_revision); + } +} + #[test] fn advanced_search_lists_results_with_context() { let mut h = harness(); diff --git a/docs/roadmap.md b/docs/roadmap.md index 3dfea674f4..1ceaca573b 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -1,5 +1,6 @@ # PdfCraft roadmap: milestones and what's next +> **Last reviewed:** 2026-10-16 | **Last updated:** 2026-10-16 | **Change:** minor (M3, #2739: wrapped revision buttons stay above Binding; estimates unchanged) > **Last reviewed:** 2026-10-13 · **Last updated:** 2026-10-14 · **Change:** minor (M14: the Italian catalog has every entry the other catalogs had and the code looks up, 2,528 entries; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS) > **Last reviewed:** 2026-10-12 · **Last updated:** 2026-10-13 · **Change:** minor (M2/M6: spot-colour images convert once per pixel value; widget placement and the Fields panel scale with the field count; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS) > **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-14 · **Change:** minor (M3 candidate: coordinate control clicks on slow frames and coalesced text selection; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS) @@ -101,7 +102,7 @@ total matches the parity estimate. | M2 | Model, render, text | 200–350 | 15% | 130–230 | hayro bootstrap renderer (vendored patches); inspector predictor scratch is bounded by available input, and JPX sample rescaling handles 1–32 bits safely. Text extraction reaches word-F1 0.98 against pdftotext. Short same-line redraws split before placement rather than interleaving. Missing: model crate, fonts, DisplayList, renderer independent of hayro. Inspector page-label traversal and output are resource-bounded, with physical-number fallback warnings. Packed TIFF-predictor RGB rows retain later rows in decoding and rendering. Page marks, added content and print output retain small page-unit transform coefficients when serialized. | | M2 | Model, render, text | 200–350 | 15% | 130–230 | hayro bootstrap renderer (vendored patches); inspector predictor scratch is bounded by available input, and JPX sample rescaling handles 1–32 bits safely. Text extraction reaches word-F1 0.98 against pdftotext. Short same-line redraws split before placement rather than interleaving. Missing: model crate, fonts, DisplayList, renderer independent of hayro. Inspector page-label traversal and output are resource-bounded, with physical-number fallback warnings. Packed TIFF-predictor RGB rows retain later rows in decoding and rendering. The renderer keeps its one-point crop-size floor in physical points after UserUnit scaling, with regression coverage for crop origins, rotations and f32 reciprocal rounding. Existing unit policy and nearly-zero fallback are retained. | | M2 | Model, render, text | 200–350 | 15% | 130–230 | hayro bootstrap renderer (vendored patches); inspector predictor scratch is bounded by available input, and JPX sample rescaling handles 1–32 bits safely. Text extraction reaches word-F1 0.98 against pdftotext. Short same-line redraws split before placement rather than interleaving. Missing: model crate, fonts, DisplayList, renderer independent of hayro. Inspector page-label traversal and output are resource-bounded, with physical-number fallback warnings | -| M3 | Viewer app (native + web) | 80–150 | 90% | 10–20 | Acrobat-style shell, find, select, panels, tiles, web build, UI control channel for agents (opt-in). Missing: 60 fps test on a 500-page document, snapshot tests of every panel
Done since: fit visible, document title in the window; Linux middle-button scrolling; shared three-state theme preferences in the toolbar, View menu and control channel; numeric Linux portal events and idle wakeups completing #881, with native appearance inheritance retained (#446); late browser startup downloads preserve the current document and Save target (#171). Selected-text Copy in Edit and the canvas context menu is covered by focused regressions and native clipboard checks (#1225); document copy restrictions apply to the floating action too. #1364 candidate: absolute local GoToR links ask before target access; headless callers explicitly opt in. Native-opening candidate: chosen tools survive preparation and password retries; startup intake waits for pending preparation. Page-label navigation displays logical labels, retains the current page on unchanged submission and refreshes focused same-document replacements; clean file-watcher reload preserves the chosen scrolling mode, preventing a Single Page reader from jumping to page 1 during replacement. Synthetic control regressions cover these behaviors. | +| M3 | Viewer app (native + web) | 80–150 | 90% | 10–20 | Acrobat-style shell, find, select, panels, tiles, web build, UI control channel for agents (opt-in). Missing: 60 fps test on a 500-page document, snapshot tests of every panel
Done since: fit visible, document title in the window; Linux middle-button scrolling; shared three-state theme preferences in the toolbar, View menu and control channel; numeric Linux portal events and idle wakeups completing #881, with native appearance inheritance retained (#446); late browser startup downloads preserve the current document and Save target (#171). Selected-text Copy in Edit and the canvas context menu is covered by focused regressions and native clipboard checks (#1225); document copy restrictions apply to the floating action too. #1364 candidate: absolute local GoToR links ask before target access; headless callers explicitly opt in. Native-opening candidate: chosen tools survive preparation and password retries; startup intake waits for pending preparation. Page-label navigation displays logical labels, retains the current page on unchanged submission and refreshes focused same-document replacements; clean file-watcher reload preserves the chosen scrolling mode, preventing a Single Page reader from jumping to page 1 during replacement. Synthetic control regressions cover these behaviors. Wrapped revision buttons stay above Binding in Document Properties (#2739); a synthetic UI test checks layout and saved revision content. | | M3 | Viewer app (native + web) | 80–150 | 90% | 10–20 | Acrobat-style shell, find, select, panels, tiles, web build, UI control channel for agents (opt-in). Missing: 60 fps test on a 500-page document, snapshot tests of every panel
Done since: fit visible, document title in the window; Linux middle-button scrolling; shared three-state theme preferences in the toolbar, View menu and control channel; numeric Linux portal events and idle wakeups completing #881, with native appearance inheritance retained (#446); late browser startup downloads preserve the current document and Save target (#171). #1364 candidate: absolute local GoToR links ask before target access; headless callers explicitly opt in. Native-opening candidate: chosen tools survive preparation and password retries; startup intake waits for pending preparation. Later native-open replies stay queued while a modal or picker owns the foreground; regressions cover recipient ownership, completion/cancellation and one-shot wakes. | | M3 | Viewer app (native + web) | 80–150 | 90% | 10–20 | Acrobat-style shell, find, select, panels, tiles, web build, UI control channel for agents (opt-in). Missing: 60 fps test on a 500-page document, snapshot tests of every panel
Done since: fit visible, document title in the window; Linux middle-button scrolling; shared three-state theme preferences in the toolbar, View menu and control channel; numeric Linux portal events and idle wakeups completing #881, with native appearance inheritance retained (#446); late browser startup downloads preserve the current document and Save target (#171). #1364 candidate: absolute local GoToR links ask before target access; headless callers explicitly opt in. Native-opening candidate: chosen tools survive preparation and password retries; startup intake waits for pending preparation. #1980: exact quit modifiers preserve Ctrl+Alt+Q email input; Ctrl+Q/Cmd+Q asks before discarding unsaved documents. | | M3 | Viewer app (native + web) | 80–150 | 90% | 10–20 | Acrobat-style shell, find, select, panels, tiles, web build, UI control channel for agents (opt-in). Missing: 60 fps test on a 500-page document, snapshot tests of every panel
Done since: fit visible, document title in the window; Linux middle-button scrolling; shared three-state theme preferences in the toolbar, View menu and control channel; numeric Linux portal events and idle wakeups completing #881, with native appearance inheritance retained (#446); late browser startup downloads preserve the current document and Save target (#171). #1364 candidate: absolute local GoToR links ask before target access; headless callers explicitly opt in. Native-opening candidate: chosen tools survive preparation and password retries; startup intake waits for pending preparation.
Candidate: coordinate control clicks pair press/release across slow frames; coalesced primary presses retain text multi-click selection. Regression cases cover slow-frame button activation, disabled controls and text multi-click selection; estimates unchanged. | From c66384f92ba0042a20196b42c5d0a50f2c94d3aa Mon Sep 17 00:00:00 2001 From: Enrico Sita Date: Fri, 16 Oct 2026 10:27:59 +0200 Subject: [PATCH 041/105] fix(ui): Read mode hides the quick toolbar (#2646) Read mode hides the tools, not the navigation panes, as Acrobat's does and as switch_mode already says, but the floating quick toolbar was still drawn over the page there. It is now hidden in Read mode, and entering Read mode puts the current tool down (back to Select), so no markup tool stays active without a toolbar to show it. The right rail, the page navigation and the V/H shortcuts are unchanged. Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/canvas.rs | 5 ++++- crates/ui-egui/src/lib.rs | 2 ++ crates/ui-egui/tests/ui.rs | 18 ++++++++++++++++++ 3 files changed, 24 insertions(+), 1 deletion(-) diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 0acab64a08..b5db6b4628 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -3893,7 +3893,10 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) { if copy_selected { app.execute("edit.copy"); } - quick_bar(app, avail, ui); + // Read mode hides the tools (#2646). + if app.mode != crate::Mode::Read { + quick_bar(app, avail, ui); + } if let Some((source, file, destination)) = remote_open { app.open_remote_link(source, file, destination); } diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 79464624c6..3ce3f76dfe 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -2169,6 +2169,7 @@ impl PdfCraftApp { /// Switches workspace because the user chose to (mode bar, Read mode command). Entering Read /// mode closes the right panel, as Acrobat's Read mode hides its panes, and leaving it reopens /// that panel. The rail stays in Read mode, and its buttons open panels there too (#932). + /// Read mode hides the quick toolbar, so entering it also puts the current tool down. pub(crate) fn switch_mode(&mut self, mode: Mode, select_tools: bool) { let was_read = self.mode == Mode::Read; if select_tools { @@ -2178,6 +2179,7 @@ impl PdfCraftApp { } if mode == Mode::Read && !was_read { self.right_before_read = self.right.take(); + self.quick_tool = QuickTool::Select; } else if mode != Mode::Read && was_read { let before = self.right_before_read.take(); if self.right.is_none() { diff --git a/crates/ui-egui/tests/ui.rs b/crates/ui-egui/tests/ui.rs index 1b6276d7c4..cc52b01a00 100644 --- a/crates/ui-egui/tests/ui.rs +++ b/crates/ui-egui/tests/ui.rs @@ -246,6 +246,24 @@ fn read_mode_closes_the_panes_and_leaving_it_brings_them_back() { assert_eq!(h.state().right, Some(RightPanel::Pages)); } +/// Read mode hides the quick toolbar too, and puts down the tool that was in use (#2646). +#[test] +fn read_mode_hides_the_quick_toolbar() { + use pdfcraft_ui_egui::{Mode, QuickTool}; + let mut h = harness(|app| app.open_bytes("fixture.pdf", None, FIXTURE.to_vec()).expect("fixture opens")); + h.get_by_label("Hand (H)").click(); + h.run_steps(2); + assert_eq!(h.state().quick_tool, QuickTool::Hand); + h.get_by_label("Read").click(); + h.run_steps(3); + assert!(h.query_by_label("Select (V)").is_none(), "no quick toolbar in Read mode"); + assert_eq!(h.state().quick_tool, QuickTool::Select); + h.state_mut().run_command("view.read_mode"); + h.run_steps(3); + assert_eq!(h.state().mode, Mode::AllTools); + h.get_by_label("Select (V)"); +} + /// A pane closed in Read mode stays closed on the way out: leaving Read mode doesn't bring back the /// pane saved on the way in, and closing Comments keeps its close preference. #[test] From da48b84b8cb00de3cb2bd045c8e41694db1fb649 Mon Sep 17 00:00:00 2001 From: hade0ntema <296480886+hade0ntema@users.noreply.github.com> Date: Fri, 16 Oct 2026 10:33:24 +0200 Subject: [PATCH 042/105] fix(select): a picked picture that is edited away no longer leaves its highlight behind With the Select tool a click picks a picture and draws a blue highlight over it. The pick was only cleared by Escape, a drag or another click. Picking a picture, then entering Edit text & images and deleting it left the highlight on the empty spot, with nothing left to click to remove it. The pick now remembers the document's edit generation and is dropped once the document has been edited since (a delete, a move, Undo), the same condition under which the copy shortcut already refused it as stale. Co-Authored-By: Claude Sonnet 5.5 --- crates/ui-egui/src/canvas.rs | 1 + crates/ui-egui/src/image_pick.rs | 16 ++++++++++++++-- crates/ui-egui/tests/viewer.rs | 29 +++++++++++++++++++++++++++++ 3 files changed, 44 insertions(+), 2 deletions(-) diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 0acab64a08..7759accf47 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -3275,6 +3275,7 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) { if tool == QuickTool::Select && !consumed { crate::image_pick::page_input(&resp, &xf, i, info, doc, pointer, view); } + crate::image_pick::drop_stale(doc, view); crate::image_pick::paint(painter, &xf, i, info, view); comments::page_after_text(&resp, &pcx, view); if tool == QuickTool::Redact && can_modify { diff --git a/crates/ui-egui/src/image_pick.rs b/crates/ui-egui/src/image_pick.rs index a05aefe2d2..53e4bdf158 100644 --- a/crates/ui-egui/src/image_pick.rs +++ b/crates/ui-egui/src/image_pick.rs @@ -9,12 +9,14 @@ use crate::PdfCraftApp; use crate::canvas::{DocView, PageXform}; use pdfcraft_render::DocInfo; -/// The selected picture: page, index in `page_images` and its box in user space. +/// The selected picture: page, index in `page_images` and its box in user space, and the +/// document's edit generation when it was picked (any later edit may have removed or renumbered it). #[derive(Clone, Copy, Debug, PartialEq)] pub struct PickedImage { pub page: usize, pub index: usize, pub rect: [f64; 4], + pub generation: u64, } const BLUE: Color32 = Color32::from_rgb(0x3A, 0x7B, 0xF0); @@ -43,8 +45,18 @@ pub(crate) fn page_input( let (vx, vy) = xf.screen_to_view(p); let u = info_page.view_to_user(vx, vy); let at = [f64::from(u[0]), f64::from(u[1])]; + let generation = doc.edit_generation(); view.picked_image = - doc.page_image_at(page, at).and_then(|index| doc.page_images(page).get(index).map(|i| PickedImage { page, index, rect: i.rect })); + doc.page_image_at(page, at).and_then(|index| doc.page_images(page).get(index).map(|i| PickedImage { page, index, rect: i.rect, generation })); +} + +/// Forget the selected picture once the document has been edited since it was picked: Edit text & +/// images (or Undo) may have deleted or moved it, and its highlight would stay behind on an empty +/// spot of the page. +pub(crate) fn drop_stale(doc: &pdfcraft_engine::Document, view: &mut DocView) { + if view.picked_image.is_some_and(|p| p.generation != doc.edit_generation()) { + view.picked_image = None; + } } /// The selected picture's highlight on page `page`. diff --git a/crates/ui-egui/tests/viewer.rs b/crates/ui-egui/tests/viewer.rs index 474360865b..aadf6c56d4 100644 --- a/crates/ui-egui/tests/viewer.rs +++ b/crates/ui-egui/tests/viewer.rs @@ -1384,6 +1384,35 @@ fn a_clicked_picture_is_selected_and_copied_in_its_original_format() { assert_eq!(h.state().views[i].picked_image, None); } +/// A picture picked with the Select tool, then deleted in Edit text & images, leaves no highlight +/// behind on the empty spot where it was. +#[test] +fn a_picked_picture_that_is_deleted_in_edit_mode_loses_its_highlight() { + let (_, mut h) = picture_harness(); + let i = h.state().active.unwrap(); + let id = h.state().views[i].id; + let at = h.state().views[i].page_screen_rect(0).expect("on screen").center(); + h.hover_at(at); + h.run_steps(1); + h.event(egui::Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed: true, modifiers: Default::default() }); + h.event(egui::Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed: false, modifiers: Default::default() }); + h.run_steps(2); + assert!(h.state().views[i].picked_image.is_some(), "picked with the Select tool"); + // Edit text & images: click the picture, delete it with the Delete key. + assert!(h.state_mut().execute("edit.edit_text")); + h.run_steps(2); + h.hover_at(at); + h.run_steps(1); + h.drag_at(at); + h.run_steps(1); + h.drop_at(at); + h.run_steps(2); + h.key_press(egui::Key::Delete); + h.run_steps(4); + assert!(h.state().session.get(id).unwrap().page_images(0).is_empty(), "the picture is gone"); + assert_eq!(h.state().views[i].picked_image, None, "and so is its highlight"); +} + #[test] fn right_click_on_a_picture_copies_it_at_full_size() { let (px, mut h) = picture_harness(); From f9db1e31fd062fb9d5c74af1c079c078bd344e29 Mon Sep 17 00:00:00 2001 From: mtlcl410 <2368091+mtlcl410@users.noreply.github.com> Date: Fri, 16 Oct 2026 16:47:49 +0800 Subject: [PATCH 043/105] M10: running text is not a table row in Word, HTML and PowerPoint export (#2806) Table detection took a sentence whose link was set in another colour and font, split into blocks that run on into each other, for two table columns, and took a paragraph's lines, whose box overlaps the line before, for a cell beside it. The table it built put the paragraph's first line in one cell, a fragment of the link in another, and dropped the paragraph's later lines. A block that starts where another on the same line ends now adds no column of its own, and a row whose cells overlap is not a table row; a superscript beside a number stays in its table. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01RUNQSPVXx3dDBabRZHAnjz --- crates/export/src/lib.rs | 108 ++++++++++++++++++++++++++++++++++- parity/acrobat-features.toml | 4 +- 2 files changed, 108 insertions(+), 4 deletions(-) diff --git a/crates/export/src/lib.rs b/crates/export/src/lib.rs index 16dd2b06d2..30299b2d76 100644 --- a/crates/export/src/lib.rs +++ b/crates/export/src/lib.rs @@ -114,6 +114,34 @@ const COL_TOL: f64 = 4.0; /// its columns, so a hostile page laid out as a staircase of blocks would otherwise make /// (blocks / 2)² cells. const MAX_COLS: usize = 63; +/// Two blocks in a row this close (as a fraction of their font size) are one line of running +/// text that changed font or colour, such as a link inside a sentence, not two table cells. +const INLINE_GAP: f64 = 0.5; +/// How many blocks ending near a block's start are looked at to find the one it continues; a +/// page laid out so that many end at the same place stays quick. +const CONTINUATION_PROBE: usize = 64; +/// Per block, whether it continues another on the same line: it starts where that one ends (up to +/// [`INLINE_GAP`] later, or a point earlier for rounding), so it is the same line of text running +/// on (a link inside a sentence, a superscript), not a cell of its own. +fn continuations(blocks: &[Block]) -> Vec { + let mut by_end: Vec<(f64, usize)> = blocks.iter().enumerate().map(|(i, b)| (b.rect[2], i)).collect(); + by_end.sort_by(|a, b| a.0.total_cmp(&b.0)); + blocks + .iter() + .enumerate() + .map(|(bi, b)| { + let k = by_end.partition_point(|(end, _)| *end < b.rect[0] - INLINE_GAP * b.size); + by_end + .get(k..) + .unwrap_or_default() + .iter() + .take_while(|(end, _)| *end <= b.rect[0] + 1.0) + .take(CONTINUATION_PROBE) + .filter_map(|(_, ai)| blocks.get(*ai).filter(|_| *ai != bi)) + .any(|a| a.rect[3].min(b.rect[3]) - a.rect[1].max(b.rect[1]) > 0.5 * (a.rect[3] - a.rect[1]).min(b.rect[3] - b.rect[1])) + }) + .collect() +} /// A cell is short: taller blocks are body text (or a multi-column layout), not table cells. fn is_cell_like(b: &Block) -> bool { (b.rect[3] - b.rect[1]) <= b.size * 5.0 && !b.text.trim().is_empty() @@ -264,13 +292,20 @@ pub(crate) fn tables(blocks: &[Block]) -> (Vec, Vec) { es.dedup_by(|a, b| (*a - *b).abs() <= COL_TOL); es }; + let cont = continuations(blocks); let qual: Vec = rows .iter() .map(|row| { - let mut cs: Vec = row.iter().map(|(c, _)| *c).collect(); + // A block that continues another adds no column of its own, and the cells of a table row + // sit side by side: blocks that overlap are a paragraph's lines, not cells (#2806). + let cells: Vec<(usize, usize)> = row.iter().copied().filter(|(_, bi)| !cont.get(*bi).copied().unwrap_or(false)).collect(); + let mut spans: Vec<[f64; 2]> = cells.iter().filter_map(|(_, bi)| blocks.get(*bi)).map(|b| [b.rect[0], b.rect[2]]).collect(); + spans.sort_by(|a, b| a[0].total_cmp(&b[0])); + let apart = spans.windows(2).all(|w| w[1][0] >= w[0][1] - COL_TOL); + let mut cs: Vec = cells.iter().map(|(c, _)| *c).collect(); cs.sort_unstable(); cs.dedup(); - cs.len() >= 2 + apart && cs.len() >= 2 }) .collect(); let mut i = 0; @@ -1186,6 +1221,75 @@ mod tests { assert_eq!(grid_cols(&grid(64)), 0); } + fn block(t: &str, x0: f64, y0: f64, x1: f64, y1: f64) -> Block { + Block { text: t.into(), rect: [x0, y0, x1, y1], size: 12.0, bold: false, italic: false, color: [0.0; 3], rotation: 0.0 } + } + + /// #2806: a sentence whose link is set in another colour or font runs on as separate blocks + /// on one line. They are not table columns, even when several such lines share left edges. + #[test] + fn a_link_inside_a_sentence_is_not_a_table_column() { + let blue = |t: &str, x0: f64, y0: f64, x1: f64, y1: f64| Block { color: [0.02, 0.388, 0.757], ..block(t, x0, y0, x1, y1) }; + let p = Page { + width: 612.0, + height: 792.0, + blocks: vec![ + block("6.3. 2. All content shown at", 121.7, 710.3, 272.0, 724.9), + blue("http", 273.5, 710.3, 294.1, 724.9), + blue("s", 294.1, 709.8, 298.8, 725.8), + blue("://women", 298.8, 710.3, 348.0, 724.9), + blue("g", 348.1, 709.8, 353.7, 725.8), + blue("randprix.fide.com", 353.7, 710.3, 443.2, 724.9), + block("shall be reviewed and approved by the Press Officer.", 164.3, 622.5, 533.1, 724.9), + block("6.3. 3. The Organiser shall warrant that", 121.7, 601.8, 345.1, 616.5), + blue("https://womengrandprix.fide.com", 350.2, 601.3, 519.9, 617.4), + block("is managed in a professional way and furthermore that it is capable of", 164.3, 557.9, 533.0, 616.5), + block("6. 3. 4. The Press Officer shall report to the FIDE Head of PR.", 121.7, 516.6, 530.3, 551.9), + block("conducted during a WGP tournament.", 164.3, 502.0, 352.5, 516.6), + ], + images: Vec::new(), + }; + let h = html(std::slice::from_ref(&p), "Regulations"); + assert!(!h.contains("
"), "running text became a table: {h}"); + for b in &p.blocks { + assert!(h.contains(&b.text), "{} was lost: {h}", b.text); + } + } + + /// #2806: lines that each run on into a link starting at the same place are sentences, not a + /// two-column table, even three in a row. + #[test] + fn sentences_that_run_on_into_links_are_not_a_table() { + let blue = |t: &str, x0: f64, y0: f64, x1: f64, y1: f64| Block { color: [0.02, 0.388, 0.757], ..block(t, x0, y0, x1, y1) }; + let mut blocks = Vec::new(); + for (n, lead) in ["Rules are published at", "Pairings are posted at", "Results are listed at"].iter().enumerate() { + let y = 700.0 - 16.0 * n as f64; + blocks.push(block(lead, 72.0, y, 211.0, y + 12.0)); + blocks.push(blue("https://example.org/event", 212.5, y, 360.0, y + 12.0)); + } + let p = Page { width: 612.0, height: 792.0, blocks, images: Vec::new() }; + let h = html(std::slice::from_ref(&p), "Event"); + assert!(!h.contains("
"), "sentences became a table: {h}"); + assert_eq!(h.matches("https://example.org/event").count(), 3, "{h}"); + } + + /// #2806: a superscript right after a number ("1" "st") continues its cell; the prize table + /// around it stays a table. + #[test] + fn a_superscript_beside_a_number_keeps_its_table() { + let mut blocks = vec![block("PLACE", 100.0, 700.0, 140.0, 712.0), block("PRIZE (euros)", 300.0, 700.0, 380.0, 712.0)]; + for (n, (row, sup)) in [("1", "st"), ("2", "nd"), ("3", "rd"), ("4", "th")].iter().enumerate() { + let y = 680.0 - 20.0 * n as f64; + blocks.push(block(row, 100.0, y, 106.0, y + 12.0)); + blocks.push(Block { size: 8.0, ..block(sup, 106.0, y + 5.0, 113.0, y + 13.0) }); + blocks.push(block(&format!("{},000", 30 - 5 * n), 300.0, y, 340.0, y + 12.0)); + } + let p = Page { width: 612.0, height: 792.0, blocks, images: Vec::new() }; + let h = html(std::slice::from_ref(&p), "Prizes"); + assert!(h.contains("
"), "the prize table was lost: {h}"); + assert!(h.contains("30,000") && h.contains("15,000"), "{h}"); + } + #[test] fn two_column_body_text_is_not_mistaken_for_a_table() { let para = |t: &str, x: f64, y: f64| Block { diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index c3d319f94f..baa1d60c95 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -6976,8 +6976,8 @@ milestone = "M10" status = "partial" commands = ["export.docx"] tools = ["doc_export_office"] -evidence = ["crates/export/src/lib.rs::word_does_not_invent_borders_for_aligned_invoice_fields", "crates/export/src/lib.rs::word_overlap_does_not_consume_invoice_body_space", "crates/export/src/lib.rs::word_keeps_a_page_with_a_clear_image_and_big_title_in_flow", "crates/export/src/lib.rs::word_rotated_pages_keep_separate_page_sizes_and_unique_shapes", "crates/engine/src/tests.rs::office_export_positions_follow_the_displayed_crop_rotation_and_units", "crates/automation/tests/automation.rs::exporting_an_invoice_to_word_keeps_the_watermark_out_of_flow", "crates/export/src/lib.rs::docx_is_a_word_package", "crates/export/src/lib.rs::text_colour_survives_every_format", "crates/engine/src/tests.rs::exporting_office_files_keeps_text_colour", "crates/export/src/lib.rs::grid_text_becomes_a_real_table_in_every_format", "crates/export/src/lib.rs::word_opens_text_with_control_characters_and_long_pages", "crates/engine/src/tests.rs::exporting_office_files_keeps_images", "crates/automation/tests/automation.rs::exporting_to_word_html_and_rtf", "crates/ui-egui/tests/compare.rs::export_to_word_html_and_rtf"] -notes = "Flowing Word document: paragraphs, Heading 1/2 from larger text, bold/italic, text colour (fill as sRGB; gray/CMYK converted naively, colour spaces via cs/sc read as black), borderless detected tables with spans/empty cells, per-page sections/sizes, and positioned editable text/artwork only for pages with rotated text or text overlapped by an image or watermark-sized text, other pages flowing with inline images (#773); synthetic invoice regression; the emitted package validates with Open XML SDK 2.20 (Office 2010 schema). Word/LibreOffice rendering of the positioned output remains unverified. Pictures on positioned pages are DrawingML anchors behind the text (a VML rectangle with an image showed no picture, #1936; not yet opened in Word); a Word file embeds at most 1000 pictures and 512 MiB of them. The earlier flowing output was checked with macOS textutil and opened in Microsoft Word (Windows). Characters XML forbids are dropped; the page size stays within Word's 22 in. Not yet: exact font matching, vector rules, arbitrary paint-order/transparency, colour per run within a paragraph, general exact layout fidelity." +evidence = ["crates/export/src/lib.rs::word_does_not_invent_borders_for_aligned_invoice_fields", "crates/export/src/lib.rs::word_overlap_does_not_consume_invoice_body_space", "crates/export/src/lib.rs::word_keeps_a_page_with_a_clear_image_and_big_title_in_flow", "crates/export/src/lib.rs::word_rotated_pages_keep_separate_page_sizes_and_unique_shapes", "crates/engine/src/tests.rs::office_export_positions_follow_the_displayed_crop_rotation_and_units", "crates/automation/tests/automation.rs::exporting_an_invoice_to_word_keeps_the_watermark_out_of_flow", "crates/export/src/lib.rs::docx_is_a_word_package", "crates/export/src/lib.rs::text_colour_survives_every_format", "crates/engine/src/tests.rs::exporting_office_files_keeps_text_colour", "crates/export/src/lib.rs::grid_text_becomes_a_real_table_in_every_format", "crates/export/src/lib.rs::a_link_inside_a_sentence_is_not_a_table_column", "crates/export/src/lib.rs::sentences_that_run_on_into_links_are_not_a_table", "crates/export/src/lib.rs::a_superscript_beside_a_number_keeps_its_table", "crates/export/src/lib.rs::word_opens_text_with_control_characters_and_long_pages", "crates/engine/src/tests.rs::exporting_office_files_keeps_images", "crates/automation/tests/automation.rs::exporting_to_word_html_and_rtf", "crates/ui-egui/tests/compare.rs::export_to_word_html_and_rtf"] +notes = "Flowing Word document: paragraphs, Heading 1/2 from larger text, bold/italic, text colour (fill as sRGB; gray/CMYK converted naively, colour spaces via cs/sc read as black), borderless detected tables with spans/empty cells (a row whose blocks run on into each other, such as a link or superscript inside a sentence, or overlap, as a paragraph's lines do, is text and not a table row, #2806), per-page sections/sizes, and positioned editable text/artwork only for pages with rotated text or text overlapped by an image or watermark-sized text, other pages flowing with inline images (#773); synthetic invoice regression; the emitted package validates with Open XML SDK 2.20 (Office 2010 schema). Word/LibreOffice rendering of the positioned output remains unverified. Pictures on positioned pages are DrawingML anchors behind the text (a VML rectangle with an image showed no picture, #1936; not yet opened in Word); a Word file embeds at most 1000 pictures and 512 MiB of them. The earlier flowing output was checked with macOS textutil and opened in Microsoft Word (Windows). Characters XML forbids are dropped; the page size stays within Word's 22 in. Not yet: exact font matching, vector rules, arbitrary paint-order/transparency, colour per run within a paragraph, general exact layout fidelity." [[feature]] id = "create.export-rtf" From 96478f325d5bded8797efea9e6cd1dbccd78a639 Mon Sep 17 00:00:00 2001 From: mtlcl410 <2368091+mtlcl410@users.noreply.github.com> Date: Fri, 16 Oct 2026 17:40:55 +0800 Subject: [PATCH 044/105] fix(ui): test that the Explorer argument reaches the process as written (#2579) Review of #2625: the test checked the argument reveal_command builds but not how it is passed, so it still passed with raw_arg replaced by args. The process is now built by reveal_process, and a Windows test runs it on `cmd /c echo`, which prints its command line back: with Rust's own quoting the argument comes back wrapped in quotes with the inner ones escaped, and the test fails. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01RUNQSPVXx3dDBabRZHAnjz --- crates/ui-egui/src/tab_context.rs | 48 +++++++++++++++++++++---------- 1 file changed, 33 insertions(+), 15 deletions(-) diff --git a/crates/ui-egui/src/tab_context.rs b/crates/ui-egui/src/tab_context.rs index 6f5319d637..7cc502722b 100644 --- a/crates/ui-egui/src/tab_context.rs +++ b/crates/ui-egui/src/tab_context.rs @@ -55,21 +55,8 @@ fn reveal(path: &std::path::Path) -> Result<(), String> { return Err("The file no longer exists".into()); } let platform = std::env::consts::OS; - let run = |program: &str, args: &[std::ffi::OsString]| { - let mut command = std::process::Command::new(program); - // Windows: `reveal_command` already quotes what Explorer needs quoted, and Rust's own - // quoting of an argument with a space breaks `/select,` (#2579), so pass them as written. - #[cfg(windows)] - { - use std::os::windows::process::CommandExt as _; - for arg in args { - command.raw_arg(arg); - } - } - #[cfg(not(windows))] - command.args(args); - command.output().map(|o| o.status.success()).map_err(|e| e.to_string()) - }; + let run = + |program: &str, args: &[std::ffi::OsString]| reveal_process(program, args).output().map(|o| o.status.success()).map_err(|e| e.to_string()); if matches!(platform, "linux" | "freebsd") { // `dbus-send --print-reply` waits up to 2 s for the file manager (then xdg-open may run), // so it runs off the UI thread; a failure there is only logged. @@ -83,6 +70,24 @@ fn reveal(path: &std::path::Path) -> Result<(), String> { reveal_via(platform, &path, run) } +/// The process that runs `program` with `args`. Windows: `reveal_command` already quotes what +/// Explorer needs quoted, and Rust's own quoting of an argument with a space breaks `/select,` +/// (#2579), so the arguments go on the command line as written. +#[cfg(not(target_arch = "wasm32"))] +fn reveal_process(program: &str, args: &[std::ffi::OsString]) -> std::process::Command { + let mut command = std::process::Command::new(program); + #[cfg(windows)] + { + use std::os::windows::process::CommandExt as _; + for arg in args { + command.raw_arg(arg); + } + } + #[cfg(not(windows))] + command.args(args); + command +} + #[cfg(not(target_arch = "wasm32"))] fn reveal_via( platform: &str, @@ -240,6 +245,19 @@ mod tests { assert!(reveal_via("linux", path, |_, _| Err("command missing".into())).is_err()); } + /// #2579 (review of #2625): the `/select,"…"` argument reaches the process exactly as written. + /// `cmd /c echo` prints its command line back; with Rust's own quoting (`args`) it would come + /// back wrapped in quotes with the inner ones escaped, which Explorer reads as no selection. + #[cfg(windows)] + #[test] + fn the_reveal_argument_reaches_the_process_as_written() { + let (_, args) = reveal_command("windows", std::path::Path::new(r"C:\Reports\annual report.pdf")).unwrap(); + let mut echo: Vec = ["/d", "/c", "echo"].map(std::ffi::OsString::from).to_vec(); + echo.extend(args); + let out = reveal_process("cmd", &echo).output().unwrap(); + assert_eq!(String::from_utf8_lossy(&out.stdout).trim_end(), r#"/select,"C:\Reports\annual report.pdf""#); + } + #[test] fn reveal_arguments_preserve_spaces_and_unicode() { let path = std::path::Path::new("/tmp/年度 report.pdf"); From 37a934662928a289c4f96506118f9d3253e92609 Mon Sep 17 00:00:00 2001 From: hade0ntema <296480886+hade0ntema@users.noreply.github.com> Date: Fri, 16 Oct 2026 11:49:06 +0200 Subject: [PATCH 045/105] fix(select): clear the picked picture in the edit itself, not on the next frame The first fix dropped a stale pick while painting the next frame. The window only redraws on the next input, so after deleting a picture in Edit text & images its highlight stayed on screen until the user clicked. Clear the pick in apply_edit, in the same step as the edit, and ask for a repaint. drop_stale stays for the paths that change the document without apply_edit (Undo, Redo). An edit-level test checks the pick right after apply_edit with no frame in between; it fails without the change. The earlier one-frame test could not tell, the test harness redraws more often than the app does. Co-Authored-By: Claude Sonnet 5.5 --- crates/ui-egui/src/editing.rs | 6 ++++++ crates/ui-egui/tests/viewer.rs | 22 +++++++++++++++++++++- 2 files changed, 27 insertions(+), 1 deletion(-) diff --git a/crates/ui-egui/src/editing.rs b/crates/ui-egui/src/editing.rs index be1347d68d..6f78199496 100644 --- a/crates/ui-egui/src/editing.rs +++ b/crates/ui-egui/src/editing.rs @@ -52,6 +52,12 @@ impl PdfCraftApp { let Some(doc) = self.session.get(id) else { return true }; let info = &doc.info; let view = &mut self.views[i]; + // A picture picked with the Select tool may be the one this edit removed or moved: + // drop its highlight now, not on the next frame (which only comes with the next input). + view.picked_image = None; + if let Some(ctx) = &self.ctx { + ctx.request_repaint(); + } if !matches!(edit, Edit::AddAnnotation(_)) { // Only a new drawing waits on its raster; any other edit may remove or move it. view.comments.settling.clear(); diff --git a/crates/ui-egui/tests/viewer.rs b/crates/ui-egui/tests/viewer.rs index aadf6c56d4..96defce320 100644 --- a/crates/ui-egui/tests/viewer.rs +++ b/crates/ui-egui/tests/viewer.rs @@ -1408,11 +1408,31 @@ fn a_picked_picture_that_is_deleted_in_edit_mode_loses_its_highlight() { h.drop_at(at); h.run_steps(2); h.key_press(egui::Key::Delete); - h.run_steps(4); + // One frame, no more input: the highlight goes at once, it does not wait for a click. + h.run_steps(1); assert!(h.state().session.get(id).unwrap().page_images(0).is_empty(), "the picture is gone"); assert_eq!(h.state().views[i].picked_image, None, "and so is its highlight"); } +/// The highlight goes in the very edit that removes the picture, not on a later frame: the window +/// only redraws on the next input, so waiting for one leaves the highlight on screen until a click. +#[test] +fn an_edit_clears_the_picked_picture_at_once() { + let (_, mut h) = picture_harness(); + let i = h.state().active.unwrap(); + let at = h.state().views[i].page_screen_rect(0).expect("on screen").center(); + h.hover_at(at); + h.run_steps(1); + h.event(egui::Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed: true, modifiers: Default::default() }); + h.event(egui::Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed: false, modifiers: Default::default() }); + h.run_steps(2); + assert!(h.state().views[i].picked_image.is_some(), "picked with the Select tool"); + let delete = pdfcraft_engine::Edit::EditPageImage { page: 0, index: 0, change: pdfcraft_engine::ImageEdit::Delete }; + assert!(h.state_mut().apply_edit(delete)); + // No frame has run since the edit. + assert_eq!(h.state().views[i].picked_image, None); +} + #[test] fn right_click_on_a_picture_copies_it_at_full_size() { let (px, mut h) = picture_harness(); From 30c174be9e723ff5f9039c47eb3ee5586283c278 Mon Sep 17 00:00:00 2001 From: mtlcl410 <2368091+mtlcl410@users.noreply.github.com> Date: Fri, 16 Oct 2026 19:20:47 +0800 Subject: [PATCH 046/105] M3: closing PdfCraft while minimized no longer saves a 0 x 0 window eframe stores the window's size and place just before App::save. A minimized window has none: Windows reports 0 x 0 at (-32000, -32000), and the next start opened at egui's 64-point floor in the screen's corner (seen on a 6K display at 225%: a 144 x 206 px window). While the window is minimized the app now puts back the last settings it was shown with (or none), and a window that opens at that floor from settings saved before this fix is given a usable size. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01RUNQSPVXx3dDBabRZHAnjz --- apps/pdfcraft/src/main.rs | 1 + crates/ui-egui/src/lib.rs | 15 ++++ crates/ui-egui/src/window_state.rs | 137 +++++++++++++++++++++++++++++ 3 files changed, 153 insertions(+) create mode 100644 crates/ui-egui/src/window_state.rs diff --git a/apps/pdfcraft/src/main.rs b/apps/pdfcraft/src/main.rs index 7a0bb20432..260cb835c7 100644 --- a/apps/pdfcraft/src/main.rs +++ b/apps/pdfcraft/src/main.rs @@ -377,6 +377,7 @@ fn app_creator<'a>( if let Some(json) = cc.storage.and_then(|s| s.get_string("pdfcraft").or_else(|| s.get_string(LEGACY_STORAGE_KEY))) { app.restore(&json); } + app.window_state = pdfcraft_ui_egui::window_state::WindowState::started_with(cc.storage); app.integrated_titlebar = integrated; app.window_controls = window_controls; app.system_text_scale = text_scale::system(); diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 79464624c6..17f277c474 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -142,6 +142,8 @@ pub mod updates; mod wheel_pager; mod widgets; pub mod window_controls; +#[cfg(not(target_arch = "wasm32"))] +pub mod window_state; #[cfg(all(test, not(target_arch = "wasm32")))] mod open_worker_tests; @@ -744,6 +746,9 @@ pub struct PdfCraftApp { dialog_epoch: u64, /// The egui context, for commands that change window or theme state. ctx: Option, + /// The window's size and place kept across a minimized close (desktop). + #[cfg(not(target_arch = "wasm32"))] + pub window_state: window_state::WindowState, styled: bool, fonts_ready: bool, /// The CJK fallback order the installed fonts follow (see `theme::installed_font_definitions`). @@ -1033,6 +1038,8 @@ impl PdfCraftApp { dialog_seen: None, dialog_epoch: 0, ctx: None, + #[cfg(not(target_arch = "wasm32"))] + window_state: Default::default(), styled: false, fonts_ready: false, fonts_cjk: theme::CjkPreference::Default, @@ -2910,11 +2917,19 @@ impl eframe::App for PdfCraftApp { } fn save(&mut self, storage: &mut dyn eframe::Storage) { + // eframe has just stored the window as it is now: not while it is minimized. + #[cfg(not(target_arch = "wasm32"))] + { + let minimized = self.ctx.as_ref().is_some_and(|c| c.input(|i| i.viewport().minimized == Some(true))); + self.window_state.keep(storage, minimized); + } storage.set_string("pdfcraft", self.persist()); } fn logic(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) { self.ctx = Some(ctx.clone()); + #[cfg(not(target_arch = "wasm32"))] + self.window_state.repair(ctx); self.dialog_epoch(); // Notices raised outside `ui` (opened files, OS events, the control channel) translate too. let lang = i18n::Lang::from_pref(&self.language); diff --git a/crates/ui-egui/src/window_state.rs b/crates/ui-egui/src/window_state.rs new file mode 100644 index 0000000000..0475604a48 --- /dev/null +++ b/crates/ui-egui/src/window_state.rs @@ -0,0 +1,137 @@ +//! The window's saved size and place across starts. eframe stores them just before +//! `App::save`, from the window as it is at that moment. A minimized window has neither: Windows +//! reports it as 0 × 0 at (-32000, -32000), and the next start then opened at egui's 64-point +//! floor in the screen's corner. So while the window is minimized the last settings it was shown +//! with are put back, and a window that opens at that floor is given a usable size. + +/// eframe's storage key for the window settings. +const WINDOW_KEY: &str = "window"; +/// The size egui opens a stored size below (its floor for "ridiculously small" windows), in points. +const EGUI_FLOOR: f32 = 64.0; +/// The size a window that opened at [`EGUI_FLOOR`] is given (apps/pdfcraft opens a first start at +/// this size too), at most nine tenths of the monitor. +const USABLE: egui::Vec2 = egui::vec2(1440.0, 920.0); + +#[derive(Debug, Default)] +pub struct WindowState { + /// The settings eframe last stored for a shown (not minimized) window. + shown: Option, + /// The size at start has been checked ([`Self::repair`]). + checked: bool, +} + +impl WindowState { + /// The settings the window started with: what to put back if it is minimized before eframe + /// stores a shown one. + pub fn started_with(storage: Option<&dyn eframe::Storage>) -> Self { + Self { shown: storage.and_then(|s| s.get_string(WINDOW_KEY)), checked: false } + } + + /// After eframe has stored the window: remember it while the window is shown, and while it + /// is minimized put the last shown settings back (or none, so the next start opens at the + /// default size). + pub fn keep(&mut self, storage: &mut dyn eframe::Storage, minimized: bool) { + if !minimized { + self.shown = storage.get_string(WINDOW_KEY); + } else if let Some(shown) = &self.shown { + storage.set_string(WINDOW_KEY, shown.clone()); + } else { + storage.remove_string(WINDOW_KEY); + } + } + + /// Once the window's size is known: a shown window at egui's floor in both directions came + /// from settings stored while it was minimized (before this fix), so give it a usable size. + pub fn repair(&mut self, ctx: &egui::Context) { + if self.checked { + return; + } + let (rect, minimized, monitor) = ctx.input(|i| (i.viewport().inner_rect, i.viewport().minimized, i.viewport().monitor_size)); + let Some(rect) = rect else { return }; + if minimized == Some(true) { + return; + } + self.checked = true; + if rect.width() <= EGUI_FLOOR + 0.5 && rect.height() <= EGUI_FLOOR + 0.5 { + // The stored settings are the broken ones: never put them back. + self.shown = None; + let size = monitor.map_or(USABLE, |m| USABLE.min(m * 0.9)); + ctx.send_viewport_cmd(egui::ViewportCommand::InnerSize(size)); + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use eframe::Storage as _; + + #[derive(Default)] + struct Store(std::collections::HashMap); + + impl eframe::Storage for Store { + fn get_string(&self, key: &str) -> Option { + self.0.get(key).cloned() + } + fn set_string(&mut self, key: &str, value: String) { + self.0.insert(key.to_string(), value); + } + fn remove_string(&mut self, key: &str) { + self.0.remove(key); + } + fn flush(&mut self) {} + } + + const SHOWN: &str = "(outer_position_pixels:Some((x:200.0,y:200.0)),inner_size_points:Some((x:1320.0,y:940.0)))"; + const MINIMIZED: &str = "(outer_position_pixels:Some((x:-32000.0,y:-32000.0)),inner_size_points:Some((x:0.0,y:0.0)))"; + + /// Closing PdfCraft while it was minimized stored a 0 × 0 window off screen. + #[test] + fn a_minimized_window_keeps_the_last_shown_settings() { + let mut store = Store::default(); + store.set_string(WINDOW_KEY, SHOWN.into()); + let mut state = WindowState::started_with(Some(&store)); + // eframe stores the minimized window, then the app saves. + store.set_string(WINDOW_KEY, MINIMIZED.into()); + state.keep(&mut store, true); + assert_eq!(store.get_string(WINDOW_KEY).as_deref(), Some(SHOWN)); + // Shown again and resized: that is what is kept from then on. + let moved = SHOWN.replace("200.0", "300.0"); + store.set_string(WINDOW_KEY, moved.clone()); + state.keep(&mut store, false); + store.set_string(WINDOW_KEY, MINIMIZED.into()); + state.keep(&mut store, true); + assert_eq!(store.get_string(WINDOW_KEY), Some(moved)); + } + + #[test] + fn with_nothing_shown_yet_a_minimized_save_stores_no_window() { + let mut store = Store::default(); + let mut state = WindowState::started_with(Some(&store)); + store.set_string(WINDOW_KEY, MINIMIZED.into()); + state.keep(&mut store, true); + assert_eq!(store.get_string(WINDOW_KEY), None, "the next start opens at the default size"); + } + + fn repaired(size: egui::Vec2, minimized: Option) -> Vec { + let ctx = egui::Context::default(); + let mut input = egui::RawInput::default(); + let info = input.viewports.entry(egui::ViewportId::ROOT).or_default(); + info.inner_rect = Some(egui::Rect::from_min_size(egui::Pos2::ZERO, size)); + info.minimized = minimized; + info.monitor_size = Some(egui::vec2(2674.0, 1504.0)); + let mut state = WindowState { shown: Some(MINIMIZED.into()), checked: false }; + let out = ctx.run_logic(&input, |ctx| state.repair(ctx)); + let commands = out.viewport_commands.get(&egui::ViewportId::ROOT).cloned().unwrap_or_default(); + // Repaired: the settings it started with are not put back by a minimized close. + assert_eq!(state.shown.is_none(), !commands.is_empty()); + commands + } + + #[test] + fn a_window_at_eguis_floor_gets_a_usable_size() { + assert_eq!(repaired(egui::vec2(64.0, 64.0), Some(false)), [egui::ViewportCommand::InnerSize(USABLE)]); + assert!(repaired(egui::vec2(820.0, 520.0), Some(false)).is_empty(), "a small window someone chose stays"); + assert!(repaired(egui::vec2(64.0, 64.0), Some(true)).is_empty(), "not while minimized"); + } +} From 540635572963958ed3a92c482c9dcf583a5e7d38 Mon Sep 17 00:00:00 2001 From: aiongg Date: Fri, 16 Oct 2026 14:02:13 +0000 Subject: [PATCH 047/105] Integration: #2681 make the self-reply assertion able to fail --- crates/xfdf/src/tests.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/xfdf/src/tests.rs b/crates/xfdf/src/tests.rs index a33f1a1013..d83befe8d5 100644 --- a/crates/xfdf/src/tests.rs +++ b/crates/xfdf/src/tests.rs @@ -157,7 +157,7 @@ fn a_replace_text_pair_survives_an_xfdf_round_trip() { let xfdf = export_xfdf(&src, true, false, "a.pdf"); assert!(xfdf.contains("name=\"strike\"") && xfdf.contains("name=\"caret\""), "each gets its own name: {xfdf}"); assert!(xfdf.contains("inreplyto=\"strike\""), "the caret replies to the strikeout: {xfdf}"); - assert!(!xfdf.contains("name=\"caret\" ... inreplyto=\"caret\""), "never a reply to itself"); + assert!(!xfdf.contains("inreplyto=\"caret\""), "never a reply to itself"); let mut dst = blank(); let r = import(&mut dst, xfdf.as_bytes()).unwrap(); From 53b8fda93ff1043e6058b484759125f76a6d2a6c Mon Sep 17 00:00:00 2001 From: aiongg Date: Fri, 16 Oct 2026 14:02:18 +0000 Subject: [PATCH 048/105] Integration: #2532 save the structure-tree test without object streams The default save packs annotation dictionaries into compressed object streams, so the URL and comment-text byte checks passed without the fix. --- crates/redact/src/tests.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/redact/src/tests.rs b/crates/redact/src/tests.rs index f9e08d35d0..5ce914b983 100644 --- a/crates/redact/src/tests.rs +++ b/crates/redact/src/tests.rs @@ -661,7 +661,7 @@ fn removed_links_and_comments_leave_the_structure_tree_and_the_saved_file() { let mut doc = pdf(objs); let done = remove_hidden(&mut doc, &[Hidden::LinksActionsScripts, Hidden::Comments]).unwrap(); assert!(done.contains(&(Hidden::LinksActionsScripts, 1)) && done.contains(&(Hidden::Comments, 1)), "{done:?}"); - let bytes = write_full(&doc, &SaveOptions::default()).unwrap(); + let bytes = write_full(&doc, &SaveOptions { object_streams: false, ..SaveOptions::default() }).unwrap(); let has = |needle: &[u8]| bytes.windows(needle.len()).any(|w| w == needle); assert!(!has(b"secret.example"), "the removed link's URL survived the save"); assert!(!has(b"COMMENTSECRETNOTE"), "the removed comment's text survived the save"); From a463e32eefd7c31dc06114c15a2d30fe03bf590e Mon Sep 17 00:00:00 2001 From: aiongg Date: Fri, 16 Oct 2026 14:02:33 +0000 Subject: [PATCH 049/105] Integration: #2717 move the valid_xref_range doc comment back above its function --- crates/cos/src/document.rs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/crates/cos/src/document.rs b/crates/cos/src/document.rs index 2cefbbfb25..e51ff58ccf 100644 --- a/crates/cos/src/document.rs +++ b/crates/cos/src/document.rs @@ -1957,9 +1957,6 @@ fn generated_id(seed: &[u8; 32]) -> Vec { out } -/// Object numbers must fit the representation used by references and the xref map. -/// Validate the whole subsection before adding entries, so neither arithmetic overflow nor -/// truncation can turn a damaged range into entries for unrelated objects. /// The object number a classic xref subsection really starts at. A common producer bug heads the /// table `1 n` and then writes object 0's free entry (`0000000000 65535 f`) as its first row, so /// every row would be read one number too high and object 1 as free. Such a subsection is read @@ -1988,6 +1985,9 @@ fn subsection_start(data: &[u8], rows: usize, start: i64, count: i64) -> i64 { } } +/// Object numbers must fit the representation used by references and the xref map. +/// Validate the whole subsection before adding entries, so neither arithmetic overflow nor +/// truncation can turn a damaged range into entries for unrelated objects. fn valid_xref_range(start: i64, count: i64) -> bool { u32::try_from(start).is_ok() && count >= 0 && start.checked_add(count).is_some_and(|end| end <= i64::from(u32::MAX) + 1) } From 6b3aade2eb15dacc54a4c9fff4c7f5d7c70eb796 Mon Sep 17 00:00:00 2001 From: aiongg Date: Fri, 16 Oct 2026 14:02:44 +0000 Subject: [PATCH 050/105] Integration: #2699 describe the read-only refusal in the Windows staging test --- crates/ui-egui/src/editing.rs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/crates/ui-egui/src/editing.rs b/crates/ui-egui/src/editing.rs index be1347d68d..799b5f1507 100644 --- a/crates/ui-egui/src/editing.rs +++ b/crates/ui-egui/src/editing.rs @@ -1226,8 +1226,8 @@ mod tests { let _ = std::fs::remove_dir_all(&dir); } - /// Windows refuses to replace a read-only file, so the rename fails: the staging file must not - /// be left behind. + /// A read-only target is refused before anything is staged, so no staging file is left + /// behind. #[cfg(windows)] #[test] fn a_failed_save_removes_the_staging_file() { @@ -1238,7 +1238,7 @@ mod tests { perms.set_readonly(true); std::fs::set_permissions(&target, perms.clone()).unwrap(); let e = write_atomically_with(&target.to_string_lossy(), b"NEW", [7]).unwrap_err(); - assert_eq!(e.kind(), std::io::ErrorKind::PermissionDenied, "the rename failed: {e}"); + assert_eq!(e.kind(), std::io::ErrorKind::PermissionDenied, "a read-only target is refused: {e}"); assert_eq!(read(&target), "OLD"); assert_eq!(listing(&dir), ["out.pdf"], "the staging file was removed"); #[allow(clippy::permissions_set_readonly_false)] From c4bd091eeb8010373f9c37901380b305f530fd33 Mon Sep 17 00:00:00 2001 From: aiongg Date: Fri, 16 Oct 2026 14:02:52 +0000 Subject: [PATCH 051/105] Integration: #2544 say that only the mode is copied to the staging file --- crates/platform/src/staging.rs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/crates/platform/src/staging.rs b/crates/platform/src/staging.rs index aab3092994..2cee86c5e9 100644 --- a/crates/platform/src/staging.rs +++ b/crates/platform/src/staging.rs @@ -32,9 +32,9 @@ pub fn create_staging( } /// [`create_staging`], giving the new file the permissions `keep` on Unix. It is created readable -/// and writable by its owner only and then given `keep`, so it is never open to more people than -/// the file it will replace, not even while it is still empty (a handle opened then would keep -/// reading what is written later). +/// and writable by its owner only and then given `keep`, so its mode is never more open than that +/// of the file it will replace, not even while it is still empty (a handle opened then would keep +/// reading what is written later). Only the mode is copied: the owner and group are the saver's. fn create_staging_as( dir: &Path, name: &str, From 7063c113f19a0c79860a3cbf8f8f1a827884728e Mon Sep 17 00:00:00 2001 From: aiongg Date: Fri, 16 Oct 2026 14:02:52 +0000 Subject: [PATCH 052/105] Integration: #2793 export images with a malformed Decode using the default mapping A Decode of the wrong length or with a non-number entry made Save Image As fail and Export All Images skip the image, which earlier versions exported. --- crates/create/README.md | 2 +- crates/create/src/extract.rs | 25 ++++++++++------------- crates/create/src/tests.rs | 39 ++++++++++++++++++------------------ 3 files changed, 31 insertions(+), 35 deletions(-) diff --git a/crates/create/README.md b/crates/create/README.md index 273b574df5..499081059a 100644 --- a/crates/create/README.md +++ b/crates/create/README.md @@ -26,6 +26,6 @@ Every function returns a `pdfcraft_cos::Document`; the caller writes it. Image export and Save Image As apply each lossless image's Decode pair before converting its color components. Indexed Decode values select palette entries after rounding and clipping. Color-key masks compare the original samples. -Malformed explicit Decode arrays return an export error. Missing or null arrays keep the default mapping. +Missing, null or malformed Decode arrays keep the default mapping. The existing 16-bit path still uses each sample's high byte. JPEG and mask Decode handling remain limited. ICC transforms and calibrated color conversion remain outside this export path. diff --git a/crates/create/src/extract.rs b/crates/create/src/extract.rs index e431e437e4..768da4d1f1 100644 --- a/crates/create/src/extract.rs +++ b/crates/create/src/extract.rs @@ -161,7 +161,7 @@ fn image(doc: &Document, s: &Stream) -> Result<(&'static str, Vec), String> (unpack(&data, w, h, space.components(), bpc).ok_or("the image data is shorter than its size says")?, bpc) }; let n = space.components(); - let decode = if jpeg || mask { None } else { decode(doc, s, n)? }; + let decode = if jpeg || mask { None } else { decode(doc, s, n) }; if alpha.is_none() { alpha = key.and_then(|k| keyed_alpha(&samples, n, bpc, &k)); } @@ -215,24 +215,21 @@ fn image(doc: &Document, s: &Stream) -> Result<(&'static str, Vec), String> } /// The explicit Decode intervals for a lossless colour image (8.9.5.2, Table 90). -fn decode(doc: &Document, s: &Stream, n: usize) -> Result>, String> { - let Some(value) = s.dict.get(b"Decode") else { return Ok(None) }; - let value = doc.resolve(value); - if matches!(&*value, Object::Null) { - return Ok(None); +/// +/// A malformed array (not an array, the wrong length, a non-number or non-finite entry) gives +/// the default mapping, so the image still exports. +fn decode(doc: &Document, s: &Stream, n: usize) -> Option> { + let value = doc.resolve(s.dict.get(b"Decode")?); + let values = value.as_array()?; + if Some(values.len()) != n.checked_mul(2) { + return None; } - let values = value.as_array().ok_or("the image Decode must be an array")?; - if values.len() != n * 2 { - return Err(format!("the image Decode must contain {} numbers", n * 2)); - } - let endpoint = |value: &Object| { - doc.resolve(value).as_f64().filter(|v| v.is_finite()).ok_or_else(|| "the image Decode must contain finite numbers".to_string()) - }; + let endpoint = |value: &Object| doc.resolve(value).as_f64().filter(|v| v.is_finite()); let mut pairs = Vec::with_capacity(n); for [lo, hi] in values.as_chunks::<2>().0 { pairs.push([endpoint(lo)?, endpoint(hi)?]); } - Ok(Some(pairs)) + Some(pairs) } /// Width and height as sizes, when both are positive and the image is at most `MAX_PIXELS`. diff --git a/crates/create/src/tests.rs b/crates/create/src/tests.rs index 978dfde83f..9eab12f3ca 100644 --- a/crates/create/src/tests.rs +++ b/crates/create/src/tests.rs @@ -1046,32 +1046,31 @@ fn lossless_decode_interpolation_avoids_endpoint_difference_overflow() { } #[test] -fn lossless_decode_rejects_malformed_explicit_values() { - for decode in [ - Object::name("bad"), - decode_array(&[]), - decode_array(&[0.]), - decode_array(&[0., 1., 0., 1.]), - Object::Array(vec![Object::Int(0), Object::name("bad")]), - Object::Array(vec![Object::Int(0), Object::Null]), +fn lossless_decode_falls_back_to_the_default_mapping_when_malformed() { + for (space, samples, decode, expected) in [ + ("DeviceRGB", vec![0, 64, 255], decode_array(&[1., 0.]), [0, 64, 255]), + ("DeviceGray", vec![64], Object::name("bad"), [64, 64, 64]), + ("DeviceGray", vec![64], decode_array(&[]), [64, 64, 64]), + ("DeviceGray", vec![64], decode_array(&[0.]), [64, 64, 64]), + ("DeviceGray", vec![64], decode_array(&[1., 0., 0., 1.]), [64, 64, 64]), + ("DeviceGray", vec![64], Object::Array(vec![Object::Int(1), Object::name("bad")]), [64, 64, 64]), + ("DeviceGray", vec![64], Object::Array(vec![Object::Int(1), Object::Null]), [64, 64, 64]), ] { - let mut dict = image_dict(1, 1, "DeviceGray", 8); + let mut dict = image_dict(1, 1, space, 8); dict.set(b"Decode".to_vec(), decode); - let (doc, image) = decode_fixture(dict, vec![0]); - assert!(image_file(&doc, image).unwrap_err().contains("Decode")); - let out = extract_images(&doc, &[0], 0); - assert!(out.images.is_empty()); - assert_eq!(out.skipped.len(), 1); - assert!(out.skipped[0].2.contains("Decode")); + assert_decode_pixels(dict, samples, &expected); } // Non-finite reals cannot be serialized as PDF numbers. Exercise the in-memory input. for value in [f64::NAN, f64::INFINITY, f64::NEG_INFINITY] { let mut dict = image_dict(1, 1, "DeviceGray", 8); - dict.set(b"Decode".to_vec(), decode_array(&[0., value])); - let (mut doc, image) = decode_fixture(image_dict(1, 1, "DeviceGray", 8), vec![0]); - doc.set(image, Object::Stream(Stream::from_raw(dict, vec![0]))); - assert!(image_file(&doc, image).unwrap_err().contains("Decode")); - assert_eq!(extract_images(&doc, &[0], 0).skipped.len(), 1); + dict.set(b"Decode".to_vec(), decode_array(&[1., value])); + let (mut doc, image) = decode_fixture(image_dict(1, 1, "DeviceGray", 8), vec![64]); + doc.set(image, Object::Stream(Stream::from_raw(dict, vec![64]))); + let out = extract_images(&doc, &[0], 0); + assert!(out.skipped.is_empty(), "{:?}", out.skipped); + assert_eq!(exported_png_pixels(&out.images[0].data), [64, 64, 64]); + let (_, bytes) = image_file(&doc, image).unwrap(); + assert_eq!(exported_png_pixels(&bytes), [64, 64, 64]); } } From e1a1d4211ef41114618eebe8d4f33306c0f2a20f Mon Sep 17 00:00:00 2001 From: aiongg Date: Fri, 16 Oct 2026 14:03:20 +0000 Subject: [PATCH 053/105] Integration: #2638 a new drag over copied text shows the action bar again --- crates/ui-egui/src/canvas.rs | 3 +++ crates/ui-egui/tests/comments.rs | 19 +++++++++++++++++++ 2 files changed, 22 insertions(+) diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 2faa05a3fe..622cf24818 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -3218,6 +3218,9 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) { } } if resp.drag_started() && press_here && !over_link { + // A drag replaces the selection without passing through none, so a + // re-drag over text whose bar Copy closed would keep the bar hidden. + view.actions_dismissed = None; view.selection_drag = true; let origin = ui.input(|inp| inp.pointer.press_origin()).unwrap_or(p); let (ox, oy) = xf.screen_to_view(origin); diff --git a/crates/ui-egui/tests/comments.rs b/crates/ui-egui/tests/comments.rs index aaea7990f1..c127e2c993 100644 --- a/crates/ui-egui/tests/comments.rs +++ b/crates/ui-egui/tests/comments.rs @@ -379,6 +379,25 @@ fn selected_text_copy_action_closes_the_bar_and_keeps_the_selection() { h.get_by_label("Add comment"); } +#[test] +fn dragging_over_copied_text_again_brings_the_action_bar_back() { + let mut h = harness(|_| {}); + let (a, b) = { + let v = &h.state().views[0]; + (v.glyph_screen_pos(0, 4).expect("text layer"), v.glyph_screen_pos(0, 8).expect("glyph")) + }; + drag(&mut h, a, b); + let copied = h.state().views[0].selected_text(); + assert!(copied.is_some(), "the drag selects text"); + h.get_by_label("Copy").click(); + h.run_steps(3); + assert!(h.query_by_label("Add comment").is_none(), "the action bar closes after Copy"); + // The same drag again selects the same words, which is a new selection: the bar is back. + drag(&mut h, a, b); + assert_eq!(h.state().views[0].selected_text(), copied, "the drag selects the same words"); + assert!(h.query_by_label("Add comment").is_some(), "the action bar is back after a new drag"); +} + #[test] fn selected_text_replace_action_opens_replacement_composer() { let mut h = harness(|_| {}); From 2d5809b7a6afc0bdc540365922e19ed4bb723fcf Mon Sep 17 00:00:00 2001 From: aiongg Date: Fri, 16 Oct 2026 14:03:56 +0000 Subject: [PATCH 054/105] Integration: #2809 opening a document into Read mode puts the tool down --- crates/ui-egui/src/lib.rs | 5 +++++ crates/ui-egui/tests/ui.rs | 15 +++++++++++++++ 2 files changed, 20 insertions(+) diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 3ce3f76dfe..bfe5179a6b 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -2152,6 +2152,11 @@ impl PdfCraftApp { } else if matches!(self.quick_tool, QuickTool::EditText | QuickTool::AddText) { self.put_down_edit_text(); } + // Read mode hides the quick toolbar, so no other tool may stay active there. Opening a + // document with Read as the default mode comes here without `switch_mode`. + if mode == Mode::Read { + self.quick_tool = QuickTool::Select; + } } /// Back to the Select tool from an editing tool. A paragraph still open in the editor is diff --git a/crates/ui-egui/tests/ui.rs b/crates/ui-egui/tests/ui.rs index cc52b01a00..7fedd2bcc8 100644 --- a/crates/ui-egui/tests/ui.rs +++ b/crates/ui-egui/tests/ui.rs @@ -264,6 +264,21 @@ fn read_mode_hides_the_quick_toolbar() { h.get_by_label("Select (V)"); } +/// Opening a document with Read as the default mode puts down the tool in use, as entering Read +/// mode from the mode bar does: the hidden toolbar would leave no way to see or change it. +#[test] +fn opening_into_read_mode_puts_the_tool_down() { + use pdfcraft_ui_egui::{Mode, QuickTool}; + let mut h = harness(|app| app.open_bytes("fixture.pdf", None, FIXTURE.to_vec()).expect("fixture opens")); + assert_eq!(h.state().mode, Mode::AllTools); + h.state_mut().set_option("quick", "hand").unwrap(); + h.state_mut().set_option("default-mode", "read").unwrap(); + h.state_mut().open_bytes("again.pdf", None, FIXTURE.to_vec()).unwrap(); + h.run_steps(3); + assert_eq!(h.state().mode, Mode::Read, "the new document opens in the default mode"); + assert_eq!(h.state().quick_tool, QuickTool::Select, "no tool stays active behind the hidden toolbar"); +} + /// A pane closed in Read mode stays closed on the way out: leaving Read mode doesn't bring back the /// pane saved on the way in, and closing Comments keeps its close preference. #[test] From bf240b612cc874ccd85628c2ff78757d9a54951b Mon Sep 17 00:00:00 2001 From: aiongg Date: Fri, 16 Oct 2026 14:04:02 +0000 Subject: [PATCH 055/105] Integration: #2688 drop the formula-cell and TSV-export sentences from the xfdf README and form.data-xml-csv-txt notes --- crates/xfdf/README.md | 2 +- parity/acrobat-features.toml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/crates/xfdf/README.md b/crates/xfdf/README.md index 322aa4fc22..f1ca8ac001 100644 --- a/crates/xfdf/README.md +++ b/crates/xfdf/README.md @@ -26,7 +26,7 @@ including CDATA and character references. Nested-element and XFA interpretation CSV export and spreadsheet merging quote tabs, and import distinguishes quoted tabs from TSV delimiters without changing field identities. Automatic detection of externally authored, ambiguous -tables remains heuristic. Formula-cell policy and tab-delimited export are unchanged. +tables remains heuristic. Import replaces comments with the same `/NM` (and drops the replaced comment's pop-up), draws appearances PdfCraft can draw, and sets values through the form's own checks (formats, diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index a1c5571d94..19c8c150b0 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -4705,7 +4705,7 @@ status = "shipped" commands = ["form.export_data", "form.import_data"] tools = ["doc_export_data", "doc_import_data"] evidence = ["crates/xfdf/src/tests.rs::form_data_as_xml_csv_and_text", "crates/automation/tests/automation.rs::comments_and_form_data_travel_as_xfdf_fdf_and_text", "crates/xfdf/src/tests.rs::xml_exports_preserve_whitespace_in_field_names", "crates/xfdf/src/tests.rs::xml_form_import_preserves_text_around_comments_and_instructions", "crates/xfdf/src/tests.rs::csv_headers_with_tabs_survive_export_import_and_save", "crates/xfdf/src/tests.rs::tabular_import_keeps_tsv_quotes_literal_and_csv_value_tabs"] -notes = "Acrobat's XML (xfdf:original), CSV, tab-delimited; XML/XFDF exports retain tabs and line breaks in field identities through character references; plain AcroForm XML imports retain all direct text around comments and processing instructions, including CDATA and character references. Nested-element and XFA interpretation remain outside this flat exchange format. CSV export and spreadsheet merging quote tabs, and import distinguishes quoted tabs from TSV delimiters without changing field identities. Ambiguous externally authored tables remain heuristic; TSV export and formula-cell policy are unchanged." +notes = "Acrobat's XML (xfdf:original), CSV, tab-delimited; XML/XFDF exports retain tabs and line breaks in field identities through character references; plain AcroForm XML imports retain all direct text around comments and processing instructions, including CDATA and character references. Nested-element and XFA interpretation remain outside this flat exchange format. CSV export and spreadsheet merging quote tabs, and import distinguishes quoted tabs from TSV delimiters without changing field identities. Ambiguous externally authored tables remain heuristic." [[feature]] id = "form.merge-to-spreadsheet" From 832402ba6469d49047a4cd39c34a1c650ad72183 Mon Sep 17 00:00:00 2001 From: aiongg Date: Fri, 16 Oct 2026 14:24:04 +0000 Subject: [PATCH 056/105] Integration: #2809 the Read mode command applies an open paragraph MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit switch_mode put the tool down by assigning Select, so Ctrl+H/⌃⌘H from Edit text left the paragraph editor open and unapplied in Read mode. --- crates/ui-egui/src/lib.rs | 7 ++++++- crates/ui-egui/tests/editing.rs | 29 +++++++++++++++++++++++++++++ 2 files changed, 35 insertions(+), 1 deletion(-) diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index c861aefce3..185453eaef 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -2191,7 +2191,12 @@ impl PdfCraftApp { } if mode == Mode::Read && !was_read { self.right_before_read = self.right.take(); - self.quick_tool = QuickTool::Select; + // An editing tool is put down so its open paragraph is applied, as `select_mode` does. + if matches!(self.quick_tool, QuickTool::EditText | QuickTool::AddText) { + self.put_down_edit_text(); + } else { + self.quick_tool = QuickTool::Select; + } } else if mode != Mode::Read && was_read { let before = self.right_before_read.take(); if self.right.is_none() { diff --git a/crates/ui-egui/tests/editing.rs b/crates/ui-egui/tests/editing.rs index f8db525751..7ce1654d07 100644 --- a/crates/ui-egui/tests/editing.rs +++ b/crates/ui-egui/tests/editing.rs @@ -4673,3 +4673,32 @@ fn leaving_organize_goes_back_to_the_workspace_panel() { h.run_steps(3); assert_eq!(h.state().left, LeftPanel::AllTools); } + +/// The Read mode command (not the mode bar) applies a paragraph still open in the editor, as +/// putting the Edit text tool down does; left open, it would stay drawn in Read mode with no tool. +#[test] +fn read_mode_shortcut_applies_the_open_paragraph() { + use pdfcraft_ui_egui::{Mode, QuickTool}; + let mut h = harness(1, |_| {}); + assert!(h.state_mut().execute("edit.edit_text")); + h.run_steps(2); + // The line "Page 1" sits at (20, 150) on a 200 × 300 page, 24 pt. + let r = h.state().views[0].page_screen_rect(0).expect("on screen"); + let at = egui::pos2(r.left() + 40.0 / 200.0 * r.width(), r.top() + (300.0 - 158.0) / 300.0 * r.height()); + h.hover_at(at); + h.run_steps(1); + h.drag_at(at); + h.run_steps(1); + h.drop_at(at); + h.run_steps(3); + assert!(h.state().views[0].line_editor.is_some(), "the line opens for editing"); + h.state_mut().views[0].line_editor.as_mut().unwrap().text = "Chapter One".into(); + h.run_steps(1); + h.state_mut().run_command("view.read_mode"); + h.run_steps(3); + let s = h.state(); + assert_eq!(s.mode, Mode::Read); + assert_eq!(s.quick_tool, QuickTool::Select); + assert!(s.views[0].line_editor.is_none(), "no editor stays open in Read mode"); + assert_eq!(s.session.get(s.views[0].id).unwrap().text_lines(0)[0].text, "Chapter One", "the edit is applied"); +} From a18aa1414ca8b6e4dc5ce9359ff556275d7612bc Mon Sep 17 00:00:00 2001 From: aiongg Date: Fri, 16 Oct 2026 14:24:04 +0000 Subject: [PATCH 057/105] Integration: #2793 parity notes say malformed Decode keeps the default mapping --- ROADMAP.md | 1 - parity/acrobat-features.toml | 4 ++-- 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index e1f1c9b522..c51f2e7856 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -176,7 +176,6 @@ Ranked; detail and the full milestone table in [docs/roadmap.md](docs/roadmap.md - **2026-10-13 (G14, Vietnamese localization):** Added Vietnamese (`vi`, Tiếng Việt) as an interface and date language with a 2,591-entry catalog (`plural_none`) covering 100% of the current localizable key union (plus `theme`/`Appearance`). Locale tags `vi` / `vi-VN` / `vi_VN.UTF-8` resolve; calendar months use numeric `Tháng 1`…`12` / `tháng 1`…`12`. Native-speaker community review recorded. The twelve-language localization mean moves from ≈59% to ≈68%; weighted readiness ≈49% → ≈50%. Status stays `partial`: engine/OS details, document contents, filenames, CLI/MCP and external help stay outside the catalog by design. - **2026-10-14 (M1, Flate fallback):** Raw deflate whose first bytes resemble a zlib header no longer loses a decoding-limit error during fallback. Strict and tolerant filter regressions and a COS stream regression cover refusal at the caller limit and successful decoding within it. No parity status or estimate changes. - - **2026-10-13 (M4, indirect page rotation):** Rotation queries and edits resolve indirect integer values on pages and their page-tree ancestors. Turning a page already at 270° by 90° now saves it at 0°, including when its rotation is inherited. Synthetic full/incremental save-and-reopen tests cover nested inheritance, sibling preservation, negative/large angles and invalid-value fallback. Estimates unchanged. - **2026-10-12 (M6, XML field names):** AcroForm XML data export constrains generated element names to XML 1.0 NCNames, so Latin-1 names such as `Area²` and `Aµ` produce readable data files. Original PDF field names travel through the existing `xfdf:original` attribute; previously valid tags, including Greek and CJK, keep their spelling. Saved-PDF regressions cover invalid prefixes, combining letters and normalized-name collisions. Estimates unchanged. - **2026-10-12 (M6, CSV carriage returns):** Form-data CSV export quotes carriage returns in field names and values, so a trailing carriage return cannot turn into a record separator and disappear on reimport. Saved-PDF round-trip regressions cover CR, LF, CRLF, commas and quotes; estimates unchanged. diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index da341e5737..51d4c65363 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -2131,7 +2131,7 @@ status = "shipped" commands = ["edit.edit_text"] tools = ["image_save"] evidence = ["crates/edit/src/tests.rs::page_images_move_turn_replace_and_delete", "crates/ui-egui/tests/editing.rs::editing_existing_images_on_the_page", "crates/automation/tests/automation.rs::editing_page_images_through_tools", "crates/create/src/tests.rs::images_export_as_jpeg_unchanged_and_others_as_png", "crates/create/src/tests.rs::lossless_decode_maps_each_component_before_colour_conversion", "crates/create/src/tests.rs::lossless_indexed_decode_rounds_in_index_units_then_clamps"] -notes = "right-click an image ▸ Save Image As: JPEG unchanged, others PNG. Lossless images apply per-component Decode; Indexed values round and clip before palette lookup. Malformed Decode arrays return an error. Still limited: 16-bit samples use high bytes; general JPEG and mask Decode, ICC transforms, and calibrated colors remain unsupported." +notes = "right-click an image ▸ Save Image As: JPEG unchanged, others PNG. Lossless images apply per-component Decode; Indexed values round and clip before palette lookup. Missing, null or malformed Decode arrays keep the default mapping. Still limited: 16-bit samples use high bytes; general JPEG and mask Decode, ICC transforms, and calibrated colors remain unsupported." [[feature]] id = "edit.image-external-editor" @@ -7077,7 +7077,7 @@ status = "shipped" commands = ["export.all_images"] tools = ["doc_export_all_images"] evidence = ["crates/create/src/tests.rs::images_export_as_jpeg_unchanged_and_others_as_png", "crates/create/src/tests.rs::indexed_and_one_bit_images_decode", "crates/create/src/tests.rs::jpeg_images_with_a_soft_mask_export_as_png_with_alpha", "crates/create/src/tests.rs::soft_masks_of_another_size_and_stencil_and_colour_key_masks_become_alpha", "crates/automation/tests/automation.rs::exporting_all_images_through_tools", "crates/ui-egui/tests/export.rs::export_dialogs_write_images_and_text", "crates/create/src/tests.rs::lossless_decode_maps_each_component_before_colour_conversion", "crates/create/src/tests.rs::lossless_indexed_decode_rounds_in_index_units_then_clamps"] -notes = "each image once (page resources and nested forms); JPEG unchanged unless it has transparency, others PNG (gray/RGB/CMYK/ICC/Indexed, 1–16 bpc, stencil masks); transparency as PNG alpha: /SMask (scaled to the image size, /Matte un-blended), stencil /Mask and colour-key /Mask, JPEG images included (#645); JPX/JBIG2/CCITT masks are left out (image exported opaque); JPX/JBIG2/CCITT/separations reported as skipped; minimum size filter. Lossless images apply per-component Decode; Indexed values round and clip before palette lookup. Malformed Decode arrays return an error. Still limited: 16-bit samples use high bytes; general JPEG and mask Decode, ICC transforms, and calibrated colors remain unsupported." +notes = "each image once (page resources and nested forms); JPEG unchanged unless it has transparency, others PNG (gray/RGB/CMYK/ICC/Indexed, 1–16 bpc, stencil masks); transparency as PNG alpha: /SMask (scaled to the image size, /Matte un-blended), stencil /Mask and colour-key /Mask, JPEG images included (#645); JPX/JBIG2/CCITT masks are left out (image exported opaque); JPX/JBIG2/CCITT/separations reported as skipped; minimum size filter. Lossless images apply per-component Decode; Indexed values round and clip before palette lookup. Missing, null or malformed Decode arrays keep the default mapping. Still limited: 16-bit samples use high bytes; general JPEG and mask Decode, ICC transforms, and calibrated colors remain unsupported." [[feature]] id = "create.export-html" From c6acaefd35a8df90dba13a92ab69fc58df0a0ef1 Mon Sep 17 00:00:00 2001 From: mtlcl410 <2368091+mtlcl410@users.noreply.github.com> Date: Fri, 16 Oct 2026 22:30:55 +0800 Subject: [PATCH 058/105] M1: read an xref table numbered from 1 that begins with object 0 (#2676) Some producers head a classic xref table "1 n" and then write object 0's free entry first, so every row is one number off and object 1 reads as free. The renderer coped, but cos did not: printing a scanned invoice built from cos dropped object 1 (page 1's image) and printed the page blank. Such a subsection is now read from 0 when its second row locates an object that is numbered 1, in both xref readers, and the repair is logged. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01RUNQSPVXx3dDBabRZHAnjz --- crates/cos/src/document.rs | 180 +++++++++++++++++++++++++++++++++-- parity/acrobat-features.toml | 4 +- 2 files changed, 176 insertions(+), 8 deletions(-) diff --git a/crates/cos/src/document.rs b/crates/cos/src/document.rs index a0b1f56eeb..e47d035363 100644 --- a/crates/cos/src/document.rs +++ b/crates/cos/src/document.rs @@ -82,6 +82,8 @@ struct XrefSection { row_count: usize, max_num: u32, has_entries: bool, + /// A table subsection was read one number lower than it says ([`subsection_start`]). + renumbered: bool, } #[derive(Clone, Debug)] @@ -1360,6 +1362,9 @@ impl Document { Err(e) => return Err(e), }; revisions.push(Revision { xref_offset: off, is_stream }); + if primary.renumbered { + log.push(format!("xref subsection at {off} is numbered from 1 but begins with object 0's entry; read from 0")); + } let Some(rows) = retained_rows.checked_add(primary.row_count).filter(|rows| *rows <= MAX_COMPACT_XREF_ROWS) else { return Ok(None) }; let Some(bytes) = retained_bytes.checked_add(primary.retained_bytes()).filter(|bytes| *bytes <= MAX_COMPACT_XREF_BYTES) else { return Ok(None); @@ -1434,6 +1439,7 @@ impl Document { let mut has_entries = false; let mut previous_end = None; let mut row_count = 0usize; + let mut renumbered = false; loop { lx.skip_ws(); if lx.eat_keyword(b"trailer") { @@ -1442,7 +1448,7 @@ impl Document { if !layout_ok { return Ok(None); } - return Ok(Some((XrefSection { data: XrefSectionData::Table, ranges, row_count, max_num, has_entries }, d, false))); + return Ok(Some((XrefSection { data: XrefSectionData::Table, ranges, row_count, max_num, has_entries, renumbered }, d, false))); } let (Ok(Object::Int(start)), Ok(Object::Int(count))) = (lx.object(), lx.object()) else { return Err(CosError::Syntax { offset: lx.pos, detail: "bad xref subsection header".into() }); @@ -1450,6 +1456,9 @@ impl Document { if !valid_xref_range(start, count) || count > 10_000_000 { return Err(CosError::Syntax { offset: lx.pos, detail: "invalid xref object range".into() }); } + let shifted = subsection_start(data, lx.pos, start, count); + renumbered |= shifted != start; + let start = shifted; let Some(next_rows) = usize::try_from(count).ok().and_then(|count| row_count.checked_add(count)) else { return Ok(None) }; if next_rows > MAX_COMPACT_XREF_ROWS { return Ok(None); @@ -1602,7 +1611,14 @@ impl Document { } let max_num = ranges.iter().filter_map(|range| range.start.checked_add(range.count.saturating_sub(1))).max().unwrap_or(0); let row_count = row_start; - let section = XrefSection { data: XrefSectionData::Stream { bytes: Arc::new(raw), widths }, ranges, row_count, max_num, has_entries: false }; + let section = XrefSection { + data: XrefSectionData::Stream { bytes: Arc::new(raw), widths }, + ranges, + row_count, + max_num, + has_entries: false, + renumbered: false, + }; let has_entries = section.has_any_entry(); let section = XrefSection { has_entries, ..section }; Ok(Some((section, stream.dict.clone(), true))) @@ -1636,13 +1652,13 @@ impl Document { let mut sect = BTreeMap::new(); // Object numbers whose entry in this section was set aside ([`Self::xref_oddities`]). let mut odd = Vec::new(); - let (sect_trailer, is_stream) = match self.read_section(off as usize, &mut sect, &mut odd) { + let (sect_trailer, is_stream) = match self.read_section(off as usize, &mut sect, &mut odd, log) { Ok(v) => v, Err(e) if self.header_offset > 0 => { log.push(format!("xref at {off} unreadable ({e}); retrying relative to the header")); sect.clear(); odd.clear(); - self.read_section(off as usize + self.header_offset, &mut sect, &mut odd)? + self.read_section(off as usize + self.header_offset, &mut sect, &mut odd, log)? } Err(e) => { // An unreadable older section loses every object only it locates (the page @@ -1658,7 +1674,7 @@ impl Document { // free entries it gives objects only a cross-reference stream can locate. if let Some(x) = sect_trailer.int(b"XRefStm").filter(|x| *x >= 0) { let mut stm = BTreeMap::new(); - match self.read_section(x as usize, &mut stm, &mut odd) { + match self.read_section(x as usize, &mut stm, &mut odd, log) { Ok(_) => { for (num, e) in stm { match sect.get(&num) { @@ -1693,7 +1709,13 @@ impl Document { /// Read one section at `off`, adding entries not already known (newer sections win). `odd` /// collects the object numbers whose entry here is set aside ([`Self::xref_oddities`]). - fn read_section(&self, off: usize, entries: &mut BTreeMap, odd: &mut Vec) -> Result<(Dict, bool), CosError> { + fn read_section( + &self, + off: usize, + entries: &mut BTreeMap, + odd: &mut Vec, + log: &mut Vec, + ) -> Result<(Dict, bool), CosError> { let data = &self.data; let mut lx = Lexer::new(data, off); if lx.eat_keyword(b"xref") { @@ -1710,6 +1732,11 @@ impl Document { if !valid_xref_range(start, count) || count > 10_000_000 { return Err(CosError::Syntax { offset: lx.pos, detail: "invalid xref object range".into() }); } + let shifted = subsection_start(data, lx.pos, start, count); + if shifted != start { + log.push(format!("xref subsection at {off} is numbered from 1 but begins with object 0's entry; read from 0")); + } + let start = shifted; for i in 0..count { lx.skip_ws(); let o = lx.token(); @@ -1923,6 +1950,34 @@ fn generated_id(seed: &[u8; 32]) -> Vec { /// Object numbers must fit the representation used by references and the xref map. /// Validate the whole subsection before adding entries, so neither arithmetic overflow nor /// truncation can turn a damaged range into entries for unrelated objects. +/// The object number a classic xref subsection really starts at. A common producer bug heads the +/// table `1 n` and then writes object 0's free entry (`0000000000 65535 f`) as its first row, so +/// every row would be read one number too high and object 1 as free. Such a subsection is read +/// from 0 when its second row, read that way, locates an object that is indeed numbered 1. `rows` +/// is the position just after the subsection header. +fn subsection_start(data: &[u8], rows: usize, start: i64, count: i64) -> i64 { + if start != 1 || count < 2 { + return start; + } + let mut lx = Lexer::new(data, rows); + let mut row = || { + lx.skip_ws(); + let offset = std::str::from_utf8(lx.token()).ok().and_then(|s| s.parse::().ok()); + lx.skip_ws(); + let generation = std::str::from_utf8(lx.token()).ok().and_then(|s| s.parse::().ok()); + lx.skip_ws(); + (offset, generation, lx.token() == b"f") + }; + let (Some(0), Some(65535), true) = row() else { return start }; + let (Some(second), _, false) = row() else { return start }; + let Some(at) = usize::try_from(second).ok().filter(|at| *at < data.len()) else { return start }; + let mut obj = Lexer::new(data, at); + match (obj.object(), obj.object()) { + (Ok(Object::Int(1)), Ok(Object::Int(_))) if obj.eat_keyword(b"obj") => 0, + _ => start, + } +} + fn valid_xref_range(start: i64, count: i64) -> bool { u32::try_from(start).is_ok() && count >= 0 && start.checked_add(count).is_some_and(|end| end <= i64::from(u32::MAX) + 1) } @@ -2085,6 +2140,119 @@ mod tests { assert!(doc.xref_entry_is_exact(1) && doc.xref_entry_is_exact(2)); } + /// A classic table with `/Size` rows and object 0's free entry first, its subsection numbered + /// `1 n` instead of `0 n` (#2676): every row is one number off. + fn misnumbered_table(start: u32) -> Vec { + let bodies = ["<< /Marker (first object) >>", "<< /Type /Pages /Kids [] /Count 0 >>", "<< /Type /Catalog /Pages 2 0 R >>"]; + let mut out = b"%PDF-1.7 +" + .to_vec(); + let mut offsets = Vec::new(); + for (i, body) in bodies.iter().enumerate() { + offsets.push(out.len()); + out.extend_from_slice( + format!( + "{} 0 obj +{body} +endobj +", + i + 1 + ) + .as_bytes(), + ); + } + let xref = out.len(); + out.extend_from_slice( + format!( + "xref +{start} 4 +0000000000 65535 f +" + ) + .as_bytes(), + ); + for o in offsets { + out.extend_from_slice( + format!( + "{o:010} 00000 n +" + ) + .as_bytes(), + ); + } + out.extend_from_slice( + format!( + "trailer +<< /Size 4 /Root 3 0 R >> +startxref +{xref} +%%EOF +" + ) + .as_bytes(), + ); + out + } + + #[test] + fn a_table_numbered_from_1_that_begins_with_object_0_is_read_from_0() { + let doc = Document::open(Arc::new(misnumbered_table(1))).unwrap(); + let first = doc.get(ObjRef::new(1, 0)).as_dict().cloned().expect("object 1 is not free"); + assert!(first.contains(b"Marker")); + assert_eq!(doc.root(), Some(ObjRef::new(3, 0))); + assert!(doc.repair_log().iter().any(|l| l.contains("numbered from 1")), "{:?}", doc.repair_log()); + // A correct table reads the same and logs nothing. + let fine = Document::open(Arc::new(misnumbered_table(0))).unwrap(); + assert!(fine.get(ObjRef::new(1, 0)).as_dict().is_some_and(|d| d.contains(b"Marker"))); + assert!(!fine.repair_log().iter().any(|l| l.contains("numbered from 1"))); + } + + #[test] + fn a_table_that_really_starts_at_1_keeps_its_numbers() { + // Object 1 deleted for good (generation 65535) in a subsection that does start at 1: its + // second row locates object 2, so nothing is shifted. + let mut bytes = b"%PDF-1.7 +" + .to_vec(); + let pages = bytes.len(); + bytes.extend_from_slice( + b"2 0 obj +<< /Type /Pages /Kids [] /Count 0 >> +endobj +", + ); + let catalog = bytes.len(); + bytes.extend_from_slice( + b"3 0 obj +<< /Type /Catalog /Pages 2 0 R >> +endobj +", + ); + let xref = bytes.len(); + bytes.extend_from_slice( + format!( + "xref +0 1 +0000000000 65535 f +1 3 +0000000000 65535 f +{pages:010} 00000 n +{catalog:010} 00000 n +trailer +<< /Size 4 /Root 3 0 R >> +startxref +{xref} +%%EOF +" + ) + .as_bytes(), + ); + let doc = Document::open(Arc::new(bytes)).unwrap(); + assert!(doc.get(ObjRef::new(2, 0)).as_dict().is_some_and(|d| d.name(b"Type") == Some(b"Pages"))); + assert!(doc.get(ObjRef::new(1, 0)).as_dict().is_none(), "object 1 stays free"); + assert!(!doc.repair_log().iter().any(|l| l.contains("numbered from 1"))); + } + #[test] fn opening_an_exhausted_classic_xref_repairs_and_allocates() { // The extra in-use row need not be loaded to exhaust the next object number. diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index c3d319f94f..412928125d 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -46,8 +46,8 @@ milestone = "M1" status = "shipped" commands = ["file.open"] tools = ["doc_open"] -evidence = ["crates/cos/src/document.rs::damaged_xref_is_reconstructed", "crates/cos/src/parser.rs::indirect_stream_with_wrong_length_recovers", "crates/cos/src/document.rs::self_referential_length_does_not_overflow"] -notes = "" +evidence = ["crates/cos/src/document.rs::damaged_xref_is_reconstructed", "crates/cos/src/parser.rs::indirect_stream_with_wrong_length_recovers", "crates/cos/src/document.rs::self_referential_length_does_not_overflow", "crates/cos/src/document.rs::a_table_numbered_from_1_that_begins_with_object_0_is_read_from_0"] +notes = "A classic table numbered 1 n that begins with object 0's free entry is read from 0 (#2676) and logged." [[feature]] id = "core.malformed-input-safety" From aea1d3d12759b03869a604ef424286eae45dbd50 Mon Sep 17 00:00:00 2001 From: mtlcl410 <2368091+mtlcl410@users.noreply.github.com> Date: Fri, 16 Oct 2026 22:30:55 +0800 Subject: [PATCH 059/105] M1: put valid_xref_range's doc comment back, and no trailing spaces in the tests (#2676) Review nits on #2717: the new doc comment had been inserted inside valid_xref_range's; the tests' xref tables are now one-line literals with \n escapes, so git diff --check is clean. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01RUNQSPVXx3dDBabRZHAnjz --- crates/cos/src/document.rs | 68 ++++++-------------------------------- 1 file changed, 10 insertions(+), 58 deletions(-) diff --git a/crates/cos/src/document.rs b/crates/cos/src/document.rs index e47d035363..00ed1fa2ef 100644 --- a/crates/cos/src/document.rs +++ b/crates/cos/src/document.rs @@ -1947,9 +1947,6 @@ fn generated_id(seed: &[u8; 32]) -> Vec { out } -/// Object numbers must fit the representation used by references and the xref map. -/// Validate the whole subsection before adding entries, so neither arithmetic overflow nor -/// truncation can turn a damaged range into entries for unrelated objects. /// The object number a classic xref subsection really starts at. A common producer bug heads the /// table `1 n` and then writes object 0's free entry (`0000000000 65535 f`) as its first row, so /// every row would be read one number too high and object 1 as free. Such a subsection is read @@ -1978,6 +1975,9 @@ fn subsection_start(data: &[u8], rows: usize, start: i64, count: i64) -> i64 { } } +/// Object numbers must fit the representation used by references and the xref map. +/// Validate the whole subsection before adding entries, so neither arithmetic overflow nor +/// truncation can turn a damaged range into entries for unrelated objects. fn valid_xref_range(start: i64, count: i64) -> bool { u32::try_from(start).is_ok() && count >= 0 && start.checked_add(count).is_some_and(|end| end <= i64::from(u32::MAX) + 1) } @@ -2162,35 +2162,11 @@ endobj ); } let xref = out.len(); - out.extend_from_slice( - format!( - "xref -{start} 4 -0000000000 65535 f -" - ) - .as_bytes(), - ); + out.extend_from_slice(format!("xref\n{start} 4\n0000000000 65535 f \n").as_bytes()); for o in offsets { - out.extend_from_slice( - format!( - "{o:010} 00000 n -" - ) - .as_bytes(), - ); + out.extend_from_slice(format!("{o:010} 00000 n \n").as_bytes()); } - out.extend_from_slice( - format!( - "trailer -<< /Size 4 /Root 3 0 R >> -startxref -{xref} -%%EOF -" - ) - .as_bytes(), - ); + out.extend_from_slice(format!("trailer\n<< /Size 4 /Root 3 0 R >>\nstartxref\n{xref}\n%%EOF\n").as_bytes()); out } @@ -2211,39 +2187,15 @@ startxref fn a_table_that_really_starts_at_1_keeps_its_numbers() { // Object 1 deleted for good (generation 65535) in a subsection that does start at 1: its // second row locates object 2, so nothing is shifted. - let mut bytes = b"%PDF-1.7 -" - .to_vec(); + let mut bytes = b"%PDF-1.7\n".to_vec(); let pages = bytes.len(); - bytes.extend_from_slice( - b"2 0 obj -<< /Type /Pages /Kids [] /Count 0 >> -endobj -", - ); + bytes.extend_from_slice(b"2 0 obj\n<< /Type /Pages /Kids [] /Count 0 >>\nendobj\n"); let catalog = bytes.len(); - bytes.extend_from_slice( - b"3 0 obj -<< /Type /Catalog /Pages 2 0 R >> -endobj -", - ); + bytes.extend_from_slice(b"3 0 obj\n<< /Type /Catalog /Pages 2 0 R >>\nendobj\n"); let xref = bytes.len(); bytes.extend_from_slice( format!( - "xref -0 1 -0000000000 65535 f -1 3 -0000000000 65535 f -{pages:010} 00000 n -{catalog:010} 00000 n -trailer -<< /Size 4 /Root 3 0 R >> -startxref -{xref} -%%EOF -" + "xref\n0 1\n0000000000 65535 f \n1 3\n0000000000 65535 f \n{pages:010} 00000 n \n{catalog:010} 00000 n \ntrailer\n<< /Size 4 /Root 3 0 R >>\nstartxref\n{xref}\n%%EOF\n" ) .as_bytes(), ); From b7b6e6fe096c88e06e3f5639ea81d373424f7e55 Mon Sep 17 00:00:00 2001 From: mtlcl410 <2368091+mtlcl410@users.noreply.github.com> Date: Fri, 16 Oct 2026 22:40:40 +0800 Subject: [PATCH 060/105] fix(redact): removed links and comments leave the structure tree too (#2447) Remove Hidden Information and Sanitize took a link or comment off its page's /Annots, but a tagged PDF's structure tree still referenced it through an /OBJR, so the full rewrite kept the annotation, its URL or text included, while link_list reported none. The annotations removed from every page are now collected, and tags::drop_object_refs drops the structure elements' object references to them (direct or indirect, array or single kid), with the same seen-set bound as tags::clean. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01RUNQSPVXx3dDBabRZHAnjz --- ROADMAP.md | 1 + crates/redact/src/sanitize.rs | 6 +++ crates/redact/src/tags.rs | 82 +++++++++++++++++++++++++++++++++++ crates/redact/src/tests.rs | 51 ++++++++++++++++++++++ parity/acrobat-features.toml | 4 +- 5 files changed, 142 insertions(+), 2 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 259dbfec04..eb5018a097 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -159,6 +159,7 @@ Ranked; detail and the full milestone table in [docs/roadmap.md](docs/roadmap.md ## Progress log - **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 (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-13 (community, M3, #1569 review):** Pixel-only Wayland wheel, wheel-tilt and continuous input use standalone moved events, so Ctrl zoom does not remain latched after the modifier is released (#991); vendored winit patch (13), with a policy test. Estimates unchanged. - **2026-10-12 (M6):** XFDF field import and data-file extraction join all text nodes in a value, preserving text after XML comments and processing instructions. Synthetic regressions cover Unicode, CDATA, empty values and PDF save/reopen. Estimates unchanged. - **2026-10-12 (M7, picture right-click menu):** The Edit text & images menu of a picture gains Cut Image, Redact Image (a redaction mark over the picture, applied from Redact a PDF), Create Link… and Recognize Text… (for that page), in all 21 catalogs. The menu was drawn only while the pointer was over the picture, so its items outside the picture (most of a logo's menu, and Delete below a page) could not be clicked; an open menu now stays drawn. Still missing from Acrobat's menu: Arrange (bring to front, send backward), Remove crop, Draw freehand and Edit image using… (opening an external editor). diff --git a/crates/redact/src/sanitize.rs b/crates/redact/src/sanitize.rs index 64d6ceefce..75b8c89bea 100644 --- a/crates/redact/src/sanitize.rs +++ b/crates/redact/src/sanitize.rs @@ -286,6 +286,8 @@ pub fn remove_hidden(doc: &mut Document, which: &[Hidden]) -> Result = std::collections::HashSet::new(); for p in pdfcraft_model::pages(doc) { let list = annots_of(doc, &p.dict); let mut removed: Vec = Vec::new(); @@ -305,6 +307,7 @@ pub fn remove_hidden(doc: &mut Document, which: &[Hidden]) -> Result Result) -> bool { + doc.resolve(o) + .as_dict() + .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))) +} + +/// 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 { + if gone.is_empty() { + return Ok(0); + } + let Some(root) = doc + .root() + .and_then(|r| doc.get(r).as_dict().cloned()) + .and_then(|c| c.get(b"StructTreeRoot").map(|s| doc.resolve(s)).and_then(|s| s.as_dict().cloned())) + else { + return Ok(0); + }; + let mut todo: Vec = root.get(b"K").cloned().into_iter().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), + Object::Ref(r) => { + if !seen.insert(r) || seen.len() > 2_000_000 { + continue; + } + let object = doc.get(r); + if let Object::Array(a) = &*object { + todo.extend(a.iter().cloned()); + continue; + } + let Some(d) = object.as_dict() else { continue }; + if !d.contains(b"S") { + 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(), + }; + if kids.iter().any(|x| refers_to(doc, x, gone)) { + hits.push(r); + } + todo.extend(kids); + } + _ => {} + } + } + let mut n = 0; + for r in hits { + let Some(d) = doc.get(r).as_dict().cloned() else { continue }; + let kids = d.get(b"K").map(|k| doc.resolve(k)); + let array = kids.as_ref().is_some_and(|k| matches!(&**k, Object::Array(_))); + let list: Vec = match kids.as_deref() { + Some(Object::Array(a)) => a.clone(), + Some(_) => d.get(b"K").cloned().into_iter().collect(), + None => Vec::new(), + }; + let before = list.len(); + let keep: Vec = list.into_iter().filter(|x| !refers_to(doc, x, gone)).collect(); + n += before - keep.len(); + doc.update_dict(r, |e| { + if array { + e.set(b"K".to_vec(), Object::Array(keep)); + } else if let Some(k) = keep.into_iter().next() { + e.set(b"K".to_vec(), k); + } else { + e.remove(b"K"); + } + })?; + } + Ok(n) +} diff --git a/crates/redact/src/tests.rs b/crates/redact/src/tests.rs index b9a230fc41..f9e08d35d0 100644 --- a/crates/redact/src/tests.rs +++ b/crates/redact/src/tests.rs @@ -631,6 +631,57 @@ fn hidden_information_is_counted_and_removed() { assert!(shown, "the field's value stays visible as page content"); } +/// Issue #2447: in a tagged PDF the structure tree points at the link and the comment it tags +/// (`/OBJR`). Removing them must drop those references too, or the full rewrite keeps the +/// annotations, the URL and the comment text included, while the page no longer lists them. +#[test] +fn removed_links_and_comments_leave_the_structure_tree_and_the_saved_file() { + use crate::sanitize::{Hidden, remove_hidden}; + let objs: Vec> = vec![ + // 1 catalog, with a structure tree + b"<< /Type /Catalog /Pages 2 0 R /MarkInfo << /Marked true >> /StructTreeRoot 8 0 R >>".to_vec(), + b"<< /Type /Pages /Kids [3 0 R] /Count 1 >>".to_vec(), + // 3 page: a tagged paragraph (MCID 0), a link and a comment, both tagged + b"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 300 300] /Contents 4 0 R /Resources << /Font << /F1 5 0 R >> >> /Annots [6 0 R 7 0 R] /StructParents 0 >>".to_vec(), + stream("", b"/P << /MCID 0 >> BDC BT /F1 10 Tf 10 230 Td (See the site) Tj ET EMC"), + FONT.replace("95 0 R", "13 0 R").into_bytes(), + // 6 link, 7 comment + b"<< /Type /Annot /Subtype /Link /Rect [10 228 80 240] /StructParent 1 /A << /S /URI /URI (https://secret.example/token=ZX81) >> >>".to_vec(), + b"<< /Type /Annot /Subtype /Text /Rect [200 200 220 220] /StructParent 2 /Contents (COMMENTSECRETNOTE) >>".to_vec(), + // 8 struct tree root, 9 document element, 10 paragraph, 11 link element (direct OBJR), + // 12 annotation element (OBJR by reference, as K's only kid) + b"<< /Type /StructTreeRoot /K 9 0 R /ParentTree << /Nums [0 [10 0 R] 1 11 0 R 2 12 0 R] >> /ParentTreeNextKey 3 >>".to_vec(), + b"<< /Type /StructElem /S /Document /P 8 0 R /K [10 0 R 11 0 R 12 0 R] >>".to_vec(), + b"<< /Type /StructElem /S /P /P 9 0 R /Pg 3 0 R /K 0 >>".to_vec(), + b"<< /Type /StructElem /S /Link /P 9 0 R /Pg 3 0 R /K [<< /Type /OBJR /Obj 6 0 R /Pg 3 0 R >>] >>".to_vec(), + b"<< /Type /StructElem /S /Annot /P 9 0 R /Pg 3 0 R /K 14 0 R >>".to_vec(), + widths(), + b"<< /Type /OBJR /Obj 7 0 R /Pg 3 0 R >>".to_vec(), + ]; + let mut doc = pdf(objs); + let done = remove_hidden(&mut doc, &[Hidden::LinksActionsScripts, Hidden::Comments]).unwrap(); + assert!(done.contains(&(Hidden::LinksActionsScripts, 1)) && done.contains(&(Hidden::Comments, 1)), "{done:?}"); + let bytes = write_full(&doc, &SaveOptions::default()).unwrap(); + let has = |needle: &[u8]| bytes.windows(needle.len()).any(|w| w == needle); + assert!(!has(b"secret.example"), "the removed link's URL survived the save"); + assert!(!has(b"COMMENTSECRETNOTE"), "the removed comment's text survived the save"); + // The tagged paragraph and its content stay; the elements lose only the references. + let saved = Document::open(Arc::new(bytes)).unwrap(); + assert!(content(&saved, 0).contains("(See the site)")); + let objrs = saved.object_numbers().into_iter().filter(|&n| { + let o = saved.get(ObjRef::new(n, saved.generation(n))); + let direct = o.as_dict().is_some_and(|d| d.name(b"Type") == Some(b"OBJR")); + let nested = o.as_dict().and_then(|d| d.get(b"K")).is_some_and(|k| match &*saved.resolve(k) { + Object::Array(a) => a.iter().any(|x| saved.resolve(x).as_dict().is_some_and(|d| d.name(b"Type") == Some(b"OBJR"))), + _ => false, + }); + direct || nested + }); + assert_eq!(objrs.count(), 0, "no object reference to a removed annotation is left"); + let root = saved.get(saved.root().unwrap()).as_dict().cloned().unwrap(); + assert!(root.contains(b"StructTreeRoot"), "the structure tree itself stays"); +} + /// Issue #1325: removing comments keeps the annotations that are not comments: the multimedia /// and print-production ones (Watermark, PrinterMark, TrapNet, Screen, Movie, RichMedia, 3D). #[test] diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index c3d319f94f..f75e56cfe6 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -5788,8 +5788,8 @@ milestone = "M8" status = "shipped" commands = ["protect.remove_hidden"] tools = ["doc_hidden_info", "doc_remove_hidden"] -evidence = ["crates/redact/src/tests.rs::hidden_information_is_counted_and_removed", "crates/ui-egui/tests/ui_group/redact.rs::removing_hidden_information_and_sanitizing", "crates/automation/tests/automation.rs::removing_hidden_information_through_tools"] -notes = "metadata, attachments, comments, form fields (flattened), hidden text, hidden layers, bookmarks, links/actions/scripts, private data; overlapping objects and cropped content pending" +evidence = ["crates/redact/src/tests.rs::hidden_information_is_counted_and_removed", "crates/ui-egui/tests/ui_group/redact.rs::removing_hidden_information_and_sanitizing", "crates/automation/tests/automation.rs::removing_hidden_information_through_tools", "crates/redact/src/tests.rs::removed_links_and_comments_leave_the_structure_tree_and_the_saved_file"] +notes = "metadata, attachments, comments, form fields (flattened), hidden text, hidden layers, bookmarks, links/actions/scripts (with the structure tree's references to removed links and comments, #2447), private data; overlapping objects and cropped content pending" [[feature]] id = "protect.sanitize" From 4d6812503ddd04aacfe3ab593ee580b4c1e7372e Mon Sep 17 00:00:00 2001 From: mtlcl410 <2368091+mtlcl410@users.noreply.github.com> Date: Fri, 16 Oct 2026 22:40:40 +0800 Subject: [PATCH 061/105] fix(redact): flattened widgets and far pop-ups leave the structure tree too (#2447) Review follow-up on #2532: - the structure-tree references dropped are those of every annotation no page lists any more after Sanitize, so widgets flattened with "form fields" go too, with their field value; - the annotations to remove are collected on every page first, so a pop-up listed on another page than its parent goes with it; - an element left with nothing to tag drops /Alt, /ActualText, /T and /E, which can repeat the URL or comment; - the test's byte search saves without object streams, so it can see the strings it looks for. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01RUNQSPVXx3dDBabRZHAnjz --- crates/redact/src/sanitize.rs | 32 ++++++++++++--------- crates/redact/src/tags.rs | 11 +++++-- crates/redact/src/tests.rs | 54 +++++++++++++++++++++++++++++++++-- 3 files changed, 80 insertions(+), 17 deletions(-) diff --git a/crates/redact/src/sanitize.rs b/crates/redact/src/sanitize.rs index 75b8c89bea..3a9935e6ed 100644 --- a/crates/redact/src/sanitize.rs +++ b/crates/redact/src/sanitize.rs @@ -266,6 +266,12 @@ pub fn remove_hidden(doc: &mut Document, which: &[Hidden]) -> Result 0) || (layers && count(Hidden::HiddenLayers) > 0) { content_pass(doc, text, layers, true)?; } + // Every annotation the pages list now: whatever is no longer listed at the end (flattened + // widgets included) leaves the structure tree too. + let listed = |doc: &Document| -> std::collections::HashSet { + pdfcraft_model::pages(doc).iter().flat_map(|p| annots_of(doc, &p.dict)).filter_map(|a| a.as_ref()).collect() + }; + let listed_before = listed(doc); if which.contains(&Hidden::FormFields) && count(Hidden::FormFields) > 0 { // Fields without appearances get one first, so their values stay visible. for f in pdfcraft_forms::fields(doc) { @@ -286,18 +292,16 @@ pub fn remove_hidden(doc: &mut Document, which: &[Hidden]) -> Result = std::collections::HashSet::new(); + // The annotations to remove, on every page first, so a pop-up listed on another page than + // its parent goes with it. + let removed: std::collections::HashSet = pdfcraft_model::pages(doc) + .iter() + .flat_map(|p| annots_of(doc, &p.dict)) + .filter_map(|a| a.as_ref()) + .filter(|&r| doc.get(r).as_dict().is_some_and(drop_annot)) + .collect(); for p in pdfcraft_model::pages(doc) { let list = annots_of(doc, &p.dict); - let mut removed: Vec = Vec::new(); - for a in &list { - if let Some(r) = a.as_ref() - && doc.get(r).as_dict().is_some_and(drop_annot) - { - removed.push(r); - } - } let mut kept = Vec::new(); for a in list { let Some(r) = a.as_ref() else { @@ -307,7 +311,6 @@ pub fn remove_hidden(doc: &mut Document, which: &[Hidden]) -> Result Result = listed_before.difference(&still).copied().collect(); crate::tags::drop_object_refs(doc, &gone)?; let off = hidden_layers(doc); let mut cat = doc.get(root).as_dict().cloned().unwrap_or_default(); diff --git a/crates/redact/src/tags.rs b/crates/redact/src/tags.rs index 16e2e37d14..a4ae540902 100644 --- a/crates/redact/src/tags.rs +++ b/crates/redact/src/tags.rs @@ -274,12 +274,19 @@ pub(crate) fn drop_object_refs(doc: &mut Document, gone: &HashSet) -> Re n += before - keep.len(); doc.update_dict(r, |e| { if array { - e.set(b"K".to_vec(), Object::Array(keep)); - } else if let Some(k) = keep.into_iter().next() { + e.set(b"K".to_vec(), Object::Array(keep.clone())); + } else if let Some(k) = keep.first().cloned() { e.set(b"K".to_vec(), k); } else { e.remove(b"K"); } + // An element left with nothing to tag keeps no text describing what it tagged: + // a link's alternate text often repeats the URL, an annotation's its comment. + if keep.is_empty() { + for key in [&b"Alt"[..], b"ActualText", b"T", b"E"] { + e.remove(key); + } + } })?; } Ok(n) diff --git a/crates/redact/src/tests.rs b/crates/redact/src/tests.rs index f9e08d35d0..c3c4f89223 100644 --- a/crates/redact/src/tests.rs +++ b/crates/redact/src/tests.rs @@ -653,7 +653,8 @@ fn removed_links_and_comments_leave_the_structure_tree_and_the_saved_file() { b"<< /Type /StructTreeRoot /K 9 0 R /ParentTree << /Nums [0 [10 0 R] 1 11 0 R 2 12 0 R] >> /ParentTreeNextKey 3 >>".to_vec(), b"<< /Type /StructElem /S /Document /P 8 0 R /K [10 0 R 11 0 R 12 0 R] >>".to_vec(), b"<< /Type /StructElem /S /P /P 9 0 R /Pg 3 0 R /K 0 >>".to_vec(), - b"<< /Type /StructElem /S /Link /P 9 0 R /Pg 3 0 R /K [<< /Type /OBJR /Obj 6 0 R /Pg 3 0 R >>] >>".to_vec(), + // the link element's alternate text repeats the URL + b"<< /Type /StructElem /S /Link /P 9 0 R /Pg 3 0 R /Alt (secret.example link) /K [<< /Type /OBJR /Obj 6 0 R /Pg 3 0 R >>] >>".to_vec(), b"<< /Type /StructElem /S /Annot /P 9 0 R /Pg 3 0 R /K 14 0 R >>".to_vec(), widths(), b"<< /Type /OBJR /Obj 7 0 R /Pg 3 0 R >>".to_vec(), @@ -661,8 +662,10 @@ fn removed_links_and_comments_leave_the_structure_tree_and_the_saved_file() { let mut doc = pdf(objs); let done = remove_hidden(&mut doc, &[Hidden::LinksActionsScripts, Hidden::Comments]).unwrap(); assert!(done.contains(&(Hidden::LinksActionsScripts, 1)) && done.contains(&(Hidden::Comments, 1)), "{done:?}"); - let bytes = write_full(&doc, &SaveOptions::default()).unwrap(); + // Without object streams, whose compression would hide the strings from the search. + let bytes = write_full(&doc, &SaveOptions { object_streams: false, ..SaveOptions::default() }).unwrap(); let has = |needle: &[u8]| bytes.windows(needle.len()).any(|w| w == needle); + assert!(has(b"(See the site)"), "the search sees the saved objects"); assert!(!has(b"secret.example"), "the removed link's URL survived the save"); assert!(!has(b"COMMENTSECRETNOTE"), "the removed comment's text survived the save"); // The tagged paragraph and its content stay; the elements lose only the references. @@ -682,6 +685,53 @@ fn removed_links_and_comments_leave_the_structure_tree_and_the_saved_file() { assert!(root.contains(b"StructTreeRoot"), "the structure tree itself stays"); } +/// #2532 review: a tagged form's widgets, flattened when form fields are removed, leave the +/// structure tree too (their field value with them), and a pop-up listed on another page than +/// its removed comment goes with it. +#[test] +fn flattened_widgets_and_far_pop_ups_leave_the_structure_tree() { + use crate::sanitize::{Hidden, remove_hidden}; + let objs: Vec> = vec![ + // 1 catalog with a form and a structure tree + b"<< /Type /Catalog /Pages 2 0 R /AcroForm << /Fields [6 0 R] >> /StructTreeRoot 8 0 R >>".to_vec(), + b"<< /Type /Pages /Kids [3 0 R 13 0 R] /Count 2 >>".to_vec(), + // 3 page 1: a text field's widget and a comment + b"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 300 300] /Contents 4 0 R /Resources << /Font << /F1 5 0 R >> >> /Annots [6 0 R 7 0 R] /StructParents 0 >>".to_vec(), + stream("", b"BT /F1 10 Tf 10 230 Td (Name:) Tj ET"), + FONT.replace("95 0 R", "12 0 R").into_bytes(), + // 6 the field and its widget, with an appearance + b"<< /Type /Annot /Subtype /Widget /FT /Tx /T (name) /V (FIELDSECRETVALUE) /Rect [50 225 200 240] /StructParent 1 /AP << /N 11 0 R >> >>".to_vec(), + // 7 comment, its pop-up (15) listed on page 2 + b"<< /Type /Annot /Subtype /Text /Rect [200 200 220 220] /Contents (note) /Popup 15 0 R >>".to_vec(), + // 8 struct tree root, 9 document, 10 form element tagging the widget + b"<< /Type /StructTreeRoot /K 9 0 R /ParentTree << /Nums [1 10 0 R] >> /ParentTreeNextKey 2 >>".to_vec(), + b"<< /Type /StructElem /S /Document /P 8 0 R /K [10 0 R] >>".to_vec(), + b"<< /Type /StructElem /S /Form /P 9 0 R /Pg 3 0 R /K [<< /Type /OBJR /Obj 6 0 R /Pg 3 0 R >>] >>".to_vec(), + stream("/Type /XObject /Subtype /Form /BBox [0 0 150 15] /Resources << /Font << /F1 5 0 R >> >>", b"BT /F1 10 Tf 2 3 Td (FIELDSECRETVALUE) Tj ET"), + widths(), + // 13 page 2, listing the pop-up of page 1's comment + b"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 300 300] /Annots [15 0 R 16 0 R] >>".to_vec(), + b"<< >>".to_vec(), + b"<< /Type /Annot /Subtype /Popup /Rect [0 0 100 50] /Parent 7 0 R >>".to_vec(), + // 16 an unrelated link on page 2 stays + b"<< /Type /Annot /Subtype /Link /Rect [0 0 10 10] >>".to_vec(), + ]; + let mut doc = pdf(objs); + remove_hidden(&mut doc, &[Hidden::FormFields, Hidden::Comments]).unwrap(); + let page2 = pdfcraft_model::pages(&doc)[1].dict.clone(); + let left: Vec = page2.get(b"Annots").map(|a| doc.resolve(a).as_array().map(|a| a.to_vec()).unwrap_or_default()).unwrap_or_default(); + assert_eq!(left, [Object::Ref(ObjRef::new(16, 0))], "the far pop-up went with its comment"); + let form = doc.get(ObjRef::new(10, 0)).as_dict().cloned().unwrap(); + let kids = form.get(b"K").map(|k| doc.resolve(k).as_array().map(Vec::len).unwrap_or(1)); + assert!(kids.unwrap_or(0) == 0, "the form element no longer references the flattened widget"); + let bytes = write_full(&doc, &SaveOptions { object_streams: false, ..SaveOptions::default() }).unwrap(); + let saved = Document::open(Arc::new(bytes)).unwrap(); + let field_left = saved.object_numbers().into_iter().any(|n| { + saved.get(ObjRef::new(n, saved.generation(n))).as_dict().is_some_and(|d| d.name(b"Subtype") == Some(b"Widget") || d.contains(b"FT")) + }); + assert!(!field_left, "the flattened field didn't survive the save through the structure tree"); +} + /// Issue #1325: removing comments keeps the annotations that are not comments: the multimedia /// and print-production ones (Watermark, PrinterMark, TrapNet, Screen, Movie, RichMedia, 3D). #[test] From f5a2376c864e137044e77f6c55ad8c88ebb6adb7 Mon Sep 17 00:00:00 2001 From: mtlcl410 <2368091+mtlcl410@users.noreply.github.com> Date: Sat, 17 Oct 2026 02:02:21 +0800 Subject: [PATCH 062/105] fix(redact): a structure tree too large to walk goes whole (#2447) Review follow-up on #2532: drop_object_refs stopped visiting at 2,000,000 references and still reported success, so a reference beyond that could keep a removed annotation reachable. A tree the walk can't finish is now removed whole, with /MarkInfo: fail-closed, at the cost of the tags. The limit is a parameter of drop_object_refs_within so the test can reach it. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01RUNQSPVXx3dDBabRZHAnjz --- crates/redact/src/tags.rs | 21 ++++++++++++++++++++- crates/redact/src/tests.rs | 33 +++++++++++++++++++++++++++++++++ 2 files changed, 53 insertions(+), 1 deletion(-) diff --git a/crates/redact/src/tags.rs b/crates/redact/src/tags.rs index a4ae540902..b70cf23ab9 100644 --- a/crates/redact/src/tags.rs +++ b/crates/redact/src/tags.rs @@ -215,6 +215,17 @@ fn refers_to(doc: &Document, o: &Object, gone: &HashSet) -> bool { /// 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 { + 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 { if gone.is_empty() { return Ok(0); } @@ -232,9 +243,17 @@ pub(crate) fn drop_object_refs(doc: &mut Document, gone: &HashSet) -> Re match k { Object::Array(a) => todo.extend(a), Object::Ref(r) => { - if !seen.insert(r) || seen.len() > 2_000_000 { + 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); + } let object = doc.get(r); if let Object::Array(a) = &*object { todo.extend(a.iter().cloned()); diff --git a/crates/redact/src/tests.rs b/crates/redact/src/tests.rs index c3c4f89223..ed076d38e7 100644 --- a/crates/redact/src/tests.rs +++ b/crates/redact/src/tests.rs @@ -732,6 +732,39 @@ fn flattened_widgets_and_far_pop_ups_leave_the_structure_tree() { assert!(!field_left, "the flattened field didn't survive the save through 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. +#[test] +fn a_structure_tree_too_large_to_walk_is_removed_whole() { + 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 + b"<< /Type /Annot /Subtype /Link /Rect [0 0 10 10] /A << /S /URI /URI (https://secret.example) >> >>".to_vec(), + // 5 root, 6 document, 7 section, 8 link element with the OBJR + b"<< /Type /StructTreeRoot /K 6 0 R >>".to_vec(), + b"<< /Type /StructElem /S /Document /P 5 0 R /K [7 0 R] >>".to_vec(), + b"<< /Type /StructElem /S /Sect /P 6 0 R /K [8 0 R] >>".to_vec(), + b"<< /Type /StructElem /S /Link /P 7 0 R /K [<< /Type /OBJR /Obj 4 0 R >>] >>".to_vec(), + ]; + let gone: std::collections::HashSet = [ObjRef::new(4, 0)].into(); + let catalog = |doc: &Document| doc.get(doc.root().unwrap()).as_dict().cloned().unwrap(); + + let mut doc = pdf(objs.clone()); + assert_eq!(crate::tags::drop_object_refs_within(&mut doc, &gone, 100).unwrap(), 1); + 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")); + 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"); +} + /// Issue #1325: removing comments keeps the annotations that are not comments: the multimedia /// and print-production ones (Watermark, PrinterMark, TrapNet, Screen, Movie, RichMedia, 3D). #[test] From c5708e1cc66b05e3d78e582cd121fc5470286045 Mon Sep 17 00:00:00 2001 From: hcchung1 <131962610+hcchung1@users.noreply.github.com> Date: Sat, 17 Oct 2026 11:42:11 +0800 Subject: [PATCH 063/105] fix(xfa): a page area's occur min gets its pages after the body (#2574) A page area with that the body never reached was never laid out, so the public ISCIII form lost the page that holds its finishing instructions and the TERMINAR MEMORIA button once the repeated copies over the body pages were removed. PageArea records occur min (0 without an ). When the body has been laid out, an area used fewer times than its minimum gets the pages it still needs, in template order, after the body's pages. The form is 7 pages with the once-only page last, which is what Acrobat Pro shows. --- ROADMAP.md | 2 +- crates/xfa/src/layout.rs | 23 +++++++++++++++++++++-- crates/xfa/src/model.rs | 3 +++ crates/xfa/src/parse.rs | 8 +++++--- crates/xfa/src/tests.rs | 36 +++++++++++++++++++++++++++++++++--- parity/acrobat-features.toml | 2 +- 6 files changed, 64 insertions(+), 10 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 7b2d27f085..4551f278d9 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -159,7 +159,7 @@ Ranked; detail and the full milestone table in [docs/roadmap.md](docs/roadmap.md ## Progress log - **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. Synthetic regression test; the page area's `occur min` (a once-only page of its own) is still not laid out, recorded in `form.xfa-dynamic`. Estimates unchanged. +- **2026-10-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; how `relevant="-print"` treats it when printing is still unchecked. Synthetic regression tests. Estimates unchanged. - **2026-10-13 (community, M3, #1569 review):** Pixel-only Wayland wheel, wheel-tilt and continuous input use standalone moved events, so Ctrl zoom does not remain latched after the modifier is released (#991); vendored winit patch (13), with a policy test. Estimates unchanged. - **2026-10-12 (M6):** XFDF field import and data-file extraction join all text nodes in a value, preserving text after XML comments and processing instructions. Synthetic regressions cover Unicode, CDATA, empty values and PDF save/reopen. Estimates unchanged. - **2026-10-12 (M7, picture right-click menu):** The Edit text & images menu of a picture gains Cut Image, Redact Image (a redaction mark over the picture, applied from Redact a PDF), Create Link… and Recognize Text… (for that page), in all 21 catalogs. The menu was drawn only while the pointer was over the picture, so its items outside the picture (most of a logo's menu, and Delete below a page) could not be clicked; an open menu now stays drawn. Still missing from Acrobat's menu: Arrange (bring to front, send backward), Remove crop, Draw freehand and Edit image using… (opening an external editor). diff --git a/crates/xfa/src/layout.rs b/crates/xfa/src/layout.rs index a491d3f493..62d7beedf1 100644 --- a/crates/xfa/src/layout.rs +++ b/crates/xfa/src/layout.rs @@ -232,6 +232,7 @@ pub fn layout(tpl: &Template, data: Option<&DataNode>) -> Result content: vec![Rect::new(36.0, 36.0, 540.0, 720.0)], items: Vec::new(), occur_max: None, + occur_min: 0, }], }; let mut values = HashMap::new(); @@ -261,6 +262,7 @@ pub fn layout(tpl: &Template, data: Option<&DataNode>) -> Result if tpl.root.common.presence.occupies() { l.flow_subform(&root_node, None, &ctx, 0, 0)?; } + l.satisfy_page_area_minimums()?; l.check_measures()?; let total = l.pages.len(); let count = total.to_string(); @@ -497,11 +499,28 @@ impl Layouter<'_> { // ───────────────────────────────────────────────────────────────────────── pages fn new_page(&mut self) -> Result<(), XfaError> { + let idx = + (0..self.areas.len()).find(|&i| self.areas[i].occur_max.is_none_or(|m| self.uses[i] < m)).unwrap_or(self.areas.len().saturating_sub(1)); + self.new_page_of(idx) + } + + /// Once the body has been laid out, give every page area that has been used fewer times than + /// its `occur min` the pages it still needs, in template order, after the body's pages. + fn satisfy_page_area_minimums(&mut self) -> Result<(), XfaError> { + let areas = Rc::clone(&self.areas); + for (idx, area) in areas.iter().enumerate() { + while self.uses.get(idx).is_some_and(|u| *u < area.occur_min) { + self.new_page_of(idx)?; + } + } + Ok(()) + } + + /// Start a page from page area `idx`. + fn new_page_of(&mut self, idx: usize) -> Result<(), XfaError> { if self.pages.len() >= MAX_PAGES { return Err(XfaError::TooLarge(format!("the form lays out to more than {MAX_PAGES} pages"))); } - let idx = - (0..self.areas.len()).find(|&i| self.areas[i].occur_max.is_none_or(|m| self.uses[i] < m)).unwrap_or(self.areas.len().saturating_sub(1)); let areas = Rc::clone(&self.areas); let Some(area) = areas.get(idx) else { return Err(XfaError::Malformed("the template has no page area".into())); diff --git a/crates/xfa/src/model.rs b/crates/xfa/src/model.rs index f4ef3fabce..572675f958 100644 --- a/crates/xfa/src/model.rs +++ b/crates/xfa/src/model.rs @@ -680,6 +680,9 @@ pub struct PageArea { pub items: Vec, /// How many pages may use this master (`None`: unbounded). pub occur_max: Option, + /// How many pages must use this master: a layout that ends with fewer adds pages for it. + /// Zero without an ``. + pub occur_min: usize, } #[derive(Clone, Debug, Default, PartialEq)] diff --git a/crates/xfa/src/parse.rs b/crates/xfa/src/parse.rs index 3384b19a67..4582d59b22 100644 --- a/crates/xfa/src/parse.rs +++ b/crates/xfa/src/parse.rs @@ -529,9 +529,11 @@ fn parse_page_set(n: XmlNode, warnings: &mut Vec, depth: usize) -> PageS content.push(Rect::new(0.0, 0.0, width, height)); } let items = pa.children().filter(|c| c.is_element()).filter_map(|c| parse_node(c, warnings, depth + 1)).collect(); - // A page area with no `` has no limit (a subform's default of one would exhaust it). - let occur_max = child(pa, "occur").and_then(|o| parse_occur(Some(o)).max); - areas.push(PageArea { name: pa.attribute("name").map(str::to_string), width, height, content, items, occur_max }); + // A page area with no `` has no limit and no minimum (a subform's default of one + // would exhaust it, and demand a page for every master). + let occur = child(pa, "occur").map(|o| parse_occur(Some(o))); + let (occur_min, occur_max) = occur.map_or((0, None), |o| (o.min, o.max)); + areas.push(PageArea { name: pa.attribute("name").map(str::to_string), width, height, content, items, occur_max, occur_min }); } // Nested page sets: take their areas too. for ps in children(n, "pageSet") { diff --git a/crates/xfa/src/tests.rs b/crates/xfa/src/tests.rs index c661929b12..03eb2c67ad 100644 --- a/crates/xfa/src/tests.rs +++ b/crates/xfa/src/tests.rs @@ -1225,9 +1225,39 @@ fn page_area_without_occur_serves_every_body_page() { ); let form = layout_xml(&xdp).unwrap(); let soms: Vec> = form.pages.iter().map(|p| widgets(p).iter().map(|w| w.som.clone()).collect()).collect(); - assert_eq!(form.pages.len(), 4, "{soms:?}"); - let buttons = soms.iter().flatten().filter(|s| s.contains("PrintButton")).count(); - assert_eq!(buttons, 0, "the single-use page area is not drawn over body pages: {soms:?}"); + let has_button = |page: &Vec| page.iter().any(|s| s.contains("PrintButton")); + // Four body pages, then the once-only page: its button is on that page alone, not over the body. + assert_eq!(form.pages.len(), 5, "{soms:?}"); + assert!(soms[..4].iter().all(|p| !has_button(p)), "the single-use page area is not drawn over body pages: {soms:?}"); + assert_eq!(soms[4].iter().filter(|s| s.contains("PrintButton")).count(), 1, "the required page is laid out once: {soms:?}"); +} + +/// A page area's `occur min` is a page it must have even when the body never reaches it, and a +/// body that already used it does not get another. An area with no `` needs none. +#[test] +fn page_area_minimum_adds_pages_after_the_body_only_when_unused() { + let area = |name: &str, occur: &str| { + format!( + r#"{occur}"# + ) + }; + let pages = |areas: String| { + let xdp = format!( + r##" +"## + ); + layout_xml(&xdp).unwrap().pages.len() + }; + // The body is one page; the required second master adds one, a third with min 2 adds two. + assert_eq!(pages(area("A", "") + &area("B", r#""#)), 2); + assert_eq!(pages(area("A", "") + &area("B", r#""#) + &area("C", r#""#)), 4); + // An area the body already used (it is first, so page 1 is its own) adds nothing. + assert_eq!(pages(area("A", r#""#)), 1); + assert_eq!(pages(area("A", r#""#) + &area("B", "")), 1); + // No `` and `min="0"` need no page. + assert_eq!(pages(area("A", "") + &area("B", r#""#)), 1); } /// Areas are named in SOM expressions but are not data scopes; unnamed subforms and areas have diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index bb5006ea7d..f6e0e8fdae 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -5221,7 +5221,7 @@ status = "partial" commands = ["file.open"] tools = ["doc_open", "doc_info", "form_fields", "form_fill", "js_run"] evidence = ["crates/xfa/src/tests.rs::layout_places_captions_fields_and_sizes_containers_to_their_content", "crates/xfa/src/tests.rs::tables_break_across_pages_and_repeat_their_header_row", "crates/xfa/src/tests.rs::rendering_into_the_pdf_replaces_the_placeholder_page_and_adds_fields", "crates/engine/src/tests.rs::dynamic_xfa_forms_are_laid_out_on_open_filled_and_saved_incrementally", "crates/automation/tests/automation.rs::dynamic_xfa_forms_open_render_fill_and_save_through_tools", "crates/ui-egui/tests/ui_group/xfa.rs::a_dynamic_xfa_form_is_laid_out_and_filled", "crates/xfa/src/tests.rs::nested_width_less_subforms_lay_out_in_polynomial_time", "crates/js/src/tests.rs::instance_managers_add_remove_and_count", "crates/xfa/src/tests.rs::the_form_tree_carries_values_presence_instances_and_the_events", "crates/engine/src/tests.rs::xfa_scripts_initialize_calculate_validate_toggle_and_add_rows", "crates/automation/tests/automation.rs::xfa_scripts_run_for_buttons_and_field_changes_through_tools", "crates/ui-egui/tests/ui_group/xfa.rs::xfa_buttons_run_their_scripts_in_the_app", "crates/engine/src/tests.rs::hostile_xfa_open_scripts_are_merged_capped_kept_from_printing_and_noted", "crates/engine/src/tests.rs::runaway_xfa_calculations_at_open_end_quickly_with_a_report", "crates/ui-egui/tests/ui_group/xfa.rs::a_form_whose_scripts_were_turned_off_at_open_says_so", "crates/ui-egui/tests/ui_group/xfa.rs::scripts_turned_off_by_a_click_bring_a_closed_notice_bar_back", "crates/automation/tests/automation.rs::doc_info_says_when_a_runaway_script_turned_the_forms_scripts_off", "crates/xfa/src/tests.rs::the_live_form_of_nested_repeating_subforms_stops_at_its_node_budget", "crates/xfa/src/tests.rs::data_ops_of_one_event_are_one_datasets_rewrite", "crates/xfa/src/tests.rs::choice_lists_signatures_passwords_and_pictures_become_fields_and_images", "crates/xfa/src/tests.rs::choice_values_come_from_the_data_as_saved_values_and_several_for_lists", "crates/xfa/src/tests.rs::pictures_are_sized_from_their_headers_and_hostile_ones_are_refused_unallocated", "crates/engine/src/tests.rs::xfa_choice_lists_signature_and_picture_fields_open_fill_script_and_save", "crates/xfa/src/tests.rs::repeating_subforms_inside_positioned_content_get_their_instances", "crates/engine/src/tests.rs::xfa_rows_in_areas_are_laid_out_read_calculated_and_written_where_adobe_binds_them", "crates/xfa/src/tests.rs::page_area_without_occur_serves_every_body_page"] -notes = "laid out from the template on open by the xfa crate (positioned, tb, lr-tb, table/row, page masters (a page area with no occur repeats without limit, one with occur max=1 is used once), breaks, repeated header rows, captions, rich text, JPEG/PNG/GIF images) into real pages and AcroForm fields (text, password, date with AFDate formats, check boxes, radio groups, drop-downs and list boxes with shown/saved item pairs and multi-select, signature fields, image fields showing their picture (pictures decoded once per document, at most 20 million pixels, and reused across re-layouts), buttons with reset/print/save/URL), filled from the datasets packet (including rows another viewer added), saved as an incremental revision with the values merged into the datasets so Adobe's viewers show them; doc_info reports xfa_layout. XFA JavaScript and FormCalc (form.xfa-formcalc) run in the js crate's XFA object model (xfa.form, SOM resolution, rawValue, presence, access, instance managers, xfa.host, xfa.layout, xfa.event): initialize and calculate on open, exit, validate and calculate after a field changes, click for buttons (through js_run and the app), with rows added and removed, subforms shown and hidden, and the form laid out again, all as one undo step and kept in the file. All the scripts of one event run on one thread, each in a fresh context on the form as the engine holds it then (nothing shared between scripts; the live form kept up to date in place for values, rebuilt only when the structure changed), so hundreds of calculations run within the open budget. Hostile scripts are bounded per event (merged and capped effects, messages and console lines, lower loop limits and a time budget on open and calculate, one datasets rewrite per event, print/save/links/focus from clicks only, a runaway script abandoned and the form's scripts turned off, which the app says in a toast and on the notice bar, with a button to the JavaScript console, and doc_info reports as xfa_scripts_off) and what open-time scripts changed is noted. Missing: a page area's occur min (a once-only page area such as a closing instructions page is not laid out as a page of its own, #2574); loops inside nested calls are only stopped by abandoning their thread, and script memory is not capped; the change event per keystroke; xfa.template, connections and data descriptions; a hidden instance of a repeating subform hides them all; barcodes are drawn as their text, not bars; keep rules; metrics are approximate and Acrobat fidelity is eyeballed on real forms, not measured (#60)" +notes = "laid out from the template on open by the xfa crate (positioned, tb, lr-tb, table/row, page masters (a page area with no occur repeats without limit, one with occur max=1 is used once, one with occur min=1 the body never reached gets a page after the body, as Acrobat Pro shows the public ISCIII form), breaks, repeated header rows, captions, rich text, JPEG/PNG/GIF images) into real pages and AcroForm fields (text, password, date with AFDate formats, check boxes, radio groups, drop-downs and list boxes with shown/saved item pairs and multi-select, signature fields, image fields showing their picture (pictures decoded once per document, at most 20 million pixels, and reused across re-layouts), buttons with reset/print/save/URL), filled from the datasets packet (including rows another viewer added), saved as an incremental revision with the values merged into the datasets so Adobe's viewers show them; doc_info reports xfa_layout. XFA JavaScript and FormCalc (form.xfa-formcalc) run in the js crate's XFA object model (xfa.form, SOM resolution, rawValue, presence, access, instance managers, xfa.host, xfa.layout, xfa.event): initialize and calculate on open, exit, validate and calculate after a field changes, click for buttons (through js_run and the app), with rows added and removed, subforms shown and hidden, and the form laid out again, all as one undo step and kept in the file. All the scripts of one event run on one thread, each in a fresh context on the form as the engine holds it then (nothing shared between scripts; the live form kept up to date in place for values, rebuilt only when the structure changed), so hundreds of calculations run within the open budget. Hostile scripts are bounded per event (merged and capped effects, messages and console lines, lower loop limits and a time budget on open and calculate, one datasets rewrite per event, print/save/links/focus from clicks only, a runaway script abandoned and the form's scripts turned off, which the app says in a toast and on the notice bar, with a button to the JavaScript console, and doc_info reports as xfa_scripts_off) and what open-time scripts changed is noted. Missing: the master's relevant=\"-print\" (how Acrobat prints a page area marked not relevant for print is unchecked, #2574); loops inside nested calls are only stopped by abandoning their thread, and script memory is not capped; the change event per keystroke; xfa.template, connections and data descriptions; a hidden instance of a repeating subform hides them all; barcodes are drawn as their text, not bars; keep rules; metrics are approximate and Acrobat fidelity is eyeballed on real forms, not measured (#60)" [[feature]] id = "form.xfa-formcalc" From db1102185a025e7a201b3d119c37ad4dc2e9908d Mon Sep 17 00:00:00 2001 From: hcchung1 <131962610+hcchung1@users.noreply.github.com> Date: Sat, 17 Oct 2026 16:01:39 +0800 Subject: [PATCH 064/105] docs: record how Acrobat prints a page area that is not relevant for print (#2574) Observed in Acrobat Pro on the public ISCIII form: the once-only page keeps its place when printing (7 pages with nothing typed) but is printed blank. PdfCraft draws its content, and has no relevant attribute or print-off layer yet; the note says so. --- ROADMAP.md | 2 +- parity/acrobat-features.toml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 4551f278d9..44b2ccf3ea 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -159,7 +159,7 @@ Ranked; detail and the full milestone table in [docs/roadmap.md](docs/roadmap.md ## Progress log - **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; how `relevant="-print"` treats it when printing is still unchecked. 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 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-13 (community, M3, #1569 review):** Pixel-only Wayland wheel, wheel-tilt and continuous input use standalone moved events, so Ctrl zoom does not remain latched after the modifier is released (#991); vendored winit patch (13), with a policy test. Estimates unchanged. - **2026-10-12 (M6):** XFDF field import and data-file extraction join all text nodes in a value, preserving text after XML comments and processing instructions. Synthetic regressions cover Unicode, CDATA, empty values and PDF save/reopen. Estimates unchanged. - **2026-10-12 (M7, picture right-click menu):** The Edit text & images menu of a picture gains Cut Image, Redact Image (a redaction mark over the picture, applied from Redact a PDF), Create Link… and Recognize Text… (for that page), in all 21 catalogs. The menu was drawn only while the pointer was over the picture, so its items outside the picture (most of a logo's menu, and Delete below a page) could not be clicked; an open menu now stays drawn. Still missing from Acrobat's menu: Arrange (bring to front, send backward), Remove crop, Draw freehand and Edit image using… (opening an external editor). diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index f6e0e8fdae..0c0a238e2d 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -5221,7 +5221,7 @@ status = "partial" commands = ["file.open"] tools = ["doc_open", "doc_info", "form_fields", "form_fill", "js_run"] evidence = ["crates/xfa/src/tests.rs::layout_places_captions_fields_and_sizes_containers_to_their_content", "crates/xfa/src/tests.rs::tables_break_across_pages_and_repeat_their_header_row", "crates/xfa/src/tests.rs::rendering_into_the_pdf_replaces_the_placeholder_page_and_adds_fields", "crates/engine/src/tests.rs::dynamic_xfa_forms_are_laid_out_on_open_filled_and_saved_incrementally", "crates/automation/tests/automation.rs::dynamic_xfa_forms_open_render_fill_and_save_through_tools", "crates/ui-egui/tests/ui_group/xfa.rs::a_dynamic_xfa_form_is_laid_out_and_filled", "crates/xfa/src/tests.rs::nested_width_less_subforms_lay_out_in_polynomial_time", "crates/js/src/tests.rs::instance_managers_add_remove_and_count", "crates/xfa/src/tests.rs::the_form_tree_carries_values_presence_instances_and_the_events", "crates/engine/src/tests.rs::xfa_scripts_initialize_calculate_validate_toggle_and_add_rows", "crates/automation/tests/automation.rs::xfa_scripts_run_for_buttons_and_field_changes_through_tools", "crates/ui-egui/tests/ui_group/xfa.rs::xfa_buttons_run_their_scripts_in_the_app", "crates/engine/src/tests.rs::hostile_xfa_open_scripts_are_merged_capped_kept_from_printing_and_noted", "crates/engine/src/tests.rs::runaway_xfa_calculations_at_open_end_quickly_with_a_report", "crates/ui-egui/tests/ui_group/xfa.rs::a_form_whose_scripts_were_turned_off_at_open_says_so", "crates/ui-egui/tests/ui_group/xfa.rs::scripts_turned_off_by_a_click_bring_a_closed_notice_bar_back", "crates/automation/tests/automation.rs::doc_info_says_when_a_runaway_script_turned_the_forms_scripts_off", "crates/xfa/src/tests.rs::the_live_form_of_nested_repeating_subforms_stops_at_its_node_budget", "crates/xfa/src/tests.rs::data_ops_of_one_event_are_one_datasets_rewrite", "crates/xfa/src/tests.rs::choice_lists_signatures_passwords_and_pictures_become_fields_and_images", "crates/xfa/src/tests.rs::choice_values_come_from_the_data_as_saved_values_and_several_for_lists", "crates/xfa/src/tests.rs::pictures_are_sized_from_their_headers_and_hostile_ones_are_refused_unallocated", "crates/engine/src/tests.rs::xfa_choice_lists_signature_and_picture_fields_open_fill_script_and_save", "crates/xfa/src/tests.rs::repeating_subforms_inside_positioned_content_get_their_instances", "crates/engine/src/tests.rs::xfa_rows_in_areas_are_laid_out_read_calculated_and_written_where_adobe_binds_them", "crates/xfa/src/tests.rs::page_area_without_occur_serves_every_body_page"] -notes = "laid out from the template on open by the xfa crate (positioned, tb, lr-tb, table/row, page masters (a page area with no occur repeats without limit, one with occur max=1 is used once, one with occur min=1 the body never reached gets a page after the body, as Acrobat Pro shows the public ISCIII form), breaks, repeated header rows, captions, rich text, JPEG/PNG/GIF images) into real pages and AcroForm fields (text, password, date with AFDate formats, check boxes, radio groups, drop-downs and list boxes with shown/saved item pairs and multi-select, signature fields, image fields showing their picture (pictures decoded once per document, at most 20 million pixels, and reused across re-layouts), buttons with reset/print/save/URL), filled from the datasets packet (including rows another viewer added), saved as an incremental revision with the values merged into the datasets so Adobe's viewers show them; doc_info reports xfa_layout. XFA JavaScript and FormCalc (form.xfa-formcalc) run in the js crate's XFA object model (xfa.form, SOM resolution, rawValue, presence, access, instance managers, xfa.host, xfa.layout, xfa.event): initialize and calculate on open, exit, validate and calculate after a field changes, click for buttons (through js_run and the app), with rows added and removed, subforms shown and hidden, and the form laid out again, all as one undo step and kept in the file. All the scripts of one event run on one thread, each in a fresh context on the form as the engine holds it then (nothing shared between scripts; the live form kept up to date in place for values, rebuilt only when the structure changed), so hundreds of calculations run within the open budget. Hostile scripts are bounded per event (merged and capped effects, messages and console lines, lower loop limits and a time budget on open and calculate, one datasets rewrite per event, print/save/links/focus from clicks only, a runaway script abandoned and the form's scripts turned off, which the app says in a toast and on the notice bar, with a button to the JavaScript console, and doc_info reports as xfa_scripts_off) and what open-time scripts changed is noted. Missing: the master's relevant=\"-print\" (how Acrobat prints a page area marked not relevant for print is unchecked, #2574); loops inside nested calls are only stopped by abandoning their thread, and script memory is not capped; the change event per keystroke; xfa.template, connections and data descriptions; a hidden instance of a repeating subform hides them all; barcodes are drawn as their text, not bars; keep rules; metrics are approximate and Acrobat fidelity is eyeballed on real forms, not measured (#60)" +notes = "laid out from the template on open by the xfa crate (positioned, tb, lr-tb, table/row, page masters (a page area with no occur repeats without limit, one with occur max=1 is used once, one with occur min=1 the body never reached gets a page after the body, as Acrobat Pro shows the public ISCIII form), breaks, repeated header rows, captions, rich text, JPEG/PNG/GIF images) into real pages and AcroForm fields (text, password, date with AFDate formats, check boxes, radio groups, drop-downs and list boxes with shown/saved item pairs and multi-select, signature fields, image fields showing their picture (pictures decoded once per document, at most 20 million pixels, and reused across re-layouts), buttons with reset/print/save/URL), filled from the datasets packet (including rows another viewer added), saved as an incremental revision with the values merged into the datasets so Adobe's viewers show them; doc_info reports xfa_layout. XFA JavaScript and FormCalc (form.xfa-formcalc) run in the js crate's XFA object model (xfa.form, SOM resolution, rawValue, presence, access, instance managers, xfa.host, xfa.layout, xfa.event): initialize and calculate on open, exit, validate and calculate after a field changes, click for buttons (through js_run and the app), with rows added and removed, subforms shown and hidden, and the form laid out again, all as one undo step and kept in the file. All the scripts of one event run on one thread, each in a fresh context on the form as the engine holds it then (nothing shared between scripts; the live form kept up to date in place for values, rebuilt only when the structure changed), so hundreds of calculations run within the open budget. Hostile scripts are bounded per event (merged and capped effects, messages and console lines, lower loop limits and a time budget on open and calculate, one datasets rewrite per event, print/save/links/focus from clicks only, a runaway script abandoned and the form's scripts turned off, which the app says in a toast and on the notice bar, with a button to the JavaScript console, and doc_info reports as xfa_scripts_off) and what open-time scripts changed is noted. Missing: a page area's relevant=\"-print\" (Acrobat still prints the page but leaves it blank; PdfCraft prints its content, #2574); loops inside nested calls are only stopped by abandoning their thread, and script memory is not capped; the change event per keystroke; xfa.template, connections and data descriptions; a hidden instance of a repeating subform hides them all; barcodes are drawn as their text, not bars; keep rules; metrics are approximate and Acrobat fidelity is eyeballed on real forms, not measured (#60)" [[feature]] id = "form.xfa-formcalc" From 0b3b63b22a16be05b57921223446130c75982dd0 Mon Sep 17 00:00:00 2001 From: aiongg Date: Sat, 17 Oct 2026 12:23:05 +0000 Subject: [PATCH 065/105] Integration: #2717 the header-relative retry keeps its flag after the merge with the new head --- crates/cos/src/document.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/cos/src/document.rs b/crates/cos/src/document.rs index 43bad70b76..f614ee2215 100644 --- a/crates/cos/src/document.rs +++ b/crates/cos/src/document.rs @@ -1668,7 +1668,7 @@ impl Document { log.push(format!("xref at {off} unreadable ({e}); retrying relative to the header")); sect.clear(); odd.clear(); - self.read_section(off as usize + self.header_offset, &mut sect, &mut odd, log)? + (self.read_section(off as usize + self.header_offset, &mut sect, &mut odd, log)?, true) } Err(e) => { // An unreadable older section loses every object only it locates (the page From f8d7eb13523372e87fad13d7a1743da11fb44d61 Mon Sep 17 00:00:00 2001 From: PerchenkoAnton <101743951+PerchAnton@users.noreply.github.com> Date: Sun, 18 Oct 2026 10:11:54 +0300 Subject: [PATCH 066/105] M3: prevent oversized page textures after replacement (#3149) (#3182) --- ROADMAP.md | 3 ++ crates/ui-egui/src/canvas.rs | 95 ++++++++++++++++++++++++++++++++++++ docs/roadmap.md | 4 +- parity/acrobat-features.toml | 4 +- 4 files changed, 103 insertions(+), 3 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index ee84a11110..40a9d8c560 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -2,6 +2,7 @@ **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 (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-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) @@ -161,6 +162,7 @@ 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-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-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-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. @@ -506,6 +508,7 @@ Newest first: the date, what moved, and the new overall estimate. | Date | Change | Summary | |---|---|---| +| 2026-10-18 | minor | M3, #3149: page replacement cancels render demand collected for the previous geometry; all viewer raster upload paths reject empty or oversized textures before cache eviction. Synthetic regressions reproduce the reported 1880×2350 upload at a 2048 limit, retain cached images and unsaved edits, and resume bounded backdrops and tiles. Estimates unchanged. | | 2026-10-15 | minor | M6: Plain AcroForm XML imports retain all direct text around comments and processing instructions, including CDATA and character references. Estimates unchanged. | | 2026-10-13 | minor | M6: XML/XFDF attribute whitespace survives form and comment export/import; estimates unchanged | | 2026-10-14 | minor | M1: raw-deflate fallback preserves decoding-limit errors in strict and tolerant decoding; synthetic filter and COS stream regressions. Estimates unchanged. | diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index b5af17b446..8fd54db90f 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -423,6 +423,8 @@ pub struct DocView { /// This frame's consumers, with visible cells ahead of prefetch. thumb_demand: HashMap, frame_queue: Vec, + /// A page-list edit after layout invalidates this frame's scales and page indexes. + frame_geometry_changed: bool, /// Pages on screen this frame. Their rasters and tiles are always admitted: the viewport /// bounds them, and the byte and count limits apply to prefetch only. frame_visible: HashSet, @@ -716,6 +718,7 @@ impl DocView { thumbs: HashMap::new(), thumb_demand: HashMap::new(), frame_queue: Vec::new(), + frame_geometry_changed: false, frame_visible: HashSet::new(), stale_thumbs: HashSet::new(), stale_tiles: HashSet::new(), @@ -802,6 +805,7 @@ impl DocView { /// adopt the new page geometry, keeping the reader's place where possible. pub fn document_changed(&mut self, info: &DocInfo) { self.invalidate_content(); + self.frame_geometry_changed = true; self.page_count = info.pages.len(); self.page_heights = info.pages.iter().map(|p| p.height).collect(); let last = self.page_count.saturating_sub(1); @@ -1517,6 +1521,7 @@ impl DocView { /// Start collecting demand from every consumer before scheduling or uploading results. pub(crate) fn begin_render_frame(&mut self) { + self.frame_geometry_changed = false; self.pages_panel = None; self.panel_gap = None; self.thumb_demand.clear(); @@ -1732,6 +1737,15 @@ impl DocView { } pub(crate) fn finish_render_frame(&mut self, ctx: &egui::Context, info: &DocInfo, pool: &RenderPool) { + if self.frame_geometry_changed { + // Dialogs and edits can replace pages after the canvas collected demand. Its + // scales and indexes belong to the old pages, not to the new renderer (#3149). + self.begin_render_frame(); + self.last_queue.clear(); + pool.set_queue(Vec::new()); + ctx.request_repaint(); + return; + } let queue = self.prepare_render_queue(info, ctx.pixels_per_point()); if queue != self.last_queue { pool.set_queue(queue.clone()); @@ -1798,6 +1812,12 @@ impl DocView { self.errors.insert(r.request.page, e); return 0; } + // Even current demand may have been sized with obsolete geometry or a different + // device limit. Check before cache eviction, conversion and every upload path. + let max_side = ctx.input(|i| i.max_texture_side); + if r.width == 0 || r.height == 0 || r.width as usize > max_side || r.height as usize > max_side { + return 0; + } // Remember heavy pages: only these get the zoom/pan settle holds, so fast pages // never pay added latency. Stale entries die with the tiles on edits. if r.millis >= SLOW_MILLIS { @@ -5525,6 +5545,81 @@ trailer << /Root 1 0 R >> } } + /// #3149: the replacement dialog commits after the canvas collected this frame's demand. + #[test] + fn replacing_a_page_after_layout_never_uploads_an_oversized_texture() { + let ctx = egui::Context::default(); + ctx.input_mut(|i| i.max_texture_side = 2048); + let mut app = PdfCraftApp::new(); + let original = app.session.create_blank(200.0, 300.0, 1).unwrap(); + let replacement = app.session.create_blank(400.0, 500.0, 1).unwrap(); + app.open_bytes("original.pdf", None, original.as_ref().clone()).unwrap(); + let request = RenderRequest { scale: 4.7, tag: scale_tag(4.7), ..Default::default() }; + app.views[0].frame_queue = vec![request]; + app.views[0].frame_visible.insert(0); + assert!(app.apply_edit(Edit::ReplacePages { pages: vec![0], name: "replacement.pdf".into(), bytes: replacement, src_pages: vec![0] })); + let doc = app.session.get(app.views[0].id).unwrap(); + assert_eq!((doc.info.pages[0].width, doc.info.pages[0].height), (400.0, 500.0)); + let edited_bytes = doc.bytes.clone(); + let pool = RenderPool::new_inline(doc.display.clone(), Default::default()); + app.views[0].finish_render_frame(&ctx, &doc.info, &pool); + assert!(app.views[0].last_queue.is_empty(), "old geometry must not reach the replacement renderer"); + + // The next layout can render a bounded backdrop and sharp tiles at the same zoom. + let backdrop = RenderRequest { scale: 2048.0 / 500.0, tag: scale_tag(2048.0 / 500.0), ..request }; + let tile = RenderRequest { tile: Some(Tile { x: 0, y: 0, w: 1024, h: 1024 }), ..request }; + for _ in 0..2 { + let view = &mut app.views[0]; + view.begin_render_frame(); + view.frame_visible.insert(0); + view.frame_queue = vec![backdrop, tile]; + view.finish_render_frame(&ctx, &doc.info, &pool); + } + let view = &app.views[0]; + assert_eq!(view.pages.get(&0).unwrap().tex.size(), [1639, 2048]); + assert_eq!(view.tiles.get(&(0, tile.tag, 0, 0)).unwrap().size(), [1024, 1024]); + assert!(view.errors.is_empty()); + assert_eq!(doc.bytes, edited_bytes, "rendering preserves the unsaved edit"); + assert!(doc.dirty); + assert_eq!(doc.can_undo(), Some("Replace page")); + } + + #[test] + fn oversized_raster_results_do_not_replace_cached_textures() { + let ctx = egui::Context::default(); + ctx.input_mut(|i| i.max_texture_side = 64); + for request in [ + RenderRequest::default(), + RenderRequest { tag: THUMB_TAG, ..Default::default() }, + RenderRequest { tag: GRID_TAG, ..Default::default() }, + RenderRequest { tag: PRINT_TAG, ..Default::default() }, + RenderRequest { tile: Some(Tile { x: 0, y: 0, w: 64, h: 32 }), ..Default::default() }, + ] { + let mut view = DocView::new(DocId(1), &thumbnail_info(1), ViewDefaults::default()); + view.last_queue = vec![request]; + let texture = |v: &DocView| { + v.pages + .values() + .map(|p| &p.tex) + .chain(v.thumbs.values().map(|p| &p.tex)) + .chain(v.grid_pages.values().map(|(_, t)| t)) + .chain(v.print_pages.values().map(|(_, t)| t)) + .chain(v.tiles.values()) + .map(|t| (t.id(), t.size())) + .collect::>() + }; + assert_eq!(view.receive_result(&ctx, solid_result(request, 64, 32)), 64 * 32 * 4); + let cached = texture(&view); + assert_eq!(cached.len(), 1); + for (w, h) in [(65, 32), (32, 65), (65, 65), (0, 32), (32, 0)] { + assert_eq!(view.receive_result(&ctx, solid_result(request, w, h)), 0, "{request:?}: {w}x{h}"); + assert_eq!(texture(&view), cached, "an invalid result must keep the previous raster"); + } + assert_eq!(view.receive_result(&ctx, solid_result(request, 32, 64)), 32 * 64 * 4); + assert_eq!(texture(&view)[0].1, [32, 64], "valid results still make progress"); + } + } + #[test] fn thumbnails_release_on_scroll_panel_close_and_request_again_after_eviction() { let ctx = egui::Context::default(); diff --git a/docs/roadmap.md b/docs/roadmap.md index 55e898b283..3e772e32e0 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -1,5 +1,6 @@ # PdfCraft roadmap: milestones and what's next +> **Last reviewed:** 2026-10-18 · **Last updated:** 2026-10-18 · **Change:** minor (M3, #3149: discard render demand after page geometry changes and check the GPU texture limit before upload; estimates unchanged) > **Last reviewed:** 2026-10-18 · **Last updated:** 2026-10-18 · **Change:** minor (M8, #1350: named replacement descriptions retired during Sanitize and across shared resources; estimates unchanged) > **Last reviewed:** 2026-10-15 · **Last updated:** 2026-10-15 · **Change:** minor (M6: AcroForm XML text; estimates unchanged) > **Last reviewed:** 2026-10-15 · **Last updated:** 2026-10-15 · **Change:** minor (M6: preserve XML/XFDF attribute whitespace; refreshed upstream; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS) @@ -105,7 +106,7 @@ total matches the parity estimate. | M2 | Model, render, text | 200–350 | 15% | 130–230 | hayro bootstrap renderer (vendored patches); inspector predictor scratch is bounded by available input, and JPX sample rescaling handles 1–32 bits safely. Text extraction reaches word-F1 0.98 against pdftotext. Short same-line redraws split before placement rather than interleaving. Missing: model crate, fonts, DisplayList, renderer independent of hayro. Inspector page-label traversal and output are resource-bounded, with physical-number fallback warnings. Packed TIFF-predictor RGB rows retain later rows in decoding and rendering. Page marks, added content and print output retain small page-unit transform coefficients when serialized. | | M2 | Model, render, text | 200–350 | 15% | 130–230 | hayro bootstrap renderer (vendored patches); inspector predictor scratch is bounded by available input, and JPX sample rescaling handles 1–32 bits safely. Text extraction reaches word-F1 0.98 against pdftotext. Short same-line redraws split before placement rather than interleaving. Missing: model crate, fonts, DisplayList, renderer independent of hayro. Inspector page-label traversal and output are resource-bounded, with physical-number fallback warnings. Packed TIFF-predictor RGB rows retain later rows in decoding and rendering. The renderer keeps its one-point crop-size floor in physical points after UserUnit scaling, with regression coverage for crop origins, rotations and f32 reciprocal rounding. Existing unit policy and nearly-zero fallback are retained. | | M2 | Model, render, text | 200–350 | 15% | 130–230 | hayro bootstrap renderer (vendored patches); inspector predictor scratch is bounded by available input, and JPX sample rescaling handles 1–32 bits safely. Text extraction reaches word-F1 0.98 against pdftotext. Short same-line redraws split before placement rather than interleaving. Missing: model crate, fonts, DisplayList, renderer independent of hayro. Inspector page-label traversal and output are resource-bounded, with physical-number fallback warnings | -| M3 | Viewer app (native + web) | 80–150 | 90% | 10–20 | Acrobat-style shell, find, select, panels, tiles, web build, UI control channel for agents (opt-in). Missing: 60 fps test on a 500-page document, snapshot tests of every panel
Done since: fit visible, document title in the window; Linux middle-button scrolling; shared three-state theme preferences in the toolbar, View menu and control channel; numeric Linux portal events and idle wakeups completing #881, with native appearance inheritance retained (#446); late browser startup downloads preserve the current document and Save target (#171). Selected-text Copy in Edit and the canvas context menu is covered by focused regressions and native clipboard checks (#1225); document copy restrictions apply to the floating action too. #1364 candidate: absolute local GoToR links ask before target access; headless callers explicitly opt in. Native-opening candidate: chosen tools survive preparation and password retries; startup intake waits for pending preparation. Page-label navigation displays logical labels, retains the current page on unchanged submission and refreshes focused same-document replacements; clean file-watcher reload preserves the chosen scrolling mode, preventing a Single Page reader from jumping to page 1 during replacement. Synthetic control regressions cover these behaviors. | +| M3 | Viewer app (native + web) | 80–150 | 90% | 10–20 | Acrobat-style shell, find, select, panels, tiles, web build, UI control channel for agents (opt-in). Missing: 60 fps test on a 500-page document, snapshot tests of every panel
Done since: fit visible, document title in the window; Linux middle-button scrolling; shared three-state theme preferences in the toolbar, View menu and control channel; numeric Linux portal events and idle wakeups completing #881, with native appearance inheritance retained (#446); late browser startup downloads preserve the current document and Save target (#171). Selected-text Copy in Edit and the canvas context menu is covered by focused regressions and native clipboard checks (#1225); document copy restrictions apply to the floating action too. #1364 candidate: absolute local GoToR links ask before target access; headless callers explicitly opt in. Native-opening candidate: chosen tools survive preparation and password retries; startup intake waits for pending preparation. Page-label navigation displays logical labels, retains the current page on unchanged submission and refreshes focused same-document replacements; clean file-watcher reload preserves the chosen scrolling mode, preventing a Single Page reader from jumping to page 1 during replacement. Synthetic control regressions cover these behaviors. #3149: page-list changes discard render demand based on old geometry; actual raster dimensions are checked against the GPU limit before upload. Synthetic crash and continued-rendering regressions cover the replacement path. | | M3 | Viewer app (native + web) | 80–150 | 90% | 10–20 | Acrobat-style shell, find, select, panels, tiles, web build, UI control channel for agents (opt-in). Missing: 60 fps test on a 500-page document, snapshot tests of every panel
Done since: fit visible, document title in the window; Linux middle-button scrolling; shared three-state theme preferences in the toolbar, View menu and control channel; numeric Linux portal events and idle wakeups completing #881, with native appearance inheritance retained (#446); late browser startup downloads preserve the current document and Save target (#171). #1364 candidate: absolute local GoToR links ask before target access; headless callers explicitly opt in. Native-opening candidate: chosen tools survive preparation and password retries; startup intake waits for pending preparation. Later native-open replies stay queued while a modal or picker owns the foreground; regressions cover recipient ownership, completion/cancellation and one-shot wakes. | | M3 | Viewer app (native + web) | 80–150 | 90% | 10–20 | Acrobat-style shell, find, select, panels, tiles, web build, UI control channel for agents (opt-in). Missing: 60 fps test on a 500-page document, snapshot tests of every panel
Done since: fit visible, document title in the window; Linux middle-button scrolling; shared three-state theme preferences in the toolbar, View menu and control channel; numeric Linux portal events and idle wakeups completing #881, with native appearance inheritance retained (#446); late browser startup downloads preserve the current document and Save target (#171). #1364 candidate: absolute local GoToR links ask before target access; headless callers explicitly opt in. Native-opening candidate: chosen tools survive preparation and password retries; startup intake waits for pending preparation. #1980: exact quit modifiers preserve Ctrl+Alt+Q email input; Ctrl+Q/Cmd+Q asks before discarding unsaved documents. | | M3 | Viewer app (native + web) | 80–150 | 90% | 10–20 | Acrobat-style shell, find, select, panels, tiles, web build, UI control channel for agents (opt-in). Missing: 60 fps test on a 500-page document, snapshot tests of every panel
Done since: fit visible, document title in the window; Linux middle-button scrolling; shared three-state theme preferences in the toolbar, View menu and control channel; numeric Linux portal events and idle wakeups completing #881, with native appearance inheritance retained (#446); late browser startup downloads preserve the current document and Save target (#171). #1364 candidate: absolute local GoToR links ask before target access; headless callers explicitly opt in. Native-opening candidate: chosen tools survive preparation and password retries; startup intake waits for pending preparation.
Candidate: coordinate control clicks pair press/release across slow frames; coalesced primary presses retain text multi-click selection. Regression cases cover slow-frame button activation, disabled controls and text multi-click selection; estimates unchanged. | @@ -184,6 +185,7 @@ M2 (renderer), M7 (content editing), M10 (OCR and Office), M11 (Preflight and st | Date | Change | Summary | |---|---|---| +| 2026-10-18 | minor | M3, #3149: page replacement cancels render demand collected for the previous geometry; all viewer raster upload paths reject empty or oversized textures before cache eviction. Synthetic regressions reproduce the reported 1880×2350 upload at a 2048 limit, retain cached images and unsaved edits, and resume bounded backdrops and tiles. Estimates unchanged. | | 2026-10-15 | minor | M6: Plain AcroForm XML imports retain all direct text around comments and processing instructions, including CDATA and character references. Estimates unchanged. | | 2026-10-13 | minor | M6: XML/XFDF attribute whitespace survives form and comment export/import; estimates unchanged | | 2026-10-14 | minor | M1: raw-deflate fallback preserves decoding-limit errors in strict and tolerant decoding; synthetic filter and COS stream regressions. Estimates unchanged. | diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index 157e9e368c..71b5725f9d 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -2812,8 +2812,8 @@ milestone = "M4" status = "shipped" commands = ["page.replace"] tools = ["page_replace"] -evidence = ["crates/organize/src/tests.rs::replace_pages_swaps_content_but_keeps_annotations", "crates/ui-egui/tests/editing.rs::replace_pages_dialog_swaps_page_content", "crates/ui-egui/tests/editing.rs::replace_button_replaces_selected_pages_in_organize", "crates/automation/tests/automation.rs::replacing_pages_through_tools"] -notes = "content, resources and boxes replaced; links, comments, fields and bookmarks kept" +evidence = ["crates/organize/src/tests.rs::replace_pages_swaps_content_but_keeps_annotations", "crates/ui-egui/tests/editing.rs::replace_pages_dialog_swaps_page_content", "crates/ui-egui/tests/editing.rs::replace_button_replaces_selected_pages_in_organize", "crates/automation/tests/automation.rs::replacing_pages_through_tools", "crates/ui-egui/src/canvas.rs::replacing_a_page_after_layout_never_uploads_an_oversized_texture", "crates/ui-egui/src/canvas.rs::oversized_raster_results_do_not_replace_cached_textures"] +notes = "Content, resources and boxes replaced; links, comments, fields and bookmarks kept. Page-list edits discard demand laid out against the previous geometry; the next frame plans bounded backdrops and tiles. Raster uploads reject empty images and dimensions beyond the current GPU texture limit before replacing cached textures (#3149). This covers the viewer's page, tile, thumbnail, organize and print-preview result paths; it does not change the renderer's output resolution or add a global GPU memory budget." [[feature]] id = "organize.duplicate-pages" From 13c86b40e84b2282de77158a9623384b90cc5478 Mon Sep 17 00:00:00 2001 From: ogzhnklvz <114744694+ogzhnklvz@users.noreply.github.com> Date: Sun, 18 Oct 2026 10:12:05 +0300 Subject: [PATCH 067/105] =?UTF-8?q?i18n:=20Turkish=20section=20titles=20an?= =?UTF-8?q?d=20in-sentence=20labels=20keep=20=C4=B0=20and=20=C4=B1=20(#311?= =?UTF-8?q?4)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Section titles are drawn upper case through to_uppercase(), and tool names inside sentences lowercased through to_lowercase(). Rust's case mapping knows no language, so in Turkish "Gelişmiş" became "GELIŞMIŞ" and "İmza" became "i̇mza" (i plus a combining dot). Turkish maps i ↔ İ and ı ↔ I. upper_label() translates and upper-cases a title with the Turkish rule when the label has a Turkish row (an untranslated label is still English and keeps the English rule); in_sentence() lowercases with it. Other languages are unchanged. The test failed before the fix ("GELIŞMIŞ"). --- crates/ui-egui/src/i18n/mod.rs | 47 +++++++++++++++++++++++++++++++++- crates/ui-egui/src/print_ui.rs | 2 +- crates/ui-egui/src/widgets.rs | 2 +- 3 files changed, 48 insertions(+), 3 deletions(-) diff --git a/crates/ui-egui/src/i18n/mod.rs b/crates/ui-egui/src/i18n/mod.rs index 5bb2889c9d..0d097f6cd3 100644 --- a/crates/ui-egui/src/i18n/mod.rs +++ b/crates/ui-egui/src/i18n/mod.rs @@ -411,7 +411,38 @@ pub fn set_current(lang: Lang) { /// `label` as it reads inside a sentence ("add a text box"): lowercased, except in languages /// that capitalise nouns (German), where lowercasing would misspell it. pub fn in_sentence(label: &str) -> std::borrow::Cow<'_, str> { - if current().code() == "de" { std::borrow::Cow::Borrowed(label) } else { std::borrow::Cow::Owned(label.to_lowercase()) } + match current().code() { + "de" => std::borrow::Cow::Borrowed(label), + "tr" => std::borrow::Cow::Owned(label.chars().flat_map(lower_turkish).collect()), + _ => std::borrow::Cow::Owned(label.to_lowercase()), + } +} + +/// The translation of `english` in upper case, for section titles. Rust's case mapping knows no +/// language, and Turkish has a dotted and a dotless i (`i` ↔ `İ`, `ı` ↔ `I`): "Gelişmiş" must +/// become "GELİŞMİŞ", not "GELIŞMIŞ". A label without a Turkish row is still English and keeps +/// the English rule. +pub fn upper_label(english: &str) -> String { + let lang = current(); + let shown = tr(lang, english); + if lang.code() == "tr" && has(lang, english) { shown.chars().flat_map(upper_turkish).collect() } else { shown.to_uppercase() } +} + +fn upper_turkish(c: char) -> std::char::ToUppercase { + match c { + 'i' => 'İ'.to_uppercase(), + 'ı' => 'I'.to_uppercase(), + c => c.to_uppercase(), + } +} + +fn lower_turkish(c: char) -> std::char::ToLowercase { + match c { + // `İ`.to_lowercase() is "i" plus a combining dot above. + 'İ' => 'i'.to_lowercase(), + 'I' => 'ı'.to_lowercase(), + c => c.to_lowercase(), + } } pub fn current() -> Lang { @@ -1961,6 +1992,20 @@ mod tests { set_current(Lang::EN); } + #[test] + fn turkish_keeps_its_dotted_and_dotless_i_when_changing_case() { + set_current(Lang::from_code("tr").expect("tr registered")); + assert_eq!(upper_label("Advanced"), "GELİŞMİŞ"); + assert_eq!(upper_label("Signatures"), "İMZALAR"); + // A label with no Turkish row is still English and keeps the English rule. + assert_eq!(upper_label("Untranslated title"), "UNTRANSLATED TITLE"); + assert_eq!(in_sentence("İmza Alanı"), "imza alanı"); + assert_eq!(in_sentence("Işık"), "ışık"); + set_current(Lang::EN); + assert_eq!(upper_label("Advanced"), "ADVANCED"); + assert_eq!(in_sentence("Image"), "image"); + } + #[test] fn german_certificate_subject_has_a_distinct_context() { let de = Lang::from_code("de").expect("de registered"); diff --git a/crates/ui-egui/src/print_ui.rs b/crates/ui-egui/src/print_ui.rs index 4e0f065885..f4e039d512 100644 --- a/crates/ui-egui/src/print_ui.rs +++ b/crates/ui-egui/src/print_ui.rs @@ -1103,7 +1103,7 @@ pub(crate) fn body( ); // Advanced (Acrobat's Advanced Print Setup): print as image. Folded unless it is on. ui.add_space(6.0); - egui::CollapsingHeader::new(egui::RichText::new(tl!("Advanced").to_uppercase()).font(theme::semibold(10.5)).color(t.text_faint)) + egui::CollapsingHeader::new(egui::RichText::new(crate::i18n::upper_label("Advanced")).font(theme::semibold(10.5)).color(t.text_faint)) .id_salt("print-advanced") .default_open(d.as_image) .show(ui, |ui| { diff --git a/crates/ui-egui/src/widgets.rs b/crates/ui-egui/src/widgets.rs index 4f5aaa79b6..c39f4cf05e 100644 --- a/crates/ui-egui/src/widgets.rs +++ b/crates/ui-egui/src/widgets.rs @@ -272,7 +272,7 @@ pub fn section_title(ui: &mut egui::Ui, text: &str) { let t = Tokens::get(ui.ctx()); ui.add_space(10.0); // Section titles across every panel go through here, so one translation point covers them. - ui.label(egui::RichText::new(tl!(text).to_uppercase()).font(theme::semibold(10.5)).color(t.text_faint).extra_letter_spacing(0.6)); + ui.label(egui::RichText::new(crate::i18n::upper_label(text)).font(theme::semibold(10.5)).color(t.text_faint).extra_letter_spacing(0.6)); ui.add_space(2.0); } From 4ff53a8d8ebb850259fec3884949ed0312f2999a Mon Sep 17 00:00:00 2001 From: mtlcl410 <2368091+mtlcl410@users.noreply.github.com> Date: Sun, 18 Oct 2026 15:12:26 +0800 Subject: [PATCH 068/105] fix(engine): printing keeps layers hidden in the Layers panel hidden (#3046) (#3050) The print-ready PDF copied the document's /OCProperties as they are, so it printed the layers in their default state: a layer hidden in the Layers panel disappeared on screen and came back on paper. Print as image already used the view's layer choices (it renders with the document's config). Once the layers have been changed, the copy imposed for printing now has its default configuration (/D) set to them: /BaseState /ON, /ON the visible layers, /OFF the hidden ones, its other entries kept. With the layers untouched, the document's own configuration prints as before. Claude-Session: https://claude.ai/code/session_01RUNQSPVXx3dDBabRZHAnjz Co-authored-by: Claude Opus 5.5 --- crates/engine/src/lib.rs | 29 ++++++++++++++++++++- crates/engine/src/tests.rs | 53 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 81 insertions(+), 1 deletion(-) diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 3432cd868e..69f7f01d0b 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -984,6 +984,27 @@ fn render_threads() -> usize { std::thread::available_parallelism().map(|n| n.get()).unwrap_or(4).clamp(2, 8) - 1 } +/// Write `layers`' visibility into the default optional-content configuration of `cos` (a copy +/// made for printing): `/BaseState /ON`, `/ON` the visible layers, `/OFF` the hidden ones. Its +/// other entries (`/Order`, `/RBGroups`, `/AS`…) are kept. A document without `/OCProperties` +/// is left alone. +fn set_default_layer_state(cos: &mut pdfcraft_cos::Document, layers: &[Layer]) { + use pdfcraft_cos::{Dict, ObjRef, Object}; + let Some(root_ref) = cos.trailer().get(b"Root").and_then(Object::as_ref) else { return }; + let Some(mut root) = cos.get(root_ref).as_dict().cloned() else { return }; + let Some(properties) = root.get(b"OCProperties").cloned() else { return }; + let Some(mut properties) = cos.resolve(&properties).as_dict().cloned() else { return }; + let mut config = properties.get(b"D").and_then(|d| cos.resolve(d).as_dict().cloned()).unwrap_or_else(Dict::new); + let refs = + |visible: bool| Object::Array(layers.iter().filter(|l| l.visible == visible).map(|l| Object::Ref(ObjRef::new(l.id.0, l.id.1))).collect()); + config.set(b"BaseState".to_vec(), Object::name("ON")); + config.set(b"ON".to_vec(), refs(true)); + config.set(b"OFF".to_vec(), refs(false)); + properties.set(b"D".to_vec(), Object::Dict(config)); + root.set(b"OCProperties".to_vec(), Object::Dict(properties)); + cos.set(root_ref, Object::Dict(root)); +} + /// Render `doc` with the layer visibility in its `info.layers`. fn use_layer_choices(doc: &mut Document) { let overrides: Vec<(i32, i32, bool)> = doc.info.layers.iter().map(|l| (l.id.0 as i32, l.id.1 as i32, l.visible)).collect(); @@ -4332,12 +4353,18 @@ impl Session { }; return pdfcraft_print::impose(&pictures, &remapped).map_err(|e| EditError::Print(e.to_string())); } - let cos = match doc.editor.as_ref() { + let mut cos = match doc.editor.as_ref() { Some(e) => e.cos.clone(), None => { pdfcraft_cos::Document::open_with_password(doc.bytes.clone(), doc.password.as_deref()).map_err(|e| EditError::Write(e.to_string()))? } }; + // #3046: the layers print as they are shown. Once the Layers panel (or a set-layer + // action) has changed one, the print copy's default configuration says which are on, so a + // hidden layer stays off on paper too; untouched, the document's own configuration prints. + if !doc.config.layers.is_empty() { + set_default_layer_state(&mut cos, &doc.info.layers); + } pdfcraft_print::impose(&cos, settings).map_err(|e| EditError::Print(e.to_string())) } diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index ec305b1ca5..22016c8c9a 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -1987,6 +1987,59 @@ fn oversized_redaction_overlay_preserves_the_document_and_undo() { assert_eq!(page_texts(&s, id), ["Page 1"]); } +/// #3046: a layer hidden in the Layers panel stays off on paper, as it is on screen; with the +/// layers untouched, the document's own configuration prints. +#[test] +fn printing_keeps_hidden_layers_hidden() { + let content = b"/OC /A BDC 1 0 0 rg 10 10 80 80 re f EMC /OC /B BDC 0 1 0 rg 110 10 80 80 re f EMC"; + let objs: Vec> = vec![ + b"<< /Type /Catalog /Pages 2 0 R /OCProperties << /OCGs [5 0 R 6 0 R] /D << /Order [5 0 R 6 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 200 100] /Contents 4 0 R /Resources << /Properties << /A 5 0 R /B 6 0 R >> >> >>".to_vec(), + [format!("<< /Length {} >>\nstream\n", content.len()).as_bytes(), content, b"\nendstream"].concat(), + b"<< /Type /OCG /Name (Layer A) >>".to_vec(), + b"<< /Type /OCG /Name (Layer B) >>".to_vec(), + ]; + let mut pdf = b"%PDF-1.7\n".to_vec(); + let mut offsets = Vec::new(); + for (i, o) in objs.iter().enumerate() { + offsets.push(pdf.len()); + pdf.extend_from_slice(format!("{} 0 obj\n", i + 1).as_bytes()); + pdf.extend_from_slice(o); + pdf.extend_from_slice(b"\nendobj\n"); + } + let xref = pdf.len(); + pdf.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objs.len() + 1).as_bytes()); + for o in offsets { + pdf.extend_from_slice(format!("{o:010} 00000 n \n").as_bytes()); + } + pdf.extend_from_slice(format!("trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n", objs.len() + 1).as_bytes()); + + // Red and green pixels on the first printed sheet. + let printed = |s: &Session, id: DocId| { + let bytes = s.print_pdf(id, &print::Settings { pages: vec![0], ..Default::default() }).unwrap(); + let mut out = Session::new(); + let printed = out.open("print.pdf", None, Arc::new(bytes), None).unwrap(); + let page = render_page(&out, printed); + let px = page.rgba.as_chunks::<4>().0; + let red = px.iter().filter(|p| p[0] > 200 && p[1] < 60 && p[2] < 60).count(); + let green = px.iter().filter(|p| p[1] > 200 && p[0] < 60 && p[2] < 60).count(); + (red, green) + }; + let mut s = Session::new(); + let id = s.open("layers.pdf", None, Arc::new(pdf), None).unwrap(); + let (red, green) = printed(&s, id); + assert!(red > 100 && green > 100, "untouched layers both print: {red} red, {green} green"); + let a = s.get(id).unwrap().info.layers.iter().position(|l| l.name == "Layer A").unwrap(); + assert!(s.set_layer_visible(id, a, false)); + let (red, green) = printed(&s, id); + assert_eq!(red, 0, "Layer A was hidden, so it doesn't print"); + assert!(green > 100, "Layer B still prints: {green} green"); + assert!(s.set_layer_visible(id, a, true)); + let (red, _) = printed(&s, id); + assert!(red > 100, "shown again, Layer A prints again: {red} red"); +} + #[test] fn printing_lays_out_sheets_that_render() { let (s, id) = session_with(5); From 15af2860f1c2a3596530824a1cbcd297d9a46495 Mon Sep 17 00:00:00 2001 From: mtlcl410 <2368091+mtlcl410@users.noreply.github.com> Date: Sun, 18 Oct 2026 15:12:37 +0800 Subject: [PATCH 069/105] fix(ocr): the text layer restores the rendering mode it sets (#3029) (#3033) text_layer wrote '/OCR BMC BT 3 Tr /PCHelv 1 Tf ... ET EMC'. Tr and the font are graphics state, and ET doesn't reset them, so every content stream after the layer on the page inherited 3 Tr: a header or footer added after OCR was in the file but drew invisibly, in PdfCraft and in MuPDF. The layer is now wrapped in q ... Q inside its marked content. Claude-Session: https://claude.ai/code/session_01RUNQSPVXx3dDBabRZHAnjz Co-authored-by: Claude Opus 5.5 --- crates/ocr/src/lib.rs | 27 +++++++++++++++++++++++++-- 1 file changed, 25 insertions(+), 2 deletions(-) diff --git a/crates/ocr/src/lib.rs b/crates/ocr/src/lib.rs index 8d56fa4aa4..affa4ab163 100644 --- a/crates/ocr/src/lib.rs +++ b/crates/ocr/src/lib.rs @@ -215,7 +215,10 @@ fn num(v: f64) -> String { /// WinAnsiEncoding), each word stretched to its box so selection and search highlight the /// right place. Marked content `/OCR` so it can be told apart from the page's own text. pub fn text_layer(words: &[PlacedWord]) -> Vec { - let mut out = b"/OCR BMC\nBT\n3 Tr\n/PCHelv 1 Tf\n".to_vec(); + // `3 Tr` and the font are graphics state, which `ET` doesn't reset: without `q … Q` they + // would stay set for every later content stream on the page, so text added after OCR (a + // header or footer) drew invisibly (#3029). + let mut out = b"/OCR BMC\nq\nBT\n3 Tr\n/PCHelv 1 Tf\n".to_vec(); for w in words { let width = helvetica_width(&w.text, 1.0); let height = w.up[0].hypot(w.up[1]); @@ -235,7 +238,7 @@ pub fn text_layer(words: &[PlacedWord]) -> Vec { out.extend(pdfcraft_fonts::literal(&pdfcraft_fonts::win_ansi(&w.text))); out.extend_from_slice(b" Tj\n"); } - out.extend_from_slice(b"ET\nEMC\n"); + out.extend_from_slice(b"ET\nQ\nEMC\n"); out } @@ -272,6 +275,26 @@ mod tests { assert!(s.starts_with("/OCR BMC") && s.ends_with("EMC\n")); } + /// #3029: the layer's invisible rendering mode and font end with the layer. Every operator + /// that sets them sits inside a `q … Q` pair that the layer closes itself, so content drawn + /// after it on the page starts from the graphics state it had before. + #[test] + fn the_layer_restores_the_graphics_state_it_changes() { + let w = PlacedWord { text: "Hi".into(), origin: [10.0, 20.0], across: [30.0, 0.0], up: [0.0, 9.3] }; + let s = String::from_utf8(text_layer(&[w])).unwrap(); + let mut depth = 0_i32; + for token in s.split_ascii_whitespace() { + match token { + "q" => depth += 1, + "Q" => depth -= 1, + "Tr" | "Tf" | "Tm" => assert!(depth > 0, "`{token}` outside q … Q in {s:?}"), + _ => {} + } + assert!(depth >= 0, "unbalanced Q in {s:?}"); + } + assert_eq!(depth, 0, "the layer leaves a q open: {s:?}"); + } + /// A word box is tight around the ink, so the text's size and baseline come from the letters: /// capitals, lowercase without ascenders, descenders. #[test] From 7cce65917d617e5c9bd341b9de4bb8d6cef584d8 Mon Sep 17 00:00:00 2001 From: skylar taylor-barrick Date: Sun, 18 Oct 2026 01:13:07 -0600 Subject: [PATCH 070/105] Control channel: don't lose the refusal reply to a TCP reset (fixes deadline test flake) (#3165) * Control channel: don't lose the refusal reply to a TCP reset When the control channel refused an unauthenticated connection (deadline passed, overlong line, bad first request) it wrote the error line and closed the socket at once. If the client had sent input the server had not read, Linux answers that close with a reset instead of a FIN, and the reset can discard the reply before the client reads it. That is the flake in an_unauthenticated_connection_is_closed_at_the_deadline: the dripping client's bytes were still unread when the server closed. Now the server shuts down its write side after the reply and reads and discards input until the client closes or a 1 s linger passes, and only then closes. The new test a_client_refused_with_input_unread_gets_the_reason pins it deterministically. * Control channel: refused connections linger outside the auth places A connection refused before auth used to keep its place among max_unauthenticated while it lingered (up to 1 s), so refusals could keep real clients out. Now `refuse` gives that place back and takes one of a separate, smaller set of lingering places (Limits::max_lingering, 8) before the reply is written. When none is free the connection is closed right after the FIN instead of lingering, so threads stay bounded as #1806 intended. Tests: a_lingering_connection_holds_no_place_to_authenticate and only_so_many_refused_connections_linger. * Control channel tests: a 5 s linger for the two linger tests Their timing checks (the auth round trips finishing within the linger, and a non-lingering connection failing a write within half of it) had only 1 s and 500 ms of margin with TEST_LIMITS. Each now serves with its own Limits and a 5 s linger; the assertions are unchanged and neither test waits for the linger on its passing path. --- crates/ui-egui/src/control.rs | 162 ++++++++++++++++++++++++++++++++-- 1 file changed, 155 insertions(+), 7 deletions(-) diff --git a/crates/ui-egui/src/control.rs b/crates/ui-egui/src/control.rs index 742641330c..630d66190c 100644 --- a/crates/ui-egui/src/control.rs +++ b/crates/ui-egui/src/control.rs @@ -793,14 +793,27 @@ struct Limits { max_unauthenticated: usize, /// How long a write may block on a client that never reads its replies. write_timeout: std::time::Duration, + /// How long a connection refused after a reply is kept half-open, discarding input, so the + /// reply is not lost to a reset (see [`close_after_reply`]). + linger: std::time::Duration, + /// How many refused connections may linger at once. A lingering connection has given back + /// its place among [`Self::max_unauthenticated`], so this is what bounds their threads; + /// beyond it a refused connection is closed at once. + max_lingering: usize, } #[cfg(not(target_arch = "wasm32"))] -const LIMITS: Limits = - Limits { auth_deadline: std::time::Duration::from_secs(10), max_unauthenticated: 8, write_timeout: std::time::Duration::from_secs(30) }; +const LIMITS: Limits = Limits { + auth_deadline: std::time::Duration::from_secs(10), + max_unauthenticated: 8, + write_timeout: std::time::Duration::from_secs(30), + linger: std::time::Duration::from_secs(1), + max_lingering: 8, +}; -/// One of the [`Limits::max_unauthenticated`] places, given back when dropped: when the -/// connection authenticates, and on every way its thread can end. +/// One of a limited number of places, given back when dropped: a place among +/// [`Limits::max_unauthenticated`] (given back when the connection authenticates or is refused, +/// and on every way its thread can end), or among [`Limits::max_lingering`]. #[cfg(not(target_arch = "wasm32"))] struct Slot(std::sync::Arc); @@ -850,6 +863,38 @@ impl std::io::Read for Reading { } } +/// Close a connection whose last reply has just been written without losing that reply. +/// +/// Closing a socket with input still unread makes the kernel send a reset instead of a FIN, and +/// the reset can discard the reply before the client reads it (Linux does): a client still +/// sending when it is refused would never learn why. So send FIN first, then read and discard +/// whatever arrives until the client closes its side or `linger` passes, and only then close. +/// +/// Only [`Limits::max_lingering`] connections linger at once: `place` is one of those places, +/// taken by [`refuse`] before the reply was written, and held for the drain only. Without one +/// the connection is closed at once after the FIN, accepting the risk of a reset rather than +/// holding another thread. +#[cfg(not(target_arch = "wasm32"))] +fn close_after_reply(reading: &mut Reading, place: Option, linger: std::time::Duration) { + use std::io::Read; + let _ = reading.stream.shutdown(std::net::Shutdown::Write); + let Some(_place) = place else { return }; + reading.until = Some(std::time::Instant::now() + linger); + let mut sink = [0u8; 1024]; + // Each read is bounded by the time left, and none is made once `linger` has passed. + while matches!(reading.read(&mut sink), Ok(n) if n > 0) {} +} + +/// A connection is being refused before `auth`: give back its place among those waiting to +/// authenticate, so it does not keep a real client out while it lingers, and take a place to +/// linger in if one is free. Done before the reply is written, so a client that has read the +/// reply sees the places as they will be. +#[cfg(not(target_arch = "wasm32"))] +fn refuse(slot: &mut Option, lingering: &std::sync::Arc, limits: Limits) -> Option { + drop(slot.take()); + Slot::take(lingering, limits.max_lingering) +} + /// Listen on `127.0.0.1` (random port) and forward authenticated requests to `client`. /// /// Protocol: newline-delimited JSON-RPC 2.0. The first request on a connection must be @@ -868,6 +913,7 @@ fn serve_with(client: ControlClient, limits: Limits) -> std::io::Result std::io::Result std::io::Result { let secs = limits.auth_deadline.as_secs_f64(); let e = rpc_error(Value::Null, -32004, &format!("not authenticated within {secs} s")); - let _ = writeln!(write, "{e}").and_then(|_| write.flush()); + let place = refuse(&mut slot, &lingering, limits); + if writeln!(write, "{e}").and_then(|_| write.flush()).is_ok() { + close_after_reply(reader.get_mut(), place, limits.linger); + } break; } // End of stream or a failed read ends the connection, as before. Ok(LineRead::End) | Err(_) => break, Ok(LineRead::TooLong) => { let e = rpc_error(Value::Null, -32600, &format!("request line longer than {max} bytes before auth")); - let _ = writeln!(write, "{e}").and_then(|_| write.flush()); + let place = refuse(&mut slot, &lingering, limits); + if writeln!(write, "{e}").and_then(|_| write.flush()).is_ok() { + close_after_reply(reader.get_mut(), place, limits.linger); + } break; } } @@ -945,7 +998,13 @@ fn serve_with(client: ControlClient, limits: Limits) -> std::io::Result = (0..limits.max_unauthenticated).map(|_| connect(ep.port)).collect(); + for s in &others { + // A refused one has already been closed, so its write may fail; its reply says why. + let _ = writeln!(s.try_clone().unwrap(), "{auth}"); + let reply = next_line(&mut BufReader::new(s.try_clone().unwrap())).expect("a reply"); + assert_eq!(reply["result"]["ok"], true, "admitted while the refused one lingers: {reply}"); + } + assert!(started.elapsed() < limits.linger, "checked while it lingered: {:?}", started.elapsed()); + drop(refused); + } + + /// Only [`super::Limits::max_lingering`] refused connections linger at once: one more still + /// gets its reply, and is then closed at once instead of lingering. + #[cfg(not(target_arch = "wasm32"))] + #[test] + fn only_so_many_refused_connections_linger() { + use std::io::{BufReader, Write}; + // A linger far longer than the checks below take, even on a loaded machine. + let limits = super::Limits { linger: std::time::Duration::from_secs(5), max_lingering: 1, ..TEST_LIMITS }; + let (_control, client) = super::attach(&egui::Context::default()); + let ep = super::serve_with(client, limits).unwrap(); + let bad = r#"{"jsonrpc": "2.0", "id": 1, "method": "page_count"}"#; + // The first refused connection takes the only lingering place and keeps it. + let first = connect(ep.port); + writeln!(first.try_clone().unwrap(), "{bad}").unwrap(); + let mut r1 = BufReader::new(first.try_clone().unwrap()); + assert_eq!(next_line(&mut r1).expect("refused with a reason")["error"]["code"], -32001); + // The second still gets its reason (it has nothing unread, so no reset)... + let second = connect(ep.port); + let mut w2 = second.try_clone().unwrap(); + writeln!(w2, "{bad}").unwrap(); + let mut r2 = BufReader::new(second.try_clone().unwrap()); + assert_eq!(next_line(&mut r2).expect("refused with a reason")["error"]["code"], -32001); + // ...and is closed outright, not lingering: writing to it soon fails, where a lingering + // connection would read and discard for the whole of `linger`. + let started = std::time::Instant::now(); + let closed = loop { + if w2.write_all(b"x").is_err() { + break true; + } + if started.elapsed() > limits.linger { + break false; + } + std::thread::sleep(std::time::Duration::from_millis(20)); + }; + assert!(closed && started.elapsed() < limits.linger / 2, "it lingered: {:?}", started.elapsed()); + drop(first); + } + /// #1806: only so many connections may wait to authenticate at once. One more is refused /// on the spot; once a waiting one authenticates or times out, its place is free again. #[cfg(not(target_arch = "wasm32"))] From 233767d181c214279da1f441589f953a207b837d Mon Sep 17 00:00:00 2001 From: Guillem Hermida <66697068+guillehr2@users.noreply.github.com> Date: Sun, 18 Oct 2026 09:13:49 +0200 Subject: [PATCH 071/105] fix(ui): the Hand tool moves the page with the pointer (#924) (#3017) The Hand tool scrolled by the page widget's drag delta from inside the page view's ScrollArea. egui applies that delta when the area ends, after the frame's pages are laid out and drawn, so the page showed each move a frame late and trailed behind the pointer. (Until the page view stopped animating its scrolls, the delta was also eased in over several frames, which is the stutter #924 and #1992 describe.) On top of that, the points the pointer moved before egui tells a drag from a click were dropped, so the page never caught up with the grab point. Pan before the area lays the pages out instead: when the page widget is being dragged with the Hand tool, set the scroll offset from the last offset minus this frame's pointer delta, or minus everything moved since the press on the frame the drag is recognised. The page now moves point for point with the pointer in the frame it moves, and still stops where it is let go (no fling). Other scrolls (go to page, links, zoom anchors, compare sync) are untouched and still win when asked for in the same frame. --- crates/ui-egui/src/canvas.rs | 26 +++++++++++++++++++++-- crates/ui-egui/tests/view.rs | 41 ++++++++++++++++++++++++++++++++++++ parity/acrobat-features.toml | 4 ++-- 3 files changed, 67 insertions(+), 4 deletions(-) diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 8fd54db90f..a5ebf3e38e 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -582,6 +582,8 @@ pub struct DocView { pub(crate) last_scroll: Vec2, /// Side-by-side compare: scroll to here this frame, following the other half. pub(crate) sync_scroll: Option, + /// The page widget's id, by which the Hand tool finds its drag before the pages are laid out. + page_widget: Option, pan_hold_until: f64, /// Pages whose raster proved slow (see `SLOW_MILLIS`): only these get the zoom/pan /// settle holds. Fast pages render straight through, with no added latency. @@ -777,6 +779,7 @@ impl DocView { sharp_hold_until: 0.0, last_scroll: Vec2::ZERO, sync_scroll: None, + page_widget: None, pan_hold_until: 0.0, slow_pages: HashSet::new(), comments: Default::default(), @@ -2578,6 +2581,25 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) { if !std::mem::replace(&mut view.shown, true) { scroll = scroll.scroll_offset(Vec2::ZERO); } + // The Hand tool pans here, before the pages are laid out, so the page moves with the pointer + // in the frame the pointer moves (#924). Scrolling by the drag from inside the area moved the + // page only after that frame was drawn, a frame behind. The frame egui tells the drag from a + // click, the page catches up with all the pointer has moved since the press. A jump asked for + // below still wins. + if app.quick_tool == QuickTool::Hand + && !middle_gesture + && let Some(id) = view.page_widget.filter(|id| ui.ctx().is_being_dragged(*id)) + { + let moved = if ui.ctx().drag_started_id() == Some(id) { + ui.input(|i| i.pointer.total_drag_delta()).unwrap_or(Vec2::ZERO) + } else { + ui.input(|i| i.pointer.delta()) + }; + // Within the content, as egui keeps it: past an end the page would show there for a + // frame and spring back. `max` is never NaN or below zero, so the clamp can't panic. + let max = vec2((content_w - avail.width()).max(0.0), (content_h - avail.height()).max(0.0)); + scroll = scroll.scroll_offset((view.last_scroll - moved).clamp(Vec2::ZERO, max)); + } if let Some(v) = view.sync_scroll.take() { scroll = scroll.scroll_offset(v); } @@ -2723,14 +2745,14 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) { 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()); + view.page_widget = Some(resp.id); // A drag lasts only while egui reports it, so the mark can't outlive one. if !resp.dragged_by(egui::PointerButton::Primary) { view.selection_drag = false; } - // The Hand tool pans: the content widget takes every drag, so scroll by its delta. + // The Hand tool pans by this widget's drag, before the pages are laid out (above). if hand { if resp.dragged() { - ui.scroll_with_delta(resp.drag_delta()); ui.ctx().set_cursor_icon(egui::CursorIcon::Grabbing); } else if resp.hovered() { ui.ctx().set_cursor_icon(egui::CursorIcon::Grab); diff --git a/crates/ui-egui/tests/view.rs b/crates/ui-egui/tests/view.rs index 3f4420d3b4..5ea5452fa7 100644 --- a/crates/ui-egui/tests/view.rs +++ b/crates/ui-egui/tests/view.rs @@ -1046,6 +1046,47 @@ fn the_hand_tool_pans_by_dragging() { assert!(h.state().views[0].selected_text().is_none(), "no text selection with the hand"); } +#[test] +fn the_hand_tool_moves_the_page_with_the_pointer() { + // With the Hand tool the page stays under the pointer, point for point, in the frame the + // pointer moves (#924, #1992). The drag used to reach the scroll offset only after the frame + // was drawn (and, before the page view stopped animating its scrolls, eased in over several + // frames), so the page trailed behind the pointer; the points moved before egui tells a drag + // from a click were lost on top. + let mut h = harness_stepping(1.0 / 60.0, &[("layout", "continuous"), ("zoom", "600"), ("quick", "hand")]); + let start = h.state().views[0].viewport_rect().center(); + h.hover_at(start); + h.run_steps(2); + let page = |h: &Harness<'static, PdfCraftApp>| rect(h, 0).expect("page 1 on screen").min; + let grabbed = page(&h); + let click_dist = h.ctx.options(|o| o.input_options.max_click_dist); + let button = |pos, pressed| egui::Event::PointerButton { pos, button: egui::PointerButton::Primary, pressed, modifiers: Modifiers::NONE }; + h.event(button(start, true)); + h.step(); + // A slow start, two points a frame, then quick strokes both ways. Once the pointer is further + // than a click may wander, the page has moved exactly as far as it, in every frame. + let mut pointer = start; + let strokes = [egui::vec2(-2.0, -2.0); 5].into_iter().chain([egui::vec2(-40.0, -150.0), egui::vec2(25.0, 60.0)]); + for stroke in strokes { + pointer += stroke; + h.event(egui::Event::PointerMoved(pointer)); + h.step(); + if (pointer - start).length() > click_dist { + assert_eq!(page(&h) - grabbed, pointer - start, "the page keeps up with the pointer in the same frame"); + } + } + // Held still, it stays: nothing is left to ease in. + h.run_steps(30); + assert_eq!(page(&h) - grabbed, pointer - start, "nothing moves the page once the pointer stops"); + // Let go mid-stroke, it stops with the pointer: the Hand tool doesn't fling the page on. + pointer += egui::vec2(0.0, -30.0); + h.event(egui::Event::PointerMoved(pointer)); + h.step(); + h.event(button(pointer, false)); + h.run_steps(30); + assert_eq!(page(&h) - grabbed, pointer - start, "the page stays where it was let go"); +} + #[test] fn required_fields_get_a_red_border_when_highlighting() { let _gpu = gpu(); diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index 71b5725f9d..9b0266053f 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -1070,8 +1070,8 @@ milestone = "M3" status = "shipped" commands = [] tools = [] -evidence = ["crates/ui-egui/tests/view.rs::the_hand_tool_pans_by_dragging", "crates/ui-egui/tests/viewer.rs::v_h_and_space_pick_the_quick_tools"] -notes = "drag to pan; Grab/Grabbing cursors; H picks it, V goes back to Select, Space held pans temporarily" +evidence = ["crates/ui-egui/tests/view.rs::the_hand_tool_pans_by_dragging", "crates/ui-egui/tests/view.rs::the_hand_tool_moves_the_page_with_the_pointer", "crates/ui-egui/tests/viewer.rs::v_h_and_space_pick_the_quick_tools"] +notes = "drag to pan: the page follows the pointer point for point in the frame it moves, and stops where it is let go (no fling); Grab/Grabbing cursors; H picks it, V goes back to Select, Space held pans temporarily. Checked with kittest pointer events, not yet on a desktop" [[feature]] id = "view.rotate-view" From 70d7df548e49b496fd2ea37e1ae6f09513e41e5b Mon Sep 17 00:00:00 2001 From: Kurtulus Gemici Date: Sun, 18 Oct 2026 03:14:01 -0400 Subject: [PATCH 072/105] fix(i18n): the web build's Auto language follows the browser (#2954) (#2998) On wasm, detect_system_lang() always returned English, so Auto showed English whatever the browser's language. It now reads navigator.languages in order, then navigator.language, and takes the first tag with a catalog, as the desktop builds do with the system languages and as VectorCraft and FilmCraft do in their browser builds. Co-authored-by: Claude Opus 5.5 --- crates/ui-egui/Cargo.toml | 2 +- crates/ui-egui/src/i18n/mod.rs | 31 ++++++++++++++++++++++++++++++- 2 files changed, 31 insertions(+), 2 deletions(-) diff --git a/crates/ui-egui/Cargo.toml b/crates/ui-egui/Cargo.toml index aa9fe54f66..42e9474f07 100644 --- a/crates/ui-egui/Cargo.toml +++ b/crates/ui-egui/Cargo.toml @@ -71,7 +71,7 @@ rfd = { workspace = true, features = ["file-handle-inner"] } wasm-bindgen-futures = "0.4" wasm-bindgen = "0.2" js-sys = "0.3" -web-sys = { version = "0.3", features = ["Window", "Document", "Element", "HtmlElement", "HtmlAnchorElement", "Blob", "BlobPropertyBag", "Url", "HtmlIFrameElement", "HtmlImageElement", "HtmlCollection", "Node", "EventTarget"] } +web-sys = { version = "0.3", features = ["Window", "Document", "Element", "HtmlElement", "HtmlAnchorElement", "Blob", "BlobPropertyBag", "Url", "HtmlIFrameElement", "HtmlImageElement", "HtmlCollection", "Node", "EventTarget", "Navigator"] } [dev-dependencies] pdfcraft-xfa = { workspace = true } diff --git a/crates/ui-egui/src/i18n/mod.rs b/crates/ui-egui/src/i18n/mod.rs index 0d097f6cd3..20c0fd0467 100644 --- a/crates/ui-egui/src/i18n/mod.rs +++ b/crates/ui-egui/src/i18n/mod.rs @@ -390,9 +390,23 @@ fn first_supported(list: &str) -> Option { list.split(['(', ')', ',', '"', '\n']).map(str::trim).filter(|s| !s.is_empty()).find_map(lang_from_tag) } +/// The first supported language among BCP 47 tags in preference order, as the browser lists them +/// in `navigator.languages` (#2954). Only the first few tags are looked at: the list is short in +/// practice, and a page can't make Auto walk an unbounded one. +#[cfg_attr(not(target_arch = "wasm32"), allow(dead_code))] +fn first_supported_tag>(tags: impl IntoIterator) -> Option { + tags.into_iter().take(32).find_map(|tag| lang_from_tag(tag.as_ref().trim())) +} + +/// The browser's preferred languages, as the desktop builds use the system's: `navigator.languages` +/// in order, then `navigator.language` (kept within the 32 tags `first_supported_tag` reads). +/// English when the browser offers none we have a catalog for. #[cfg(target_arch = "wasm32")] fn detect_system_lang() -> Lang { - Lang::EN + let Some(navigator) = web_sys::window().map(|w| w.navigator()) else { return Lang::EN }; + let mut tags: Vec = navigator.languages().iter().take(31).filter_map(|v| v.as_string()).collect(); + tags.extend(navigator.language()); + first_supported_tag(&tags).unwrap_or(Lang::EN) } thread_local! { @@ -650,6 +664,21 @@ mod tests { assert_eq!(first_supported("("), None); } + /// #2954: the web build's Auto language follows the browser's preferred languages in order, + /// skipping ones without a catalog, and is English only when none is supported. + #[test] + fn browser_language_list_is_followed_in_order() { + assert_eq!(first_supported_tag(["it-IT", "en-US"]), Lang::from_code("it")); + assert_eq!(first_supported_tag(["xx-YY", "ja", "fr-FR"]), Some(JA())); + assert_eq!(first_supported_tag(["zh-TW"]), Lang::from_code("zh-hant")); + assert_eq!(first_supported_tag(["en-GB", "fr-FR"]), Some(Lang::EN)); + assert_eq!(first_supported_tag([" pt-PT "]), Lang::from_code("pt-br")); + assert_eq!(first_supported_tag(["", "xx"]), None); + assert_eq!(first_supported_tag(Vec::::new()), None); + let long = std::iter::repeat_n("xx", 10_000).chain(["fr"]); + assert_eq!(first_supported_tag(long), None, "only the first tags are read"); + } + #[test] fn windows_display_language_resolves_chinese_scripts() { for tag in ["zh-CN", "zh-SG", "zh-Hans", "zh-Hans-CN"] { From 17ddc8707d813f6975155ae76daf1ec3c81c4c5d Mon Sep 17 00:00:00 2001 From: NetterSilver <133535268+Silvertree2010@users.noreply.github.com> Date: Sun, 18 Oct 2026 09:14:13 +0200 Subject: [PATCH 073/105] Fix overflow panic for i64::MIN index in content_update/delete (#2979) Found while testing crates/automation/src/content.rs:141 Co-authored-by: Claude Haiku 5.5 --- crates/automation/src/content.rs | 2 +- crates/automation/tests/automation.rs | 18 ++++++++++++++++++ 2 files changed, 19 insertions(+), 1 deletion(-) diff --git a/crates/automation/src/content.rs b/crates/automation/src/content.rs index 710952d7c2..faefc5fa7c 100644 --- a/crates/automation/src/content.rs +++ b/crates/automation/src/content.rs @@ -138,7 +138,7 @@ impl Automation { .added() .iter() .filter(|x| x.page == page) - .nth((index - 1).max(0) as usize) + .nth(usize::try_from(index.saturating_sub(1)).unwrap_or(usize::MAX)) .filter(|_| index >= 1) .ok_or_else(|| failed(format!("page {} has no added item {index} (see content_list)", page + 1)))?; Ok((page, index as usize - 1, it.content.clone(), ph)) diff --git a/crates/automation/tests/automation.rs b/crates/automation/tests/automation.rs index 1e662d8f38..5889b6e4aa 100644 --- a/crates/automation/tests/automation.rs +++ b/crates/automation/tests/automation.rs @@ -3582,6 +3582,24 @@ fn adding_content_through_tools() { assert!(matches!(a.call("content_delete", &json!({ "doc": doc, "page": 1, "index": 5 })), Err(ToolError::Failed(_)))); } +/// An out-of-range `index` (including `i64::MIN`, which overflowed `index - 1`) is a readable +/// failure for `content_update` and `content_delete`, not a panic. +#[test] +fn content_index_out_of_range_is_an_error_not_a_panic() { + let dir = workdir("content-index-range"); + let mut a = auto(&dir); + let doc = ok(&mut a, "doc_open", json!({ "path": "a.pdf" }))["doc"].as_u64().unwrap(); + ok(&mut a, "page_add_text", json!({ "doc": doc, "page": 1, "text": "hi", "at": [10, 10] })); + for index in [i64::MIN, i64::MIN + 1, -1, 0, i64::MAX] { + for tool in ["content_delete", "content_update"] { + let args = json!({ "doc": doc, "page": 1, "index": index, "text": "x" }); + let args = if tool == "content_delete" { json!({ "doc": doc, "page": 1, "index": index }) } else { args }; + assert!(matches!(a.call(tool, &args), Err(ToolError::Failed(_))), "{tool} index {index}"); + } + } + assert_eq!(ok(&mut a, "content_list", json!({ "doc": doc }))["count"], 1); +} + /// Text styling on an added image is refused (#1331), as image transforms are on text, and the /// refusal leaves the content, the dirty flag and the undo and redo steps as they were. #[test] From 43f8ddf26427ae73ee00b9cc4af5e314faff5f06 Mon Sep 17 00:00:00 2001 From: NetterSilver <133535268+Silvertree2010@users.noreply.github.com> Date: Sun, 18 Oct 2026 09:14:25 +0200 Subject: [PATCH 074/105] Apply JPEG EXIF orientation when creating PDFs from images (#2951) Fixes #2944 Co-authored-by: Claude Haiku 5.5 --- crates/create/src/lib.rs | 3 +- crates/create/src/tests.rs | 33 +++++++++++++++ .../{engine => create}/src/upright_image.rs | 42 ++----------------- crates/engine/src/lib.rs | 3 +- crates/engine/src/tests.rs | 36 ++++++++++++++++ 5 files changed, 75 insertions(+), 42 deletions(-) rename crates/{engine => create}/src/upright_image.rs (62%) diff --git a/crates/create/src/lib.rs b/crates/create/src/lib.rs index a42ec84fa8..5717977b1e 100644 --- a/crates/create/src/lib.rs +++ b/crates/create/src/lib.rs @@ -5,6 +5,7 @@ use pdfcraft_cos::{Dict, Document, ObjRef, Object, PdfString, Stream}; mod extract; +mod upright_image; pub use extract::{ExtractedImage, ImageExport, extract_images, image_file}; use pdfcraft_fonts::{literal, win_ansi, wrap}; @@ -528,7 +529,7 @@ pub fn source_kind(name: &str, bytes: &[u8]) -> Option { /// Detect the image format from its bytes; a TIFF may hold several pages. fn embed(name: &str, bytes: &[u8]) -> Result, CreateError> { if bytes.starts_with(&[0xFF, 0xD8]) { - Ok(vec![jpeg(name, bytes)?]) + Ok(vec![jpeg(name, &upright_image::upright(bytes))?]) } else if bytes.starts_with(b"\x89PNG\r\n\x1a\n") { Ok(vec![png_image(name, bytes)?]) } else if is_tiff(bytes) { diff --git a/crates/create/src/tests.rs b/crates/create/src/tests.rs index 993c38d9f3..a996b703f3 100644 --- a/crates/create/src/tests.rs +++ b/crates/create/src/tests.rs @@ -949,3 +949,36 @@ fn cmyk_and_ycck_jpegs_export_in_the_colours_the_page_shows() { } } } + +/// A 60 x 20 JPEG at `dpi` x 72 dpi, tagged with EXIF `orientation` (6: shown turned 90 degrees +/// clockwise). +pub(crate) fn phone_jpeg(orientation: u8, dpi: u16) -> Vec { + use image::codecs::jpeg::{JpegEncoder, PixelDensity, PixelDensityUnit}; + let mut jpeg = Vec::new(); + let mut encoder = JpegEncoder::new(&mut jpeg); + encoder.set_pixel_density(PixelDensity { density: (dpi, 72), unit: PixelDensityUnit::Inches }); + encoder.encode_image(&image::RgbImage::from_pixel(60, 20, image::Rgb([20, 20, 20]))).unwrap(); + let mut exif = b"Exif\0\0II\x2a\0\x08\0\0\0\x01\0\x12\x01\x03\0\x01\0\0\0".to_vec(); + exif.extend_from_slice(&[orientation, 0, 0, 0, 0, 0, 0, 0]); + let mut out = jpeg[..2].to_vec(); + out.extend_from_slice(&[0xff, 0xe1]); + out.extend_from_slice(&((exif.len() + 2) as u16).to_be_bytes()); + out.extend_from_slice(&exif); + out.extend_from_slice(&jpeg[2..]); + out +} + +#[test] +fn a_jpeg_with_an_exif_orientation_becomes_an_upright_page() { + let doc = reopen(&from_images(&[("photo.jpg".into(), phone_jpeg(6, 144)), ("plain.jpg".into(), phone_jpeg(1, 72))]).unwrap()); + // Stored 60 x 20 px at 144 x 72 dpi; turned it is 20 x 60 px at 72 x 144 dpi: 20 x 30 pt. + let m0 = media(&pages(&doc)[0]); + assert!((m0[2] - 20.0).abs() < 0.01 && (m0[3] - 30.0).abs() < 0.01, "{m0:?}"); + let img = image_of(&doc, 0); + assert_eq!((img.int(b"Width"), img.int(b"Height")), (Some(20), Some(60))); + // "As stored" stays as it is. + let m1 = media(&pages(&doc)[1]); + assert!((m1[2] - 60.0).abs() < 0.01 && (m1[3] - 20.0).abs() < 0.01, "{m1:?}"); + let img = image_of(&doc, 1); + assert_eq!((img.int(b"Width"), img.int(b"Height")), (Some(60), Some(20))); +} diff --git a/crates/engine/src/upright_image.rs b/crates/create/src/upright_image.rs similarity index 62% rename from crates/engine/src/upright_image.rs rename to crates/create/src/upright_image.rs index dc2b9cdbfc..9816dfc4b2 100644 --- a/crates/engine/src/upright_image.rs +++ b/crates/create/src/upright_image.rs @@ -1,6 +1,6 @@ //! Photos from phones are stored sideways with an EXIF Orientation tag. PDF image XObjects have -//! no such tag, so a JPEG embedded as it is would show up turned. Images placed into a page -//! (stamps, added images, replacements) go through here first. +//! no such tag, so a JPEG embedded as it is would show up turned. Every JPEG embedded by this +//! crate (pages made from images, stamps, added images, replacements) goes through here first. use std::borrow::Cow; use std::io::Cursor; @@ -79,24 +79,7 @@ fn jfif_density(bytes: &[u8]) -> Option { #[cfg(test)] mod tests { use super::*; - use crate::{Edit, MarkFile}; - - /// A 60 x 20 JPEG at `dpi` x 72 dpi, tagged with EXIF `orientation` (6: shown turned 90 - /// degrees clockwise). - fn phone_jpeg(orientation: u8, dpi: u16) -> Vec { - let mut jpeg = Vec::new(); - let mut encoder = JpegEncoder::new(&mut jpeg); - encoder.set_pixel_density(PixelDensity { density: (dpi, 72), unit: PixelDensityUnit::Inches }); - encoder.encode_image(&image::RgbImage::from_pixel(60, 20, image::Rgb([20, 20, 20]))).unwrap(); - let mut exif = b"Exif\0\0II\x2a\0\x08\0\0\0\x01\0\x12\x01\x03\0\x01\0\0\0".to_vec(); - exif.extend_from_slice(&[orientation, 0, 0, 0, 0, 0, 0, 0]); - let mut out = jpeg[..2].to_vec(); - out.extend_from_slice(&[0xff, 0xe1]); - out.extend_from_slice(&((exif.len() + 2) as u16).to_be_bytes()); - out.extend_from_slice(&exif); - out.extend_from_slice(&jpeg[2..]); - out - } + use crate::tests::phone_jpeg; #[test] fn orientation_is_applied_and_plain_images_are_left_alone() { @@ -111,23 +94,4 @@ mod tests { assert!(matches!(upright(b"\x89PNG\r\n\x1a\n"), Cow::Borrowed(_))); assert!(matches!(upright(b"\xff\xd8 not a jpeg"), Cow::Borrowed(_))); } - - #[test] - fn a_phone_photo_used_as_a_custom_stamp_is_embedded_upright() { - let mut session = crate::Session::new(); - let blank = session.create_blank(300.0, 400.0, 1).unwrap(); - let id = session.open_new("form.pdf", blank).unwrap(); - // A click places the stamp at its natural size: 30 x 20 pt as stored, 20 x 30 pt upright. - // Named "Signature" so `signature_image` finds its picture. - let file = MarkFile { name: "photo.jpg".into(), bytes: std::sync::Arc::new(phone_jpeg(6, 144)), page: 0 }; - let stamp = Edit::AddCustomStamp { page: 0, rect: [100.0, 100.0, 100.0, 100.0], name: "Signature".into(), file, author: "Ada".into() }; - session.apply(id, stamp).unwrap(); - let cos = &session.get(id).unwrap().editor.as_ref().unwrap().cos; - let picture = pdfcraft_annot::signature_image(cos, 0, 0).unwrap().unwrap(); - let obj = cos.get(picture); - let pdfcraft_cos::Object::Stream(s) = &*obj else { panic!() }; - assert_eq!((s.dict.int(b"Width"), s.dict.int(b"Height")), (Some(20), Some(60))); - let rect = pdfcraft_annot::summaries(cos)[0].rect; - assert!(((rect[2] - rect[0]) - 20.0).abs() < 0.01 && ((rect[3] - rect[1]) - 30.0).abs() < 0.01, "{rect:?}"); - } } diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 69f7f01d0b..5fefe9b4b9 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -36,7 +36,6 @@ pub mod signature_image; pub mod signature_scan; pub mod text_fonts; mod text_preview; -mod upright_image; pub mod word_count; pub mod xfa; @@ -4933,7 +4932,7 @@ fn embed_image( name: &str, bytes: &[u8], ) -> Result<(pdfcraft_cos::ObjRef, (f64, f64)), pdfcraft_create::CreateError> { - pdfcraft_create::image_xobject(doc, name, &upright_image::upright(bytes)) + pdfcraft_create::image_xobject(doc, name, bytes) } /// Bring a mark's picture into `doc`: a PDF page as a form XObject, or an image. diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 22016c8c9a..ea410cafbb 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -5854,3 +5854,39 @@ fn cropping_an_existing_image_clips_it_and_moves_with_it() { // No area, or a box off the image, is refused. assert!(s.apply(id, Edit::EditPageImage { page: 0, index: 0, change: ImageEdit::Crop([10.0, 10.0, 10.0, 50.0]) }).is_err()); } + +/// A 60 x 20 JPEG at 144 x 72 dpi, tagged with EXIF Orientation 6 (shown turned 90 degrees clockwise). +fn phone_jpeg() -> Vec { + use image::codecs::jpeg::{JpegEncoder, PixelDensity, PixelDensityUnit}; + let mut jpeg = Vec::new(); + let mut encoder = JpegEncoder::new(&mut jpeg); + encoder.set_pixel_density(PixelDensity { density: (144, 72), unit: PixelDensityUnit::Inches }); + encoder.encode_image(&image::RgbImage::from_pixel(60, 20, image::Rgb([20, 20, 20]))).unwrap(); + let mut exif = b"Exif\0\0II\x2a\0\x08\0\0\0\x01\0\x12\x01\x03\0\x01\0\0\0".to_vec(); + exif.extend_from_slice(&[6, 0, 0, 0, 0, 0, 0, 0]); + let mut out = jpeg[..2].to_vec(); + out.extend_from_slice(&[0xff, 0xe1]); + out.extend_from_slice(&((exif.len() + 2) as u16).to_be_bytes()); + out.extend_from_slice(&exif); + out.extend_from_slice(&jpeg[2..]); + out +} + +#[test] +fn a_phone_photo_used_as_a_custom_stamp_is_embedded_upright() { + let mut session = Session::new(); + let blank = session.create_blank(300.0, 400.0, 1).unwrap(); + let id = session.open_new("form.pdf", blank).unwrap(); + // A click places the stamp at its natural size: 30 x 20 pt as stored, 20 x 30 pt upright. + // Named "Signature" so `signature_image` finds its picture. + let file = MarkFile { name: "photo.jpg".into(), bytes: Arc::new(phone_jpeg()), page: 0 }; + let stamp = Edit::AddCustomStamp { page: 0, rect: [100.0, 100.0, 100.0, 100.0], name: "Signature".into(), file, author: "Ada".into() }; + session.apply(id, stamp).unwrap(); + let cos = &session.get(id).unwrap().editor.as_ref().unwrap().cos; + let picture = pdfcraft_annot::signature_image(cos, 0, 0).unwrap().unwrap(); + let obj = cos.get(picture); + let pdfcraft_cos::Object::Stream(s) = &*obj else { panic!() }; + assert_eq!((s.dict.int(b"Width"), s.dict.int(b"Height")), (Some(20), Some(60))); + let rect = pdfcraft_annot::summaries(cos)[0].rect; + assert!(((rect[2] - rect[0]) - 20.0).abs() < 0.01 && ((rect[3] - rect[1]) - 30.0).abs() < 0.01, "{rect:?}"); +} From e827a2a17ce786a21535074538182220e44644ce Mon Sep 17 00:00:00 2001 From: mtlcl410 <2368091+mtlcl410@users.noreply.github.com> Date: Sun, 18 Oct 2026 15:14:36 +0800 Subject: [PATCH 075/105] M6: a box drawn with points along its sides is detected as a box (#2942) (#2947) Field detection read a path as a rectangle only when it had exactly four corners, and gave up on a subpath once it reached five points. The French income tax form 2042 draws its letter cells with a midpoint on each vertical side (six points), so most of its cells were not found. A subpath now keeps up to 16 points, and a point lying on a straight side between its neighbours is dropped before the four-corner test. A point that turns back along a side is not between its neighbours and stays, so a path that doubles back is still not a box. Claude-Session: https://claude.ai/code/session_01RUNQSPVXx3dDBabRZHAnjz Co-authored-by: Claude Opus 5.5 --- crates/forms/src/detect.rs | 33 +++++++++++++++++++++++++++++---- crates/forms/src/tests.rs | 23 +++++++++++++++++++++++ parity/acrobat-features.toml | 4 ++-- 3 files changed, 54 insertions(+), 6 deletions(-) diff --git a/crates/forms/src/detect.rs b/crates/forms/src/detect.rs index dbee788e3b..51f0b5c394 100644 --- a/crates/forms/src/detect.rs +++ b/crates/forms/src/detect.rs @@ -40,6 +40,10 @@ pub struct Candidate { pub name: String, } +/// The most points a subpath may have and still be read as a rectangle: its four corners plus +/// points along its sides (#2942: a cell drawn with a midpoint on each vertical side). +const MAX_RECT_POINTS: usize = 16; + /// The rectangles and horizontal segments a page draws (stroked or filled), in user space. #[derive(Default)] struct DetectionPath { @@ -65,12 +69,33 @@ impl DetectionPath { return None; } let near = |a: [f64; 2], b: [f64; 2]| (a[0] - b[0]).abs() < 1e-6 && (a[1] - b[1]).abs() < 1e-6; - let mut points = self.points.as_slice(); + let mut points = self.points.clone(); let repeated_end = points.len() > 1 && near(points[0], *points.last()?); if repeated_end { - points = &points[..points.len() - 1]; + points.pop(); } - if points.len() != 4 || !(self.closed || implicitly_closed || repeated_end) { + if !(self.closed || implicitly_closed || repeated_end) { + return None; + } + // A point on a straight side, between its neighbours, doesn't change the shape: drop it, + // so a cell drawn with a midpoint on a side is the rectangle it looks like (#2942). A + // point that turns back along the side is not between its neighbours and stays, so a + // path that doubles back is still not a rectangle. + let between = |a: f64, m: f64, b: f64| a.min(b) - 1e-6 <= m && m <= a.max(b) + 1e-6; + let mut i = 0; + while points.len() > 4 && i < points.len() { + let n = points.len(); + let (Some(&a), Some(&m), Some(&b)) = (points.get((i + n - 1) % n), points.get(i), points.get((i + 1) % n)) else { break }; + let on_row = (a[1] - m[1]).abs() < 1e-6 && (m[1] - b[1]).abs() < 1e-6 && between(a[0], m[0], b[0]); + let on_column = (a[0] - m[0]).abs() < 1e-6 && (m[0] - b[0]).abs() < 1e-6 && between(a[1], m[1], b[1]); + if on_row || on_column { + points.remove(i); + i = 0; + } else { + i += 1; + } + } + if points.len() != 4 { return None; } let mut horizontal = [false; 4]; @@ -157,7 +182,7 @@ pub fn page_shapes(doc: &Document, page: usize) -> Shapes { let (bx, by) = ctm.apply(x, y); let b = [bx, by]; if !path.complex { - if path.points.len() >= 5 { + if path.points.len() > MAX_RECT_POINTS { path.complex = true; } else { if path.points.is_empty() { diff --git a/crates/forms/src/tests.rs b/crates/forms/src/tests.rs index ef545cd433..35fcaaa45b 100644 --- a/crates/forms/src/tests.rs +++ b/crates/forms/src/tests.rs @@ -2023,6 +2023,29 @@ fn page_shapes_accepts_rectangle_closure_and_paint_variants() { } } +/// #2942: a cell drawn with a point in the middle of each vertical side (the French 2042 form's +/// letter cells), or on every side, is the rectangle it looks like. A point that turns back +/// along a side is not on the way between its neighbours, so such a path is still not a box. +#[test] +fn page_shapes_reads_rectangles_with_points_along_their_sides() { + for content in [ + "100 100 m 111.1 100 l 111.1 107.35 l 111.1 114.7 l 100 114.7 l 100 107.35 l h f", + "100 100 m 105 100 l 111.1 100 l 111.1 107.35 l 111.1 114.7 l 105 114.7 l 100 114.7 l 100 107.35 l h S", + "100 107.35 m 100 100 l 111.1 100 l 111.1 114.7 l 100 114.7 l h f", + ] { + let shapes = detection_test_shapes(content.as_bytes()); + assert_eq!(shapes.boxes, [[100.0, 100.0, 111.1, 114.7]], "{content}"); + } + for content in [ + // Out past the corner and back: a spike, not a side. + "100 100 m 130 100 l 111.1 100 l 111.1 114.7 l 100 114.7 l h S", + // A sixth point off the sides. + "100 100 m 111.1 100 l 115 107 l 111.1 114.7 l 100 114.7 l 100 107.35 l h S", + ] { + assert!(detection_test_shapes(content.as_bytes()).boxes.is_empty(), "{content}"); + } +} + #[test] fn page_shapes_rejects_open_diagonal_curved_and_complex_rectangles() { for content in [ diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index 9b0266053f..12247dac64 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -5088,8 +5088,8 @@ milestone = "M6" status = "partial" commands = ["form.detect", "form.prepare"] tools = ["form_detect_fields"] -evidence = ["crates/forms/src/tests.rs::detection_finds_blanks_rules_boxes_and_names_them", "crates/engine/src/tests.rs::detecting_fields_on_a_printed_form", "crates/engine/src/tests.rs::detecting_fields_on_a_scanned_form", "crates/forms/src/detect/raster.rs::a_scanned_form_gives_its_rules_boxes_and_check_boxes", "crates/forms/src/detect/raster.rs::noise_and_uneven_paper_do_not_hide_the_lines", "crates/automation/tests/automation.rs::detecting_form_fields_through_tools", "crates/ui-egui/tests/javascript.rs::prepare_a_form_detects_fields_on_a_paper_form", "crates/ui-egui/tests/prepare.rs::deleting_several_selected_fields_and_detecting_fields_from_the_panel"] -notes = "Underscore runs, rules, empty boxes and small squares (check boxes); runs when Prepare a form starts on a document without fields, and from Prepare a form ▸ Detect form fields at any time (existing fields are skipped). Scanned pages (an image covers at least half the page and the content draws no lines) are rendered and their rules, boxes and check boxes read from the pixels; a box with a small printed label gets a field in its empty part. Tested on synthetic scans only: straight, stored sideways with /Rotate 90 (fields get /MK /R so typing reads upright), noisy, and 1 degree askew (fields stay inside their boxes but lose up to the skew in height). Labels come from the text layer or, when the OCR models are installed, from text recognition (Latin without accents, so German labels with umlauts make poorer names); without either, fields get default names (Text1, Check Box1). Recognition runs synchronously, about a second per scanned page. Not yet: comb boxes, dotted leader lines, tables of cells as such, deskewing, radio groups." +evidence = ["crates/forms/src/tests.rs::detection_finds_blanks_rules_boxes_and_names_them", "crates/forms/src/tests.rs::page_shapes_reads_rectangles_with_points_along_their_sides", "crates/engine/src/tests.rs::detecting_fields_on_a_printed_form", "crates/engine/src/tests.rs::detecting_fields_on_a_scanned_form", "crates/forms/src/detect/raster.rs::a_scanned_form_gives_its_rules_boxes_and_check_boxes", "crates/forms/src/detect/raster.rs::noise_and_uneven_paper_do_not_hide_the_lines", "crates/automation/tests/automation.rs::detecting_form_fields_through_tools", "crates/ui-egui/tests/javascript.rs::prepare_a_form_detects_fields_on_a_paper_form", "crates/ui-egui/tests/prepare.rs::deleting_several_selected_fields_and_detecting_fields_from_the_panel"] +notes = "Underscore runs, rules, empty boxes and small squares (check boxes); a box drawn with points along its sides (a midpoint on each vertical side, as the French 2042 form's cells) is read as the rectangle it is (#2942); runs when Prepare a form starts on a document without fields, and from Prepare a form ▸ Detect form fields at any time (existing fields are skipped). Scanned pages (an image covers at least half the page and the content draws no lines) are rendered and their rules, boxes and check boxes read from the pixels; a box with a small printed label gets a field in its empty part. Tested on synthetic scans only: straight, stored sideways with /Rotate 90 (fields get /MK /R so typing reads upright), noisy, and 1 degree askew (fields stay inside their boxes but lose up to the skew in height). Labels come from the text layer or, when the OCR models are installed, from text recognition (Latin without accents, so German labels with umlauts make poorer names); without either, fields get default names (Text1, Check Box1). Recognition runs synchronously, about a second per scanned page. Not yet: comb boxes, dotted leader lines, tables of cells as such, deskewing, radio groups." [[feature]] id = "form.auto-name-fields" From 5377a223e9c8f83b2c124a3fa5ad12fafc9e5330 Mon Sep 17 00:00:00 2001 From: NetterSilver <133535268+Silvertree2010@users.noreply.github.com> Date: Sun, 18 Oct 2026 09:14:51 +0200 Subject: [PATCH 076/105] Extract RTL lines with numbers and punctuation in logical order (#2736) Fixes #2667 Co-authored-by: Claude Haiku 5.5 --- crates/render/src/text.rs | 156 ++++++++++++++++++++++++++++++++++---- 1 file changed, 142 insertions(+), 14 deletions(-) diff --git a/crates/render/src/text.rs b/crates/render/src/text.rs index b767e28314..5054859961 100644 --- a/crates/render/src/text.rs +++ b/crates/render/src/text.rs @@ -1257,26 +1257,134 @@ fn collide(glyphs: &[&TextGlyph], height: &dyn Fn(usize) -> f32, h: f32, sc: &mu dense } -/// Reverses each right-to-left span of `idx` (visual → logical order). A space between two right-to-left glyphs -/// belongs to the span. -/// Reverse right-to-left runs of `idx` (visual → logical), and the word gaps between their glyphs -/// with them: `spaces[w]` is a gap left of `idx[w]` (#1062). +/// Simplified bidi class of a glyph or word gap (UAX #9: strong L/R, numbers, neutrals). +#[derive(Clone, Copy, PartialEq, Eq)] +enum Bidi { + L, + R, + Num, + N, +} + +fn bidi_class(text: &str) -> Bidi { + if text.chars().any(|c| is_rtl(c) && !c.is_numeric()) { + Bidi::R + } else if text.chars().any(char::is_numeric) { + Bidi::Num + } else if text.chars().any(char::is_alphabetic) { + Bidi::L + } else { + Bidi::N + } +} + +/// Reorders a line of `idx` from visual to logical order with a simplified bidi level pass (#2667), carrying the +/// word gaps along: `spaces[w]` is a gap left of `idx[w]` (#1062). The paragraph is right-to-left when it has +/// more right-to-left than left-to-right glyphs; numbers keep their left-to-right order, and neutrals +/// (punctuation, gaps) take the direction of their neighbours, or the paragraph's between opposite ones. fn reverse_rtl(glyphs: &[&TextGlyph], idx: &mut [usize], spaces: &mut [bool]) { - let rtl = |i: usize| glyphs[i].text.chars().any(is_rtl); + // Glyphs and word gaps (None) in visual order. + let mut items: Vec<(Option, Bidi)> = Vec::with_capacity(idx.len() * 2); + for (w, &i) in idx.iter().enumerate() { + if w > 0 && spaces.get(w).copied().unwrap_or(false) { + items.push((None, Bidi::N)); + } + items.push((Some(i), bidi_class(&glyphs[i].text))); + } + let texts: Vec<&str> = items.iter().map(|it| it.0.map_or("", |i| glyphs[i].text.as_str())).collect(); + let text = |k: usize| texts.get(k).copied().unwrap_or(""); + let count = |b: Bidi| items.iter().filter(|it| it.1 == b).count(); + let para = if count(Bidi::R) > count(Bidi::L) { Bidi::R } else { Bidi::L }; + let n = items.len(); + // W4: a single separator between two digits belongs to the number. + for k in 1..n.saturating_sub(1) { + let sep = matches!(text(k), "." | "," | ":" | "/" | "+" | "-" | "\u{066B}" | "\u{066C}"); + if sep && items[k - 1].1 == Bidi::Num && items[k + 1].1 == Bidi::Num { + items[k].1 = Bidi::Num; + } + } + // W5: terminators (percent, currency) next to a number belong to it. + let term = + |s: &str| !s.is_empty() && s.chars().all(|c| matches!(c, '%' | '‰' | '#' | '$' | '¢'..='¥' | '°' | '\u{066A}' | '\u{20A0}'..='\u{20CF}')); + for k in 1..n { + if items[k].1 == Bidi::N && items[k - 1].1 == Bidi::Num && term(text(k)) { + items[k].1 = Bidi::Num; + } + } + for k in (0..n.saturating_sub(1)).rev() { + if items[k].1 == Bidi::N && items[k + 1].1 == Bidi::Num && term(text(k)) { + items[k].1 = Bidi::Num; + } + } + // W7: a number after left-to-right text (or at the start of a left-to-right paragraph) is left-to-right. + let mut strong = para; + for it in &mut items { + match it.1 { + Bidi::L | Bidi::R => strong = it.1, + Bidi::Num if strong == Bidi::L => it.1 = Bidi::L, + _ => {} + } + } + // N1/N2: neutrals between same-direction text take it (numbers count as right-to-left), else the paragraph's. + let dir = |b: Bidi| if b == Bidi::L { Bidi::L } else { Bidi::R }; let mut k = 0; - while k < idx.len() { - if rtl(idx[k]) { + while k < n { + if items[k].1 != Bidi::N { + k += 1; + continue; + } + let mut e = k; + while e < n && items[e].1 == Bidi::N { + e += 1; + } + let before = if k == 0 { para } else { dir(items[k - 1].1) }; + let after = if e == n { para } else { dir(items[e].1) }; + let resolved = if before == after { before } else { para }; + for it in &mut items[k..e] { + it.1 = resolved; + } + k = e; + } + // Levels (I1/I2), then L2: reverse every run at or above each level, highest first. + let rtl_para = para == Bidi::R; + let levels: Vec = items + .iter() + .map(|it| match it.1 { + Bidi::R => 1, + Bidi::Num => 2, + _ if rtl_para => 2, + _ => 0, + }) + .collect(); + let mut order: Vec = (0..n).collect(); + for level in (1..=levels.iter().copied().max().unwrap_or(0)).rev() { + let mut k = 0; + while k < n { + if levels[order[k]] < level { + k += 1; + continue; + } let mut e = k; - while e + 1 < idx.len() && (rtl(idx[e + 1]) || (glyphs[idx[e + 1]].text.trim().is_empty() && e + 2 < idx.len() && rtl(idx[e + 2]))) { + while e < n && levels[order[e]] >= level { e += 1; } - idx[k..=e].reverse(); - if let Some(gaps) = spaces.get_mut(k + 1..=e) { - gaps.reverse(); + order[k..e].reverse(); + k = e; + } + } + let mut j = 0; + let mut gap = false; + for &o in &order { + match items[o].0 { + None => gap = true, + Some(i) => { + if let (Some(slot), Some(space)) = (idx.get_mut(j), spaces.get_mut(j)) { + *slot = i; + *space = gap && j > 0; + } + gap = false; + j += 1; } - k = e + 1; - } else { - k += 1; } } } @@ -2092,6 +2200,26 @@ mod tests { assert_eq!(layout(v).plain_text(), "שלום עולם"); } + /// #2667: a number or punctuation on a right-to-left line must not split it into separately reversed words. + #[test] + fn rtl_line_with_number_and_punctuation_reads_in_logical_order() { + let line = |words: &[&str]| { + let mut v = Vec::new(); + let mut x = 10.0; + for w in words { + word(&mut v, w, x, 10.0, 6.0); + x += w.chars().count() as f32 * 6.0 + 6.0; + } + layout(v).plain_text() + }; + // Each line as drawn, left to right. + assert_eq!(line(&["םילקש", "120", "ריחמ"]), "מחיר 120 שקלים"); + assert_eq!(line(&[".הקידב", "ךמסמ", "והז", ":תירבע"]), "עברית: זהו מסמך בדיקה."); + assert_eq!(line(&["باتك", "12.5", "ددع"]), "عدد 12.5 كتاب"); + assert_eq!(line(&["םלוע", "םולש"]), "שלום עולם"); + assert_eq!(line(&["Total", "םולש", "price"]), "Total שלום price"); + } + /// Two lines, "ab cd" and "e fg": words stop at blank glyphs, word gaps and line ends. #[test] fn words_and_lines_around_a_glyph() { From 2c4b84f6afc58ae7c0cdf0433f3eeba6c1588526 Mon Sep 17 00:00:00 2001 From: mtlcl410 <2368091+mtlcl410@users.noreply.github.com> Date: Sun, 18 Oct 2026 15:45:46 +0800 Subject: [PATCH 077/105] M8: redaction removes the replies of the annotations it removes (#2659) (#2697) * M8: redaction removes the replies of the annotations it removes (#2659) A reply (/IRT) placed away from the mark survived, and its /IRT kept the removed annotation - its comment text and the QuadPoints of the redacted words - in the saved file. Replies to a removed annotation, and replies to those, now go with it. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01RUNQSPVXx3dDBabRZHAnjz * M8: test replies listed before their parent, with a pop-up on a reply (#2659) Review of #2697: the replies precede the annotation they answer in /Annots, deepest first, and a reply has a pop-up. A single pass in /Annots order would keep them; the test fails with the loop cut to one pass, which the first test does not notice. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01RUNQSPVXx3dDBabRZHAnjz --------- Co-authored-by: Claude Opus 5.5 --- crates/redact/README.md | 2 +- crates/redact/src/lib.rs | 26 ++++++++++++++++++ crates/redact/src/tests.rs | 53 ++++++++++++++++++++++++++++++++++++ parity/acrobat-features.toml | 4 +-- 4 files changed, 82 insertions(+), 3 deletions(-) diff --git a/crates/redact/README.md b/crates/redact/README.md index 8abac244a7..7cd5ded1e4 100644 --- a/crates/redact/README.md +++ b/crates/redact/README.md @@ -25,7 +25,7 @@ clear_marks(&mut doc, None)?; // remove marks without app - **vectors**: covered paths are removed; partly covered paths and shadings are clipped so nothing paints inside the areas; - **form XObjects**: rewritten recursively into new objects (pages sharing the original keep it); -- **annotations** whose rectangle overlaps (with their pop-ups), and **form fields** with a +- **annotations** whose rectangle overlaps (with their pop-ups and their replies, `/IRT`), and **form fields** with a widget under a mark; - the marks themselves, replaced by boxes in their fill colour (and overlay text) drawn into the page as a `/PCMark /Redaction` stream. diff --git a/crates/redact/src/lib.rs b/crates/redact/src/lib.rs index 5c905610fa..a8dbf64d9b 100644 --- a/crates/redact/src/lib.rs +++ b/crates/redact/src/lib.rs @@ -455,6 +455,32 @@ pub fn apply(doc: &mut Document, pages: Option<&[usize]>) -> Result = annots + .iter() + .filter_map(Object::as_ref) + .filter_map(|r| { + let obj = doc.get(r); + let d = obj.as_dict()?; + (d.name(b"Subtype") != Some(b"Widget")).then_some(())?; + Some((r, d.get(b"IRT").and_then(Object::as_ref)?)) + }) + .collect(); + loop { + let before = removed.len(); + for (r, parent) in &replies { + if !removed.contains(r) && removed.contains(parent) { + removed.push(*r); + report.annotations += 1; + } + } + if removed.len() == before { + break; + } + } let kept: Vec = annots .into_iter() .filter(|a| { diff --git a/crates/redact/src/tests.rs b/crates/redact/src/tests.rs index 300aa90b50..47458cfd2b 100644 --- a/crates/redact/src/tests.rs +++ b/crates/redact/src/tests.rs @@ -490,6 +490,59 @@ fn redacting_an_xfa_field_drops_the_xfa_packet_so_its_value_cannot_survive() { assert!(cat.get(b"NeedsRendering").is_none(), "/NeedsRendering is cleared"); } +#[test] +fn replies_to_a_redacted_comment_go_with_it() { + // #2659: a highlight under the mark has a reply placed elsewhere, and that reply a reply of + // its own. The replies' /IRT would keep the highlight (its comment and QuadPoints) in the file. + let objs: Vec> = vec![ + b"<< /Type /Catalog /Pages 2 0 R >>".to_vec(), + b"<< /Type /Pages /Kids [3 0 R] /Count 1 >>".to_vec(), + b"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 300 300] /Contents 4 0 R /Annots [5 0 R 6 0 R 7 0 R 8 0 R] >>".to_vec(), + stream("", b"BT ET"), + b"<< /Type /Annot /Subtype /Highlight /Rect [20 20 60 30] /QuadPoints [20 30 60 30 20 20 60 20] /Contents (HIGHLIGHT-NOTE) >>".to_vec(), + b"<< /Type /Annot /Subtype /Text /Rect [250 250 270 270] /IRT 5 0 R /Contents (FIRST-REPLY) >>".to_vec(), + b"<< /Type /Annot /Subtype /Text /Rect [250 200 270 220] /IRT 6 0 R /Contents (SECOND-REPLY) >>".to_vec(), + b"<< /Type /Annot /Subtype /Text /Rect [250 100 270 120] /Contents (UNRELATED) >>".to_vec(), + ]; + let mut doc = pdf(objs); + mark(&mut doc, 0, &[[10.0, 10.0, 70.0, 40.0]], ""); + let r = apply(&mut doc, None).unwrap(); + assert_eq!(r.annotations, 3, "the highlight and both replies: {r:?}"); + let p = pdfcraft_model::pages(&doc).swap_remove(0); + assert_eq!(annots_of(&doc, &p.dict).len(), 1, "only the unrelated note stays"); + // A classic table, so the objects' strings are plain in the file. + let bytes = write_full(&doc, &SaveOptions { object_streams: false, ..SaveOptions::default() }).unwrap(); + for gone in [&b"HIGHLIGHT-NOTE"[..], b"FIRST-REPLY", b"SECOND-REPLY"] { + assert!(!bytes.windows(gone.len()).any(|w| w == gone), "{} survived the save", String::from_utf8_lossy(gone)); + } + assert!(bytes.windows(9).any(|w| w == b"UNRELATED")); +} + +/// The replies listed before the annotations they answer, deepest first, and a pop-up on a +/// reply: one pass in `/Annots` order would keep them, the repeated passes don't (review of +/// #2697). +#[test] +fn replies_listed_before_their_parent_and_their_pop_ups_go_too() { + let objs: Vec> = vec![ + b"<< /Type /Catalog /Pages 2 0 R >>".to_vec(), + b"<< /Type /Pages /Kids [3 0 R] /Count 1 >>".to_vec(), + b"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 300 300] /Contents 4 0 R /Annots [7 0 R 9 0 R 6 0 R 5 0 R 8 0 R] >>".to_vec(), + stream("", b"BT ET"), + b"<< /Type /Annot /Subtype /Highlight /Rect [20 20 60 30] /QuadPoints [20 30 60 30 20 20 60 20] /Contents (HIGHLIGHT-NOTE) >>".to_vec(), + b"<< /Type /Annot /Subtype /Text /Rect [250 250 270 270] /IRT 5 0 R /Contents (FIRST-REPLY) /Popup 9 0 R >>".to_vec(), + b"<< /Type /Annot /Subtype /Text /Rect [250 200 270 220] /IRT 6 0 R /Contents (SECOND-REPLY) >>".to_vec(), + b"<< /Type /Annot /Subtype /Text /Rect [250 100 270 120] /Contents (UNRELATED) >>".to_vec(), + b"<< /Type /Annot /Subtype /Popup /Rect [200 150 290 190] /Parent 6 0 R >>".to_vec(), + ]; + let mut doc = pdf(objs); + mark(&mut doc, 0, &[[10.0, 10.0, 70.0, 40.0]], ""); + let r = apply(&mut doc, None).unwrap(); + assert_eq!(r.annotations, 3, "the highlight and both replies: {r:?}"); + let p = pdfcraft_model::pages(&doc).swap_remove(0); + let left = annots_of(&doc, &p.dict); + assert_eq!(left, vec![Object::Ref(ObjRef::new(8, 0))], "only the unrelated note stays, the reply's pop-up goes too"); +} + #[test] fn comments_links_and_fields_under_a_mark_go() { let mut doc = one_page(b"", "", vec![]); diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index 12247dac64..e54e5fc14a 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -5728,8 +5728,8 @@ milestone = "M8" status = "shipped" commands = ["redact.apply"] tools = ["redact_apply"] -evidence = ["crates/redact/src/tests.rs::comments_links_and_fields_under_a_mark_go", "crates/ui-egui/tests/ui_group/redact.rs::marking_applying_and_clearing"] -notes = "with pop-ups; fields lose all widgets" +evidence = ["crates/redact/src/tests.rs::comments_links_and_fields_under_a_mark_go", "crates/redact/src/tests.rs::replies_to_a_redacted_comment_go_with_it", "crates/ui-egui/tests/ui_group/redact.rs::marking_applying_and_clearing"] +notes = "with pop-ups and replies (/IRT, transitively; replies on other pages are not followed); fields lose all widgets" [[feature]] id = "protect.redact-struct-tree" From cef2255da533fee2cc30016eb90b5eb2021cceb4 Mon Sep 17 00:00:00 2001 From: Marco Arnulfo <103679419+marcoarnulfo@users.noreply.github.com> Date: Sun, 18 Oct 2026 09:45:58 +0200 Subject: [PATCH 078/105] Edit text: a loaded font is applied to the paragraph being edited (part of #2591) (#2599) --- crates/ui-egui/src/edit_text_ui.rs | 15 +++++++++++++++ crates/ui-egui/src/files.rs | 25 ++++++++++++++++++------ crates/ui-egui/src/panels.rs | 31 +++++++++++++++++++----------- 3 files changed, 54 insertions(+), 17 deletions(-) diff --git a/crates/ui-egui/src/edit_text_ui.rs b/crates/ui-egui/src/edit_text_ui.rs index d8881ba47d..cbec130b86 100644 --- a/crates/ui-egui/src/edit_text_ui.rs +++ b/crates/ui-egui/src/edit_text_ui.rs @@ -1475,6 +1475,21 @@ mod tests { (app, ed) } + #[test] + fn a_loaded_font_is_applied_to_the_paragraph_being_edited() { + // Loading a font used to only register it on the page: the paragraph kept its source + // face until the font was found again in the document-font menu (#2591). + let (mut app, ed) = preview_app(); + app.views[0].line_editor = Some(ed); + let inter = include_bytes!("../../../assets/fonts/Inter-Regular.ttf").to_vec(); + app.use_files(crate::files::FilePurpose::TextFont { page: 0 }, vec![("Inter-Regular.ttf".into(), inter)]); + let doc = app.session.get(app.views[0].id).unwrap(); + let lines = doc.text_lines(0); + assert_eq!(lines.iter().map(|l| l.text.as_str()).collect::(), "Preview text"); + assert!(lines.iter().all(|l| l.base_font == "Inter-Regular"), "{:?}", lines.iter().map(|l| &l.base_font).collect::>()); + assert!(app.views[0].line_editor.is_some(), "the paragraph stays open for editing"); + } + #[test] fn text_background_is_cached_without_editing_and_released_on_cancel() { let (app, ed) = preview_app(); diff --git a/crates/ui-egui/src/files.rs b/crates/ui-egui/src/files.rs index f5e9e0319b..03c773807c 100644 --- a/crates/ui-egui/src/files.rs +++ b/crates/ui-egui/src/files.rs @@ -476,13 +476,26 @@ impl PdfCraftApp { match purpose { FilePurpose::TextFont { page } => { let Some((_, bytes)) = files.into_iter().next() else { return }; - if self.apply_edit(Edit::RegisterTextFont { page, bytes: Arc::new(bytes) }) - && let Some((index, id)) = self.active_ids() - && let Some(doc) = self.session.get(id) - && let Some(ed) = self.views[index].line_editor.as_mut() - && let Some(ctx) = self.ctx.as_ref() + let bytes = Arc::new(bytes); + if !self.apply_edit(Edit::RegisterTextFont { page, bytes: bytes.clone() }) { + return; + } + let Some((index, id)) = self.active_ids() else { return }; + let Some(mut ed) = self.views[index].line_editor.clone().filter(|ed| ed.page == page) else { return }; + // The font just loaded is the one the user wants: apply it to the paragraph, as + // choosing it from the document-font menu does, instead of only registering it (#2591). + let resource = self.session.get(id).and_then(|doc| { + doc.text_editor_fonts(page).into_iter().find(|f| f.bytes == *bytes && !f.resource.starts_with("PCApproved")).map(|f| f.resource) + }); + let ctx = self.ctx.clone(); + if resource.is_some() { + ed.font_resource = resource; + self.apply_line_format(index, ed, ctx.as_ref()); + } else if let Some(doc) = self.session.get(id) + && let Some(ctx) = ctx { - ed.fonts = crate::edit_text_ui::install_source_fonts(ctx, doc, page); + ed.fonts = crate::edit_text_ui::install_source_fonts(&ctx, doc, page); + self.views[index].line_editor = Some(ed); } } FilePurpose::Combine => self.stage_combine(files), diff --git a/crates/ui-egui/src/panels.rs b/crates/ui-egui/src/panels.rs index 2007dc0afa..fae129b308 100644 --- a/crates/ui-egui/src/panels.rs +++ b/crates/ui-egui/src/panels.rs @@ -418,17 +418,7 @@ fn format_section(app: &mut PdfCraftApp, ui: &mut egui::Ui, t: &Tokens) { } changed |= crate::edit_text_ui::extras_panel(ui, &mut ed.extras); if changed { - let edit = pdfcraft_engine::Edit::EditTextBlock { page: ed.page, block: ed.block, text: ed.text.clone(), style: ed.style() }; - if app.apply_edit(edit) { - ed.applied(); - if let Some(doc) = app.session.get(app.views[i].id) - && let Some(block) = doc.text_blocks(ed.page).get(ed.block) - { - ed.refresh_source(block, doc.text_lines(ed.page)); - ed.fonts = crate::edit_text_ui::install_source_fonts(ui.ctx(), doc, ed.page); - } - } - app.views[i].line_editor = Some(ed); + app.apply_line_format(i, ed, Some(ui.ctx())); } ui.add_space(6.0); ui.separator(); @@ -482,6 +472,25 @@ fn format_section(app: &mut PdfCraftApp, ui: &mut egui::Ui, t: &Tokens) { ui.separator(); } +impl PdfCraftApp { + /// Apply the open paragraph's formatting as one change; the paragraph stays open. + pub(crate) fn apply_line_format(&mut self, index: usize, mut ed: crate::edit_text_ui::LineEditor, ctx: Option<&egui::Context>) { + let edit = pdfcraft_engine::Edit::EditTextBlock { page: ed.page, block: ed.block, text: ed.text.clone(), style: ed.style() }; + if self.apply_edit(edit) { + ed.applied(); + if let Some(doc) = self.session.get(self.views[index].id) + && let Some(block) = doc.text_blocks(ed.page).get(ed.block) + { + ed.refresh_source(block, doc.text_lines(ed.page)); + if let Some(ctx) = ctx { + ed.fonts = crate::edit_text_ui::install_source_fonts(ctx, doc, ed.page); + } + } + } + self.views[index].line_editor = Some(ed); + } +} + // ───────────────────────────────────────────────────────────────────────────── right panels pub(crate) enum Nav { From 946c6fdfc470f188de27d6ffd3d1f9f38e645efc Mon Sep 17 00:00:00 2001 From: Marco Arnulfo <103679419+marcoarnulfo@users.noreply.github.com> Date: Sun, 18 Oct 2026 09:46:10 +0200 Subject: [PATCH 079/105] Comments: an attachment comment's menu saves or opens its file (fixes #2565) (#2585) --- crates/ui-egui/src/canvas.rs | 4 ++ crates/ui-egui/src/comments.rs | 21 +++++++ crates/ui-egui/src/comments_panel.rs | 4 +- crates/ui-egui/src/lib.rs | 9 +++ crates/ui-egui/src/panels.rs | 3 + crates/ui-egui/tests/comments.rs | 85 ++++++++++++++++++++++++++++ parity/acrobat-features.toml | 4 +- 7 files changed, 127 insertions(+), 3 deletions(-) diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index a5ebf3e38e..cd9a4163a2 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -3870,6 +3870,10 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) { if let Some((p, i)) = app.views[index].comments.default_request.take() { app.make_comment_default(p, i); } + if let Some((p, i, open)) = app.views[index].comments.attachment_request.take() { + let doc = app.views[index].id; + app.comment_attachment_action(doc, p, i, open); + } if let Some((name, w)) = field_props { app.open_field_props(&name, w); } diff --git a/crates/ui-egui/src/comments.rs b/crates/ui-egui/src/comments.rs index b49a0dce25..68b1a72a0c 100644 --- a/crates/ui-egui/src/comments.rs +++ b/crates/ui-egui/src/comments.rs @@ -504,6 +504,9 @@ pub struct CommentView { pub props_request: Option<(usize, usize)>, /// Make Current Properties Default was asked for: (page, index). pub default_request: Option<(usize, usize)>, + /// A file attachment comment's file was asked for: (page, index, open it in a new tab + /// rather than save it). + pub attachment_request: Option<(usize, usize, bool)>, /// Replace Text: the struck-out text's quads while the replacement is typed. pub replace: Option>, /// Attach file: where the icon goes (page, top-left in user space), for the app to pick a file. @@ -1595,6 +1598,23 @@ pub(crate) fn keys(ctx: &egui::Context, view: &mut DocView, tool: &mut QuickTool } } +/// Save attachment… and, for a PDF, Open in a new tab, first in a file attachment comment's +/// menus as in the Attachments panel: the comment is where the reader finds its file. Reading +/// the file changes nothing, so a read-only document offers them too. +pub(crate) fn attachment_items(ui: &mut egui::Ui, view: &mut DocView, info: &DocInfo, a: &Annotation) { + let source = pdfcraft_render::AttachmentSource::Annotation { page: a.page, index: a.index }; + let Some(file) = info.attachments.iter().find(|f| f.source == source) else { return }; + if ui.button(tl!("Save attachment…")).clicked() { + view.comments.attachment_request = Some((a.page, a.index, false)); + ui.close(); + } + if file.name.to_lowercase().ends_with(".pdf") && ui.button(tl!("Open in a new tab")).clicked() { + view.comments.attachment_request = Some((a.page, a.index, true)); + ui.close(); + } + ui.separator(); +} + /// Items of the canvas context menu for the selected comment (or the page). pub(crate) fn context_menu(ui: &mut egui::Ui, view: &mut DocView, info: &DocInfo, prefs: &CommentPrefs, allowed: bool) -> Option { let mut action = None; @@ -1602,6 +1622,7 @@ pub(crate) fn context_menu(ui: &mut egui::Ui, view: &mut DocView, info: &DocInfo match selected { Some(a) => { let (page, index) = (a.page, a.index); + attachment_items(ui, view, info, a); if ui.add_enabled(allowed, egui::Button::new(tl!("Edit text…"))).clicked() { view.comments.composer = Some(Composer { page, diff --git a/crates/ui-egui/src/comments_panel.rs b/crates/ui-egui/src/comments_panel.rs index c5fbbd0ce3..2ac1853f4e 100644 --- a/crates/ui-egui/src/comments_panel.rs +++ b/crates/ui-egui/src/comments_panel.rs @@ -255,7 +255,7 @@ pub(crate) fn show( continue; } let thread = replies_of(a); - if let Some(e) = card(ui, t, a, &thread, view, prefs, allowed, nav) { + if let Some(e) = card(ui, t, a, &thread, info, view, prefs, allowed, nav) { edit = Some(e); } } @@ -268,6 +268,7 @@ fn card( t: &Tokens, a: &Annotation, thread: &[&Annotation], + info: &DocInfo, view: &mut DocView, prefs: &CommentPrefs, allowed: bool, @@ -447,6 +448,7 @@ fn card( .layout(Layout::top_down_justified(Align::Min)) .show(|ui| { ui.set_min_width(160.0); + crate::comments::attachment_items(ui, view, info, a); if ui.button(tl_ctx!("comment menu", "Edit")).clicked() { view.comments.editing = Some((a.page, a.index, a.contents.clone().unwrap_or_default())); ui.close(); diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index e7df5d6706..e173b93d4a 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -1541,6 +1541,15 @@ impl PdfCraftApp { } } + /// [`Self::attachment_action`] for the file of the attachment comment at (page, index). + pub fn comment_attachment_action(&mut self, doc: DocId, page: usize, index: usize, open: bool) { + let source = pdfcraft_render::AttachmentSource::Annotation { page, index }; + // The menus offer the items only for a listed file, so a miss is a comment gone since. + if let Some(i) = self.session.get(doc).and_then(|d| d.info.attachments.iter().position(|a| a.source == source)) { + self.attachment_action(doc, i, open); + } + } + /// Whether the app draws the window's title bar, buttons and resize edges: the desktop leaves /// the frame to the app and Use system title bar is off. pub fn draws_window_frame(&self) -> bool { diff --git a/crates/ui-egui/src/panels.rs b/crates/ui-egui/src/panels.rs index fae129b308..69600dac0e 100644 --- a/crates/ui-egui/src/panels.rs +++ b/crates/ui-egui/src/panels.rs @@ -800,6 +800,9 @@ pub fn right_panel(app: &mut PdfCraftApp, ui: &mut egui::Ui) { if let Some((p, i)) = app.views.get_mut(index).and_then(|v| v.comments.default_request.take()) { app.make_comment_default(p, i); } + if let Some((p, i, open)) = app.views.get_mut(index).and_then(|v| v.comments.attachment_request.take()) { + app.comment_attachment_action(id, p, i, open); + } if let Some((p, i)) = app.views.get_mut(index).and_then(|v| v.comments.props_request.take()) { app.open_comment_props(p, i); } diff --git a/crates/ui-egui/tests/comments.rs b/crates/ui-egui/tests/comments.rs index f4eb84359b..9a3716c3ee 100644 --- a/crates/ui-egui/tests/comments.rs +++ b/crates/ui-egui/tests/comments.rs @@ -1213,6 +1213,91 @@ fn attaching_a_file_as_a_comment() { assert_eq!(s.quick_tool, QuickTool::Select); } +/// One 300×200 page with a paperclip FileAttachment comment at (100, 100)–(120, 124) carrying +/// `inner.pdf`, and no /Contents: the card shows only "Attachment" (#2565). The cross-reference +/// table keeps the reader from mistaking the embedded PDF's objects for the outer ones. +fn attachment_comment_fixture() -> Vec { + let inner = "%PDF-1.7\n1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj\n2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 >> endobj\n\ + 3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 100 100] >> endobj\ntrailer << /Root 1 0 R >>\n%%EOF"; + let objs = [ + "<< /Type /Catalog /Pages 2 0 R >>".to_string(), + "<< /Type /Pages /Kids [3 0 R] /Count 1 >>".into(), + "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 300 200] /Annots [4 0 R] >>".into(), + "<< /Type /Annot /Subtype /FileAttachment /Rect [100 100 120 124] /Name /Paperclip /T (Reviewer) /M (D:20261014105200) /FS 5 0 R >>".into(), + "<< /Type /Filespec /F (inner.pdf) /UF (inner.pdf) /EF << /F 6 0 R >> >>".into(), + format!("<< /Type /EmbeddedFile /Length {} >>\nstream\n{inner}\nendstream", inner.len()), + ]; + let mut out = b"%PDF-1.7\n".to_vec(); + let mut offs = Vec::new(); + for (i, o) in objs.iter().enumerate() { + offs.push(out.len()); + out.extend_from_slice(format!("{} 0 obj\n{o}\nendobj\n", i + 1).as_bytes()); + } + let x = out.len(); + out.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objs.len() + 1).as_bytes()); + for o in offs { + 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{x}\n%%EOF\n", objs.len() + 1).as_bytes()); + out +} + +fn attachment_harness(setup: impl FnOnce(&mut PdfCraftApp) + 'static) -> Harness<'static, PdfCraftApp> { + let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).build_eframe(move |_cc| { + let mut app = PdfCraftApp::new(); + app.set_option("language", "en").unwrap(); + app.open_bytes("annexed.pdf", None, attachment_comment_fixture()).expect("opens"); + app.set_option("left", "closed").unwrap(); + setup(&mut app); + app + }); + h.run_steps(4); + settle(&mut h); + h +} + +fn open_tab_names(h: &Harness<'static, PdfCraftApp>) -> Vec { + let s = h.state(); + s.views.iter().filter_map(|v| s.session.get(v.id)).map(|d| d.name.clone()).collect() +} + +#[test] +fn an_attachment_comment_opens_its_file_from_the_page_menu() { + let mut h = attachment_harness(|app| { + app.set_option("panel", "comments").unwrap(); + app.set_option("quick", "select").unwrap(); + }); + assert_eq!(comments(&h)[0].subtype, "FileAttachment"); + click_pt(&mut h, (110.0, 112.0)); + assert_eq!(h.state().views[0].comments.selected, Some((0, 0)), "the attachment comment is selected"); + let p = at(&h, 110.0, 112.0); + h.hover_at(p); + h.run_steps(1); + h.event(egui::Event::PointerButton { pos: p, button: egui::PointerButton::Secondary, pressed: true, modifiers: Default::default() }); + h.event(egui::Event::PointerButton { pos: p, button: egui::PointerButton::Secondary, pressed: false, modifiers: Default::default() }); + h.run_steps(3); + h.get_by_label("Copy text"); + // The Attachments panel is closed, so these items can only come from the comment's menu. + h.get_by_label("Save attachment…"); + h.get_by_label("Open in a new tab").click(); + h.run_steps(4); + assert_eq!(open_tab_names(&h), ["annexed.pdf", "inner.pdf"]); +} + +#[test] +fn an_attachment_comment_opens_its_file_from_the_card_menu() { + let mut h = attachment_harness(|app| app.set_option("panel", "comments").unwrap()); + h.state_mut().views[0].comments.selected = Some((0, 0)); + h.run_steps(2); + h.get_by_label("More").click(); + h.run_steps(2); + h.get_by_label("Copy text"); + h.get_by_label("Save attachment…"); + h.get_by_label("Open in a new tab").click(); + h.run_steps(4); + assert_eq!(open_tab_names(&h), ["annexed.pdf", "inner.pdf"]); +} + /// Drag with the middle button (the mouse wheel) in small steps. fn middle_drag(h: &mut Harness<'static, PdfCraftApp>, from: Pos2, to: Pos2) { let button = |pos, pressed| egui::Event::PointerButton { pos, button: egui::PointerButton::Middle, pressed, modifiers: egui::Modifiers::NONE }; diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index e54e5fc14a..938e87a95d 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -3812,8 +3812,8 @@ milestone = "M5" status = "shipped" commands = ["comment.attach"] tools = ["comment_add"] -evidence = ["crates/annot/src/tests.rs::files_attach_as_comments", "crates/ui-egui/tests/comments.rs::attaching_a_file_as_a_comment", "crates/automation/tests/automation.rs::drawing_comments_through_tools"] -notes = "push pin, paperclip, graph or tag icon; the file is embedded and listed in the Attachments panel" +evidence = ["crates/annot/src/tests.rs::files_attach_as_comments", "crates/ui-egui/tests/comments.rs::attaching_a_file_as_a_comment", "crates/ui-egui/tests/comments.rs::an_attachment_comment_opens_its_file_from_the_page_menu", "crates/ui-egui/tests/comments.rs::an_attachment_comment_opens_its_file_from_the_card_menu", "crates/automation/tests/automation.rs::drawing_comments_through_tools"] +notes = "push pin, paperclip, graph or tag icon; the file is embedded and listed in the Attachments panel. The comment's menu, on the page and in the Comments panel, saves the file or opens a PDF in a new tab (#2565)" [[feature]] id = "comment.record-audio" From 7c8ca0bde80f6c969b4c7c675ae0fe979b87422c Mon Sep 17 00:00:00 2001 From: komo1iddin <45395275+komo1iddin@users.noreply.github.com> Date: Sun, 18 Oct 2026 15:46:56 +0800 Subject: [PATCH 080/105] M7: keep the text blocks cache after the document is edited (#2504) The engine caches a page's text blocks for opening a paragraph and applying its edit, but only while the document is unmodified: it was keyed by the source identity, which edits keep. After the first edit the cache never hit again, and the page was grouped again on every call (the inline editor and the panel each call it after an apply). pdfcraft_cos::Document::read_state() returns what the document reads as: its source identity, its trailer and every changed object, compared as allocations that the state keeps alive. A clone or a snapshot restored by undo has the same state; any set, add, free or trailer change makes a different one. The cache is keyed by it, so it hits after edits and never returns another state's blocks. On a page of 120 rows, text_blocks after an edit took 0.215 ms per call and now 0.006 ms (release build, best of three; a throwaway timing test). Tests: a read state matches a clone and not a document changed by set, free or a trailer change, nor another document; after an edit the engine groups the page once and undo and redo get their own blocks. The engine test fails without this change. Co-authored-by: Claude Opus 5.5 Co-authored-by: Brandon Thomas --- crates/cos/src/document.rs | 62 ++++++++++++++++++++++++++++++++++++++ crates/cos/src/lib.rs | 2 +- crates/engine/src/lib.rs | 24 ++++++--------- crates/engine/src/tests.rs | 21 +++++++++++++ 4 files changed, 94 insertions(+), 15 deletions(-) diff --git a/crates/cos/src/document.rs b/crates/cos/src/document.rs index a0b1f56eeb..f12f539dae 100644 --- a/crates/cos/src/document.rs +++ b/crates/cos/src/document.rs @@ -337,6 +337,40 @@ impl PartialEq for SourceIdentity { impl Eq for SourceIdentity {} +/// What a document reads as: its source ([`SourceIdentity`]), its trailer and every object changed +/// since it was opened. A clone, or a snapshot that undo restores, has the same state, and every +/// change makes a different one. Changed objects are compared as allocations, which the state +/// keeps alive, so a later object can't take an address one of them had. +#[derive(Clone)] +pub struct ReadState { + source: SourceIdentity, + trailer: Dict, + overlay: BTreeMap, +} + +impl ReadState { + /// Whether `doc` reads as this state. + pub fn matches(&self, doc: &Document) -> bool { + self.source == doc.source_identity() + && self.trailer == doc.trailer + && self.overlay.len() == doc.overlay.len() + && self.overlay.iter().zip(&doc.overlay).all(|((a, x), (b, y))| { + a == b + && match (x, y) { + (Slot::Set(g, o), Slot::Set(h, p)) => g == h && Arc::ptr_eq(o, p), + (Slot::Freed(g), Slot::Freed(h)) => g == h, + _ => false, + } + }) + } +} + +impl std::fmt::Debug for ReadState { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("ReadState").field("changed_objects", &self.overlay.len()).finish_non_exhaustive() + } +} + #[derive(Clone)] pub struct Document { data: Arc>, @@ -801,6 +835,12 @@ impl Document { SourceIdentity(Arc::downgrade(&self.source_identity)) } + /// What the document reads as now (see [`ReadState`]): unlike [`Self::source_identity`], it + /// changes with every edit, so a value derived from the document can be cached against it. + pub fn read_state(&self) -> ReadState { + ReadState { source: self.source_identity(), trailer: self.trailer.clone(), overlay: self.overlay.clone() } + } + pub fn version(&self) -> &str { &self.version } @@ -1935,6 +1975,28 @@ const MAX_XREF_OBJECTS: usize = 8_388_607; mod tests { use super::*; + #[test] + fn a_read_state_matches_a_clone_and_no_changed_document() { + let mut doc = Document::new_empty(); + let a = doc.add(Object::Int(1)); + let state = doc.read_state(); + let snapshot = doc.clone(); + assert!(state.matches(&doc) && state.matches(&snapshot)); + // The same value set again is still a change: another allocation. + doc.set(a, Object::Int(1)); + assert!(!state.matches(&doc) && state.matches(&snapshot)); + let edited = doc.read_state(); + doc.free(a); + assert!(!edited.matches(&doc)); + let mut trailer_changed = snapshot.clone(); + trailer_changed.trailer_mut().set(b"Info".to_vec(), Object::Null); + assert!(!state.matches(&trailer_changed)); + // Another document's state never matches, even with the same changes. + let mut other = Document::new_empty(); + other.add(Object::Int(1)); + assert!(!other.read_state().matches(&snapshot)); + } + /// Build a classic-xref file from object bodies (object i+1 = bodies[i]). pub(crate) fn build(bodies: &[&str], trailer: &str) -> Vec { let mut out = b"%PDF-1.7\n".to_vec(); diff --git a/crates/cos/src/lib.rs b/crates/cos/src/lib.rs index 213ff341be..152daed2de 100644 --- a/crates/cos/src/lib.rs +++ b/crates/cos/src/lib.rs @@ -22,7 +22,7 @@ mod security; mod writer; pub use bytes::Bytes; -pub use document::{Document, Revision, SourceIdentity, XrefEntry}; +pub use document::{Document, ReadState, Revision, SourceIdentity, XrefEntry}; pub use object::{Dict, MAX_DECODED, Name, ObjRef, Object, PdfString, Stream}; pub use parser::{Lexer, is_whitespace, parse_indirect}; pub use pdfcraft_crypt::{ diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 5fefe9b4b9..f86af3de0a 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -316,15 +316,15 @@ struct Editor { text_blocks: TextBlocksCache, } -/// One page's text blocks ([`pdfcraft_edit::text_blocks`]) and the unmodified document state they -/// were read from. Opening a paragraph and applying its edit both need them, and grouping a long -/// page is not cheap. Only an unmodified document is cached: its identity changes on every edit, -/// undo and save (each reopens the working file), so a stale entry never matches. +/// One page's text blocks ([`pdfcraft_edit::text_blocks`]) and the document state they were read +/// from. Opening a paragraph and applying its edit both need them, and grouping a long page is not +/// cheap. The state ([`pdfcraft_cos::ReadState`]) changes with every edit and save and returns +/// with undo, so a stale entry never matches, also after the document has been edited. #[derive(Default)] struct TextBlocksCache(std::sync::Mutex>); /// The document state, the page (0-based) and its text blocks. -type CachedTextBlocks = (pdfcraft_cos::SourceIdentity, usize, Arc>); +type CachedTextBlocks = (pdfcraft_cos::ReadState, usize, Arc>); impl Clone for TextBlocksCache { fn clone(&self) -> Self { @@ -336,20 +336,16 @@ impl Clone for TextBlocksCache { impl Editor { /// The text blocks on `page` (0-based), from the cache when the document hasn't changed. fn text_blocks(&self, page: usize) -> Result>, EditError> { - let source = (!self.cos.is_modified()).then(|| self.cos.source_identity()); - if let Some(source) = &source - && let Ok(cache) = self.text_blocks.0.lock() - && let Some((s, p, blocks)) = cache.as_ref() - && s == source + if let Ok(cache) = self.text_blocks.0.lock() + && let Some((state, p, blocks)) = cache.as_ref() && *p == page + && state.matches(&self.cos) { return Ok(blocks.clone()); } let blocks = Arc::new(pdfcraft_edit::text_blocks(&self.cos, page)?); - if let Some(source) = source - && let Ok(mut cache) = self.text_blocks.0.lock() - { - *cache = Some((source, page, blocks.clone())); + if let Ok(mut cache) = self.text_blocks.0.lock() { + *cache = Some((self.cos.read_state(), page, blocks.clone())); } Ok(blocks) } diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index ea410cafbb..61c99bb707 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -5890,3 +5890,24 @@ fn a_phone_photo_used_as_a_custom_stamp_is_embedded_upright() { let rect = pdfcraft_annot::summaries(cos)[0].rect; assert!(((rect[2] - rect[0]) - 20.0).abs() < 0.01 && ((rect[3] - rect[1]) - 30.0).abs() < 0.01, "{rect:?}"); } + +/// Opening a paragraph and applying its edit read the page's text blocks; after an edit they are +/// still read once per document state, and undo and redo never get another state's blocks. +#[test] +fn text_blocks_stay_cached_after_an_edit_and_follow_undo_and_redo() { + let (mut session, id) = session_with(1); + let blocks = |s: &Session| s.get(id).unwrap().editor.as_ref().unwrap().text_blocks(0).unwrap(); + let before = blocks(&session); + assert_eq!(before[0].editor_text, "Page 1"); + let edit = Edit::EditTextBlock { page: 0, block: 0, text: "Edited".into(), style: pdfcraft_edit::BlockStyle::default() }; + session.apply(id, edit).unwrap(); + let edited = blocks(&session); + assert_eq!(edited[0].editor_text, "Edited"); + assert!(Arc::ptr_eq(&edited, &blocks(&session)), "the edited page is grouped once"); + session.undo(id).unwrap(); + assert_eq!(blocks(&session)[0].editor_text, "Page 1"); + session.redo(id).unwrap(); + let redone = blocks(&session); + assert_eq!(redone[0].editor_text, "Edited"); + assert!(Arc::ptr_eq(&redone, &blocks(&session))); +} From 36d239d2b1179cded620f3a18e4e0035e3f6621d Mon Sep 17 00:00:00 2001 From: komo1iddin <45395275+komo1iddin@users.noreply.github.com> Date: Sun, 18 Oct 2026 15:47:08 +0800 Subject: [PATCH 081/105] M7: fold repeated group moves of a cropped image into one clip cm (#2476) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Moving an image whose crop is its own (`q re W n cm Do Q`) as part of a group put a fresh `cm` before the clip path on every move, so twenty moves left twenty `cm` operators in front of one `re`. It never nested deeper, but the content stream grew with every drag. When a `cm` already sits right before that clip (one an earlier move added, or the file's own), `translate_images` now replaces it with the product of the new and old matrices. The CTM at the clip is `c · prev · outer` either way (ISO 32000-1 8.3.4, 8.4.4). A previous `cm` that is not finite, or a product larger than 1e12, keeps the old behaviour and inserts a separate `cm`. The new test moves an image cropped smaller than itself twenty times and checks that the image and its visible crop both moved by the full offset, that the operator count stays at its value after the first move, and that the caption after it is untouched. It fails without this change (32 operators against 13). Co-authored-by: Claude Opus 5.5 --- crates/edit/src/images.rs | 15 ++++++++++++--- crates/edit/src/tests.rs | 25 +++++++++++++++++++++++++ docs/editing-images.md | 3 ++- 3 files changed, 39 insertions(+), 4 deletions(-) diff --git a/crates/edit/src/images.rs b/crates/edit/src/images.rs index fb62125880..62bef8bc5e 100644 --- a/crates/edit/src/images.rs +++ b/crates/edit/src/images.rs @@ -784,7 +784,16 @@ pub(crate) fn translate_images(doc: &mut Document, page: usize, indexes: &[usize return Err(EditError::Invalid("the image move is too large".into())); }; plans.remove(&(stream, index)); - clips.insert((stream, at), c); + // 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(); @@ -799,8 +808,8 @@ pub(crate) fn translate_images(doc: &mut Document, page: usize, indexes: &[usize folds.insert(at, folded); } let changed = crate::text::splice(&pieces, &ops, |index| { - if let Some(c) = clips.remove(&(stream, index)) { - return (vec![Op::new("cm", c.0.map(Object::Real).to_vec())], true); + 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); diff --git a/crates/edit/src/tests.rs b/crates/edit/src/tests.rs index 88e3d78065..7930a7eade 100644 --- a/crates/edit/src/tests.rs +++ b/crates/edit/src/tests.rs @@ -1921,6 +1921,31 @@ fn repeated_image_edits_change_one_transform_instead_of_nesting() { } } +#[test] +fn repeated_group_moves_of_a_cropped_image_change_one_clip_transform() { + // The crop is smaller than the image, so a clip left behind would shrink `visible_rect`. + let mut doc = image_page_with("q 150 120 100 50 re W n 200 0 0 100 100 100 cm /Im0 Do Q BT /F1 12 Tf 72 700 Td (Caption) Tj ET"); + let operators = |doc: &Document| pdfcraft_content::parse(&page_content_bytes(doc, 0)).ops.len(); + let start = images::page_images(&doc, 0).unwrap()[0].clone(); + let mut first = 0; + for i in 0..20 { + images::translate_images(&mut doc, 0, &[0], [1.0, -2.0]).unwrap(); + if i == 0 { + first = operators(&doc); + } + } + let moved = |r: [f64; 4]| [r[0] + 20.0, r[1] - 40.0, r[2] + 20.0, r[3] - 40.0]; + let now = images::page_images(&doc, 0).unwrap()[0].clone(); + assert!(close(now.rect, moved(start.rect)), "{:?}", now.rect); + assert!(close(now.visible_rect.unwrap(), moved(start.visible_rect.unwrap())), "the crop moves with the image: {:?}", now.visible_rect); + assert_eq!(operators(&doc), first, "twenty group moves add no more operators than one"); + assert_eq!(q_depth(&doc), 1); + let doc = reopen(&doc); + assert!(close(images::page_images(&doc, 0).unwrap()[0].visible_rect.unwrap(), moved(start.visible_rect.unwrap()))); + let caption: Vec<_> = text::text_lines(&doc, 0).unwrap().into_iter().map(|l| l.text).collect(); + assert_eq!(caption, ["Caption"]); +} + #[test] fn page_images_move_turn_replace_and_delete() { let mut doc = image_page(); diff --git a/docs/editing-images.md b/docs/editing-images.md index eba918ef39..67d3613bf1 100644 --- a/docs/editing-images.md +++ b/docs/editing-images.md @@ -8,7 +8,8 @@ together with its pixels, including nested graphics states, intervening state op and paths split across content streams. A clip shared with other content stays where it is. When the figure already sits alone in a `q … cm … Q` (its own placement, or the one an earlier edit added), moving or resizing it again changes that `cm`, so repeated edits don't -nest one more graphics state each time. +nest one more graphics state each time. Moving a privately cropped figure again likewise +changes the `cm` before its clip instead of adding another. PDF figures are not always bitmap images. Imported diagrams often use a **Form XObject**, which packages text, vector paths, and nested images into one object. PdfCraft selects From 94f1a0d15788cb6a9fc598ae56081260286e712a Mon Sep 17 00:00:00 2001 From: mtlcl410 <2368091+mtlcl410@users.noreply.github.com> Date: Sun, 18 Oct 2026 15:47:43 +0800 Subject: [PATCH 082/105] fix(engine): a new or recovered document stays unsaved through undo (#2346) * fix(engine): a new or recovered document stays unsaved through undo #1435 made undo back to the last saved bytes clean. For a document created by Combine, Extract or Create, or restored from recovery, those bytes were never written where the document belongs, so edit then Undo made it clean: Close no longer asked to save and autosave stopped keeping it. Editor::unsaved_file, set by open_new and mark_recovered and cleared by mark_saved, keeps such a document unsaved until it is saved; Revert follows the same rule. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01RUNQSPVXx3dDBabRZHAnjz * fix(engine): test that doc_close refuses a recovered or new document after undo Review follow-up on #2346: a recovered document and a new one, rotated and undone, are refused by doc_close without discard_changes, and close with it. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01RUNQSPVXx3dDBabRZHAnjz --------- Co-authored-by: Claude Opus 5.5 Co-authored-by: Brandon Thomas --- crates/automation/src/lib.rs | 19 +++++++++++++++++++ crates/engine/src/lib.rs | 16 ++++++++++++++-- crates/engine/src/tests.rs | 28 ++++++++++++++++++++++++++++ 3 files changed, 61 insertions(+), 2 deletions(-) diff --git a/crates/automation/src/lib.rs b/crates/automation/src/lib.rs index 88856305b2..52008170c0 100644 --- a/crates/automation/src/lib.rs +++ b/crates/automation/src/lib.rs @@ -3101,4 +3101,23 @@ mod tests { std::fs::set_permissions(&target, perms).unwrap(); let _ = std::fs::remove_dir_all(&dir); } + + /// A recovered document, or a new one, rotated and then undone still has never been saved: + /// `doc_close` refuses it unless told to discard it (#2346 review). + #[test] + fn closing_a_recovered_or_new_document_after_undo_is_refused() { + let mut a = Automation::new(); + let recovered = a.session.open("Report.pdf", None, Arc::new(numbered_pages(2)), None).unwrap(); + a.session.mark_recovered(recovered, Some("Report.pdf".into())); + let new = a.session.open_new("Combined.pdf", Arc::new(numbered_pages(2))).unwrap(); + for id in [recovered, new] { + json(&mut a, "page_rotate", json!({"doc": id.0, "pages": [1], "degrees": 90})); + json(&mut a, "edit_undo", json!({"doc": id.0})); + let refused = a.call("doc_close", &json!({"doc": id.0})); + assert!(matches!(&refused, Err(e) if e.to_string().contains("unsaved changes")), "{id:?}: {refused:?}"); + assert!(a.session.get(id).is_some(), "still open"); + json(&mut a, "doc_close", json!({"doc": id.0, "discard_changes": true})); + assert!(a.session.get(id).is_none()); + } + } } diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index f86af3de0a..0a1b018281 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -305,6 +305,10 @@ struct Editor { saved: Arc>, /// Passwords that open [`Self::saved`]. saved_keys: Keys, + /// [`Self::saved`] was never written where the document belongs: a new document (combine, + /// extract, create) or one restored from recovery. It stays unsaved until a save, whatever + /// undo brings back, or closing it would lose the whole document without asking. + unsaved_file: bool, undo: Vec, redo: Vec, keys: Keys, @@ -3542,6 +3546,7 @@ impl Session { cos, saved, saved_keys: keys.clone(), + unsaved_file: false, undo: Vec::new(), redo: Vec::new(), keys, @@ -3839,7 +3844,7 @@ impl Session { /// file is clean; undo past a later save is not, because the restored snapshot's own base /// is an earlier revision. fn sync_dirty(doc: &mut Document) { - doc.dirty = doc.editor.as_ref().is_some_and(|e| e.cos.is_modified() || !Arc::ptr_eq(e.cos.bytes(), &e.saved)); + doc.dirty = doc.editor.as_ref().is_some_and(|e| e.unsaved_file || e.cos.is_modified() || !Arc::ptr_eq(e.cos.bytes(), &e.saved)); } /// Use the current state's password for viewing (protection edits change it). @@ -4124,6 +4129,7 @@ impl Session { pdfcraft_cos::Document::open_with_password(bytes.clone(), keys.reopen.as_deref()).map_err(|e| EditError::Reopen(e.to_string()))?; editor.saved = bytes; editor.saved_keys = keys; + editor.unsaved_file = false; } if let Some(p) = path { doc.name = std::path::Path::new(&p).file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_else(|| p.clone()); @@ -4187,7 +4193,7 @@ impl Session { editor.undo.clear(); editor.redo.clear(); Self::adopt_keys(doc); - doc.dirty = false; + Self::sync_dirty(doc); doc.generation += 1; Self::refresh(doc).inspect_err(|_| Self::show_released(doc)) } @@ -4667,6 +4673,9 @@ impl Session { let id = self.open(name, None, bytes, None)?; if let Some(d) = self.docs.iter_mut().find(|d| d.id == id) { d.dirty = true; + if let Some(e) = d.editor.as_mut() { + e.unsaved_file = true; + } // Unsaved work with no edit yet still needs its first recovery snapshot. d.generation += 1; } @@ -4719,6 +4728,9 @@ impl Session { if let Some(d) = self.docs.iter_mut().find(|d| d.id == id) { d.path = path; d.dirty = true; + if let Some(e) = d.editor.as_mut() { + e.unsaved_file = true; + } d.generation += 1; d.snapshot_generation = d.generation; // its bytes are already in the recovery store } diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 61c99bb707..204e75c822 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -5911,3 +5911,31 @@ fn text_blocks_stay_cached_after_an_edit_and_follow_undo_and_redo() { assert_eq!(redone[0].editor_text, "Edited"); assert!(Arc::ptr_eq(&redone, &blocks(&session))); } + +/// A new document, or one restored from recovery, has never been written where it belongs: undo +/// back to the bytes it was opened from must not make it clean, or closing it would discard the +/// whole document without asking. Saving makes it clean; Revert of a saved one too. +#[test] +fn undo_never_makes_an_unsaved_document_clean() { + let mut s = Session::new().with_clock(|| 1_700_000_000); + let new = s.open_new("Combined.pdf", Arc::new(fixture(2))).unwrap(); + s.apply(new, Edit::RotatePages { pages: vec![0], degrees: 90 }).unwrap(); + s.undo(new).unwrap(); + assert!(s.get(new).unwrap().dirty, "a new document is unsaved until it is saved"); + assert_eq!(s.autosave_snapshots().len(), 1, "and still needs recovery"); + s.revert(new).unwrap(); + assert!(s.get(new).unwrap().dirty, "Revert goes back to the created bytes, which aren't saved anywhere"); + + let recovered = s.open("Report.pdf", None, Arc::new(fixture(2)), None).unwrap(); + s.mark_recovered(recovered, Some("Report.pdf".into())); + s.apply(recovered, Edit::RotatePages { pages: vec![0], degrees: 90 }).unwrap(); + s.undo(recovered).unwrap(); + assert!(s.get(recovered).unwrap().dirty, "the recovered work was never saved"); + + let saved = s.save_bytes(new).unwrap(); + s.mark_saved(new, saved, Some("Combined.pdf".into())).unwrap(); + assert!(!s.get(new).unwrap().dirty); + s.apply(new, Edit::RotatePages { pages: vec![0], degrees: 90 }).unwrap(); + s.undo(new).unwrap(); + assert!(!s.get(new).unwrap().dirty, "once saved, undo back to the saved state is clean"); +} From 5827afdcded1ff81212ac2af44686124f646ad6d Mon Sep 17 00:00:00 2001 From: Ridwan <49554047+Ridzzz0Alam@users.noreply.github.com> Date: Sun, 18 Oct 2026 09:48:00 +0200 Subject: [PATCH 083/105] M8: measure fonts without /Widths with their embedded program (#2130) (#2232) Find and the renderer place glyphs of a simple font that has no /Widths with the embedded font program's own advances, but redaction guessed the widths from the font's name (a standard-14 face, Helvetica by default). When the guess was wider than the real font, the end of a line under a mark seemed to lie past it and was kept in the content stream, and the verify pass, using the same guess, agreed. Metrics now fills the width table from an embedded sfnt program (FontFile2, or OpenType FontFile3) when /Widths is missing, looking the glyph up as the renderer does. A subset's unmapped codes get width 0 so has_glyph stays false for them. Fonts with /Widths, and bare CFF or Type 1 programs, are unchanged. Fixes #2130 --- crates/fonts/src/pdf.rs | 76 +++++++++++++++++++++++++++++++++++++- crates/redact/src/tests.rs | 24 ++++++++++++ 2 files changed, 98 insertions(+), 2 deletions(-) diff --git a/crates/fonts/src/pdf.rs b/crates/fonts/src/pdf.rs index 0cc456cba6..bb22d15458 100644 --- a/crates/fonts/src/pdf.rs +++ b/crates/fonts/src/pdf.rs @@ -1,8 +1,8 @@ //! What content editing and redaction need from a PDF font: how to split a string into character //! codes, each code's advance width, the glyph height (ascent, descent), and what each code means //! (Unicode) — and back, which text the font can show. Widths come from the font dictionary -//! (`/Widths`, `/W`, `/DW`, `/MissingWidth`); a standard-14 font with no `/Widths` uses the exact -//! published metrics ([`crate::Std14`]). +//! (`/Widths`, `/W`, `/DW`, `/MissingWidth`); a simple font with no `/Widths` uses its embedded +//! sfnt program's advances, else the exact published standard-14 metrics ([`crate::Std14`]). //! //! Meanings come from `/ToUnicode`, else the encoding (`/Encoding` base and `/Differences` glyph //! names, ISO 32000-2 Annex D). A code may deliberately have **no** meaning: where the font's own @@ -167,6 +167,44 @@ fn cids_with_outlines(doc: &Document, font: &Dict) -> Vec { } } +/// A simple font with no `/Widths` is drawn (and found by text search) with its embedded +/// program's own advances, so measure with them too; a standard-14 guess from the name can be +/// wider and leave the end of a line outside a redaction mark (#2130). The glyph is looked up +/// the way the renderer does: the encoding's character in the Unicode cmap, else the code itself +/// (as in a symbolic cmap), else `.notdef`. Only sfnt programs are read; others keep the guess. +fn embedded_widths(doc: &Document, font: &Dict, m: &mut Metrics) { + use skrifa::instance::{LocationRef, Size}; + use skrifa::{GlyphId, MetadataProvider as _, raw::TableProvider as _}; + let Some(descriptor) = dict(doc, font.get(b"FontDescriptor")) else { return }; + for key in [b"FontFile2".as_slice(), b"FontFile3".as_slice()] { + let Some(object) = descriptor.get(key) else { continue }; + let object = doc.resolve(object); + let Object::Stream(stream) = object.as_ref() else { continue }; + let Ok(bytes) = stream.decoded_within(16 << 20) else { continue }; + let Ok(face) = skrifa::FontRef::from_index(&bytes, 0) else { continue }; + let upem = f64::from(face.metrics(Size::unscaled(), LocationRef::default()).units_per_em); + if upem == 0.0 { + continue; + } + let advances = face.glyph_metrics(Size::unscaled(), LocationRef::default()); + let (charmap, cmap) = (face.charmap(), face.cmap().ok()); + let found = |g: Option| g.filter(|g| *g != GlyphId::NOTDEF); + m.widths = (0..=255u32) + .map(|code| { + let glyph = found(m.unicode.encoding.get(&code).and_then(|t| only_char(t)).and_then(|c| charmap.map(c))) + .or_else(|| [0, 0xF000, 0xF100, 0xF200].into_iter().find_map(|o| found(cmap.as_ref()?.map_codepoint(code + o)))); + // A subset holds only the glyphs it draws: 0 keeps `has_glyph` false for the rest. + match glyph { + None if m.subset => 0.0, + _ => advances.advance_width(glyph.unwrap_or(GlyphId::NOTDEF)).map_or(0.0, |w| f64::from(w) * 1000.0 / upem), + } + }) + .collect(); + m.first = 0; + return; + } +} + /// Replace an existing cmap directory entry with a bounded Unicode format-12 table. /// Checked ranges prevent damaged sfnt offsets from indexing outside the source program. fn append_editor_cmap(bytes: &mut Vec, mappings: &std::collections::BTreeMap) -> Option<()> { @@ -607,6 +645,9 @@ impl Metrics { Codes::Ranges(r) => r.last().map_or(2, |x| x.0), }; m.unicode = unicode_map(doc, font, m.composite); + if m.std14.is_some() { + embedded_widths(doc, font, &mut m); + } let mut reverse = HashMap::new(); for (&code, text) in m.unicode.explicit.iter().map(|(c, (_, t))| (c, t)).chain(m.unicode.encoding.iter()) { if let Some(ch) = only_char(text) @@ -1218,6 +1259,37 @@ mod tests { assert!(embedded_sfnt(&doc, &Dict::new()).is_none()); } + /// #2130: with no `/Widths`, an embedded program's advances win over a guess from the name + /// ("Mono" would mean Courier, 600 units for every glyph). + #[test] + fn a_font_without_widths_measures_with_its_embedded_program() { + use pdfcraft_cos::Stream; + use skrifa::MetadataProvider as _; + let mut doc = Document::new_empty(); + let bytes = include_bytes!("../../../assets/fonts/Inter-Regular.ttf"); + let face = skrifa::FontRef::new(bytes).unwrap(); + let (size, location) = (skrifa::instance::Size::unscaled(), skrifa::instance::LocationRef::default()); + let upem = f64::from(face.metrics(size, location).units_per_em); + let advance = |c: char| f64::from(face.glyph_metrics(size, location).advance_width(face.charmap().map(c).unwrap()).unwrap()) / upem; + let program = doc.add(Object::Stream(Stream::flate(Dict::new(), bytes))); + let mut descriptor = Dict::new(); + descriptor.set(b"FontFile2".to_vec(), Object::Ref(program)); + let f = font( + &mut doc, + vec![ + ("Subtype", Object::name("TrueType")), + ("BaseFont", Object::name("HouseMono")), + ("Encoding", Object::name("WinAnsiEncoding")), + ("FontDescriptor", Object::Dict(descriptor)), + ], + ); + let m = Metrics::from_dict(&doc, &f); + for c in ['i', 'l', 'W', ' '] { + assert!((m.width(u32::from(c)) - advance(c)).abs() < 1e-9, "{c}: {} vs {}", m.width(u32::from(c)), advance(c)); + } + assert!(m.width(u32::from('i')) < 0.4, "not Courier's 0.6"); + } + #[test] fn mac_subset_cmap_uses_pdf_unicode_without_changing_source_program() { use skrifa::{MetadataProvider as _, raw::TableProvider as _}; diff --git a/crates/redact/src/tests.rs b/crates/redact/src/tests.rs index 47458cfd2b..a4e3530aba 100644 --- a/crates/redact/src/tests.rs +++ b/crates/redact/src/tests.rs @@ -79,6 +79,30 @@ fn glyphs_under_a_mark_go_and_the_rest_stays_put() { assert!(!content(&doc, 0).contains("1234")); } +/// #2130: a font with no /Widths is drawn with its embedded program's advances. Measured with a +/// guess from its name instead (Courier's 600 units for "Mono"), the end of the line seemed to +/// lie past a mark that covers all of it on screen, and was kept. +#[test] +fn a_whole_line_under_a_mark_goes_even_without_widths() { + let text = b"illicit little lilies"; + let font = "<< /Type /Font /Subtype /TrueType /BaseFont /HouseMono /Encoding /WinAnsiEncoding /FontDescriptor 6 0 R >>"; + let mut doc = pdf(vec![ + b"<< /Type /Catalog /Pages 2 0 R >>".to_vec(), + b"<< /Type /Pages /Kids [3 0 R] /Count 1 >>".to_vec(), + b"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 300 300] /Contents 4 0 R /Resources << /Font << /F1 5 0 R >> >> >>".to_vec(), + stream("", [b"BT /F1 10 Tf 10 100 Td (".as_slice(), text, b") Tj ET"].concat()), + font.as_bytes().to_vec(), + b"<< /Type /FontDescriptor /FontName /HouseMono /Flags 32 /Ascent 900 /Descent -200 /FontFile2 7 0 R >>".to_vec(), + stream(&format!("/Length1 {}", pdfcraft_fonts::INTER_REGULAR.len()), pdfcraft_fonts::INTER_REGULAR), + ]); + // Inter draws these 21 narrow glyphs in about 60 pt; Courier's guess would need 126. + let line = [5.0, 95.0, 90.0, 112.0]; + mark(&mut doc, 0, &[line], ""); + let r = apply(&mut doc, None).unwrap(); + assert_eq!(r.glyphs, text.len(), "every glyph of the line goes"); + assert_eq!(under(&mut doc, 0, &[[0.0, 0.0, 300.0, 300.0]]), 0, "{}", content(&doc, 0)); +} + #[test] fn added_text_parameters_do_not_keep_redacted_text() { // An Edit ▸ Add content item: its stream dictionary keeps the source text under /PCAdded. From 72dac33f8e82397be653f224f5155d3c896619f0 Mon Sep 17 00:00:00 2001 From: wstock <1394687+wstock@users.noreply.github.com> Date: Sun, 18 Oct 2026 07:48:23 +0000 Subject: [PATCH 084/105] Issue #2367: an object stream whose entry cannot place it is found by its header (#2398) * Issue #2367: an object stream whose entry cannot place it is found by its header qpdf's xref-compressed-in-compressed.pdf marks object stream 69 as itself compressed inside stream 81, which the format forbids and which does not exist. objstm() loaded the container through its cross-reference entry only, so every object inside (the outlines, the page labels) read as null and a full save wrote `/Outlines null /PageLabels null`, while an incremental save (which copies the original bytes) kept them, and MuPDF recovers them. The container is now found by its own `N G obj` header when its entry cannot load it (scan_for, the same last resort a plain object with a wrong offset has). Regression test: document::tests::an_object_stream_marked_as_compressed_is_found_by_its_header. Verified on the fixture: objects 70 and 82 load (Count 6; 12 page-label ranges) and a full save keeps both. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_011K1peyrh5MdZ6ttHkVq8Gh The fallback is for file entries only: once the stream is freed or replaced in the overlay, its objects must not come back from the file's copy (sign's object_stream_changes_cannot_reuse_old_candidate_membership caught this after the rebase); a cos test covers both cases. * cos: a container the saved table marks free is not found by its header either Freeing an object stream, saving incrementally and reopening left the overlay empty and the table saying the stream is free; the header fallback then rescanned the file and the stream's objects came back on the next full save. The fallback now also stops at an on-disk free entry. * M1: put the #2367 test's description on the test, not its fixture --------- Co-authored-by: Brandon Thomas --- crates/cos/src/document.rs | 101 ++++++++++++++++++++++++++++++++++++- 1 file changed, 100 insertions(+), 1 deletion(-) diff --git a/crates/cos/src/document.rs b/crates/cos/src/document.rs index f12f539dae..e943555905 100644 --- a/crates/cos/src/document.rs +++ b/crates/cos/src/document.rs @@ -1153,7 +1153,19 @@ impl Document { if let Some(s) = self.objstms.lock().map_err(|_| CosError::Poisoned)?.get(&num) { return Ok(s.clone()); } - let Object::Stream(s) = &*self.try_get(num)? else { return Err(CosError::MissingObject(num)) }; + // A file entry that cannot place the stream (one that says it is itself compressed + // inside another stream, as in #2367, or points nowhere) is tried by the stream's own + // header, as a plain object with a wrong offset is. An edited or freed stream, in the + // overlay or in a saved revision's table, is what that says it is: the file's copy + // must not come back. + let loaded = match self.try_get(num) { + Ok(o) if matches!(*o, Object::Stream(_)) => o, + _ if self.overlay.contains_key(&num) || matches!(self.xref_entry(num), Some(XrefEntry::Free { .. })) => { + return Err(CosError::MissingObject(num)); + } + _ => Arc::new(self.scan_for(num).map(|(id, o)| self.decrypted(id, o)).ok_or(CosError::MissingObject(num))?), + }; + let Object::Stream(s) = &*loaded else { return Err(CosError::MissingObject(num)) }; let n = s.dict.int(b"N").unwrap_or(0).clamp(0, 1_000_000) as usize; let first = s.dict.int(b"First").unwrap_or(0).max(0) as usize; let data = s.decoded_within(self.stream_limit)?; @@ -2597,6 +2609,93 @@ mod tests { assert!(old.get(ObjRef::new(1, 0)).as_dict().unwrap().get(b"Lang").is_none()); } + /// qpdf's `xref-compressed-in-compressed.pdf` in miniature: object 3 is an object stream + /// holding object 4, and the cross-reference stream says object 3 itself lives in stream 99. + fn compressed_in_compressed_fixture() -> Document { + let mut out = b"%PDF-1.5\n".to_vec(); + let at = |out: &mut Vec, num: u32, body: &str| { + let off = out.len(); + out.extend_from_slice(format!("{num} 0 obj\n{body}\nendobj\n").as_bytes()); + off as u16 + }; + let o1 = at(&mut out, 1, "<< /Type /Catalog /Pages 2 0 R /Outlines 4 0 R >>"); + let o2 = at(&mut out, 2, "<< /Type /Pages /Kids [] /Count 0 >>"); + let inner = "<< /Type /Outlines /Count 0 >>"; + at(&mut out, 3, &format!("<< /Type /ObjStm /N 1 /First 4 /Length {} >>\nstream\n4 0 {inner}\nendstream", 4 + inner.len())); + // /W [1 2 1]: object 3 is said to live in stream 99 (which does not exist), 4 in stream 3. + let rows: Vec<[u8; 4]> = vec![ + [0, 0, 0, 255], + [1, 0, o1 as u8, 0], + [1, 0, o2 as u8, 0], + [2, 0, 99, 0], + [2, 0, 3, 0], + [1, (out.len() >> 8) as u8, out.len() as u8, 0], + ]; + let rows: Vec = rows.concat(); + let xref = out.len(); + out.extend_from_slice( + format!("5 0 obj\n<< /Type /XRef /W [1 2 1] /Index [0 6] /Size 6 /Root 1 0 R /Length {} >>\nstream\n", rows.len()).as_bytes(), + ); + out.extend_from_slice(&rows); + out.extend_from_slice(format!("\nendstream\nendobj\nstartxref\n{xref}\n%%EOF\n").as_bytes()); + Document::open(Arc::new(out)).unwrap() + } + + /// An object stream whose own cross-reference entry says it is compressed inside another + /// stream (which the format forbids, and which does not exist here: qpdf's + /// `xref-compressed-in-compressed.pdf`, #2367) could not be loaded, so every object in it + /// read as null and a full save wrote `/Outlines null`. The stream is now found by its own + /// header, as a plain object with a wrong offset is. + #[test] + fn an_object_stream_marked_as_compressed_is_found_by_its_header() { + let doc = compressed_in_compressed_fixture(); + assert_eq!(doc.revisions().len(), 1, "the chain itself reads: {:?}", doc.repair_log()); + let outlines = doc.get(ObjRef::new(4, 0)); + assert_eq!(outlines.as_dict().and_then(|d| d.name(b"Type")), Some(&b"Outlines"[..]), "{outlines:?}"); + let saved = crate::write_full(&doc, &crate::SaveOptions::default()).unwrap(); + let back = Document::open(Arc::new(saved)).unwrap(); + let catalog = back.get(back.root().unwrap()); + let outlines = back.resolve(catalog.as_dict().unwrap().get(b"Outlines").unwrap()); + assert_eq!(outlines.as_dict().and_then(|d| d.name(b"Type")), Some(&b"Outlines"[..]), "the full save keeps it: {outlines:?}"); + } + + /// The header fallback is for file entries only: once the stream is freed (or replaced) in + /// the overlay, its objects must not come back from the file's copy of it. + #[test] + fn a_freed_object_stream_is_not_found_by_its_header() { + let doc = compressed_in_compressed_fixture(); + assert!(doc.get(ObjRef::new(4, 0)).as_dict().is_some()); + let mut edited = doc.clone(); + edited.free(ObjRef::new(3, 0)); + let fresh = edited.object_reader().document; + assert!(matches!(*fresh.get(ObjRef::new(4, 0)), Object::Null), "{:?}", fresh.get(ObjRef::new(4, 0))); + let mut replaced = doc.clone(); + replaced.set(ObjRef::new(3, 0), Object::Int(0)); + let fresh = replaced.object_reader().document; + assert!(matches!(*fresh.get(ObjRef::new(4, 0)), Object::Null), "{:?}", fresh.get(ObjRef::new(4, 0))); + } + + /// The same once the free is saved: the revision's table says the stream is free, and + /// neither reading the file back nor rewriting it in full brings its objects back. + #[test] + fn a_container_freed_in_a_saved_revision_is_not_found_by_its_header() { + let mut edited = compressed_in_compressed_fixture(); + edited.free(ObjRef::new(3, 0)); + let incremental = crate::write_incremental(&edited, &crate::SaveOptions::default()).unwrap(); + let reopened = Document::open(Arc::new(incremental)).unwrap(); + assert_eq!(reopened.revisions().len(), 2); + assert!(matches!(reopened.xref_entry(3), Some(XrefEntry::Free { .. }))); + assert!(matches!(*reopened.get(ObjRef::new(4, 0)), Object::Null), "{:?}", reopened.get(ObjRef::new(4, 0))); + for object_streams in [true, false] { + let opts = crate::SaveOptions { object_streams, ..Default::default() }; + let full = crate::write_full(&reopened, &opts).unwrap(); + let back = Document::open(Arc::new(full)).unwrap(); + let catalog = back.get(back.root().unwrap()); + let outlines = catalog.as_dict().unwrap().get(b"Outlines").map(|o| back.resolve(o)); + assert!(outlines.as_ref().is_none_or(|o| matches!(**o, Object::Null)), "the full save keeps it out: {outlines:?}"); + } + } + #[test] fn damaged_xref_is_reconstructed() { let mut bytes = build(&["<< /Type /Catalog /Pages 2 0 R >>", "<< /Type /Pages /Kids [] /Count 0 >>"], "/Root 1 0 R"); From bd0b3fdb65d2c57a22c003d834720ecd6e96eee6 Mon Sep 17 00:00:00 2001 From: Marco Arnulfo <103679419+marcoarnulfo@users.noreply.github.com> Date: Sun, 18 Oct 2026 09:48:35 +0200 Subject: [PATCH 085/105] Save As to another file writes a clean copy without earlier revisions (fixes #2165) (#2174) --- crates/ui-egui/src/editing.rs | 29 ++++++++++++++++++++--------- crates/ui-egui/tests/editing.rs | 21 +++++++++++++++++++++ crates/ui-egui/tests/signing.rs | 32 ++++++++++++++++++++++++++++++++ docs/architecture.md | 3 ++- 4 files changed, 75 insertions(+), 10 deletions(-) diff --git a/crates/ui-egui/src/editing.rs b/crates/ui-egui/src/editing.rs index 8b63c49fe9..0e5307f0e9 100644 --- a/crates/ui-egui/src/editing.rs +++ b/crates/ui-egui/src/editing.rs @@ -24,6 +24,7 @@ pub enum CloseRequest { pub struct PendingOverwrite { pub id: pdfcraft_engine::DocId, pub dest: String, + target: SaveTarget, /// What was to run once the document is written (close its tab, quit). after: Box, } @@ -621,12 +622,12 @@ impl PdfCraftApp { let dialog = rfd::AsyncFileDialog::new().add_filter("PDF", &["pdf"]).set_file_name(name); // The bytes are taken once the user has chosen, so edits made meanwhile are saved too. self.ask_one(crate::pickers::Ask::Save(dialog), None, move |app, dest| { - app.save_doc_to_then(id, &dest.to_string_lossy(), Box::new(after)); + app.save_doc_to_then(id, &dest.to_string_lossy(), target, Box::new(after)); }); return false; } }; - self.save_doc_to_then(id, &destination, Box::new(after)) + self.save_doc_to_then(id, &destination, target, Box::new(after)) } #[cfg(target_arch = "wasm32")] { @@ -681,14 +682,14 @@ impl PdfCraftApp { /// `pending_overwrite` behind [`Dialog::OverwriteChanged`](crate::Dialog::OverwriteChanged). /// Returns `true` if written now. #[cfg(not(target_arch = "wasm32"))] - fn save_doc_to_then(&mut self, id: pdfcraft_engine::DocId, dest: &str, after: Box) -> bool { + fn save_doc_to_then(&mut self, id: pdfcraft_engine::DocId, dest: &str, target: SaveTarget, after: Box) -> bool { let own = self.session.get(id).and_then(|d| d.path.as_deref()).is_some_and(|p| crate::same_file(p, dest)); if own && self.watch.disk_differs(id, dest) { - self.pending_overwrite = Some(PendingOverwrite { id, dest: dest.to_string(), after }); + self.pending_overwrite = Some(PendingOverwrite { id, dest: dest.to_string(), target, after }); self.dialog = Some(crate::Dialog::OverwriteChanged); return false; } - let saved = self.save_doc_to(id, dest); + let saved = self.save_doc_to(id, dest, target); if saved { after(self); } @@ -699,7 +700,7 @@ impl PdfCraftApp { #[cfg(not(target_arch = "wasm32"))] pub(crate) fn overwrite_changed_file(&mut self) { let Some(p) = self.pending_overwrite.take() else { return }; - if self.save_doc_to(p.id, &p.dest) { + if self.save_doc_to(p.id, &p.dest, p.target) { (p.after)(self); } } @@ -715,17 +716,27 @@ impl PdfCraftApp { /// Write document `id` to `dest` and make it the document's file. Returns `true` if written. #[cfg(not(target_arch = "wasm32"))] - fn save_doc_to(&mut self, id: pdfcraft_engine::DocId, dest: &str) -> bool { + fn save_doc_to(&mut self, id: pdfcraft_engine::DocId, dest: &str, target: SaveTarget) -> bool { // Save As answers on a later frame: commit what was typed meanwhile, too (#166), even // if another tab is active by then. if !self.commit_typing_in(id) || !self.flatten_fill_sign_for_save(id) { return false; } - let Some(name) = self.session.get(id).map(|d| d.name.clone()) else { + let Some((name, full)) = self.session.get(id).map(|d| { + // Save As to another file is a clean copy, without the earlier revisions that still + // hold deleted pages or replaced text (#2165). A signed document stays incremental so + // its signatures keep covering the file. + let full = target == SaveTarget::As + && !d.path.as_deref().is_some_and(|p| crate::same_file(p, dest)) + && d.read_only_reason.is_none() + && !d.is_signed(); + (d.name.clone(), full) + }) else { self.notify_tr("The document was closed before it could be saved."); return false; }; - let bytes = match self.session.save_bytes(id).and_then(|b| self.session.check_save(id, &b).map(|()| b)) { + let bytes = if full { self.session.save_full_bytes(id) } else { self.session.save_bytes(id) }; + let bytes = match bytes.and_then(|b| self.session.check_save(id, &b).map(|()| b)) { Ok(b) => b, Err(e) => { self.notify_fmt("Couldn't save {name}: {e}", &[("name", &name), ("e", &e.to_string())]); diff --git a/crates/ui-egui/tests/editing.rs b/crates/ui-egui/tests/editing.rs index 6957d47f06..05dbc7ca2a 100644 --- a/crates/ui-egui/tests/editing.rs +++ b/crates/ui-egui/tests/editing.rs @@ -520,6 +520,27 @@ fn save_writes_an_incremental_update_and_clears_dirty() { let _ = std::fs::remove_file(out); } +#[test] +fn save_as_to_a_new_file_drops_earlier_revisions() { + // #2165: Save As appended a revision, so a deleted page stayed in the new file's first one. + let out = temp_path("save-as-clean.pdf"); + let mut h = organize(3); + h.get_by_label("Page 2").hover(); + h.run_steps(2); + h.get_by_label("Delete page 2").click(); + h.run_steps(3); + h.state_mut().save_override = Some(out.to_string_lossy().into_owned()); + assert!(h.state_mut().save_active(pdfcraft_ui_egui::SaveTarget::As)); + let saved = std::fs::read(&out).expect("file written"); + let count = |needle: &[u8]| saved.windows(needle.len()).filter(|w| *w == needle).count(); + assert_eq!(count(b"%%EOF"), 1, "one revision only"); + assert_eq!((count(b"(Page 1)"), count(b"(Page 2)")), (1, 0), "the deleted page's content is gone"); + assert!(!dirty(&h)); + assert_eq!(h.state().session.get(h.state().views[0].id).unwrap().bytes.as_slice(), saved.as_slice()); + assert_eq!(page_texts(h.state()), ["Page 1", "Page 3"]); + let _ = std::fs::remove_file(out); +} + #[test] fn closing_a_dirty_tab_asks_and_cancel_keeps_it() { let mut h = organize(2); diff --git a/crates/ui-egui/tests/signing.rs b/crates/ui-egui/tests/signing.rs index ccb6dece7b..c7fd4c42fa 100644 --- a/crates/ui-egui/tests/signing.rs +++ b/crates/ui-egui/tests/signing.rs @@ -285,6 +285,38 @@ fn certifying_without_a_visible_signature() { h.get_by_label_contains("Certified by Test Signer EC"); } +#[test] +fn save_as_keeps_a_signed_document_incremental() { + // #2165: Save As to a new file rewrites unsigned documents in full; a signed one is appended + // to, or its signature would no longer cover the file. + let p12 = concat!(env!("CARGO_MANIFEST_DIR"), "/../sign/tests/data/ec-p256.p12"); + let digital_id = pdfcraft_engine::sign::pkcs12::open(&std::fs::read(p12).unwrap(), "test").unwrap(); + let mut s = pdfcraft_engine::Session::new(); + let id = s.open("contract.pdf", None, std::sync::Arc::new(FIXTURE.to_vec()), None).unwrap(); + let opts = pdfcraft_engine::SignOptions { page: 0, rect: Some([20.0, 20.0, 180.0, 60.0]), ..Default::default() }; + let signed = s.sign(id, &digital_id, opts).unwrap(); + let out = dir().join("copy.pdf"); + let (bytes, cert) = (signed.to_vec(), digital_id.certificate.clone()); + let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).build_eframe(move |_cc| { + let mut app = PdfCraftApp::new(); + app.set_option("language", "en").unwrap(); + app.open_bytes("contract.pdf", None, bytes).expect("opens"); + app.session.set_trusted_certificates(vec![cert]); + app + }); + h.run_steps(4); + let status = |h: &Harness<'static, PdfCraftApp>| { + let s = h.state(); + s.session.get(s.views[0].id).unwrap().signatures.iter().find(|x| x.signed).map(|x| x.status) + }; + assert_eq!(status(&h), Some(SignatureStatus::Valid)); + h.state_mut().save_override = Some(out.to_string_lossy().into_owned()); + assert!(h.state_mut().save_active(pdfcraft_ui_egui::SaveTarget::As), "a signed document can be saved as a copy"); + let saved = std::fs::read(&out).expect("file written"); + assert!(saved.starts_with(&signed), "the signed revision is kept as it was"); + assert_eq!(status(&h), Some(SignatureStatus::Valid)); +} + /// The fixture with an empty signature field across the bottom of the page. const WITH_FIELD: &[u8] = b"%PDF-1.7 1 0 obj << /Type /Catalog /Pages 2 0 R /AcroForm << /Fields [6 0 R] >> >> endobj diff --git a/docs/architecture.md b/docs/architecture.md index aa91c36c4e..6b44817e15 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -49,7 +49,8 @@ The PDF object graph *is* the model (`crates/engine/README.md` §The editing mod 3. After every edit an incremental write produces the working file (original bytes plus one revision); the view is refreshed from those bytes, so what you see is what Save writes. 4. Saves are incremental and atomic. A full rewrite happens only where it must: redaction, PDF/A - conversion, print imposition, a changed password, Save As with garbage collection. + conversion, print imposition, a changed password, Save As to another file (garbage-collected, + unless the document is signed). 5. Autosave and crash recovery keep encrypted documents encrypted. Signed documents are protected from rewrites. From eeadc79d1504bd131f707219535c3c508edb3148 Mon Sep 17 00:00:00 2001 From: Enrico Date: Sun, 18 Oct 2026 09:48:47 +0200 Subject: [PATCH 086/105] M4: reverse page order (organize.reverse-pages) (#1720) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Organize pages ▸ More ▸ Reverse pages (and the command palette) reverses the selected pages among their own positions, or the whole document when fewer than two pages are selected. It is one undoable step, needs the assemble permission like the other page edits, and is available headless as the page_reverse automation tool (pages optional, default all). Co-authored-by: Claude Opus 5.5 --- crates/automation/src/lib.rs | 7 +++++++ crates/automation/src/tools.rs | 3 +++ crates/automation/tests/automation.rs | 15 +++++++++++++++ crates/engine/src/catalog.rs | 1 + crates/engine/src/commands.rs | 1 + crates/engine/src/lib.rs | 8 ++++++++ crates/organize/src/lib.rs | 20 ++++++++++++++++++++ crates/organize/src/tests.rs | 19 +++++++++++++++++++ crates/ui-egui/src/commands.rs | 6 ++++++ crates/ui-egui/tests/editing.rs | 15 +++++++++++++++ parity/acrobat-features.toml | 10 +++++----- 11 files changed, 100 insertions(+), 5 deletions(-) diff --git a/crates/automation/src/lib.rs b/crates/automation/src/lib.rs index 52008170c0..8a4dcec16c 100644 --- a/crates/automation/src/lib.rs +++ b/crates/automation/src/lib.rs @@ -283,6 +283,13 @@ impl Automation { let to = self.position(&a, "to")?; self.apply(&a, Edit::MovePages { pages, to })? } + "page_reverse" => { + let pages = match a.opt_ints("pages")? { + Some(_) => self.pages(&a, "pages")?, + None => (0..self.doc(&a)?.info.pages.len()).collect(), + }; + self.apply(&a, Edit::ReversePages { pages })? + } "page_insert_blank" => self.insert_blank(&a)?, "page_insert_file" => self.insert_file(&a)?, "page_extract" => self.page_extract(&a)?, diff --git a/crates/automation/src/tools.rs b/crates/automation/src/tools.rs index 5e89aef78a..22fc214956 100644 --- a/crates/automation/src/tools.rs +++ b/crates/automation/src/tools.rs @@ -224,6 +224,9 @@ pub fn tools() -> Vec { json!({ "doc": doc(), "pages": pages("to move"), "to": { "type": "integer", "minimum": 1 } }), &["doc", "pages", "to"], )), + t("page_reverse", "Reverse pages", "Reverse the order of pages among the positions they occupy: the listed pages (at least two), or the whole document when pages is omitted. Undoable.") + .cmd("page.reverse") + .with(schema(json!({ "doc": doc(), "pages": pages("to reverse (default: all)") }), &["doc"])), t("page_insert_blank", "Insert a blank page", "Insert a blank page so it becomes page `at`. Size defaults to the neighbouring page. Undoable.") .cmd("page.insert_blank") .with(schema( diff --git a/crates/automation/tests/automation.rs b/crates/automation/tests/automation.rs index 5889b6e4aa..61e365fdaf 100644 --- a/crates/automation/tests/automation.rs +++ b/crates/automation/tests/automation.rs @@ -2082,6 +2082,21 @@ fn orphan_form_fields_through_tools() { assert_ne!(v, "Filled by an agent"); } +#[test] +fn reversing_pages_through_tools() { + let dir = workdir("reverse"); + let mut a = auto(&dir); + let doc = ok(&mut a, "doc_open", json!({ "path": "a.pdf" }))["doc"].as_u64().unwrap(); + ok(&mut a, "page_reverse", json!({ "doc": doc })); + assert_eq!(page_text(&mut a, doc), ["Page 3", "Page 2", "Page 1"], "no pages reverses the whole document"); + ok(&mut a, "page_reverse", json!({ "doc": doc, "pages": [1, 2] })); + assert_eq!(page_text(&mut a, doc), ["Page 2", "Page 3", "Page 1"], "only the listed pages swap"); + ok(&mut a, "edit_undo", json!({ "doc": doc })); + assert_eq!(page_text(&mut a, doc), ["Page 3", "Page 2", "Page 1"]); + assert!(matches!(a.call("page_reverse", &json!({ "doc": doc, "pages": [4] })), Err(ToolError::InvalidArgs(_)))); + assert!(matches!(a.call("page_reverse", &json!({ "doc": doc, "pages": [2] })), Err(ToolError::Failed(_))), "one page has nothing to reverse"); +} + #[test] fn duplicating_and_cropping_through_tools() { let dir = workdir("boxes"); diff --git a/crates/engine/src/catalog.rs b/crates/engine/src/catalog.rs index b46d88acf1..3d2ba30820 100644 --- a/crates/engine/src/catalog.rs +++ b/crates/engine/src/catalog.rs @@ -206,6 +206,7 @@ pub static TOOL_GROUPS: &[ToolGroup] = &[ item("Set page boxes", "square-dashed-mouse-pointer", "page.boxes", Ready), item("Crop pages", "crop", "page.crop", Ready), item("Duplicate pages", "copy-plus", "page.duplicate", Ready), + item("Reverse pages", "arrow-up-down", "page.reverse", Ready), item("Page labels", "tag", "page.number", Ready), item("Page transitions", "presentation", "page.transitions", Planned("M4")), ], diff --git a/crates/engine/src/commands.rs b/crates/engine/src/commands.rs index b28758e241..fcd56efee4 100644 --- a/crates/engine/src/commands.rs +++ b/crates/engine/src/commands.rs @@ -362,6 +362,7 @@ pub const COMMANDS: &[CommandSpec] = &[ c("page.insert_blank", "Insert blank page", PAGES, None, Assembly, "file-plus"), c("page.rotate_dialog", "Rotate pages…", PAGES, None, Assembly, "rotate-cw"), c("page.duplicate", "Duplicate pages", PAGES, None, Assembly, "copy-plus"), + c("page.reverse", "Reverse pages", PAGES, None, Assembly, "arrow-up-down"), c("page.crop", "Crop pages", PAGES, None, Assembly, "crop"), c("page.boxes", "Set page boxes…", PAGES, None, Assembly, "square-dashed-mouse-pointer"), c("page.insert", "Insert pages from file…", PAGES, None, Assembly, "file-input"), diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 0a1b018281..147fc634ec 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -1267,6 +1267,10 @@ pub enum Edit { pages: Vec, to: usize, }, + /// `pages` reversed among the positions they occupy. + ReversePages { + pages: Vec, + }, InsertBlankPage { at: usize, width: f64, @@ -1797,6 +1801,7 @@ pub const EDIT_LABELS: &[&str] = &[ "Delete pages", "Move page", "Move pages", + "Reverse pages", "Insert blank page", "Duplicate page", "Duplicate pages", @@ -1936,6 +1941,7 @@ impl Edit { Edit::RotatePages { pages, .. } => plural("Rotate page", "Rotate pages", pages.len()), Edit::DeletePages { pages } => plural("Delete page", "Delete pages", pages.len()), 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::ReplacePages { pages, .. } => plural("Replace page", "Replace pages", pages.len()), @@ -2108,6 +2114,7 @@ fn check_permission(edit: &Edit, p: &pdfcraft_cos::Permissions) -> Result<(), Ed Edit::RotatePages { .. } | Edit::DeletePages { .. } | Edit::MovePages { .. } + | Edit::ReversePages { .. } | Edit::InsertBlankPage { .. } | Edit::DuplicatePages { .. } | Edit::ReplacePages { .. } @@ -2318,6 +2325,7 @@ fn run_edit(doc: &mut pdfcraft_cos::Document, edit: &Edit, cx: &mut EditCtx) -> Edit::RotatePages { pages, degrees } => pdfcraft_organize::rotate_pages(doc, pages, *degrees)?, Edit::DeletePages { pages } => pdfcraft_organize::delete_pages(doc, pages)?, Edit::MovePages { pages, to } => pdfcraft_organize::move_pages(doc, pages, *to)?, + Edit::ReversePages { pages } => pdfcraft_organize::reverse_pages(doc, pages)?, Edit::InsertBlankPage { at, width, height } => { pdfcraft_organize::insert_blank_page(doc, *at, *width, *height)?; } diff --git a/crates/organize/src/lib.rs b/crates/organize/src/lib.rs index 3b2873abec..133bc03ecf 100644 --- a/crates/organize/src/lib.rs +++ b/crates/organize/src/lib.rs @@ -472,6 +472,26 @@ pub fn move_pages(doc: &mut Document, indices: &[usize], to: usize) -> Result<() rebuild(doc, &rest) } +/// Reverse the order of the pages at `indices` among the positions they occupy; every other +/// page stays where it is. Fewer than two distinct pages is refused, since nothing would change. +pub fn reverse_pages(doc: &mut Document, indices: &[usize]) -> Result<(), OrganizeError> { + let mut all = walk(doc)?; + check(indices, all.len())?; + let mut slots = indices.to_vec(); + slots.sort_unstable(); + slots.dedup(); + if slots.len() < 2 { + return Err(OrganizeError::Invalid("reversing needs at least two pages".into())); + } + let pages: Vec<(ObjRef, Dict)> = slots.iter().filter_map(|i| all.get(*i).cloned()).collect(); + for (slot, page) in slots.iter().zip(pages.into_iter().rev()) { + if let Some(entry) = all.get_mut(*slot) { + *entry = page; + } + } + rebuild(doc, &all) +} + /// Duplicate pages: copies of `indices` (in order) are inserted after the last of them, sharing /// fonts and images with the originals. pub fn duplicate_pages(doc: &mut Document, indices: &[usize]) -> Result<(), OrganizeError> { diff --git a/crates/organize/src/tests.rs b/crates/organize/src/tests.rs index d34b1e431d..c857cd4da7 100644 --- a/crates/organize/src/tests.rs +++ b/crates/organize/src/tests.rs @@ -244,6 +244,25 @@ fn move_pages_reorders() { assert_eq!(labels(&doc), ["Page 2", "Page 3", "Page 1"]); } +#[test] +fn reverse_pages_reorders() { + let mut doc = open(fixture()); + reverse_pages(&mut doc, &[0, 1, 2]).unwrap(); + assert_eq!(labels(&doc), ["Page 3", "Page 2", "Page 1"]); + // Unsorted, repeated indices name the same slots; page 1's slot stays put. + reverse_pages(&mut doc, &[2, 1, 2]).unwrap(); + let doc = save_and_reopen(&doc); + assert_eq!(labels(&doc), ["Page 3", "Page 1", "Page 2"]); +} + +#[test] +fn cannot_reverse_missing_or_fewer_than_two_pages() { + let mut doc = open(fixture()); + assert_eq!(reverse_pages(&mut doc, &[0, 3]), Err(OrganizeError::NoSuchPage(3))); + assert!(matches!(reverse_pages(&mut doc, &[1, 1]), Err(OrganizeError::Invalid(_)))); + assert!(!doc.is_modified(), "failed operations leave the document untouched"); +} + #[test] fn inserts_blank_page() { let mut doc = open(fixture()); diff --git a/crates/ui-egui/src/commands.rs b/crates/ui-egui/src/commands.rs index e3609fcc9c..832ce7cfbd 100644 --- a/crates/ui-egui/src/commands.rs +++ b/crates/ui-egui/src/commands.rs @@ -629,6 +629,12 @@ impl PdfCraftApp { "page.duplicate" => { self.apply_edit(Edit::DuplicatePages { pages: targets }); } + // Two or more selected pages reverse among themselves; otherwise the whole document. + "page.reverse" => { + let all = active.and_then(|i| self.views.get(i)).map_or(0, |v| v.page_count()); + let pages = if targets.len() >= 2 { targets } else { (0..all).collect() }; + self.apply_edit(Edit::ReversePages { pages }); + } "ocr.enhance_camera" => { // The corners start on the document's edges when they can be found, else // inset on the current page (camera_ui::page_input). diff --git a/crates/ui-egui/tests/editing.rs b/crates/ui-egui/tests/editing.rs index 05dbc7ca2a..b30d8a70ef 100644 --- a/crates/ui-egui/tests/editing.rs +++ b/crates/ui-egui/tests/editing.rs @@ -351,6 +351,21 @@ fn shift_click_selects_a_range_and_moves_it() { assert_eq!(page_texts(h.state()), ["Page 3", "Page 4", "Page 1", "Page 2"]); } +#[test] +fn reverse_pages_reverses_the_selection_or_else_the_whole_document() { + let mut h = organize(4); + assert!(h.state_mut().execute("page.reverse")); + h.run_steps(3); + assert_eq!(page_texts(h.state()), ["Page 4", "Page 3", "Page 2", "Page 1"]); + h.get_by_label("Page 1").click(); + h.run_steps(1); + h.get_by_label("Page 3").click_modifiers(Modifiers::COMMAND); + h.run_steps(2); + assert!(h.state_mut().execute("page.reverse")); + h.run_steps(3); + assert_eq!(page_texts(h.state()), ["Page 2", "Page 3", "Page 4", "Page 1"]); +} + #[test] fn command_click_toggles_and_rotation_applies_to_selection() { let mut h = organize(3); diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index 938e87a95d..ed3409995c 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -2833,11 +2833,11 @@ title = "Reverse page order" area = "D" tier = "P2" milestone = "M4" -status = "planned" -commands = [] -tools = [] -evidence = [] -notes = "" +status = "shipped" +commands = ["page.reverse"] +tools = ["page_reverse"] +evidence = ["crates/organize/src/tests.rs::reverse_pages_reorders", "crates/organize/src/tests.rs::cannot_reverse_missing_or_fewer_than_two_pages", "crates/automation/tests/automation.rs::reversing_pages_through_tools", "crates/ui-egui/tests/editing.rs::reverse_pages_reverses_the_selection_or_else_the_whole_document"] +notes = "Organize Pages ▸ More ▸ Reverse pages, or the command palette: two or more selected pages swap among their own positions, otherwise the whole document reverses. One undo step. Not yet: an entry in the page thumbnails' right-click menu" [[feature]] id = "organize.cut-copy-paste-pages" From 50f261d70c8e199600a82dbaa6912c6fbc4b4612 Mon Sep 17 00:00:00 2001 From: Marco Arnulfo <103679419+marcoarnulfo@users.noreply.github.com> Date: Sun, 18 Oct 2026 09:50:00 +0200 Subject: [PATCH 087/105] M6: detect a row of comb boxes as one comb text field (#2111) * M6: detect a row of comb boxes as one comb text field (fixes #1887) * M6: keep empty tables of wide cells out of comb detection (#1887) --------- Co-authored-by: Brandon Thomas --- crates/automation/src/a11y.rs | 13 +++++-- crates/automation/src/tools.rs | 2 +- crates/automation/tests/automation.rs | 35 +++++++++++++++++ crates/engine/src/js.rs | 31 ++++++++------- crates/engine/src/tests.rs | 28 ++++++++++++++ crates/forms/src/detect.rs | 56 +++++++++++++++++++++++++-- crates/forms/src/tests.rs | 48 +++++++++++++++++++++++ parity/acrobat-features.toml | 4 +- 8 files changed, 194 insertions(+), 23 deletions(-) diff --git a/crates/automation/src/a11y.rs b/crates/automation/src/a11y.rs index 1e8d71bd1c..8130ed084d 100644 --- a/crates/automation/src/a11y.rs +++ b/crates/automation/src/a11y.rs @@ -918,11 +918,16 @@ impl Automation { let list: Vec = found .iter() .map(|(p, c)| { - let kind = match c.kind { - pdfcraft_engine::detect::Kind::Text => "text", - pdfcraft_engine::detect::Kind::CheckBox => "checkbox", + let (kind, comb) = match c.kind { + pdfcraft_engine::detect::Kind::Text => ("text", None), + pdfcraft_engine::detect::Kind::CheckBox => ("checkbox", None), + pdfcraft_engine::detect::Kind::Comb { cells } => ("text", Some(cells)), }; - json!({ "page": p + 1, "kind": kind, "name": c.name, "rect": c.rect.map(|v| (v * 100.0).round() / 100.0) }) + let mut v = json!({ "page": p + 1, "kind": kind, "name": c.name, "rect": c.rect.map(|v| (v * 100.0).round() / 100.0) }); + if let Some(cells) = comb { + v["comb"] = json!(cells); + } + v }) .collect(); if a.opt_bool("add")?.unwrap_or(true) && !found.is_empty() { diff --git a/crates/automation/src/tools.rs b/crates/automation/src/tools.rs index 22fc214956..6b7b8a306a 100644 --- a/crates/automation/src/tools.rs +++ b/crates/automation/src/tools.rs @@ -1101,7 +1101,7 @@ pub fn tools() -> Vec { t("doc_compare_mark", "Mark differences as comments", "Add the differences from other (older) to doc (newer) as comments in doc: highlights over replaced (blue) and inserted (green) text, notes where text was deleted (red), authored Compare. Undoable.") .cmd("doc.compare") .with(schema(json!({ "doc": doc(), "other": { "type": "integer" } }), &["doc", "other"])), - t("form_detect_fields", "Detect form fields", "Prepare a form ▸ automatic field detection: find the blanks a printed form asks to be filled (underscore runs, lines, empty boxes, small squares for check boxes; on scanned pages read from the pixels) and name each field from its label. With add (default true) the fields are created as one undoable step; otherwise they are only proposed. Returns page (1-based), kind, name and rect (points, origin bottom-left).") + t("form_detect_fields", "Detect form fields", "Prepare a form ▸ automatic field detection: find the blanks a printed form asks to be filled (underscore runs, lines, empty boxes, rows of comb boxes, small squares for check boxes; on scanned pages read from the pixels) and name each field from its label. With add (default true) the fields are created as one undoable step; otherwise they are only proposed. Returns page (1-based), kind, name and rect (points, origin bottom-left); a row of comb boxes is one text field with comb set to its number of cells, created with the comb flag and that character limit.") .cmd("form.detect") .with(schema(json!({ "doc": doc(), "pages": pages("to look at (default: all)"), "add": { "type": "boolean" } }), &["doc"])), t("form_actions", "Field actions", "Field Properties ▸ Actions: the field's action for each trigger (mouse_up, mouse_down, mouse_enter, mouse_exit, on_focus, on_blur).") diff --git a/crates/automation/tests/automation.rs b/crates/automation/tests/automation.rs index 61e365fdaf..30658fdfd7 100644 --- a/crates/automation/tests/automation.rs +++ b/crates/automation/tests/automation.rs @@ -4992,6 +4992,41 @@ fn detecting_form_fields_through_tools() { assert_eq!(f["fields"][1]["value"], "Lisbon", "{f}"); } +#[test] +fn detecting_comb_boxes_through_tools() { + let mut body = String::from("BT /F1 10 Tf 40 105 Td (Postcode) Tj ET\n"); + for i in 0..5 { + body.push_str(&format!("{} 100 16 20 re S\n", 120 + i * 16)); + } + let objs = [ + "<< /Type /Catalog /Pages 2 0 R >>".to_string(), + "<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 400 300] >>".to_string(), + "<< /Type /Page /Parent 2 0 R /Contents 4 0 R /Resources << /Font << /F1 5 0 R >> >> >>".to_string(), + format!("<< /Length {} >>\nstream\n{body}\nendstream", body.len()), + "<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding /WinAnsiEncoding >>".to_string(), + ]; + let mut pdf = b"%PDF-1.7\n".to_vec(); + let mut offsets = Vec::new(); + for (i, o) in objs.iter().enumerate() { + offsets.push(pdf.len()); + pdf.extend_from_slice(format!("{} 0 obj\n{o}\nendobj\n", i + 1).as_bytes()); + } + let xref = pdf.len(); + pdf.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objs.len() + 1).as_bytes()); + for o in offsets { + pdf.extend_from_slice(format!("{o:010} 00000 n \n").as_bytes()); + } + pdf.extend_from_slice(format!("trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n", objs.len() + 1).as_bytes()); + let dir = workdir("detect-comb"); + std::fs::write(dir.join("comb.pdf"), pdf).unwrap(); + let mut a = auto(&dir); + let doc = ok(&mut a, "doc_open", json!({ "path": "comb.pdf" }))["doc"].as_u64().unwrap(); + let r = ok(&mut a, "form_detect_fields", json!({ "doc": doc })); + assert_eq!(r["fields"], json!([{ "page": 1, "kind": "text", "comb": 5, "name": "Postcode", "rect": [120.0, 100.0, 200.0, 120.0] }]), "{r}"); + ok(&mut a, "form_fill", json!({ "doc": doc, "values": { "Postcode": "75001" } })); + assert_eq!(ok(&mut a, "form_fields", json!({ "doc": doc }))["fields"][0]["value"], "75001"); +} + #[test] fn comparing_documents_through_tools() { let dir = workdir("compare"); diff --git a/crates/engine/src/js.rs b/crates/engine/src/js.rs index 8958688d2d..b797bc8d83 100644 --- a/crates/engine/src/js.rs +++ b/crates/engine/src/js.rs @@ -6,7 +6,7 @@ //! Preferences ▸ JavaScript ▸ Enable Acrobat JavaScript is [`Session::set_javascript`]; with it //! off, scripts don't run and AF calls keep working natively. -use pdfcraft_forms::{Field, FieldChange, FieldEvent, FieldKind, ScriptResult, Scripts}; +use pdfcraft_forms::{Field, FieldChange, FieldEvent, FieldKind, FieldProps, ScriptResult, Scripts}; pub use pdfcraft_js::{Limits, Outcome, Request}; use crate::{DocId, Edit, EditError, Session}; @@ -343,19 +343,24 @@ impl Session { let names = found.iter().map(|(_, c, _)| c.name.clone()).collect(); let mut edits = Vec::with_capacity(found.len()); for (page, c, turn) in found { - // A turned field keeps its place: the rectangle is already the one on the page. - let props = (turn != 0).then(|| pdfcraft_forms::FieldProps { rotation: Some((0, turn)), rect: Some((0, c.rect)), ..Default::default() }); let tall = if turn % 180 == 0 { c.rect[3] - c.rect[1] } else { c.rect[2] - c.rect[0] }; - edits.push(Edit::AddField { - page, - rect: c.rect, - kind: match c.kind { - pdfcraft_forms::detect::Kind::Text => pdfcraft_forms::NewField::Text { multiline: tall > 30.0 }, - pdfcraft_forms::detect::Kind::CheckBox => pdfcraft_forms::NewField::CheckBox, - }, - name: Some(c.name.clone()), - }); - if let Some(props) = props { + let (kind, comb) = match c.kind { + pdfcraft_forms::detect::Kind::Text => (pdfcraft_forms::NewField::Text { multiline: tall > 30.0 }, None), + pdfcraft_forms::detect::Kind::CheckBox => (pdfcraft_forms::NewField::CheckBox, None), + pdfcraft_forms::detect::Kind::Comb { cells } => (pdfcraft_forms::NewField::Text { multiline: false }, Some(cells)), + }; + let mut props = FieldProps::default(); + if turn != 0 { + // A turned field keeps its place: the rectangle is already the one on the page. + props.rotation = Some((0, turn)); + props.rect = Some((0, c.rect)); + } + if let Some(cells) = comb { + props.max_len = Some(Some(cells)); + props.flags = vec![(pdfcraft_forms::flags::COMB, true)]; + } + edits.push(Edit::AddField { page, rect: c.rect, kind, name: Some(c.name.clone()) }); + if turn != 0 || comb.is_some() { edits.push(Edit::SetFieldProps { name: c.name, props: Box::new(props) }); } } diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 204e75c822..55e8eee0fa 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -3691,6 +3691,34 @@ fn detecting_fields_on_a_scanned_form() { } } +#[test] +fn detecting_comb_boxes_adds_one_comb_field_per_row() { + // The rows from #1887: five 18 x 22 cells and ten 16 x 20 cells, each a stroked `re`. + let mut body = String::from("BT /F1 10 Tf 40 205 Td (Postcode A) Tj ET BT /F1 10 Tf 40 105 Td (Postcode B) Tj ET\n"); + for i in 0..5 { + body.push_str(&format!("{} 200 18 22 re S\n", 120 + i * 18)); + } + for i in 0..10 { + body.push_str(&format!("{} 100 16 20 re S\n", 120 + i * 16)); + } + let mut s = Session::new().with_clock(|| 1_700_000_000); + let id = s.open("comb.pdf", None, Arc::new(one_line_page("/Font << /F1 4 0 R >>", &body)), None).unwrap(); + let found: Vec<(String, pdfcraft_forms::detect::Kind)> = s.detect_fields(id, &[]).into_iter().map(|(_, c)| (c.name, c.kind)).collect(); + assert_eq!( + found, + [ + ("Postcode A".to_string(), pdfcraft_forms::detect::Kind::Comb { cells: 5 }), + ("Postcode B".to_string(), pdfcraft_forms::detect::Kind::Comb { cells: 10 }) + ] + ); + s.auto_detect_fields(id, &[]).unwrap(); + let d = s.get(id).unwrap(); + let combs: Vec<(&str, FormFieldKind, bool, Option)> = + d.form.iter().map(|f| (f.name.as_str(), f.kind, f.has(pdfcraft_forms::flags::COMB), f.max_len)).collect(); + assert_eq!(combs, [("Postcode A", FormFieldKind::Text, true, Some(5)), ("Postcode B", FormFieldKind::Text, true, Some(10))]); + assert_eq!(d.can_undo(), Some("Detect form fields")); +} + #[test] fn comparing_two_versions_of_a_document() { let mut s = Session::new().with_clock(|| 1_700_000_000); diff --git a/crates/forms/src/detect.rs b/crates/forms/src/detect.rs index 51f0b5c394..d5bf073b1e 100644 --- a/crates/forms/src/detect.rs +++ b/crates/forms/src/detect.rs @@ -1,6 +1,6 @@ //! Prepare a form ▸ automatic field detection: find where a printed form expects answers — -//! underscore runs, rules (horizontal lines), empty boxes and small squares — and name each new -//! field from the label next to it (Acrobat's auto-naming). +//! underscore runs, rules (horizontal lines), empty boxes, rows of comb cells and small squares — +//! and name each new field from the label next to it (Acrobat's auto-naming). //! //! [`detect`] is a pure function over the page's words and drawn shapes; [`page_shapes`] reads //! the shapes from a page's content, [`raster::shapes`] from a picture of the page (a scan). @@ -30,6 +30,11 @@ pub struct Shapes { pub enum Kind { Text, CheckBox, + /// A row of touching cells, one character each: a text field with the comb flag and + /// `cells` as its character limit. + Comb { + cells: usize, + }, } #[derive(Clone, Debug, PartialEq)] @@ -309,6 +314,42 @@ fn label_for(words: &[Word], rect: [f64; 4], right_of: bool) -> String { clean(&on.iter().map(|w| w.text.as_str()).collect::>().join(" ")) } +/// Rows of two or more touching cells of one size, as (the row's rect, cell count). Two is +/// enough: dates split into day and month groups of two cells. Cells are square or taller +/// than wide; an empty table of wider cells is not a comb. +fn combs(boxes: &[[f64; 4]]) -> Vec<([f64; 4], usize)> { + let cells: Vec<[f64; 4]> = boxes + .iter() + .copied() + .filter(|r| { + let (w, h) = (r[2] - r[0], r[3] - r[1]); + (5.0..=30.0).contains(&w) && (8.0..=40.0).contains(&h) && w <= h * 1.2 + }) + .collect(); + // `b` comes right after `a` in a row: same size, its left edge on a's right edge. + let follows = |a: &[f64; 4], b: &[f64; 4]| { + (b[0] - a[2]).abs() <= 1.0 && (b[1] - a[1]).abs() <= 0.5 && (b[3] - a[3]).abs() <= 0.5 && ((b[2] - b[0]) - (a[2] - a[0])).abs() <= 0.5 + }; + let mut out = Vec::new(); + for first in &cells { + if cells.iter().any(|a| follows(a, first)) { + continue; + } + let (mut last, mut n) = (first, 1usize); + // Each step moves at least 4 pt right, so this ends; the bound is for safety. + while let Some(b) = cells.iter().find(|b| follows(last, b)) + && n < cells.len() + { + last = b; + n += 1; + } + if n >= 2 { + out.push(([first[0], first[1], last[2], first[3]], n)); + } + } + out +} + /// Propose fields for a page. `existing` are the page's widget rectangles (no candidate /// overlaps them); names are unique against `taken`. pub fn detect(words: &[Word], shapes: &Shapes, existing: &[[f64; 4]], taken: &[String]) -> Vec { @@ -329,6 +370,15 @@ pub fn detect(words: &[Word], shapes: &Shapes, existing: &[[f64; 4]], taken: &[S let l = label_for(&labels, rect, false); push(Kind::Text, rect, l, &mut out); } + // Comb boxes (postcodes, dates, IBANs): one text field per row of cells, found before + // check boxes so the cells don't each become one. + for (rect, cells) in combs(&shapes.boxes) { + if labels.iter().any(|x| overlaps(x.rect, rect)) { + continue; + } + let l = label_for(&labels, rect, false); + push(Kind::Comb { cells }, rect, l, &mut out); + } // Small squares: check boxes, labelled by the text to their right. for r in &shapes.boxes { let (w, h) = (r[2] - r[0], r[3] - r[1]); @@ -381,7 +431,7 @@ pub fn detect(words: &[Word], shapes: &Shapes, existing: &[[f64; 4]], taken: &[S for c in &mut out { let base = if c.name.is_empty() { match c.kind { - Kind::Text => "Text".to_string(), + Kind::Text | Kind::Comb { .. } => "Text".to_string(), Kind::CheckBox => "Check Box".to_string(), } } else { diff --git a/crates/forms/src/tests.rs b/crates/forms/src/tests.rs index 35fcaaa45b..60066e4739 100644 --- a/crates/forms/src/tests.rs +++ b/crates/forms/src/tests.rs @@ -1411,6 +1411,54 @@ fn detection_finds_blanks_rules_boxes_and_names_them() { assert!(again.is_empty(), "{again:?}"); } +#[test] +fn detection_makes_a_row_of_touching_cells_one_comb_field() { + use crate::detect::*; + let w = |t: &str, x0: f64, y0: f64, x1: f64| Word { text: t.into(), rect: [x0, y0, x1, y0 + 10.0] }; + let words = vec![ + w("Postcode", 40.0, 205.0, 85.0), + w("A", 88.0, 205.0, 95.0), + w("Postcode", 40.0, 105.0, 85.0), + w("B", 88.0, 105.0, 95.0), + w("Yes", 70.0, 41.0, 90.0), + ]; + let row = |n: usize, x: f64, y: f64, cw: f64, ch: f64| (0..n).map(move |i| [x + i as f64 * cw, y, x + (i + 1) as f64 * cw, y + ch]); + // 18 x 22 cells (too tall for a check box), 16 x 20 cells (each one check-box sized) drawn + // right to left, and a lone square. + let mut boxes: Vec<[f64; 4]> = row(5, 120.0, 200.0, 18.0, 22.0).collect(); + boxes.extend(row(10, 120.0, 100.0, 16.0, 20.0).rev()); + boxes.push([50.0, 40.0, 62.0, 52.0]); + let found = detect(&words, &Shapes { boxes, rules: Vec::new() }, &[], &[]); + let got: Vec<(&str, &Kind, [f64; 4])> = found.iter().map(|c| (c.name.as_str(), &c.kind, c.rect)).collect(); + assert_eq!( + got, + [ + ("Postcode A", &Kind::Comb { cells: 5 }, [120.0, 200.0, 210.0, 222.0]), + ("Postcode B", &Kind::Comb { cells: 10 }, [120.0, 100.0, 280.0, 120.0]), + ("Yes", &Kind::CheckBox, [50.0, 40.0, 62.0, 52.0]), + ], + "{found:?}" + ); + // Squares with a gap between them stay check boxes, and so do touching squares of + // different sizes. + let apart = Shapes { + boxes: vec![[50.0, 40.0, 62.0, 52.0], [66.0, 40.0, 78.0, 52.0], [80.0, 40.0, 92.0, 52.0], [92.0, 40.0, 102.0, 52.0]], + rules: Vec::new(), + }; + let kinds: Vec = detect(&[], &apart, &[], &[]).into_iter().map(|c| c.kind).collect(); + assert_eq!(kinds, [Kind::CheckBox, Kind::CheckBox, Kind::CheckBox, Kind::CheckBox]); + // An empty table of cells wider than tall (an attendance sheet) is not a comb. + let grid = |cw: f64, ch: f64| { + (0..3).flat_map(move |r| { + (0..3).map(move |c| [100.0 + c as f64 * cw, 300.0 + r as f64 * ch, 100.0 + (c + 1) as f64 * cw, 300.0 + (r + 1) as f64 * ch]) + }) + }; + for (cw, ch) in [(24.0, 18.0), (28.0, 20.0)] { + let table = detect(&[], &Shapes { boxes: grid(cw, ch).collect(), rules: Vec::new() }, &[], &[]); + assert!(table.is_empty(), "{cw} x {ch}: {table:?}"); + } +} + #[test] fn page_shapes_reads_boxes_and_rules() { let mut doc = fixture(); diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index ed3409995c..f05387aa21 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -5088,8 +5088,8 @@ milestone = "M6" status = "partial" commands = ["form.detect", "form.prepare"] tools = ["form_detect_fields"] -evidence = ["crates/forms/src/tests.rs::detection_finds_blanks_rules_boxes_and_names_them", "crates/forms/src/tests.rs::page_shapes_reads_rectangles_with_points_along_their_sides", "crates/engine/src/tests.rs::detecting_fields_on_a_printed_form", "crates/engine/src/tests.rs::detecting_fields_on_a_scanned_form", "crates/forms/src/detect/raster.rs::a_scanned_form_gives_its_rules_boxes_and_check_boxes", "crates/forms/src/detect/raster.rs::noise_and_uneven_paper_do_not_hide_the_lines", "crates/automation/tests/automation.rs::detecting_form_fields_through_tools", "crates/ui-egui/tests/javascript.rs::prepare_a_form_detects_fields_on_a_paper_form", "crates/ui-egui/tests/prepare.rs::deleting_several_selected_fields_and_detecting_fields_from_the_panel"] -notes = "Underscore runs, rules, empty boxes and small squares (check boxes); a box drawn with points along its sides (a midpoint on each vertical side, as the French 2042 form's cells) is read as the rectangle it is (#2942); runs when Prepare a form starts on a document without fields, and from Prepare a form ▸ Detect form fields at any time (existing fields are skipped). Scanned pages (an image covers at least half the page and the content draws no lines) are rendered and their rules, boxes and check boxes read from the pixels; a box with a small printed label gets a field in its empty part. Tested on synthetic scans only: straight, stored sideways with /Rotate 90 (fields get /MK /R so typing reads upright), noisy, and 1 degree askew (fields stay inside their boxes but lose up to the skew in height). Labels come from the text layer or, when the OCR models are installed, from text recognition (Latin without accents, so German labels with umlauts make poorer names); without either, fields get default names (Text1, Check Box1). Recognition runs synchronously, about a second per scanned page. Not yet: comb boxes, dotted leader lines, tables of cells as such, deskewing, radio groups." +evidence = ["crates/forms/src/tests.rs::detection_finds_blanks_rules_boxes_and_names_them", "crates/forms/src/tests.rs::page_shapes_reads_rectangles_with_points_along_their_sides", "crates/engine/src/tests.rs::detecting_fields_on_a_printed_form", "crates/forms/src/tests.rs::detection_makes_a_row_of_touching_cells_one_comb_field", "crates/engine/src/tests.rs::detecting_comb_boxes_adds_one_comb_field_per_row", "crates/engine/src/tests.rs::detecting_fields_on_a_scanned_form", "crates/forms/src/detect/raster.rs::a_scanned_form_gives_its_rules_boxes_and_check_boxes", "crates/forms/src/detect/raster.rs::noise_and_uneven_paper_do_not_hide_the_lines", "crates/automation/tests/automation.rs::detecting_form_fields_through_tools", "crates/automation/tests/automation.rs::detecting_comb_boxes_through_tools", "crates/ui-egui/tests/javascript.rs::prepare_a_form_detects_fields_on_a_paper_form", "crates/ui-egui/tests/prepare.rs::deleting_several_selected_fields_and_detecting_fields_from_the_panel"] +notes = "Underscore runs, rules, empty boxes, small squares (check boxes) and rows of two or more touching cells of one size (one comb text field, character limit = cells; tested on drawn forms only); a box drawn with points along its sides (a midpoint on each vertical side, as the French 2042 form's cells) is read as the rectangle it is (#2942); runs when Prepare a form starts on a document without fields, and from Prepare a form ▸ Detect form fields at any time (existing fields are skipped). Scanned pages (an image covers at least half the page and the content draws no lines) are rendered and their rules, boxes and check boxes read from the pixels; a box with a small printed label gets a field in its empty part. Tested on synthetic scans only: straight, stored sideways with /Rotate 90 (fields get /MK /R so typing reads upright), noisy, and 1 degree askew (fields stay inside their boxes but lose up to the skew in height). Labels come from the text layer or, when the OCR models are installed, from text recognition (Latin without accents, so German labels with umlauts make poorer names); without either, fields get default names (Text1, Check Box1). Recognition runs synchronously, about a second per scanned page. Not yet: dotted leader lines, tables of cells as such, deskewing, radio groups." [[feature]] id = "form.auto-name-fields" From 19d138e951d46ad4e230857f13c5e081d560b41b Mon Sep 17 00:00:00 2001 From: Mex <44814932+MMexicann@users.noreply.github.com> Date: Sun, 18 Oct 2026 11:51:29 +0200 Subject: [PATCH 088/105] M4: accumulate repeated page rotation indices Address maintainer feedback on the existing PR. Preserve upstream work and document tested scope and validation limits. --- crates/organize/src/lib.rs | 5 ++++- crates/organize/src/tests.rs | 32 ++++++++++++++++++++++++++++++++ 2 files changed, 36 insertions(+), 1 deletion(-) diff --git a/crates/organize/src/lib.rs b/crates/organize/src/lib.rs index c7f5c968a6..bdd038527e 100644 --- a/crates/organize/src/lib.rs +++ b/crates/organize/src/lib.rs @@ -205,7 +205,10 @@ pub fn rotate_pages(doc: &mut Document, indices: &[usize], degrees: i64) -> Resu let delta = degrees.rem_euclid(360); for &i in indices { let (page, inherited) = &all[i]; - let current = resolved_rotation(doc, inherited); + // Repeated indices must see earlier edits instead of the page walk's snapshot. + let current_page = doc.get(*page); + let current_attrs = current_page.as_dict().filter(|attrs| attrs.get(b"Rotate").is_some()).unwrap_or(inherited); + let current = resolved_rotation(doc, current_attrs); let new = (current.rem_euclid(360) + delta).rem_euclid(360); doc.update_dict(*page, |d| d.set(b"Rotate".to_vec(), Object::Int(new)))?; } diff --git a/crates/organize/src/tests.rs b/crates/organize/src/tests.rs index 5dad127057..1b17320825 100644 --- a/crates/organize/src/tests.rs +++ b/crates/organize/src/tests.rs @@ -2723,3 +2723,35 @@ fn a_chain_of_objects_too_deep_to_copy_is_refused_and_leaves_the_destination_alo .join(); assert!(run.is_ok(), "the copy must not overflow the stack"); } + +#[test] +fn rotate_repeated_indices_accumulate_from_the_current_page_value() { + for level in 0..3 { + for indices in [vec![0, 0], vec![0, 1, 0]] { + let mut doc = open(fixture()); + let rotation = doc.add(Object::Int(90)); + let page = pages(&doc).unwrap()[0].obj; + let target = match level { + 0 => page, + 1 => doc.get(page).as_dict().unwrap().reference(b"Parent").unwrap(), + _ => pages_root(&doc).unwrap(), + }; + doc.update_dict(target, |d| d.set(b"Rotate".to_vec(), Object::Ref(rotation))).unwrap(); + let mut doc = open(write_full(&doc, &SaveOptions::default()).unwrap()); + let rotation = walk(&doc).unwrap()[0].1.reference(b"Rotate").unwrap(); + let original_labels = labels(&doc); + rotate_pages(&mut doc, &indices, 90).unwrap(); + assert_eq!(page_rotation(&doc, 0).unwrap(), 270, "level {level}, indices {indices:?}"); + assert_eq!(*doc.get(rotation), Object::Int(90), "shared source angle is unchanged"); + for saved in [write_incremental(&doc, &SaveOptions::default()).unwrap(), write_full(&doc, &SaveOptions::default()).unwrap()] { + let reopened = open(saved.clone()); + assert_eq!(page_rotation(&reopened, 0).unwrap(), 270); + assert_eq!(page_rotation(&reopened, 1).unwrap(), if indices.len() == 3 { 180 } else { 90 }); + assert_eq!(page_rotation(&reopened, 2).unwrap(), 90); + assert_eq!(labels(&reopened), original_labels); + let independent = hayro_syntax::Pdf::new(saved).unwrap(); + assert_eq!(independent.pages()[0].render_dimensions(), (400.0, 300.0)); + } + } + } +} From 2a60b2fb0e7bf0732797c3ea73cc56bfa56084bc Mon Sep 17 00:00:00 2001 From: Mex <44814932+MMexicann@users.noreply.github.com> Date: Sun, 18 Oct 2026 11:51:40 +0200 Subject: [PATCH 089/105] M4: record repeated rotation regression evidence Address maintainer feedback on the existing PR. Preserve upstream work and document tested scope and validation limits. --- ROADMAP.md | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/ROADMAP.md b/ROADMAP.md index 122a9a9859..ce2e01690e 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -2,7 +2,7 @@ **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 (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-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) @@ -163,6 +163,8 @@ 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-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-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. @@ -511,6 +513,7 @@ Newest first: the date, what moved, and the new overall estimate. | Date | Change | Summary | |---|---|---| +| 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-13 | minor | M4: rotation queries and edits resolve indirect page-tree values; inherited, negative and large rotations verified through both saves. Estimates unchanged. | | 2026-10-18 | minor | M3, #3149: page replacement cancels render demand collected for the previous geometry; all viewer raster upload paths reject empty or oversized textures before cache eviction. Synthetic regressions reproduce the reported 1880×2350 upload at a 2048 limit, retain cached images and unsaved edits, and resume bounded backdrops and tiles. Estimates unchanged. | | 2026-10-15 | minor | M6: Plain AcroForm XML imports retain all direct text around comments and processing instructions, including CDATA and character references. Estimates unchanged. | From 9bda202dba852427a2095bb01c0789ac5a37a455 Mon Sep 17 00:00:00 2001 From: Mex <44814932+MMexicann@users.noreply.github.com> Date: Sun, 18 Oct 2026 11:51:55 +0200 Subject: [PATCH 090/105] M4: document repeated rotation preservation controls Address maintainer feedback on the existing PR. Preserve upstream work and document tested scope and validation limits. --- docs/roadmap.md | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/docs/roadmap.md b/docs/roadmap.md index 1fcd1477e5..d82b95a0b4 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -1,6 +1,6 @@ # PdfCraft roadmap: milestones and what's next -> **Last reviewed:** 2026-10-15 · **Last updated:** 2026-10-15 · **Change:** minor (M4: indirect page rotations refreshed upstream; M13: CLI check visits each directory once through aliases and cycles; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS) +> **Last reviewed:** 2026-10-15 · **Last updated:** 2026-10-18 · **Change:** minor (M4, #1919: repeated page indices accumulate rotations; M4: indirect page rotations refreshed upstream; M13: CLI check visits each directory once through aliases and cycles; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS) > **Last reviewed:** 2026-10-18 · **Last updated:** 2026-10-18 · **Change:** minor (M3, #3149: discard render demand after page geometry changes and check the GPU texture limit before upload; estimates unchanged) > **Last reviewed:** 2026-10-18 · **Last updated:** 2026-10-18 · **Change:** minor (M8, #1350: named replacement descriptions retired during Sanitize and across shared resources; estimates unchanged) > **Last reviewed:** 2026-10-15 · **Last updated:** 2026-10-15 · **Change:** minor (M6: AcroForm XML text; estimates unchanged) @@ -115,7 +115,7 @@ total matches the parity estimate. | M2 | Model, render, text | 200–350 | 15% | 130–230 | hayro bootstrap renderer (vendored patches); inspector predictor scratch is bounded by available input, and JPX sample rescaling handles 1–32 bits safely. Text extraction reaches word-F1 0.98 against pdftotext. Short same-line redraws split before placement rather than interleaving. A custom text match that ends on a reconstructed separator stops before the next glyph (#1315). Missing: model crate, fonts, DisplayList, renderer independent of hayro. Inspector page-label traversal and output are resource-bounded, with physical-number fallback warnings | | M2 | Model, render, text | 200–350 | 15% | 130–230 | hayro bootstrap renderer (vendored patches); Lab Bradford adaptation uses LMS white-point ratios, covered by independent neutral/chromatic and direct/Indexed render oracles; inspector predictor scratch is bounded by available input, and JPX sample rescaling handles 1–32 bits safely. Text extraction reaches word-F1 0.98 against pdftotext. Short same-line redraws split before placement rather than interleaving. Missing: model crate, fonts, DisplayList, renderer independent of hayro. Inspector page-label traversal and output are resource-bounded, with physical-number fallback warnings. Literal page-label prefix whitespace is preserved; nine public-inspection cases cover prefix spaces, encodings and empty-prefix controls | | M3 | Viewer app (native + web) | 80–150 | 90% | 10–20 | Acrobat-style shell, find, select, panels, tiles, web build, UI control channel for agents (opt-in). Missing: 60 fps test on a 500-page document, snapshot tests of every panel
Done since: fit visible, document title in the window; Linux middle-button scrolling; shared three-state theme preferences in the toolbar, View menu and control channel; numeric Linux portal events and idle wakeups completing #881, with native appearance inheritance retained (#446); late browser startup downloads preserve the current document and Save target (#171). | -| M4 | Engine, history, save, organize | 100–180 | 95% | 8–15 | Done:
- command registry (menus, shortcuts and palette all use it);
- undo/redo; incremental, atomic and encrypted saves;
- autosave and crash recovery;
- organize, combine, extract, split and insert-from-file, with identical fonts and images stored once;
- CLI `edit/combine/extract/split`.
Done since: Organize opens directly into the page overview, with checkbox selection, an editable page-range selection (#1092), and per-page rotate/delete controls; Combine files in its own tab (sortable, resizable columns; folders; early warnings; unlocking protected files), bookmark editing, page labels (Number pages), CLI `run`, Set Page Boxes, Crop tool, Duplicate pages, Ctrl+A / Cmd+A to select all pages in Organize, Replace pages, bookmarks from tagged headings. Extract and Split preserve shared Type 3 glyph resource graphs, retaining conservative pruning limits. Undo back to the opened or last saved file is clean; Revert opens that saved file after an undo; a later save appends to it so the saved revision stays in the file (#1310, #1311, #1312). Rotation queries and edits resolve indirect values on pages and ancestors. Refreshed against upstream `99941daa` (2026-10-15). Missing: page transitions, recovery on the web | +| M4 | Engine, history, save, organize | 100–180 | 95% | 8–15 | Done:
- command registry (menus, shortcuts and palette all use it);
- undo/redo; incremental, atomic and encrypted saves;
- autosave and crash recovery;
- organize, combine, extract, split and insert-from-file, with identical fonts and images stored once;
- CLI `edit/combine/extract/split`.
Done since: Organize opens directly into the page overview, with checkbox selection, an editable page-range selection (#1092), and per-page rotate/delete controls; Combine files in its own tab (sortable, resizable columns; folders; early warnings; unlocking protected files), bookmark editing, page labels (Number pages), CLI `run`, Set Page Boxes, Crop tool, Duplicate pages, Ctrl+A / Cmd+A to select all pages in Organize, Replace pages, bookmarks from tagged headings. Extract and Split preserve shared Type 3 glyph resource graphs, retaining conservative pruning limits. Undo back to the opened or last saved file is clean; Revert opens that saved file after an undo; a later save appends to it so the saved revision stays in the file (#1310, #1311, #1312). Rotation queries and edits resolve indirect values on pages and ancestors. Refreshed against upstream `99941daa` (2026-10-15). Repeated page indices accumulate rotations from the latest local value (#1919); saved/reopened regressions retain inherited angles and siblings. Missing: page transitions, recovery on the web | | M4 | Engine, history, save, organize | 100–180 | 95% | 8–15 | Done:
- command registry (menus, shortcuts and palette all use it);
- undo/redo; incremental, atomic and encrypted saves;
- autosave and crash recovery;
- organize, combine, extract, split and insert-from-file, with identical fonts and images stored once;
- CLI `edit/combine/extract/split`.
Done since: Organize opens directly into the page overview, with checkbox selection, an editable page-range selection (#1092), and per-page rotate/delete controls; Combine files in its own tab (sortable, resizable columns; folders; early warnings; unlocking protected files), bookmark editing, page labels (Number pages), CLI `run`, Set Page Boxes, Crop tool, Duplicate pages, Ctrl+A / Cmd+A to select all pages in Organize, Replace pages, bookmarks from tagged headings. Extract and Split preserve shared Type 3 glyph resource graphs, retaining conservative pruning limits. Missing: page transitions, recovery on the web M4: Insert-from-file preserves source bookmarks; integration tests retained with current main. | | M3 | Viewer app (native + web) | 80–150 | 90% | 10–20 | Acrobat-style shell, find, select, panels, tiles, web build, UI control channel for agents (opt-in). Missing: 60 fps test on a 500-page document, snapshot tests of every panel
Done since: fit visible, document title in the window; Linux middle-button scrolling; shared three-state theme preferences in the toolbar, View menu and control channel; numeric Linux portal events and idle wakeups completing #881, with native appearance inheritance retained (#446); late browser startup downloads preserve the current document and Save target (#171). Renderer-creation errors return to the existing OpenGL startup fallback; a real-GPU regression covers errors and device reuse. The underlying x86 DX12 shader compiler failure remains unresolved; see `windows-renderer-startup.md`. With Windows' Animation effects turned off, the wheel scrolls and zooms without easing and egui's animations take no time (`motion.rs`; macOS, Linux and the browser aren't asked yet); Preferences ▸ Animation effects (Use system setting, On, Off) decides it on every platform. | | M4 | Engine, history, save, organize | 100–180 | 95% | 8–15 | Done:
- command registry (menus, shortcuts and palette all use it);
- undo/redo; incremental, atomic and encrypted saves;
- autosave and crash recovery;
- organize, combine, extract, split and insert-from-file, with identical fonts and images stored once;
- CLI `edit/combine/extract/split`.
Done since: Combine files in its own tab (sortable, resizable columns; folders; early warnings; unlocking protected files), bookmark editing, page labels (Number pages), CLI `run`, Set Page Boxes, Crop tool, Duplicate pages, Ctrl+A / Cmd+A to select all pages in Organize, Replace pages, bookmarks from tagged headings. Missing: page transitions, recovery on the web | @@ -186,6 +186,7 @@ M2 (renderer), M7 (content editing), M10 (OCR and Office), M11 (Preflight and st | Date | Change | Summary | |---|---|---| +| 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-13 | minor | M4: rotation queries and edits resolve indirect page-tree values; inherited, negative and large rotations verified through both saves. Estimates unchanged. | | 2026-10-18 | minor | M3, #3149: page replacement cancels render demand collected for the previous geometry; all viewer raster upload paths reject empty or oversized textures before cache eviction. Synthetic regressions reproduce the reported 1880×2350 upload at a 2048 limit, retain cached images and unsaved edits, and resume bounded backdrops and tiles. Estimates unchanged. | | 2026-10-15 | minor | M6: Plain AcroForm XML imports retain all direct text around comments and processing instructions, including CDATA and character references. Estimates unchanged. | From a694e5dbba4e73f2f3d3c06b7f3d887c812dfab6 Mon Sep 17 00:00:00 2001 From: Mex <44814932+MMexicann@users.noreply.github.com> Date: Sun, 18 Oct 2026 11:52:04 +0200 Subject: [PATCH 091/105] M4: add duplicate-index rotation parity evidence Address maintainer feedback on the existing PR. Preserve upstream work and document tested scope and validation limits. --- parity/acrobat-features.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index ac9dbf81de..adc5b5fe87 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -2644,7 +2644,7 @@ milestone = "M4" status = "shipped" commands = ["page.rotate", "page.rotate_ccw"] tools = ["page_rotate"] -evidence = ["crates/organize/src/tests.rs::rotate_uses_inherited_rotation_and_round_trips", "crates/ui-egui/tests/editing.rs::command_click_toggles_and_rotation_applies_to_selection", "crates/ui-egui/tests/editing.rs::organize_page_actions_rotate_only_the_named_page_and_undo", "crates/ui-egui/tests/editing.rs::organize_local_actions_respect_document_permissions", "crates/organize/src/tests.rs::indirect_page_rotation_is_read_and_updated_from_its_effective_value", "crates/organize/src/tests.rs::malformed_indirect_rotations_keep_the_zero_fallback"] +evidence = ["crates/organize/src/tests.rs::rotate_repeated_indices_accumulate_from_the_current_page_value", "crates/organize/src/tests.rs::rotate_uses_inherited_rotation_and_round_trips", "crates/ui-egui/tests/editing.rs::command_click_toggles_and_rotation_applies_to_selection", "crates/ui-egui/tests/editing.rs::organize_page_actions_rotate_only_the_named_page_and_undo", "crates/ui-egui/tests/editing.rs::organize_local_actions_respect_document_permissions", "crates/organize/src/tests.rs::indirect_page_rotation_is_read_and_updated_from_its_effective_value", "crates/organize/src/tests.rs::malformed_indirect_rotations_keep_the_zero_fallback"] notes = "Rotation queries and edits resolve direct and inherited indirect integer values; full and incremental save regressions preserve sibling rotations. Existing quarter-turn restrictions and malformed-value zero fallback remain." [[feature]] From 7e3f413f528a867411fa1fae5911a94a548cdcff Mon Sep 17 00:00:00 2001 From: NetterSilver <133535268+Silvertree2010@users.noreply.github.com> Date: Sun, 18 Oct 2026 15:48:02 +0200 Subject: [PATCH 092/105] Redact email addresses whose local part starts with punctuation (#2948) Found while testing crates/redact/src/patterns.rs:141-146 (email) Co-authored-by: Claude Haiku 5.5 --- crates/redact/src/patterns.rs | 18 ++++++++++++++++-- 1 file changed, 16 insertions(+), 2 deletions(-) diff --git a/crates/redact/src/patterns.rs b/crates/redact/src/patterns.rs index 18fc65e339..f66d159e73 100644 --- a/crates/redact/src/patterns.rs +++ b/crates/redact/src/patterns.rs @@ -140,14 +140,16 @@ fn address_char(c: char) -> bool { fn email(s: &[char], i: usize) -> Option { let local = |c: &char| address_char(*c) || "._%+-".contains(*c); - if !address_char(s[i]) || i.checked_sub(1).and_then(|p| s.get(p)).is_some_and(local) { + // A match starts at the first character of a run of local characters, which may be + // punctuation ("_admin@host.com"), but the run needs a letter or digit. + if !local(&s[i]) || i.checked_sub(1).and_then(|p| s.get(p)).is_some_and(local) { return None; } let mut j = i; while j < s.len() && local(&s[j]) { j += 1; } - if s.get(j) != Some(&'@') { + if s.get(j) != Some(&'@') || !s[i..j].iter().any(|c| address_char(*c)) { return None; } let d0 = j + 1; @@ -401,6 +403,18 @@ mod tests { ); } + #[test] + fn emails_with_a_leading_punctuation_character() { + for lead in ["_", "-", ".", "%", "+"] { + let address = format!("{lead}john@example.com"); + assert_eq!(found(Pattern::Email, &format!("Write to {address} now")), [address.as_str()], "lead {lead:?}"); + } + // A run of local characters is still matched whole, never from its middle. + assert_eq!(found(Pattern::Email, "a.b@x.com, foo_john@example.com"), ["a.b@x.com", "foo_john@example.com"]); + // Punctuation alone is no local part. + assert_eq!(found(Pattern::Email, "Write to ..@example.com or -@example.com"), Vec::::new()); + } + #[test] fn internationalized_emails() { assert_eq!( From f0c275669ad80a4d643965b97ae8b859c333c00d Mon Sep 17 00:00:00 2001 From: mtlcl410 <2368091+mtlcl410@users.noreply.github.com> Date: Sun, 18 Oct 2026 21:48:14 +0800 Subject: [PATCH 093/105] M7: never encode typed text with CID 0 (#3223) (#3235) Word's Type 0 subsets map CID 0 (.notdef) as well as the real space glyph to U+0020 in ToUnicode and list a width for CID 0. encode keeps the lowest code for each character, so an edited line wrote every space as <0000> and drew a box where each space was. CID 0 is .notdef whatever ToUnicode says, so has_glyph now refuses it and the space takes the real CID. Claude-Session: https://claude.ai/code/session_01Gv2WwdgjmBz33GWkwgNn3Q Co-authored-by: Claude Opus 5.5 --- crates/fonts/src/pdf.rs | 36 +++++++++++++++++++++++++++++++++++- 1 file changed, 35 insertions(+), 1 deletion(-) diff --git a/crates/fonts/src/pdf.rs b/crates/fonts/src/pdf.rs index bb22d15458..26685866b3 100644 --- a/crates/fonts/src/pdf.rs +++ b/crates/fonts/src/pdf.rs @@ -839,6 +839,11 @@ impl Metrics { fn has_glyph(&self, code: u32) -> bool { if self.composite { let cid = self.cid(code); + // CID 0 is `.notdef` (an unmapped code lands there too), whatever ToUnicode says: Word + // maps it to a space and gives it a width, and typing with it draws boxes (#3223). + if cid == 0 { + return false; + } // A CID /W leaves out takes /DW, and Skia (Chrome) leaves out every CID of that width: for // those, only the subset's program says whether the glyph is there. return self.cid_widths.contains_key(&cid) @@ -1456,6 +1461,34 @@ mod tests { assert_eq!(m.encode("Z"), None); } + /// Word's Type 0 subsets map CID 0 as well as the real space glyph to U+0020 and give CID 0 a + /// width; CID 0 is `.notdef`, so a typed space takes the real one (#3223). + #[test] + fn composite_fonts_never_encode_with_cid_0() { + let mut doc = Document::new_empty(); + let cmap = b"/CIDInit /ProcSet findresource begin 1 begincodespacerange <0000> endcodespacerange \ + 1 beginbfrange <0000> <0002> [<0020> <004D> <00E1>] endbfrange \ + 1 beginbfrange <0005> <0007> [<0065> <0072> <0020>] endbfrange end"; + let tu = doc.add(Object::Stream(Stream::from_raw(Dict::new(), cmap.to_vec()))); + let mut desc = Dict::new(); + desc.set(b"Subtype".to_vec(), Object::name("CIDFontType2")); + let widths = [889, 444, 389, 278, 444, 333, 250].map(Object::Int).to_vec(); + desc.set(b"W".to_vec(), Object::Array(vec![Object::Int(0), Object::Array(vec![Object::Int(778)]), Object::Int(1), Object::Array(widths)])); + let f = font( + &mut doc, + vec![ + ("Subtype", Object::name("Type0")), + ("BaseFont", Object::name("BCDEEE+TimesNewRomanPSMT")), + ("Encoding", Object::name("Identity-H")), + ("DescendantFonts", Object::Array(vec![Object::Dict(desc)])), + ("ToUnicode", Object::Ref(tu)), + ], + ); + let m = Metrics::from_dict(&doc, &f); + assert_eq!(m.encode("Me er").as_deref(), Some(&[0, 1, 0, 5, 0, 7, 0, 5, 0, 6][..])); + assert_eq!(m.decode(&[0, 1, 0, 7, 0, 2]), "M á"); + } + /// Chrome's subsets leave the glyphs of `/DW` width out of `/W` (#1443): "s" (CID 0x56) is /// still in the font, but nothing is when the font gives no default width to point at. #[test] @@ -1754,7 +1787,8 @@ mod tests { assert_eq!(got, expected.get(&code).cloned(), "code {code:#06x} in {cmap}"); } let mut reverse: HashMap = HashMap::new(); - for (&code, text) in &expected { + // Code 0 is CID 0, `.notdef`, which no character is encoded with. + for (&code, text) in expected.iter().filter(|&(&code, _)| code != 0) { if let Some(c) = only_char(text) { keep(&mut reverse, c, code); } From 2bd6d1357da4129be429127c000966eaa8ffa5e2 Mon Sep 17 00:00:00 2001 From: mtlcl410 <2368091+mtlcl410@users.noreply.github.com> Date: Sun, 18 Oct 2026 21:48:26 +0800 Subject: [PATCH 094/105] M5: flattening comments leaves 3D, multimedia and print-production annotations (#3087) (#3089) doc_flatten with comments chose every non-widget annotation except Link and Popup, so a comments-only flatten baked in and deleted 3D, Screen, Movie, RichMedia, PrinterMark, TrapNet and Watermark annotations with their data (a 3D annotation's U3D model and default view). The comments branch now uses pdfcraft_annot::is_comment_subtype, the classifier the comment list uses; widgets follow `fields` only. Claude-Session: https://claude.ai/code/session_01RUNQSPVXx3dDBabRZHAnjz Co-authored-by: Claude Opus 5.5 --- crates/edit/src/flatten.rs | 10 +++++++-- crates/edit/src/tests.rs | 40 ++++++++++++++++++++++++++++++++++++ parity/acrobat-features.toml | 4 ++-- 3 files changed, 50 insertions(+), 4 deletions(-) diff --git a/crates/edit/src/flatten.rs b/crates/edit/src/flatten.rs index 8202c18d83..3ac5468057 100644 --- a/crates/edit/src/flatten.rs +++ b/crates/edit/src/flatten.rs @@ -63,9 +63,15 @@ enum Which { fn selected(doc: &Document, d: &Dict, which: Which) -> bool { let subtype = d.name(b"Subtype").unwrap_or_default(); match which { + // Comments are what the comment list calls comments: links, pop-ups and the non-markup + // multimedia and print-production annotations (3D, Screen, Movie, RichMedia, + // PrinterMark, TrapNet, Watermark) are neither comments nor fields, and stay (#3087). Which::Comments { comments, fields } => { - let widget = subtype == b"Widget"; - !matches!(subtype, b"Link" | b"Popup") && (!widget || fields) && (widget || comments) + if subtype == b"Widget" { + fields + } else { + comments && pdfcraft_annot::is_comment_subtype(&String::from_utf8_lossy(subtype)) + } } Which::FillSign => !matches!(subtype, b"Link" | b"Popup" | b"Widget") && pdfcraft_annot::is_fill_sign(doc, d), } diff --git a/crates/edit/src/tests.rs b/crates/edit/src/tests.rs index 7930a7eade..299356b293 100644 --- a/crates/edit/src/tests.rs +++ b/crates/edit/src/tests.rs @@ -4498,3 +4498,43 @@ fn removing_marks_strips_wrappers_that_closed_open_state() { assert_eq!(remove_marks(&mut doc, &[0], MarkKind::Watermark).unwrap(), 1); assert_eq!(streams(&doc, 0), ["q 2 0 0 2 0 0 cm"]); } + +/// #3087: flattening comments leaves alone what isn't a comment: links, and the non-markup +/// multimedia and print-production annotations (3D, Screen, Movie, RichMedia, PrinterMark, +/// TrapNet, Watermark), which it used to bake into the page and delete with their data. +/// Flattening fields only leaves them alone too; real comments are still flattened. +#[test] +fn flattening_comments_keeps_annotations_that_are_not_comments() { + let kept = ["Widget", "Link", "3D", "Screen", "Movie", "RichMedia", "PrinterMark", "TrapNet", "Watermark"]; + let flattened = ["Text", "Square", "FileAttachment"]; + let all: Vec<&str> = flattened.iter().chain(kept.iter()).copied().collect(); + let form = "<< /Type /XObject /Subtype /Form /BBox [0 0 40 20] /Length 26 >>\nstream\n0 0 1 rg 0 0 40 20 re f\nendstream"; + let annots: Vec = all + .iter() + .enumerate() + .map(|(i, s)| { + format!("<< /Type /Annot /Subtype /{s} /Rect [{x} 700 {r} 720] /AP << /N 4 0 R >> /Contents ({s}) >>", x = 20 + 50 * i, r = 60 + 50 * i) + }) + .collect(); + let refs: Vec = (0..all.len()).map(|i| format!("{} 0 R", 6 + i)).collect(); + let page = format!("<< /Type /Page /Parent 2 0 R /MediaBox [0 0 600 800] /Resources << >> /Contents 5 0 R /Annots [{}] >>", refs.join(" ")); + let content = stream("", " "); + let mut objs: Vec<&str> = vec!["<< /Type /Catalog /Pages 2 0 R >>", "<< /Type /Pages /Kids [3 0 R] /Count 1 >>", &page, form, &content]; + objs.extend(annots.iter().map(String::as_str)); + let left = |doc: &Document| -> Vec { + let p = &pdfcraft_model::pages(doc)[0]; + let Some(a) = p.dict.get(b"Annots").map(|a| doc.resolve(a)) else { return Vec::new() }; + a.as_array() + .unwrap() + .iter() + .filter_map(|o| doc.resolve(o).as_dict().and_then(|d| d.name(b"Subtype")).map(|s| String::from_utf8_lossy(s).into_owned())) + .collect() + }; + let mut doc = build(&objs); + assert_eq!(flatten(&mut doc, &[0], true, false).unwrap(), flattened.len()); + assert_eq!(left(&reopen(&doc)), kept, "only the comments were flattened"); + let mut doc = build(&objs); + assert_eq!(flatten(&mut doc, &[0], false, true).unwrap(), 1, "the widget"); + 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"); +} diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index f05387aa21..245d439af9 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -4208,8 +4208,8 @@ milestone = "M5" status = "shipped" commands = ["comment.flatten"] tools = ["doc_flatten"] -evidence = ["crates/edit/src/tests.rs::flattening_draws_appearances_into_the_page_and_removes_the_comments", "crates/edit/src/tests.rs::flattening_keeps_an_undrawn_reply_to_a_flattened_comment", "crates/engine/src/tests.rs::flattening_keeps_the_look_and_drops_the_objects", "crates/automation/tests/automation.rs::flattening_through_tools"] -notes = "appearances become page content; pop-ups and replies removed; a comment or reply whose appearance cannot be drawn (a cloudy border, an unsupported subtype) stays as it is instead of being deleted" +evidence = ["crates/edit/src/tests.rs::flattening_draws_appearances_into_the_page_and_removes_the_comments", "crates/edit/src/tests.rs::flattening_keeps_an_undrawn_reply_to_a_flattened_comment", "crates/engine/src/tests.rs::flattening_keeps_the_look_and_drops_the_objects", "crates/automation/tests/automation.rs::flattening_through_tools", "crates/edit/src/tests.rs::flattening_comments_keeps_annotations_that_are_not_comments"] +notes = "appearances become page content; pop-ups and replies removed; a comment or reply whose appearance cannot be drawn (a cloudy border, an unsupported subtype) stays as it is instead of being deleted; annotations that are not comments (links, 3D, Screen, Movie, RichMedia, PrinterMark, TrapNet, Watermark) are left in place (#3087)" [[feature]] id = "comment.migrate" From 25495b959b1fe4f9480e19cb14dc9869da3e0f74 Mon Sep 17 00:00:00 2001 From: MarekSpodBiedry <73122968+MarekSpodBiedry@users.noreply.github.com> Date: Sun, 18 Oct 2026 15:48:49 +0200 Subject: [PATCH 095/105] Keep a subscript or superscript in its line when extracting text (#3174) (#3244) * Keep a subscript or superscript in its line when extracting text (#3174) * Text extraction: only a glyph right beside the previous one gets the sub/superscript centre limit A large negative gap (a small glyph drawn back under a large one) kept the wider limit. --------- Co-authored-by: Brandon Thomas --- crates/render/src/text.rs | 23 ++++++++++++++++++++++- 1 file changed, 22 insertions(+), 1 deletion(-) diff --git a/crates/render/src/text.rs b/crates/render/src/text.rs index 5054859961..6784f14eb0 100644 --- a/crates/render/src/text.rs +++ b/crates/render/src/text.rs @@ -1401,6 +1401,7 @@ fn union(a: &mut [f32; 4], b: &[f32; 4]) { /// /// Heuristics (documented so they can be tuned and tested; plan/architecture.md §8): /// 1. *Segments:* consecutive glyphs (content order) on one baseline with no column-sized gap. +/// A smaller glyph right next to the previous one (a sub- or superscript) stays in the segment. /// Segments on the same baseline that nearly touch are merged (text drawn out of order). /// 2. *Blocks:* segments stacked with line-sized gaps and overlapping horizontally. /// 3. *Block order:* repeatedly take the top-most remaining block, preferring a block to its left @@ -1453,7 +1454,10 @@ fn arrange(glyphs: &[TextGlyph]) -> (Vec, Vec, Vec) { let Some(p) = s.first.end.checked_sub(1) else { return false }; let ph = height(p).min(height(i)); let gap = g[0] - glyphs[p].rect[2]; - (cy(i) - cy(p)).abs() < ph * 0.5 && gap < ph * 3.0 && g[0] > glyphs[p].rect[0] - ph * 2.0 + // A sub- or superscript is a smaller glyph on a shifted baseline: its em box centre sits off the line by + // more than half its own height, but by less than half the height of the full-size glyph beside it. + let centre_limit = if gap.abs() < ph { height(p).max(height(i)) * 0.5 } else { ph * 0.5 }; + (cy(i) - cy(p)).abs() < centre_limit && gap < ph * 3.0 && g[0] > glyphs[p].rect[0] - ph * 2.0 }); let reset = cont && segs.last().is_some_and(|s| restarts_run(&glyphs, s.first.start, i, furthest_progress)); if cont @@ -1636,6 +1640,23 @@ fn arrange(glyphs: &[TextGlyph]) -> (Vec, Vec, Vec) { mod tests { use super::*; + fn em_box(text: &str, x0: f32, y0: f32, x1: f32, y1: f32) -> TextGlyph { + TextGlyph { text: text.into(), rect: [x0, y0, x1, y1], quad: [[x0, y0], [x1, y0], [x1, y1], [x0, y1]], direction: [1.0, 0.0] } + } + + #[test] + fn subscript_stays_in_its_word() { + // "CO2-E": an 8.1 pt "CO" and "-E", with a 4.7 pt "2" drawn 2.7 pt lower (#3174). + let glyphs = vec![ + em_box("C", 0.0, 92.5, 5.0, 100.6), + em_box("O", 5.0, 92.5, 11.0, 100.6), + em_box("2", 11.0, 97.9, 13.5, 102.6), + em_box("-", 13.5, 92.5, 16.0, 100.6), + em_box("E", 16.0, 92.5, 21.0, 100.6), + ]; + assert_eq!(layout(glyphs).plain_text(), "CO2-E"); + } + #[test] fn identity_v_columns_are_searchable_in_reading_order() { use lopdf::{Document, Object, Stream, dictionary}; From 2eb16390976b7864a7a57731fec35a4d1a56dbee Mon Sep 17 00:00:00 2001 From: NetterSilver <133535268+Silvertree2010@users.noreply.github.com> Date: Sun, 18 Oct 2026 15:49:13 +0200 Subject: [PATCH 096/105] Find credit card numbers followed by an expiry, year or CVV (#2969) * Find credit card numbers followed by an expiry, year or CVV Found while testing crates/redact/src/patterns.rs:180-190 (credit_card, via digit_run at :53) Co-Authored-By: Claude Haiku 5.5 Co-Authored-By: Claude Sonnet 5.5 * Redact: a card followed by a number drops at most four trailing digits A longer grouped number (an IBAN's digits) no longer matches on a Luhn-valid prefix; the run is read far enough to see the tail and refused when it goes on past that. --------- Co-authored-by: Claude Haiku 5.5 Co-authored-by: Brandon Thomas --- crates/redact/src/patterns.rs | 41 +++++++++++++++++++++++++++++++---- 1 file changed, 37 insertions(+), 4 deletions(-) diff --git a/crates/redact/src/patterns.rs b/crates/redact/src/patterns.rs index f66d159e73..4da8236c22 100644 --- a/crates/redact/src/patterns.rs +++ b/crates/redact/src/patterns.rs @@ -183,12 +183,33 @@ fn credit_card(s: &[char], i: usize) -> Option { if !s[i].is_ascii_digit() || digit(i.checked_sub(1).and_then(|p| s.get(p))) { return None; } - let (end, n) = digit_run(s, i, &[' ', '-'], 23); - if !(13..=19).contains(&n) || digit(s.get(end)) { + // Room for the longest card (19 digits in groups, 23 characters) and a tail of up to four digits. + let (end, n) = digit_run(s, i, &[' ', '-'], 29); + // A run longer than that is some other, longer number. + if end == i || run_continues(s, end - 1, 1, &[' ', '-']) { return None; } - let digits: Vec = s[i..end].iter().filter_map(|c| c.to_digit(10)).collect(); - luhn(&digits).then_some(end - i) + // The longest run may carry a trailing number (an expiry, a year, a CVV), so try it and then + // each shorter run ending at a group boundary, longest first. The dropped tail is at most four + // digits, so a longer number (an IBAN's digits) doesn't match on a prefix. + let mut count = n; + for e in (i + 1..=end).rev() { + if n - count > 4 { + break; + } + if !s.get(e - 1).is_some_and(char::is_ascii_digit) { + continue; + } + let boundary = if e == end { !digit(s.get(end)) } else { matches!(s.get(e), Some(' ' | '-')) }; + if boundary && (13..=19).contains(&count) { + let digits: Vec = s[i..e].iter().filter_map(|c| c.to_digit(10)).collect(); + if luhn(&digits) { + return Some(e - i); + } + } + count = count.saturating_sub(1); + } + None } fn ssn(s: &[char], i: usize) -> Option { @@ -435,6 +456,18 @@ mod tests { ); } + #[test] + fn credit_card_followed_by_another_number() { + for tail in ["12/26", "2026", "123", "12 26"] { + assert_eq!(found(Pattern::CreditCard, &format!("Card 4111 1111 1111 1111 {tail}")), ["4111 1111 1111 1111"], "tail {tail}"); + } + assert_eq!(found(Pattern::CreditCard, "Card 3782-822463-10005 123"), ["3782-822463-10005"]); + // A run that is not a card at any group boundary still matches nothing. + assert_eq!(found(Pattern::CreditCard, "Card 4111 1111 1111 1112 12/26"), Vec::::new()); + // A longer grouped number doesn't match on a card-shaped prefix. + assert_eq!(found(Pattern::CreditCard, "IBAN 4111 1111 1111 1111 0000 0000"), Vec::::new()); + } + #[test] fn social_security_numbers() { assert_eq!( From a50ddd4de112b4b85f79ed4618e7395e49bc6e1c Mon Sep 17 00:00:00 2001 From: hms5232 <43672033+hms5232@users.noreply.github.com> Date: Sun, 18 Oct 2026 21:49:36 +0800 Subject: [PATCH 097/105] fix(i18n): zh-hant catalog uses Taiwan vocabulary consistently (#2856) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Several entries carried mainland-Chinese computing terms in a catalog written in the vocabulary used in Taiwan: - 丟失 → 遺失 (unsaved-changes warning) - 郵政編碼 → 郵遞區號 (Zip Code field formats) - 自簽名 → 自我簽署 (self-signed digital IDs, three entries) - 灰度 → 灰階 (Grayscale Images, Print in grayscale; the rest of the file already used 灰階) - 匹配 → 相符 (ten search/match entries; 符合 stays for the fit/conform sense: 符合頁面, 符合 PDF/A) Translation column only — source keys, placeholders and ellipses unchanged. ATTRIBUTION.toml hash re-pinned. Generated with [Devin](https://devin.ai) Co-authored-by: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com> Co-authored-by: Brandon Thomas --- ATTRIBUTION.toml | 2 +- crates/ui-egui/src/i18n/zh-hant.tsv | 36 ++++++++++++++--------------- 2 files changed, 19 insertions(+), 19 deletions(-) diff --git a/ATTRIBUTION.toml b/ATTRIBUTION.toml index 87b11279e7..80a5e0f542 100644 --- a/ATTRIBUTION.toml +++ b/ATTRIBUTION.toml @@ -41,7 +41,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Traditional Chinese UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "349a2ead371bdbfeefb9ab2279c676b205cefe163cbef174f8123fe4bf739ab9" +sha256 = "7b98d720bc0c433d62b7b4fb4a5b79d3e74111590c29a59a51f3944e5e0622a5" [[asset]] path = "crates/ui-egui/src/i18n/cs.tsv" diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index 0cd22d7ff5..ded4ad9048 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -109,7 +109,7 @@ Sanitize 清理 Mark all 全部標記 Save changes to “{name}” before closing? 在關閉前儲存對「{name}」的更改? - Your changes will be lost if you don't save them. 如果不儲存,更改將會丟失。 + Your changes will be lost if you don't save them. 如果不儲存,更改將會遺失。 Password required 需要密碼 “{name}” is protected. Enter a password to open it. 「{name}」受保護。請輸入密碼以開啟。 Password 密碼 @@ -190,7 +190,7 @@ Discard User Data 捨棄使用者資料 Clean Up 清理 Color Images 彩色圖片 - Grayscale Images 灰度圖片 + Grayscale Images 灰階圖片 Bicubic downsampling to 雙三次降取樣至 for images above (高於此解析度的圖片) Compression 壓縮 @@ -303,8 +303,8 @@ Negative number style: 負數樣式: Format: 格式: Custom: 自訂: - Zip Code 郵政編碼 - Zip Code + 4 郵政編碼 + 4 + Zip Code 郵遞區號 + Zip Code + 4 郵遞區號 + 4 Phone Number 電話號碼 Social Security Number 社會安全號碼 Arbitrary Mask 任意掩碼 @@ -503,7 +503,7 @@ Continue 繼續 Configure a Digital ID for Signing 設定簽署用的數位 ID Use a Digital ID from a file 使用檔案中的數位 ID - Create a new Digital ID (self-signed, saved to a password-protected file) 建立新數位 ID(自簽名,儲存到受密碼保護的檔案) + Create a new Digital ID (self-signed, saved to a password-protected file) 建立新數位 ID(自我簽署,儲存到受密碼保護的檔案) Name 名稱 Organizational Unit 組織單位 Organization Name 組織名稱 @@ -511,7 +511,7 @@ Country/Region 國家/地區 Key Algorithm 金鑰演算法 Confirm Password 確認密碼 - Valid for 5 years, for digital signatures. Self-signed IDs are usually not trusted by others. 有效期 5 年,用於數位簽章。自簽名 ID 通常不受他人信任。 + Valid for 5 years, for digital signatures. Self-signed IDs are usually not trusted by others. 有效期 5 年,用於數位簽章。自我簽署 ID 通常不受他人信任。 File (.p12, .pfx) 檔案 (.p12, .pfx) Digital ID 數位 ID Browse… 瀏覽… @@ -566,7 +566,7 @@ Public key 公鑰 Key usage 金鑰用途 Not present 不存在 - Self-signed 自簽名 + Self-signed 自我簽署 Edit Action 編輯操作 New Action 新建操作 Action name: 操作名稱: @@ -772,9 +772,9 @@ Social Security Numbers 社會安全號碼 Dates 日期 Text in images isn't found; recognise text (OCR) first. 圖片中的文字無法找到;請先識別文字(OCR)。 - No matches. 無匹配。 + No matches. 無相符。 Some bookmarks are not shown here: the panel lists a limited number, nested a limited depth. 此處未顯示部分書籤:面板列出的書籤數量和巢狀深度有限。 - {n} match(es) marked for redaction. 已標記 {n} 個匹配以待遮蔽內容。 + {n} match(es) marked for redaction. 已標記 {n} 個相符以待遮蔽內容。 Multiple words or phrases 多個單字或片語 One word or phrase per line 每行一個單字或片語 Import list… 匯入清單… @@ -815,7 +815,7 @@ {name} (default) {name}(預設) Copies: 份數: Collate 逐份列印 - Print in grayscale 灰度列印 + Print in grayscale 灰階列印 Two-sided: 雙面: Off 關 Flip on long edge 沿長邊翻轉 @@ -1174,7 +1174,7 @@ Add a comment 新增註解 No comments yet. 尚無註解。 Pick a tool in the quick bar, or type above. 在快捷欄中選擇工具,或在上方輸入。 - No comments match. 沒有匹配的註解。 + No comments match. 沒有相符的註解。 Unknown author 未知作者 No colour 無顏色 Remove checkmark 刪除勾選標記 @@ -1231,13 +1231,13 @@ Open in a new tab 在新索引標籤中開啟 This document has no pages. 本文件沒有頁面。 Find text 尋找文字 - No matches 無匹配 + No matches 無相符 Searching… {s}/{p} 正在搜尋… {s}/{p} {c} of {n}{more} {c} / {n}{more} Previous ({key}) 上一個 ({key}) Next ({key}) 下一個 ({key}) Find options 尋找選項 - Whole words only 僅全字匹配 + Whole words only 僅全字相符 Case-sensitive 區分大小寫 Close (Esc) 關閉 (Esc) Align 對齊 @@ -1302,7 +1302,7 @@ · password-protected · 受密碼保護 Recover 恢復 Discard 放棄 - Next / previous match 下一個/上一個符合項目 + Next / previous match 下一個/上一個相符項目 Copy selected text 複製所選文字 Select a word 選取一個詞 Clear selection / close find 清除選取/關閉尋找 @@ -1618,7 +1618,7 @@ the name is not a plain file name 名稱不是單純的檔案名稱 Wrote 1 file to {dir} 已將 1 個檔案寫入{dir} Wrote {n} files to {dir} 已將{n}個檔案寫入{dir} - No pages match those choices 所選條件沒有匹配的頁面 + No pages match those choices 所選條件沒有相符的頁面 Couldn't split: {e} 無法拆分:{e} Couldn't summarize comments: {e} 無法彙總註解:{e} Couldn't open the result: {e} 無法開啟結果:{e} @@ -1693,8 +1693,8 @@ What word or phrase would you like to search for? 您想搜尋哪個詞或短語? Searching… {s} of {p} pages 正在搜尋… {s}/{p} 頁 (searching… {s} of {p} pages) (正在搜尋… {s}/{p} 頁) - 1 instance 1 個匹配 - {n} instances {n} 個匹配 + 1 instance 1 個相符 + {n} instances {n} 個相符 Go to this result 轉到此結果 Highlight 突出顯示 Draw 繪製 @@ -1743,7 +1743,7 @@ Tool 工具 Command 指令 Search tools and commands… 搜尋工具與指令… - No matching tools 找不到符合的工具 + No matching tools 找不到相符的工具 All tools 所有工具 Read 閱讀 Convert 轉換 From 1581cd1edd883708ece0d3fc381b84296be5a938 Mon Sep 17 00:00:00 2001 From: Ian Holloway <72767437+IanHollow@users.noreply.github.com> Date: Sun, 18 Oct 2026 06:50:16 -0700 Subject: [PATCH 098/105] M10: keep the OCR scan fixture at its original page size (#2399) Assisted-by: GPT-6 Signed-off-by: IanHollow <72767437+IanHollow@users.noreply.github.com> Co-authored-by: Brandon Thomas --- crates/engine/src/tests.rs | 36 ++++++++++++++++++++++++++++++------ 1 file changed, 30 insertions(+), 6 deletions(-) diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 55e8eee0fa..1c473b7807 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -3339,6 +3339,35 @@ fn recognize_text_reads_the_euro_sign() { assert!(text.contains("€250"), "{text}"); } +const SYNTHETIC_SCAN_DPI: f64 = 300.0; + +/// A scan of the OCR sentence at the source page's physical size, without a text layer. +fn synthetic_ocr_scan(s: &mut Session) -> (DocId, [f32; 2]) { + let text = s.create_from_text("t", "The quick brown fox jumps over the lazy dog.").unwrap(); + let id = s.open("text.pdf", None, text, None).unwrap(); + let original = &s.get(id).unwrap().info.pages[0]; + let size = [original.width, original.height]; + // A typical 300 DPI scan keeps the small leading glyph in "quick" legible to OCR. + let png = export::Exporter::new(s.get(id).unwrap()).png(0, SYNTHETIC_SCAN_DPI).unwrap(); + // Exported PNGs have no resolution metadata. The 72 DPI import default would enlarge + // the page and change the PDF-point coordinates the placement assertions exercise. + let scan = s.create_from_images_with_resolution(&[("scan.png".into(), png)], ImageResolution::Dpi(SYNTHETIC_SCAN_DPI)).unwrap(); + (s.open("scan.pdf", None, scan, None).unwrap(), size) +} + +#[test] +fn synthetic_ocr_scan_keeps_the_original_page_size_without_models() { + let mut s = Session::new().with_clock(|| 1_700_000_000); + let (id, expected) = synthetic_ocr_scan(&mut s); + let page = &s.get(id).unwrap().info.pages[0]; + // Rounding to whole raster pixels may change a page edge by at most one pixel. + let tolerance = 72.0 / SYNTHETIC_SCAN_DPI; + for (actual, expected) in [page.width, page.height].into_iter().zip(expected) { + assert!(f64::from((actual - expected).abs()) <= tolerance, "page edge {actual} pt, expected {expected} pt"); + } + assert_eq!(page_texts(&s, id), [""], "the scan has no text until OCR runs"); +} + #[test] fn recognize_text_makes_a_scanned_page_searchable() { if !ocr::available() { @@ -3346,12 +3375,7 @@ fn recognize_text_makes_a_scanned_page_searchable() { return; } let mut s = Session::new().with_clock(|| 1_700_000_000); - let text = s.create_from_text("t", "The quick brown fox jumps over the lazy dog.").unwrap(); - let id = s.open("text.pdf", None, text, None).unwrap(); - // A typical 300 DPI scan keeps the small leading glyph in "quick" legible to OCR. - let png = export::Exporter::new(s.get(id).unwrap()).png(0, 300.0).unwrap(); - let scan = s.create_from_images(&[("scan.png".into(), png)]).unwrap(); - let id = s.open("scan.pdf", None, scan, None).unwrap(); + let (id, _) = synthetic_ocr_scan(&mut s); assert_eq!(page_texts(&s, id), [""], "a picture has no text"); let found = s.recognize_text(id, &[], ocr::OcrSettings::default()).unwrap(); From 6ce3bc198bda36ef8246d875b97b72c495473c81 Mon Sep 17 00:00:00 2001 From: Andrew Barnes <169967362+Bortlesboat@users.noreply.github.com> Date: Sun, 18 Oct 2026 09:50:42 -0400 Subject: [PATCH 099/105] fix(gpu): request adapter-supported device limits (#2383) (#2433) Clamp the surface device descriptor to the adapter's capabilities while preserving its maximum 2D texture extent. Cover V3D shader variables, lower GL capabilities, alignment ordering and capable-device defaults. Co-authored-by: Brandon Thomas --- ROADMAP.md | 4 ++++ apps/pdfcraft/src/main.rs | 42 ++++++++++++++++++++++++++++++++++++--- docs/roadmap.md | 5 ++++- 3 files changed, 47 insertions(+), 4 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 40a9d8c560..fda6dca108 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -6,6 +6,7 @@ > **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-15 · **Last updated:** 2026-10-15 · **Change:** minor (M6: AcroForm XML text; estimates unchanged) > **Last reviewed:** 2026-10-15 · **Last updated:** 2026-10-15 · **Change:** minor (M2/gap 12: the Annex D.2 alias codes read as the ink they draw in both tables; the text oracle scores reading order; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS) +> **Last reviewed:** 2026-10-15 · **Last updated:** 2026-10-15 · **Change:** minor (M14/#2383 GPU-limit candidate; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS) > **Last reviewed:** 2026-10-13 · **Last updated:** 2026-10-13 · **Change:** minor (M6: preserve XML/XFDF attribute whitespace; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS) > **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-14 · **Change:** minor (M1: raw-deflate fallback preserves decoding-limit errors; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS) > **Last reviewed:** 2026-10-13 · **Last updated:** 2026-10-14 · **Change:** minor (M14: the Italian catalog has every entry the other catalogs had and the code looks up; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS) @@ -170,6 +171,7 @@ Ranked; detail and the full milestone table in [docs/roadmap.md](docs/roadmap.md - **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-13 (community, M3, #1569 review):** Pixel-only Wayland wheel, wheel-tilt and continuous input use standalone moved events, so Ctrl zoom does not remain latched after the modifier is released (#991); vendored winit patch (13), with a policy test. Estimates unchanged. +- **2026-10-15 (M14, #2383 candidate):** Device-limit requests now clamp unsupported maxima and minimum alignments before enabling the adapter's full 2D texture extent. This addresses the reported V3D request of 16 shader variables on an adapter supporting 15. Synthetic regressions fail before the clamp and pass afterward, along with the existing HiDPI case and Windows GPU-required application tests. Raspberry Pi startup remains unverified; milestone and overall estimates are unchanged. - **2026-10-12 (M6):** XFDF field import and data-file extraction join all text nodes in a value, preserving text after XML comments and processing instructions. Synthetic regressions cover Unicode, CDATA, empty values and PDF save/reopen. Estimates unchanged. - **2026-10-12 (M7, picture right-click menu):** The Edit text & images menu of a picture gains Cut Image, Redact Image (a redaction mark over the picture, applied from Redact a PDF), Create Link… and Recognize Text… (for that page), in all 21 catalogs. The menu was drawn only while the pointer was over the picture, so its items outside the picture (most of a logo's menu, and Delete below a page) could not be clicked; an open menu now stays drawn. Still missing from Acrobat's menu: Arrange (bring to front, send backward), Remove crop, Draw freehand and Edit image using… (opening an external editor). - **2026-10-12 (M2, inline-image boundaries):** Unfiltered inline images with known sample layouts keep pixel bytes that look like the `EI` terminator when content is rewritten. Regression tests cover row padding, colour-space and parameter aliases, masks and malformed sizes; classic and packed saved PDFs retain the pixels in independent readers and renderers. Filtered and resource-defined layouts retain the existing tolerant heuristic. Estimates unchanged. @@ -506,6 +508,8 @@ Newest first: the date, what moved, and the new overall estimate. ## Revision history +**2026-10-15 · minor:** M14/#2383 candidate: GPU device requests clamp unsupported limits while retaining the adapter texture extent. Synthetic V3D/downlevel regressions and Windows GPU-required tests pass; Raspberry Pi hardware remains unverified. Estimates unchanged. + | Date | Change | Summary | |---|---|---| | 2026-10-18 | minor | 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/src/main.rs b/apps/pdfcraft/src/main.rs index 4d9857ce19..b274dc53a8 100644 --- a/apps/pdfcraft/src/main.rs +++ b/apps/pdfcraft/src/main.rs @@ -565,7 +565,7 @@ fn configure_gpu(native: &mut eframe::NativeOptions) { setup.instance_descriptor.flags = setup.instance_descriptor.flags.difference(eframe::wgpu::InstanceFlags::VALIDATION_INDIRECT_CALL).with_env(); // egui-wgpu requests only 8192 pixels even when the adapter can render larger surfaces. // A restored 3440-point window at 250% DPI requests 8600 pixels and otherwise panics in - // Surface::configure before the app starts. Keep the renderer's other device requirements, + // Surface::configure before the app starts. Keep the renderer's other compatible requirements, // but enable the adapter's actual 2D texture extent (also for smaller/downlevel adapters). let device_descriptor = std::sync::Arc::clone(&setup.device_descriptor); setup.device_descriptor = std::sync::Arc::new(move |adapter| { @@ -606,8 +606,9 @@ fn configure_gpu(native: &mut eframe::NativeOptions) { } /// Surface textures must fit the device's enabled limits, not just the physical GPU's limits. -/// Changing only the 2D extent preserves egui-wgpu's backend-specific downlevel requirements. +/// Clamp egui-wgpu's backend-specific requirements before enabling the adapter's full 2D extent. fn surface_texture_limits(mut required: eframe::wgpu::Limits, supported: &eframe::wgpu::Limits) -> eframe::wgpu::Limits { + required = required.or_worse_values_from(supported); required.max_texture_dimension_2d = supported.max_texture_dimension_2d; required } @@ -912,16 +913,51 @@ mod tests { fn surface_limits_preserve_other_requirements_and_never_overrequest() { for required in [eframe::wgpu::Limits::default(), eframe::wgpu::Limits::downlevel_webgl2_defaults()] { for extent in [4096, 8192, 16384] { - let supported = eframe::wgpu::Limits { max_texture_dimension_2d: extent, ..required.clone() }; + let supported = eframe::wgpu::Limits { + max_texture_dimension_2d: extent, + max_bind_groups: 8, + min_uniform_buffer_offset_alignment: 128, + min_storage_buffer_offset_alignment: 128, + ..required.clone() + }; let enabled = super::surface_texture_limits(required.clone(), &supported); let mut expected = required.clone(); expected.max_texture_dimension_2d = extent; assert_eq!(enabled, expected); assert_eq!(enabled.max_texture_dimension_2d, supported.max_texture_dimension_2d); + assert!(enabled.check_limits(&supported)); } } } + #[test] + fn surface_limits_fit_v3d_shader_variable_limit() { + // Issue #2383: V3D supports 15 variables, below the default request of 16. + let required = eframe::wgpu::Limits::default(); + let supported = eframe::wgpu::Limits { max_texture_dimension_2d: 7680, max_inter_stage_shader_variables: 15, ..required.clone() }; + let enabled = super::surface_texture_limits(required.clone(), &supported); + assert!(enabled.check_limits(&supported), "unsupported device limits: {enabled:?}"); + let expected = eframe::wgpu::Limits { max_texture_dimension_2d: 7680, max_inter_stage_shader_variables: 15, ..required }; + assert_eq!(enabled, expected); + } + + #[test] + fn surface_limits_fit_downlevel_maxima_and_minimum_alignments() { + let required = eframe::wgpu::Limits::downlevel_webgl2_defaults(); + let supported = eframe::wgpu::Limits { + max_texture_dimension_2d: 2048, + max_bind_groups: 2, + max_vertex_attributes: 8, + min_uniform_buffer_offset_alignment: 512, + min_storage_buffer_offset_alignment: 512, + ..required.clone() + }; + let enabled = super::surface_texture_limits(required, &supported); + assert!(enabled.check_limits(&supported), "unsupported device limits: {enabled:?}"); + // Larger minimum alignments are weaker capabilities, unlike maximum limits. + assert_eq!(enabled, supported); + } + #[test] fn a_window_larger_than_the_gpu_limit_gets_a_surface_within_it() { use eframe::egui_wgpu::winit::surface_fit; diff --git a/docs/roadmap.md b/docs/roadmap.md index 3e772e32e0..f27029f5a9 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -1,5 +1,6 @@ # PdfCraft roadmap: milestones and what's next +> **Last reviewed:** 2026-10-15 · **Last updated:** 2026-10-15 · **Change:** minor (M14/#2383 GPU-limit candidate; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS) > **Last reviewed:** 2026-10-18 · **Last updated:** 2026-10-18 · **Change:** minor (M3, #3149: discard render demand after page geometry changes and check the GPU texture limit before upload; estimates unchanged) > **Last reviewed:** 2026-10-18 · **Last updated:** 2026-10-18 · **Change:** minor (M8, #1350: named replacement descriptions retired during Sanitize and across shared resources; estimates unchanged) > **Last reviewed:** 2026-10-15 · **Last updated:** 2026-10-15 · **Change:** minor (M6: AcroForm XML text; estimates unchanged) @@ -138,7 +139,7 @@ total matches the parity estimate. | M13 | Automation (MCP, Action Wizard, CLI) + AI providers | 60–120 | 45% | 25–45 | Done: MCP resources (document info, text, page images); headless tool table (123 tools incl. signing, optimizing, initial view, links, stamps, data exchange, comment review, forms authoring and scripts, redaction, sanitize, print, add content), opt-in MCP server over stdio, CLI `run`/`tools` (closed stdout pipes exit cleanly), UI control channel with drag. Missing: Action Wizard, AI providers
Done since: CLI run and MCP reject missing, empty, repeated and option-valued --root arguments before any tool/script execution or MCP initialization. A single-hyphen directory name remains valid; synthetic CLI regressions preserve a would-be overwritten file. `text_find` also caps the per-page match ranges it materializes at the remaining result limit plus one continuation match. CLI robustness scans visit each directory once through overlapping roots and symlink aliases. | | M12 | Accessibility, compare, measure, search, XFA | 200–380 | 30% | 90–160 | Done: new `a11y` crate with the Accessibility Checker (all 32 rules, report, Fix/Skip/Explain, options dialog and results panel, agent tools). Locatable figure, annotation and form findings highlight their page bounds.
Done since: 2D distance, perimeter and area measurements with persistent viewport calibration, snapping, live information and CSV export; a bounded text-only auto-tag proposal tool; text-only Apply writes marked content, paragraph tags and a parent tree, then opens the Tags panel for review. Missing: mixed-content and field autotag, heading/list/table/figure classification, Order/Content panels, Reading Order tool, compare, geospatial/3D measurement, search index, XFA | | M13 | Automation (MCP, Action Wizard, CLI) + AI providers | 60–120 | 45% | 25–45 | Done: MCP resources (document info, text, page images); headless tool table (123 tools incl. signing, optimizing, initial view, links, stamps, data exchange, comment review, forms authoring and scripts, redaction, sanitize, print, add content), opt-in MCP server over stdio, CLI `run`/`tools` (closed stdout pipes exit cleanly), UI control channel with drag. Missing: Action Wizard, AI providers
Done since: CLI run and MCP reject missing, empty, repeated and option-valued --root arguments before any tool/script execution or MCP initialization. A single-hyphen directory name remains valid; synthetic CLI regressions preserve a would-be overwritten file. `text_find` also caps the per-page match ranges it materializes at the remaining result limit plus one continuation match. | -| M14 | 1.0 polish: performance, localization, installers | 120–250 | 15% | 80–150 | Done: PhotoCraft's translation system (`i18n/`: TSV catalogs, `tl!`, command-id and plural entries, system-language detection, strict catalog tests); every dialog, panel and notice goes through `tl!`. Catalogs of ≈ 2,000–2,140 entries for Japanese, Simplified and Traditional Chinese, Russian, Bulgarian, German, Spanish, French, Telugu, Hungarian, Ukrainian, Brazilian Portuguese and Arabic (logical-order catalog put into display order; layout not yet mirrored); Czech covers the menus. Italian has 2,528 entries: every key of the other catalogs that the code looks up. Signed and notarized macOS builds, signed Windows installers (x64, x86, ARM64), AppImage, deb, rpm, Flatpak and FreeBSD packages. #307 (bounded large-PDF memory): shared resource indexes and parser metadata, lazy inspection, bounded caches; a 9,156-page file inspects in ≈ 1.8 s at ≈ 500 MiB instead of exceeding 10 GiB. Edits let go of the previous working file before writing the next (a 1 GiB file peaks at two copies while editing, not three; gap 13). Shortcut display uses localized modifier/key names across menus, toolbar, palette, Help and tooltips (#496). Lithuanian adds a 2,578-entry catalog and date names, reviewed by a native-speaking contributor. Validated candidate (macOS raster probe; Windows runtime unverified): the Windows policy changes only embedded JetBrains Mono; horizontal alpha-edge energy per ink improves 2.78%, 3.87% and 1.59% at 100%, 125% and 150%, and is down 0.79% at 200%. Inter is pixel-identical; layout hashes, glyph coverage and the 8 MiB atlas are unchanged. Timing and RSS ranges overlap, with no improvement claimed. Estimates unchanged. Missing: a right-to-left layout, a Chinese UI face in releases, Hindi/Indonesian/Korean/Vietnamese, file-backed input and global memory budgets, performance budgets, keyboard-only operation Linux/Unix UI fallback fonts also use Fontconfig directories, including Nix store paths, with bounded discovery and the existing candidate allowlist; relative includes and digit-prefixed config filtering have isolated regressions. | +| M14 | 1.0 polish: performance, localization, installers | 120–250 | 15% | 80–150 | Done: PhotoCraft's translation system (`i18n/`: TSV catalogs, `tl!`, command-id and plural entries, system-language detection, strict catalog tests); every dialog, panel and notice goes through `tl!`. Catalogs of ≈ 2,000–2,140 entries for Japanese, Simplified and Traditional Chinese, Russian, Bulgarian, German, Spanish, French, Telugu, Hungarian, Ukrainian, Brazilian Portuguese and Arabic (logical-order catalog put into display order; layout not yet mirrored); Czech covers the menus. Italian has 2,528 entries: every key of the other catalogs that the code looks up. Signed and notarized macOS builds, signed Windows installers (x64, x86, ARM64), AppImage, deb, rpm, Flatpak and FreeBSD packages. #307 (bounded large-PDF memory): shared resource indexes and parser metadata, lazy inspection, bounded caches; a 9,156-page file inspects in ≈ 1.8 s at ≈ 500 MiB instead of exceeding 10 GiB. Edits let go of the previous working file before writing the next (a 1 GiB file peaks at two copies while editing, not three; gap 13). Shortcut display uses localized modifier/key names across menus, toolbar, palette, Help and tooltips (#496). Lithuanian adds a 2,578-entry catalog and date names, reviewed by a native-speaking contributor. Validated candidate (macOS raster probe; Windows runtime unverified): the Windows policy changes only embedded JetBrains Mono; horizontal alpha-edge energy per ink improves 2.78%, 3.87% and 1.59% at 100%, 125% and 150%, and is down 0.79% at 200%. Inter is pixel-identical; layout hashes, glyph coverage and the 8 MiB atlas are unchanged. Timing and RSS ranges overlap, with no improvement claimed. Estimates unchanged. Candidate #2383 clamps GPU requests to supported limits while retaining the adapter texture extent; synthetic V3D/downlevel and Windows GPU-required tests pass, but Raspberry Pi startup is unverified. Estimates unchanged. Missing: a right-to-left layout, a Chinese UI face in releases, Hindi/Indonesian/Korean/Vietnamese, file-backed input and global memory budgets, performance budgets, keyboard-only operation Linux/Unix UI fallback fonts also use Fontconfig directories, including Nix store paths, with bounded discovery and the existing candidate allowlist; relative includes and digit-prefixed config filtering have isolated regressions. | | M14 | 1.0 polish: performance, localization, installers | 120–250 | 15% | 80–150 | Done: PhotoCraft's translation system (`i18n/`: TSV catalogs, `tl!`, command-id and plural entries, system-language detection, strict catalog tests); every dialog, panel and notice goes through `tl!`. Catalogs of ≈ 2,000–2,140 entries for Japanese, Simplified and Traditional Chinese, Russian, Bulgarian, German, Spanish, French, Telugu, Hungarian, Ukrainian, Brazilian Portuguese and Arabic (logical-order catalog put into display order; layout not yet mirrored); Czech covers the menus. Italian has 2,528 entries: every key of the other catalogs that the code looks up. Signed and notarized macOS builds, signed Windows installers (x64, x86, ARM64), AppImage, deb, rpm, Flatpak and FreeBSD packages. #307 (bounded large-PDF memory): shared resource indexes and parser metadata, lazy inspection, bounded caches; a 9,156-page file inspects in ≈ 1.8 s at ≈ 500 MiB instead of exceeding 10 GiB. Edits let go of the previous working file before writing the next (a 1 GiB file peaks at two copies while editing, not three; gap 13). Shortcut display uses localized modifier/key names across menus, toolbar, palette, Help and tooltips (#496). Lithuanian adds a 2,578-entry catalog and date names, reviewed by a native-speaking contributor. Validated candidate (macOS raster probe; Windows runtime unverified): the Windows policy changes only embedded JetBrains Mono; horizontal alpha-edge energy per ink improves 2.78%, 3.87% and 1.59% at 100%, 125% and 150%, and is down 0.79% at 200%. Inter is pixel-identical; layout hashes, glyph coverage and the 8 MiB atlas are unchanged. Timing and RSS ranges overlap, with no improvement claimed. Estimates unchanged. Missing: a right-to-left layout, a Chinese UI face in releases, Hindi/Indonesian/Korean/Vietnamese, file-backed input and global memory budgets, performance budgets, keyboard-only operation XDG desktops use the configuration base for preferences with non-overwriting migration and original-data preservation (#1395). | | M13 | Automation (MCP, Action Wizard, CLI) + AI providers | 60–120 | 45% | 25–45 | Done: MCP resources (document info, text, page images); headless tool table (123 tools incl. signing, optimizing, initial view, links, stamps, data exchange, comment review, forms authoring and scripts, redaction, sanitize, print, add content), opt-in MCP server over stdio, CLI `run`/`tools` (closed stdout pipes exit cleanly), UI control channel with drag. Missing: Action Wizard, AI providers | | M14 | 1.0 polish: performance, localization, installers | 120–250 | 15% | 80–150 | Done: PhotoCraft's translation system (`i18n/`: TSV catalogs, `tl!`, command-id and plural entries, system-language detection, strict catalog tests); every dialog, panel and notice goes through `tl!`. Catalogs of ≈ 2,000–2,140 entries for Japanese, Simplified and Traditional Chinese, Russian, Bulgarian, German, Spanish, French, Telugu, Hungarian, Ukrainian, Brazilian Portuguese and Arabic (logical-order catalog put into display order; layout not yet mirrored); Czech covers the menus. Italian has 2,528 entries: every key of the other catalogs that the code looks up. Signed and notarized macOS builds, signed Windows installers (x64, x86, ARM64), AppImage, deb, rpm, Flatpak and FreeBSD packages. #307 (bounded large-PDF memory): shared resource indexes and parser metadata, lazy inspection, bounded caches; a 9,156-page file inspects in ≈ 1.8 s at ≈ 500 MiB instead of exceeding 10 GiB. Edits let go of the previous working file before writing the next (a 1 GiB file peaks at two copies while editing, not three; gap 13). Shortcut display uses localized modifier/key names across menus, toolbar, palette, Help and tooltips (#496). Traditional Chinese accessibility statuses, rule semantics and Taiwan form/table terms have catalog regressions. Missing: a right-to-left layout, a Chinese UI face in releases, Hindi/Indonesian/Korean/Vietnamese, file-backed input and global memory budgets, performance budgets, keyboard-only operation | @@ -183,6 +184,8 @@ M2 (renderer), M7 (content editing), M10 (OCR and Office), M11 (Preflight and st ## Revision history +**2026-10-15 · minor:** M14/#2383 candidate: GPU device requests clamp unsupported limits while retaining the adapter texture extent. Synthetic V3D/downlevel regressions and Windows GPU-required tests pass; Raspberry Pi hardware remains unverified. Estimates unchanged. + | Date | Change | Summary | |---|---|---| | 2026-10-18 | minor | 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. | From 2c4989413fc100b4110f828e1b27d9ec0c2869de Mon Sep 17 00:00:00 2001 From: Yoann LAISSUS Date: Sun, 18 Oct 2026 15:51:52 +0200 Subject: [PATCH 100/105] M7: the Edit text caret is as tall as the row it is on (#2462) In a 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 TextEdit's font tall, and that font had the paragraph's first size. Each row already carries its source row's height, so the TextEdit's font is now half the region's smallest size and only sets that floor. Co-authored-by: Claude Opus 5.5 Co-authored-by: Brandon Thomas --- ROADMAP.md | 3 ++ crates/ui-egui/src/edit_text_ui.rs | 7 +++- crates/ui-egui/tests/editing.rs | 52 ++++++++++++++++++++++++++++++ docs/roadmap.md | 2 ++ parity/acrobat-features.toml | 2 +- 5 files changed, 64 insertions(+), 2 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index fda6dca108..13d57e14a1 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -2,6 +2,7 @@ **Stage: alpha** · next: beta, ~25% (ready for real work 50% → 75%) and ~400–700 h away +> **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-18 · **Last updated:** 2026-10-18 · **Change:** minor (M3, #3149: discard render demand after page geometry changes and check the GPU texture limit before upload; estimates unchanged) > **Last reviewed:** 2026-10-18 · **Last updated:** 2026-10-18 · **Change:** minor (M8, #1350: named replacement descriptions retired during Sanitize and across shared resources; estimates unchanged) > **Last reviewed:** 2026-10-15 · **Last updated:** 2026-10-15 · **Change:** minor (M6: AcroForm XML text; estimates unchanged) @@ -163,6 +164,7 @@ 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-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-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-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-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. @@ -512,6 +514,7 @@ Newest first: the date, what moved, and the new overall estimate. | Date | Change | Summary | |---|---|---| +| 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 | M3, #3149: page replacement cancels render demand collected for the previous geometry; all viewer raster upload paths reject empty or oversized textures before cache eviction. Synthetic regressions reproduce the reported 1880×2350 upload at a 2048 limit, retain cached images and unsaved edits, and resume bounded backdrops and tiles. Estimates unchanged. | | 2026-10-15 | minor | M6: Plain AcroForm XML imports retain all direct text around comments and processing instructions, including CDATA and character references. Estimates unchanged. | | 2026-10-13 | minor | M6: XML/XFDF attribute whitespace survives form and comment export/import; estimates unchanged | diff --git a/crates/ui-egui/src/edit_text_ui.rs b/crates/ui-egui/src/edit_text_ui.rs index cbec130b86..66ad98ffe6 100644 --- a/crates/ui-egui/src/edit_text_ui.rs +++ b/crates/ui-egui/src/edit_text_ui.rs @@ -1329,7 +1329,12 @@ pub(crate) fn overlay(ctx: &egui::Context, view: &mut DocView, info: &DocInfo) - ed.max_width = ed.rect.width(); let scale = (ed.rect.width() / (ed.source_rect[2] - ed.source_rect[0]).abs().max(0.01)).max(0.01); ed.size = (ed.look.size as f32 * scale).clamp(1.0, 300.0); - let font = editor_font(&ed.look, ed.size); + // egui draws the caret at least one row of this font tall. Each row already has its source + // row's height, so the font stays below every row (half the region's smallest size): on a + // 12 pt row under a 24 pt title the caret is as tall as the row, not the title. + let smallest = + ed.source.as_ref().and_then(|(block, lines)| block.lines.iter().filter_map(|i| lines.get(*i)).map(|l| l.size as f32).reduce(f32::min)); + let font = editor_font(&ed.look, smallest.map_or(ed.size, |s| (s * scale * 0.5).clamp(1.0, 300.0).min(ed.size))); let text_color = color32(ed.look.color); let mut done = None; egui::Area::new(egui::Id::new("edit-text-line")).order(egui::Order::Foreground).fixed_pos(Pos2::new(ed.rect.left(), ed.rect.top())).show( diff --git a/crates/ui-egui/tests/editing.rs b/crates/ui-egui/tests/editing.rs index b30d8a70ef..0c7f7196c8 100644 --- a/crates/ui-egui/tests/editing.rs +++ b/crates/ui-egui/tests/editing.rs @@ -3832,6 +3832,58 @@ fn one_editor_contains_mixed_styles_and_visual_rows() { assert_eq!(h.state().session.get(h.state().views[0].id).unwrap().text_blocks(0)[0].rows.len(), 3); } +#[test] +fn the_caret_on_a_small_row_is_as_tall_as_that_row() { + // It was one row of the region's first (18 pt) size tall, reaching into the row below. + let bytes = source_font_fixture_with_text( + "BT 0 0.2 0.5 rg /F1 1 Tf 18 0 0 18 20 240 Tm (Heading) Tj ET \ + BT 0 g /F2 12 Tf 20 215 Td (Plain ) Tj /F1 12 Tf 0 0.2 0.5 rg (bold) Tj /F2 12 Tf 0 g ( words) Tj ET \ + BT /F2 10 Tf 20 195 Td (Last row) Tj ET", + ); + let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).build_eframe(move |_cc| { + let mut app = PdfCraftApp::new(); + app.set_option("language", "en").unwrap(); + app.open_bytes("mixed.pdf", None, bytes).unwrap(); + app + }); + h.run_steps(4); + assert!(h.state_mut().execute("edit.edit_text")); + h.run_steps(2); + let page = h.state().views[0].page_screen_rect(0).unwrap(); + let k = page.width() / 300.0; + let screen = |x: f32, y: f32| egui::pos2(page.left() + x * k, page.top() + (300.0 - y) * k); + let click = |h: &mut Harness<'static, PdfCraftApp>, at: egui::Pos2| { + h.hover_at(at); + h.run_steps(1); + h.drag_at(at); + h.run_steps(1); + h.drop_at(at); + h.run_steps(4); + }; + click(&mut h, screen(30.0, 245.0)); + assert!(h.state().views[0].line_editor.is_some(), "the region opens"); + let row = h.state().session.get(h.state().views[0].id).unwrap().text_lines(0).into_iter().find(|l| l.text == "Last row").unwrap().rect; + click(&mut h, screen(40.0, ((row[1] + row[3]) / 2.0) as f32)); + let row_height = (row[3] - row[1]) as f32 * k; + let (left, right) = (screen(row[0] as f32, 0.0).x - 2.0, screen(row[2] as f32, 0.0).x + 2.0); + let middle = screen(0.0, ((row[1] + row[3]) / 2.0) as f32).y; + // The caret: a vertical segment within the row's span, through its middle. + let caret = h + .output() + .shapes + .iter() + .find_map(|s| match &s.shape { + egui::Shape::LineSegment { points: [a, b], .. } + if (a.x - b.x).abs() < 0.01 && (left..right).contains(&a.x) && a.y.min(b.y) < middle && a.y.max(b.y) > middle => + { + Some((b.y - a.y).abs()) + } + _ => None, + }) + .expect("a caret on the last row"); + assert!(caret > row_height * 0.8 && caret < row_height + 4.0, "caret {caret} px on a {row_height} px row"); +} + /// Opt-in private-document regression. This never captures images or saves the source file. /// Run with PDFCRAFT_TEXT_REGRESSION_PDF=/path/to/document.pdf and --ignored. #[test] diff --git a/docs/roadmap.md b/docs/roadmap.md index f27029f5a9..7980337939 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -1,6 +1,7 @@ # PdfCraft roadmap: milestones and what's next > **Last reviewed:** 2026-10-15 · **Last updated:** 2026-10-15 · **Change:** minor (M14/#2383 GPU-limit candidate; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS) +> **Last reviewed:** 2026-10-15 · **Last updated:** 2026-10-15 · **Change:** minor (M7: the Edit text caret is as tall as the row it is on; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS) > **Last reviewed:** 2026-10-18 · **Last updated:** 2026-10-18 · **Change:** minor (M3, #3149: discard render demand after page geometry changes and check the GPU texture limit before upload; estimates unchanged) > **Last reviewed:** 2026-10-18 · **Last updated:** 2026-10-18 · **Change:** minor (M8, #1350: named replacement descriptions retired during Sanitize and across shared resources; estimates unchanged) > **Last reviewed:** 2026-10-15 · **Last updated:** 2026-10-15 · **Change:** minor (M6: AcroForm XML text; estimates unchanged) @@ -188,6 +189,7 @@ M2 (renderer), M7 (content editing), M10 (OCR and Office), M11 (Preflight and st | Date | Change | Summary | |---|---|---| +| 2026-10-15 | minor | M7: Edit text caret height follows its row, with a kittest regression; estimates unchanged | | 2026-10-18 | minor | M3, #3149: page replacement cancels render demand collected for the previous geometry; all viewer raster upload paths reject empty or oversized textures before cache eviction. Synthetic regressions reproduce the reported 1880×2350 upload at a 2048 limit, retain cached images and unsaved edits, and resume bounded backdrops and tiles. Estimates unchanged. | | 2026-10-15 | minor | M6: Plain AcroForm XML imports retain all direct text around comments and processing instructions, including CDATA and character references. Estimates unchanged. | | 2026-10-13 | minor | M6: XML/XFDF attribute whitespace survives form and comment export/import; estimates unchanged | diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index 245d439af9..28f608e1ae 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -1866,7 +1866,7 @@ milestone = "M7" status = "partial" commands = ["edit.edit_text"] tools = ["text_lines", "text_paragraphs", "text_edit", "text_edit_layout", "text_font_files", "text_font_import", "text_fonts"] -evidence = ["crates/edit/src/tests.rs::paragraphs_are_found_and_rewrapped", "crates/edit/src/tests.rs::text_lines_are_found_and_replaced_in_place", "crates/ui-egui/tests/editing.rs::editing_existing_text_in_place", "crates/automation/tests/automation.rs::editing_existing_text_through_tools", "crates/edit/src/tests.rs::a_line_whose_operator_starts_the_next_stream_can_be_edited", "crates/automation/tests/automation.rs::a_line_split_across_content_streams_is_listed_and_edited", "crates/ui-egui/tests/editing.rs::selected_text_formatting_keeps_the_rest_of_the_paragraph_and_reopens", "crates/edit/src/tests.rs::marked_paragraph_boundaries_and_cross_stream_transforms_survive_rewrite", "crates/automation/tests/automation.rs::explicit_font_and_disjoint_ranges_save_reopen_through_tools", "crates/edit/src/tests.rs::disjoint_style_ranges_preserve_unselected_runs_and_validate_utf8_atomically", "crates/engine/tests/document_fonts.rs::opentype_cff_document_font_preserves_program_codes_and_rendering_after_reopen", "crates/ui-egui/tests/editing.rs::cropped_images_do_not_intercept_text_and_stay_inside_each_page", "crates/automation/tests/automation.rs::clipped_image_bounds_and_text_edit_save_reopen_through_tools", "crates/edit/src/tests.rs::mixed_styles_and_matrix_sizes_form_visual_regions_without_merging_columns", "crates/edit/src/tests.rs::rich_region_edits_preserve_styles_positions_and_delete_all_runs_after_reopen", "crates/ui-egui/tests/editing.rs::one_editor_contains_mixed_styles_and_visual_rows", "crates/ui-egui/tests/editing.rs::inline_editor_preserves_source_font_and_previews_live_bounds", "crates/fonts/src/pdf.rs::mac_subset_cmap_uses_pdf_unicode_without_changing_source_program", "crates/automation/tests/automation.rs::mixed_visual_region_edit_preview_save_reopen_and_undo_through_tools", "crates/ui-egui/tests/ui.rs::the_edit_tab_turns_on_edit_text_and_images"] +evidence = ["crates/edit/src/tests.rs::paragraphs_are_found_and_rewrapped", "crates/edit/src/tests.rs::text_lines_are_found_and_replaced_in_place", "crates/ui-egui/tests/editing.rs::editing_existing_text_in_place", "crates/automation/tests/automation.rs::editing_existing_text_through_tools", "crates/edit/src/tests.rs::a_line_whose_operator_starts_the_next_stream_can_be_edited", "crates/automation/tests/automation.rs::a_line_split_across_content_streams_is_listed_and_edited", "crates/ui-egui/tests/editing.rs::selected_text_formatting_keeps_the_rest_of_the_paragraph_and_reopens", "crates/edit/src/tests.rs::marked_paragraph_boundaries_and_cross_stream_transforms_survive_rewrite", "crates/automation/tests/automation.rs::explicit_font_and_disjoint_ranges_save_reopen_through_tools", "crates/edit/src/tests.rs::disjoint_style_ranges_preserve_unselected_runs_and_validate_utf8_atomically", "crates/engine/tests/document_fonts.rs::opentype_cff_document_font_preserves_program_codes_and_rendering_after_reopen", "crates/ui-egui/tests/editing.rs::cropped_images_do_not_intercept_text_and_stay_inside_each_page", "crates/automation/tests/automation.rs::clipped_image_bounds_and_text_edit_save_reopen_through_tools", "crates/edit/src/tests.rs::mixed_styles_and_matrix_sizes_form_visual_regions_without_merging_columns", "crates/edit/src/tests.rs::rich_region_edits_preserve_styles_positions_and_delete_all_runs_after_reopen", "crates/ui-egui/tests/editing.rs::one_editor_contains_mixed_styles_and_visual_rows", "crates/ui-egui/tests/editing.rs::inline_editor_preserves_source_font_and_previews_live_bounds", "crates/fonts/src/pdf.rs::mac_subset_cmap_uses_pdf_unicode_without_changing_source_program", "crates/automation/tests/automation.rs::mixed_visual_region_edit_preview_save_reopen_and_undo_through_tools", "crates/ui-egui/tests/ui.rs::the_edit_tab_turns_on_edit_text_and_images", "crates/ui-egui/tests/editing.rs::the_caret_on_a_small_row_is_as_tall_as_that_row"] notes = "The Edit tab turns Edit text & images on at once, as in Acrobat (leaving the tab puts the tool down and applies an edit in progress); click a paragraph box and retype it in place (font reused or Helvetica substituted, colour and spacing kept, rewrapped); a page's /Contents pieces are parsed joined, so a line whose operands and operator sit in different pieces is listed and edited (lines and paragraphs still don't join across pieces; Edit image still parses each piece alone); drag a box to move it or its right-edge handle to rewrap it (edit.text-move-resize); per-character formatting changes are next Since #1116: mixed-style visual regions retain source rows, subset encodings, colour and baseline anchors in one textless-background editor. Explicit paragraph/heading scopes constrain grouping; compatible graphics planes permit cross-stream regions. Full matching document fonts complete absent subset glyphs; installed/file selection, TrueType/OpenType-CFF embedding and disjoint UTF-8 range styles have save/reopen coverage. Single-face OpenType shaping/bidi retains logical text through ToUnicode/ActualText. Legacy non-SFNT preview, mixed-font/style contextual runs, full structure-tree preservation and author intent in untagged PDFs remain limited; see docs/editing-existing-text.md" [[feature]] From b68b3c111967049afc357652bb34fbcae91841ad Mon Sep 17 00:00:00 2001 From: Marco Arnulfo <103679419+marcoarnulfo@users.noreply.github.com> Date: Sun, 18 Oct 2026 15:52:40 +0200 Subject: [PATCH 101/105] M3: the Pages panel follows the current page (#2785) Co-authored-by: Brandon Thomas --- ROADMAP.md | 1 + crates/ui-egui/src/canvas.rs | 4 +++ crates/ui-egui/src/panels.rs | 16 ++++++++- crates/ui-egui/tests/viewer.rs | 64 ++++++++++++++++++++++++++++++++++ parity/acrobat-features.toml | 4 +-- 5 files changed, 86 insertions(+), 3 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 13d57e14a1..d81487afb6 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -165,6 +165,7 @@ Ranked; detail and the full milestone table in [docs/roadmap.md](docs/roadmap.md ## Progress log - **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-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-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-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. diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index cd9a4163a2..6148c27482 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -554,6 +554,9 @@ pub struct DocView { pub pages_panel: Option, /// The Pages-panel gap under the pointer (where dropped files go), when it can take pages. pub panel_gap: Option, + /// The current page the Pages panel last followed: it scrolls to the current page's + /// thumbnail only when the page changes. + pub(crate) panel_followed: Option, /// How large the page grid draws its pages (1.0 = the usual thumbnails). grid_zoom: f32, /// A zoom asked for this frame (toolbar, pinch, keys); the grid applies it once it has laid @@ -770,6 +773,7 @@ impl DocView { grid_gap: None, pages_panel: None, panel_gap: None, + panel_followed: None, grid_zoom: 1.0, grid_zoom_request: None, grid_anchor: None, diff --git a/crates/ui-egui/src/panels.rs b/crates/ui-egui/src/panels.rs index 69600dac0e..0f2c601497 100644 --- a/crates/ui-egui/src/panels.rs +++ b/crates/ui-egui/src/panels.rs @@ -1064,6 +1064,11 @@ fn pages(ui: &mut egui::Ui, t: &Tokens, info: &DocInfo, view: &mut crate::DocVie { delete_pages(view, info.pages.len()); } + // A new current page scrolls its thumbnail into view, as little as it takes (Acrobat), but + // not while thumbnails are dragged: then the change counts as seen. A wheel turned or a + // scroll bar dragged by the user already wins over a scroll to a place in egui. + let follow = view.panel_followed != Some(view.current) && view.panel_drag.is_none(); + view.panel_followed = Some(view.current); let w = (ui.available_width() - 40.0).min(150.0); let count = info.pages.len(); let mut rows: Vec = Vec::with_capacity(info.pages.len()); @@ -1108,7 +1113,14 @@ fn pages(ui: &mut egui::Ui, t: &Tokens, info: &DocInfo, view: &mut crate::DocVie Stroke::new(if selected { 2.0 } else { 1.0 }, if selected { t.accent } else { t.border }), egui::StrokeKind::Outside, ); - ui.label(egui::RichText::new(&p.label).font(theme::medium(12.0)).color(if selected || picked { t.accent_text } else { t.text_muted })); + let label = ui.label(egui::RichText::new(&p.label).font(theme::medium(12.0)).color(if selected || picked { + t.accent_text + } else { + t.text_muted + })); + if follow && selected { + ui.scroll_to_rect(rect.union(label.rect), None); + } let resp = theme::hand(resp); if resp.clicked() { let m = ui.input(|i| i.modifiers); @@ -1116,6 +1128,8 @@ fn pages(ui: &mut egui::Ui, t: &Tokens, info: &DocInfo, view: &mut crate::DocVie view.click_page(i, m, true); } else { view.clear_page_selection(Some(i)); + // A page clicked here is in view already: the panel stays put. + view.panel_followed = Some(i); *nav = Some(Nav::Page(i)); } } diff --git a/crates/ui-egui/tests/viewer.rs b/crates/ui-egui/tests/viewer.rs index 474360865b..31530bf235 100644 --- a/crates/ui-egui/tests/viewer.rs +++ b/crates/ui-egui/tests/viewer.rs @@ -1304,6 +1304,70 @@ fn pages_panel_thumbnails_have_a_context_menu() { assert_eq!(h.state().page_clipboard.as_ref().map(|c| c.pages.clone()), Some(vec![2])); } +/// Where the Pages panel draws page `n`'s thumbnail. +fn thumbnail_rect(h: &Harness<'static, PdfCraftApp>, n: usize) -> egui::Rect { + h.get_all_by_label(&format!("Page {n}")).find(|t| t.rect().left() > 1000.0).expect("the Pages panel thumbnail").rect() +} + +/// Whether `r` lies wholly in the part of the Pages panel in view. +fn in_panel_view(h: &Harness<'static, PdfCraftApp>, r: egui::Rect) -> bool { + h.state().views[0].pages_panel.expect("the Pages panel is shown").contains_rect(r) +} + +/// #2781: as in Acrobat, going to a page whose thumbnail is out of sight scrolls the Pages +/// panel to show it. +#[test] +fn going_to_a_far_page_scrolls_its_thumbnail_into_view() { + let mut h = pages_panel_harness(&[(200, 300); 30]); + assert!(!in_panel_view(&h, thumbnail_rect(&h, 25)), "page 25 starts out of sight"); + h.state_mut().views[0].go_to_page(24); + h.run_steps(4); + assert_eq!(h.state().views[0].current, 24); + assert!(in_panel_view(&h, thumbnail_rect(&h, 25)), "page 25's thumbnail is shown"); +} + +/// #2781: a page whose thumbnail is already in view leaves the panel where it is, rather than +/// centring the thumbnail on every page change. +#[test] +fn going_to_a_page_in_view_leaves_the_pages_panel_still() { + // Landscape pages, so several thumbnails share the panel. + let mut h = pages_panel_harness(&[(300, 200); 30]); + h.state_mut().views[0].go_to_page(14); + h.run_steps(4); + let clip = h.state().views[0].pages_panel.expect("the Pages panel is shown"); + // The topmost thumbnail wholly in view, other than the current page's. + let (n, before) = + (1..=30).map(|n| (n, thumbnail_rect(&h, n))).find(|(n, r)| *n != 15 && in_panel_view(&h, *r)).expect("another thumbnail in view"); + assert!( + (before.center().y - clip.center().y).abs() > 50.0, + "page {n} at {before:?} is away from the middle of {clip:?}, where centring would move it" + ); + h.state_mut().views[0].go_to_page(n - 1); + h.run_steps(4); + assert_eq!(h.state().views[0].current, n - 1); + assert_eq!(thumbnail_rect(&h, n), before, "page {n}'s thumbnail stayed put"); +} + +/// #2781: while the user drags thumbnails, a page change does not pull the panel away from +/// where they are being dropped. +#[test] +fn a_page_change_while_thumbnails_are_dragged_does_not_scroll_the_pages_panel() { + let mut h = pages_panel_harness(&[(200, 300); 30]); + let first = thumbnail_rect(&h, 1); + let at = first.center(); + h.hover_at(at); + h.run_steps(1); + h.event(egui::Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed: true, modifiers: Default::default() }); + h.run_steps(1); + h.hover_at(at + egui::vec2(0.0, 30.0)); + h.run_steps(2); + assert_eq!(h.state().views[0].panel_drag, Some(vec![0]), "page 1 is being dragged"); + h.state_mut().views[0].go_to_page(24); + h.run_steps(4); + assert_eq!(h.state().views[0].current, 24); + assert_eq!(thumbnail_rect(&h, 1), first, "the panel stays put under the drag"); +} + /// #444, #186: a mouse-wheel notch scrolls as far as the scroll speed preference says; /// trackpad (point) scrolling keeps its own speed. The choice is saved. #[test] diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index 28f608e1ae..47ea3f4e19 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -1262,8 +1262,8 @@ milestone = "M3" status = "shipped" commands = [] tools = [] -evidence = ["crates/ui-egui/tests/ui.rs::theme_and_view_options_apply"] -notes = "" +evidence = ["crates/ui-egui/tests/ui.rs::theme_and_view_options_apply", "crates/ui-egui/tests/viewer.rs::going_to_a_far_page_scrolls_its_thumbnail_into_view", "crates/ui-egui/tests/viewer.rs::going_to_a_page_in_view_leaves_the_pages_panel_still", "crates/ui-egui/tests/viewer.rs::a_page_change_while_thumbnails_are_dragged_does_not_scroll_the_pages_panel"] +notes = "The panel follows the current page (#2781): a page whose thumbnail is out of sight scrolls it into view, as little as it takes; a thumbnail already in view, or clicked, leaves the panel still, and a page change while the user scrolls the panel or drags thumbnails does not move it." [[feature]] id = "view.thumbnail-context-ops" From 652514095535d91f59ff4f8e4cbce5f6c5f2c2d7 Mon Sep 17 00:00:00 2001 From: Benjamin Michel Date: Sun, 18 Oct 2026 15:53:24 +0200 Subject: [PATCH 102/105] M3: type an exact zoom into the rail's zoom field (#1755) * M3: type an exact zoom into the rail's zoom field The rail's zoom percentage was a read-only label. It is now a text field: Enter applies a typed percentage (150, 150%, 62.5 %), Escape or unparsable input keeps the current zoom. The control channel's zoom option uses the same parser. Co-Authored-By: Claude Opus 5.5 * M3: document parse_zoom_percent * i18n: Vietnamese for the zoom field's tooltip --------- Co-authored-by: Claude Opus 5.5 Co-authored-by: Brandon Thomas --- ATTRIBUTION.toml | 44 ++++++++++++++--------------- ROADMAP.md | 1 + crates/ui-egui/src/canvas.rs | 17 +++++++++++ crates/ui-egui/src/chrome.rs | 34 +++++++++++++++++++--- crates/ui-egui/src/i18n/ar.tsv | 1 + crates/ui-egui/src/i18n/bg.tsv | 1 + crates/ui-egui/src/i18n/cs.tsv | 1 + crates/ui-egui/src/i18n/de.tsv | 1 + crates/ui-egui/src/i18n/el.tsv | 1 + crates/ui-egui/src/i18n/es.tsv | 1 + crates/ui-egui/src/i18n/fr.tsv | 1 + crates/ui-egui/src/i18n/hu.tsv | 1 + crates/ui-egui/src/i18n/it.tsv | 1 + crates/ui-egui/src/i18n/ja.tsv | 1 + crates/ui-egui/src/i18n/kk.tsv | 1 + crates/ui-egui/src/i18n/lt.tsv | 1 + crates/ui-egui/src/i18n/pl.tsv | 1 + crates/ui-egui/src/i18n/pt-br.tsv | 1 + crates/ui-egui/src/i18n/ru.tsv | 1 + crates/ui-egui/src/i18n/sl.tsv | 1 + crates/ui-egui/src/i18n/te.tsv | 1 + crates/ui-egui/src/i18n/tr.tsv | 1 + crates/ui-egui/src/i18n/uk.tsv | 1 + crates/ui-egui/src/i18n/vi.tsv | 1 + crates/ui-egui/src/i18n/zh-hans.tsv | 1 + crates/ui-egui/src/i18n/zh-hant.tsv | 1 + crates/ui-egui/src/lib.rs | 2 +- crates/ui-egui/tests/view.rs | 29 +++++++++++++++++++ parity/acrobat-features.toml | 4 +-- 29 files changed, 124 insertions(+), 29 deletions(-) diff --git a/ATTRIBUTION.toml b/ATTRIBUTION.toml index 80a5e0f542..db3dd6124f 100644 --- a/ATTRIBUTION.toml +++ b/ATTRIBUTION.toml @@ -7,7 +7,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Greek UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "a3234684d5318ce38213c79533d3eebb9c635497ba9e00fb9ca7ebe2fad9d73d" +sha256 = "6eee0b34db6529f26235bfc07fa1fdb9030b8e7b0b10ede271ca9242cb491919" # ATTRIBUTION.toml: one entry per asset (AGENTS.md §1). The source of truth for provenance and licences. # ATTRIBUTION.md is generated from this file: `cargo xtask assets --write`. Checked by `cargo xtask assets` (part of `xtask ci`). @@ -30,7 +30,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Simplified Chinese UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "c5e0206846b009bcb366d092c3d64fd8b9e42d29d5406fb73ae36ecb1f2083d5" +sha256 = "9870c0fb9f7198155be16adee7f45d36cb9a254af90b545a3ce5b04cbbbf24d4" [[asset]] path = "crates/ui-egui/src/i18n/zh-hant.tsv" @@ -41,7 +41,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Traditional Chinese UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "7b98d720bc0c433d62b7b4fb4a5b79d3e74111590c29a59a51f3944e5e0622a5" +sha256 = "fc31e40877ce115cfb7033c57dd6acabcae3725b249364387e3fe23e04d94e5e" [[asset]] path = "crates/ui-egui/src/i18n/cs.tsv" @@ -52,7 +52,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Czech UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "cb332907792d4aa760ca9862160e24a7b469afdbcfb93235e99deb8d58604ae2" +sha256 = "5de19834f293efaea68cb6b7f51a71d320391f53345f56cfebcd73dab828b665" [[asset]] path = "crates/ui-egui/src/i18n/pt-br.tsv" @@ -63,7 +63,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Brazilian Portuguese UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "ebd3a12e471ba9d9a69e6532fbac2d027dec3c91b79cdfbb1c6c386d13de3bd6" +sha256 = "cef0d22341d0722f029c6543fc846fce164331b7ad8f693a6754b7567b9d9e95" [[asset]] path = "crates/ui-egui/src/i18n/es.tsv" @@ -74,7 +74,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Spanish UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "f5e0553165ab0223bfbc809b1630170d1af11d6546ae5a682d4090104b6e4a05" +sha256 = "c0ca7761cbabd8096bc9af54cdf6b88b3ae0cd8210003db8b3356e2706e44fc8" [[asset]] path = "crates/ui-egui/src/i18n/fr.tsv" @@ -85,7 +85,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "French UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "dd2171a0a940a84e512fd6408a11cddf104be897ec4ab670c5ef6f1781ccb671" +sha256 = "a8afd51c3296f44b0446d6b06492b1348444b3e3c448a326ad213d18ce7af714" [[asset]] path = "crates/ui-egui/src/i18n/de.tsv" @@ -96,7 +96,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "German UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "1b9b3d0d6dab626c3acb8c9aa64d64e56b98eb3f2faf71dcb1957067e72706db" +sha256 = "7676a2dfaa7d66e2e9ef608d2d9de7f863557dcecbef6d94bafa981097405e6d" [[asset]] path = "crates/ui-egui/src/i18n/te.tsv" @@ -107,7 +107,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Telugu UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "668f23f4095120343e3f0910dc8f5844765370fae40400c48d685cad20dcf60e" +sha256 = "9a22996c0308ea5b20ec5e328504cd83cbb560147763faaf0e6987f974844c29" [[asset]] path = "crates/ui-egui/src/i18n/it.tsv" @@ -118,7 +118,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Italian UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "02f3fd4b2721b0ea9c01c99d477a0339a28180514568179d3fc420e7fb81c671" +sha256 = "eacc972d65a2d951e94e1a8cf944638e8328f73f6c05759de6059a425f159cd7" [[asset]] path = "crates/ui-egui/src/i18n/pl.tsv" @@ -129,7 +129,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Polish UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "26eb438c309d34e55eba959a21f139db91fa5fd1d6d4b7c3d1cd0fa79a60a5b0" +sha256 = "dfa4aa540cc7d61fa22a27a949e6eec0f78dcc5b883d0e4e004d695e460b95aa" [[asset]] path = "crates/ui-egui/src/i18n/ru.tsv" @@ -140,7 +140,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Russian UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "c04b6d0320bce999b33bd189bffcec8a196d4169e56cc65675dc920c5a671dac" +sha256 = "27e02e7bb3d378bceca76988a9dceaa68d974f57cbee962954deca6184cf62aa" [[asset]] path = "crates/ui-egui/src/i18n/hu.tsv" @@ -151,7 +151,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Hungarian UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "37b042977d934b0b550e5f52faa5d90abbcb9e7ff61ff9588021610ce709e474" +sha256 = "9675edd36c5ae54d9893fd10c4325aab664941bf714c08fbfeef9134fa42056c" [[asset]] path = "crates/ui-egui/src/i18n/bg.tsv" @@ -162,7 +162,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Bulgarian UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "3d1f1d2558eec1271fa653bc38616feeefc6559deeb2a0cdbf07d67634e9ac97" +sha256 = "ce18fc96f8516aa917e4a8bcfd290edec13a9800b3d1ee102e4b2a71b8467d2b" [[asset]] path = "crates/ui-egui/src/i18n/kk.tsv" @@ -173,7 +173,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Kazakh UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "03071249bd6c13f79da72262415e484fb1aa36098a37a49094b9dab58cebc4e2" +sha256 = "286a1e2e051748eb4bcee3906918dc78d0704f8a37bde22dc50d3376d5e75017" [[asset]] path = "crates/ui-egui/src/i18n/ja.tsv" @@ -184,7 +184,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Japanese UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "c0ba73b27b95b275f5eebc040ab1c3c6c3b227c63e1c637b4cdb27d0a9264a8b" +sha256 = "77593b7497c67f4a79cc31fa43fdd6eae11601dd87c079a8e666b25cdd9117ba" [[asset]] path = "crates/ui-egui/src/i18n/uk.tsv" @@ -195,7 +195,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Ukrainian UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "e3af76714ee9899328a453ec83195f4dc462fe48ba445fbe3688c777e74bc9dc" +sha256 = "d7d93a03f22ab380696f69b20b12104bf30e78c4440b683df63594af716894e2" [[asset]] path = "crates/ui-egui/src/i18n/ar.tsv" @@ -206,7 +206,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Arabic UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "0855d7f785c433799da83400ebc932f987eb543008486bc4f553aadb4102c2ba" +sha256 = "889a0d0a9ea53ca0a818c2ba672c053c232d2cca140f8d63127353e6f36505fe" [[asset]] path = "crates/ui-egui/src/i18n/tr.tsv" @@ -217,7 +217,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Turkish UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "884ab18d60db52f65cd0e30530e196c73c0e627f450bc948517e2f4b7deeb04b" +sha256 = "bae8b21651ae2a69c71c91b69ba79727254dbfb078c1be312a13383332934927" [[asset]] path = "crates/ui-egui/src/i18n/lt.tsv" @@ -228,7 +228,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Lithuanian UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "c805a2c6da08e77a9a7455856d82c168cf6f353d96e1dc140fcc8898f12ba538" +sha256 = "7fa70f68a9fa24d0b1cac05fe3aaabaeaca9750c57416124bd317a4eda806d5a" [[asset]] @@ -3352,7 +3352,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Vietnamese UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "11619064ae9edaeee5d601b7008e40095a40375a3e1a298155d59cb758fe7d34" +sha256 = "95b5fc6d10f7bc8371688772741870448de716fa2dd9af62fdfe4e009d269b45" [[asset]] path = "crates/ui-egui/src/i18n/sl.tsv" @@ -3363,7 +3363,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Slovene UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "89f2c7495cfb0fc9d36d35b95c056dbeda18ccae521250babac719ff5df62d89" +sha256 = "3ac2b113045b7e399dd6d9ddb7773e5b0874c8b43c2ba4cca5854598a2a7ba92" [[asset]] diff --git a/ROADMAP.md b/ROADMAP.md index d81487afb6..0d1cfee90b 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -183,6 +183,7 @@ Ranked; detail and the full milestone table in [docs/roadmap.md](docs/roadmap.md - **2026-10-15 (M14, gap 24):** `misc.localization` and `misc.installers` move from planned to partial. Localization: 22 catalogs ship, but Chinese shows tofu in releases, Telugu has no release font and Arabic is not mirrored. Installers: macOS and Windows releases are signed, Linux packages are not. The Acrobat menu diff itself remains open. Estimates unchanged. - **2026-10-13 (G14, Vietnamese localization):** Added Vietnamese (`vi`, Tiếng Việt) as an interface and date language with a 2,591-entry catalog (`plural_none`) covering 100% of the current localizable key union (plus `theme`/`Appearance`). Locale tags `vi` / `vi-VN` / `vi_VN.UTF-8` resolve; calendar months use numeric `Tháng 1`…`12` / `tháng 1`…`12`. Native-speaker community review recorded. The twelve-language localization mean moves from ≈59% to ≈68%; weighted readiness ≈49% → ≈50%. Status stays `partial`: engine/OS details, document contents, filenames, CLI/MCP and external help stay outside the catalog by design. +- **2026-10-13 (M3, typed zoom):** The right rail's zoom percentage is an editable field: type 150, 150% or 62.5 % and press Enter, Escape discards, and nonsense keeps the current zoom. The control channel's zoom option shares its parser, which now refuses zero, negative and non-finite values. - **2026-10-13 (M6, data exchange fidelity):** XML and XFDF exports preserve tabs and line breaks in field names and comment metadata using character references. Saved-PDF regressions cover distinct names that XML would normalize to the same attribute value, restored field values and replacement of the original comment identities. Estimates unchanged. - **2026-10-14 (M1, Flate fallback):** Raw deflate whose first bytes resemble a zlib header no longer loses a decoding-limit error during fallback. Strict and tolerant filter regressions and a COS stream regression cover refusal at the caller limit and successful decoding within it. No parity status or estimate changes. diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 6148c27482..855361f6f3 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -1516,6 +1516,13 @@ impl DocView { } } + /// A zoom typed as a percentage ("150", "150%", " 62.5 % ") as a zoom factor; `None` unless it is a + /// finite, positive number. `set_zoom` clamps it to the supported range. + pub fn parse_zoom_percent(typed: &str) -> Option { + let percent = typed.trim().trim_end_matches('%').trim_end().parse::().ok()?; + (percent.is_finite() && percent > 0.0).then_some(percent / 100.0) + } + /// Standard zoom steps (as in Acrobat's zoom menu). pub fn zoom_step(&mut self, up: bool) { const STEPS: [f32; 17] = [0.1, 0.25, 0.33, 0.5, 0.66, 0.75, 1.0, 1.25, 1.5, 2.0, 3.0, 4.0, 6.0, 8.0, 12.0, 16.0, 32.0]; @@ -5156,6 +5163,16 @@ mod tests { use super::*; + #[test] + fn typed_zoom_percentages_parse() { + for (typed, zoom) in [("150", 1.5), ("150%", 1.5), (" 62.5 % ", 0.625), ("6400%", 64.0)] { + assert_eq!(DocView::parse_zoom_percent(typed), Some(zoom), "{typed}"); + } + for typed in ["", "%", "abc", "0", "-50", "NaN", "inf", "1e40", "150%%x", "%150"] { + assert_eq!(DocView::parse_zoom_percent(typed), None, "{typed}"); + } + } + /// A rotated (landscape) page is drawn as wide as the cell allows and centred in the picture /// area, as Acrobat draws it, not as a small strip pinned to the top; a portrait page still /// fills the area's height; neither covers the checkbox corner or the label below. diff --git a/crates/ui-egui/src/chrome.rs b/crates/ui-egui/src/chrome.rs index 84e62622fb..fb9e0a7c0d 100644 --- a/crates/ui-egui/src/chrome.rs +++ b/crates/ui-egui/src/chrome.rs @@ -694,10 +694,36 @@ pub fn right_rail(app: &mut PdfCraftApp, ui: &mut egui::Ui) { view.fit = if view.fit == Fit::Width { Fit::Page } else { Fit::Width }; view.goto = Some((view.current, 0.0)); } - let zoom = format!("{:.0}%", view.zoom * 100.0); - ui.allocate_ui_with_layout(vec2(40.0, 16.0), Layout::top_down(Align::Center), |ui| { - ui.label(egui::RichText::new(zoom).font(theme::medium(11.0)).color(t.text_muted)); - }); + let id = egui::Id::new("zoom-input"); + let draft = id.with("draft"); + let mut typed = ui.data(|d| d.get_temp::(draft)).unwrap_or_else(|| format!("{:.0}%", view.zoom * 100.0)); + let edit = egui::TextEdit::singleline(&mut typed) + .id(id) + .desired_width(40.0) + .horizontal_align(Align::Center) + .font(theme::medium(11.0)) + .text_color(t.text_muted) + .frame(egui::Frame::NONE) + .margin(vec2(2.0, 2.0)); + let r = ui.add(edit).on_hover_text(tl!("Zoom — type a percentage and press Enter")); + ui.ctx().accesskit_node_builder(id, |node| node.set_label(tl!("Zoom"))); + if r.gained_focus() { + let mut state = egui::TextEdit::load_state(ui.ctx(), id).unwrap_or_default(); + let all = egui::text::CCursorRange::two(egui::text::CCursor::new(0), egui::text::CCursor::new(typed.chars().count())); + state.cursor.set_char_range(Some(all)); + state.store(ui.ctx(), id); + } + if r.has_focus() { + ui.data_mut(|d| d.insert_temp(draft, typed.clone())); + } else { + ui.data_mut(|d| d.remove::(draft)); + if r.lost_focus() + && ui.input(|i| i.key_pressed(egui::Key::Enter)) + && let Some(zoom) = DocView::parse_zoom_percent(&typed) + { + view.set_zoom(zoom); + } + } ui.add_space(6.0); if icons::button(ui, "chevron-down", 30.0, false, tl!("Next page")).clicked() { view.step_page(true); diff --git a/crates/ui-egui/src/i18n/ar.tsv b/crates/ui-egui/src/i18n/ar.tsv index 246a26fa6c..8697887e10 100644 --- a/crates/ui-egui/src/i18n/ar.tsv +++ b/crates/ui-egui/src/i18n/ar.tsv @@ -162,6 +162,7 @@ Next page الصفحة التالية Previous page الصفحة السابقة Current page — type a page number or label (such as iv) and press Enter الصفحة الحالية — اكتب رقم صفحة أو تسميتها (مثل iv) ثم اضغط Enter + Zoom — type a percentage and press Enter التكبير — اكتب نسبة مئوية واضغط Enter Welcome to PdfCraft مرحبًا بك في PdfCraft An open-source PDF workbench — local, private, and scriptable. منضدة عمل مفتوحة المصدر لملفات PDF — محلية وخاصة وقابلة للبرمجة. Join the ArtCraft community انضم إلى مجتمع ArtCraft diff --git a/crates/ui-egui/src/i18n/bg.tsv b/crates/ui-egui/src/i18n/bg.tsv index a39489aedc..813a2c7ae8 100644 --- a/crates/ui-egui/src/i18n/bg.tsv +++ b/crates/ui-egui/src/i18n/bg.tsv @@ -1123,6 +1123,7 @@ Next page Следваща страница Previous page Предишна страница Current page — type a page number or label (such as iv) and press Enter Текуща страница — въведете номер или етикет на страница (например iv) и натиснете Enter + Zoom — type a percentage and press Enter Мащаб — въведете процент и натиснете Enter Open a PDF ({key}) Отвори PDF файл ({key}) Dark gray theme Тъмносива тема Light theme Светла тема diff --git a/crates/ui-egui/src/i18n/cs.tsv b/crates/ui-egui/src/i18n/cs.tsv index 3b7c677e00..9936325523 100644 --- a/crates/ui-egui/src/i18n/cs.tsv +++ b/crates/ui-egui/src/i18n/cs.tsv @@ -279,6 +279,7 @@ key Space Mezerník Next page Další stránka Previous page Předchozí stránka Current page — type a page number or label (such as iv) and press Enter Aktuální stránka — zadejte číslo nebo označení stránky (třeba iv) a stiskněte Enter + Zoom — type a percentage and press Enter Lupa — zadejte procenta a stiskněte Enter Welcome to PdfCraft Vítá vás PdfCraft An open-source PDF workbench — local, private, and scriptable. Open-source dílna pro PDF — lokální, soukromá a skriptovatelná. Join the ArtCraft community Připojte se ke komunitě ArtCraft diff --git a/crates/ui-egui/src/i18n/de.tsv b/crates/ui-egui/src/i18n/de.tsv index c98c405616..4d9207eb9d 100644 --- a/crates/ui-egui/src/i18n/de.tsv +++ b/crates/ui-egui/src/i18n/de.tsv @@ -135,6 +135,7 @@ Next page Nächste Seite Previous page Vorherige Seite Current page — type a page number or label (such as iv) and press Enter Aktuelle Seite – gib eine Seitenzahl oder -beschriftung ein (z. B. iv) und drücke die Eingabetaste + Zoom — type a percentage and press Enter Zoom – gib einen Prozentwert ein und drücke die Eingabetaste Welcome to PdfCraft Willkommen bei PdfCraft An open-source PDF workbench — local, private, and scriptable. Eine Open-Source-PDF-Werkbank – lokal, privat und skriptfähig. Join the ArtCraft community Tritt der ArtCraft-Community bei diff --git a/crates/ui-egui/src/i18n/el.tsv b/crates/ui-egui/src/i18n/el.tsv index 9be3e19c2e..06fe2b540b 100644 --- a/crates/ui-egui/src/i18n/el.tsv +++ b/crates/ui-egui/src/i18n/el.tsv @@ -282,6 +282,7 @@ Flatten Fill & Sign when saving Ενσωμάτωση συμπληρώσεων και υπογραφών κατά την αποθήκευση Text, marks and signatures become part of the page. Other comments stay editable. Το κείμενο, τα σημάδια και οι υπογραφές γίνονται μέρος της σελίδας. Τα υπόλοιπα σχόλια παραμένουν επεξεργάσιμα. Current page — type a page number or label (such as iv) and press Enter Τρέχουσα σελίδα — πληκτρολογήστε αριθμό ή ετικέτα σελίδας (π.χ. iv) και πατήστε Enter + Zoom — type a percentage and press Enter Ζουμ — πληκτρολογήστε ένα ποσοστό και πατήστε Enter Files you open in PdfCraft appear here. Drop a PDF anywhere to open it. Τα αρχεία που ανοίγετε στο PdfCraft εμφανίζονται εδώ. Σύρετε ένα PDF οπουδήποτε για να το ανοίξετε. Scripts run in a sandbox without file or network access. With JavaScript off, Acrobat's standard format, validate and calculate functions still work. Τα σενάρια εκτελούνται σε απομονωμένο περιβάλλον χωρίς πρόσβαση σε αρχεία ή δίκτυο. Με απενεργοποιημένη τη JavaScript, οι τυπικές λειτουργίες μορφοποίησης, επικύρωσης και υπολογισμού του Acrobat εξακολουθούν να λειτουργούν. Move up Μετακίνηση πάνω diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index 6a48611a37..79c668992c 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -158,6 +158,7 @@ Next page Página siguiente Previous page Página anterior Current page — type a page number or label (such as iv) and press Enter Página actual: escribe un número o una etiqueta de página (por ejemplo, iv) y pulsa Intro + Zoom — type a percentage and press Enter Zoom: escribe un porcentaje y pulsa Intro Welcome to PdfCraft Te damos la bienvenida a PdfCraft An open-source PDF workbench — local, private, and scriptable. Un banco de trabajo PDF de código abierto: local, privado y automatizable. Join the ArtCraft community Únete a la comunidad de ArtCraft diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index 0de88637a7..9bdcf0bc8a 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -135,6 +135,7 @@ Next page Page suivante Previous page Page précédente Current page — type a page number or label (such as iv) and press Enter Page actuelle — saisissez un numéro ou une étiquette de page (comme iv) et appuyez sur Entrée + Zoom — type a percentage and press Enter Zoom — saisissez un pourcentage et appuyez sur Entrée Welcome to PdfCraft Bienvenue dans PdfCraft An open-source PDF workbench — local, private, and scriptable. Un atelier PDF open source — local, privé et automatisable. Join the ArtCraft community Rejoindre la communauté ArtCraft diff --git a/crates/ui-egui/src/i18n/hu.tsv b/crates/ui-egui/src/i18n/hu.tsv index 2d7dd82a1b..d537677c7d 100644 --- a/crates/ui-egui/src/i18n/hu.tsv +++ b/crates/ui-egui/src/i18n/hu.tsv @@ -133,6 +133,7 @@ Next page Következő oldal Previous page Előző oldal Current page — type a page number or label (such as iv) and press Enter Aktuális oldal — írj be egy oldalszámot vagy címkét (például iv), majd nyomd meg az Entert + Zoom — type a percentage and press Enter Nagyítás – írjon be egy százalékértéket, és nyomja meg az Entert Welcome to PdfCraft Üdvözöljük a PdfCraftban An open-source PDF workbench — local, private, and scriptable. Nyílt forráskódú PDF-munkapad — helyi, privát és parancsfájlokkal vezérelhető. Join the ArtCraft community Csatlakozz az ArtCraft közösséghez diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv index 1695333744..35cba7a410 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -159,6 +159,7 @@ Next page Pagina successiva Previous page Pagina precedente Current page — type a page number or label (such as iv) and press Enter Pagina corrente: digita un numero o un'etichetta di pagina (ad esempio iv) e premi Invio + Zoom — type a percentage and press Enter Zoom: digita una percentuale e premi Invio Welcome to PdfCraft Benvenuto in PdfCraft An open-source PDF workbench — local, private, and scriptable. Un banco di lavoro PDF open source: locale, riservato e programmabile. Join the ArtCraft community Unisciti alla community di ArtCraft diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index c863031743..e845a736da 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -162,6 +162,7 @@ Next page 次のページ Previous page 前のページ Current page — type a page number or label (such as iv) and press Enter 現在のページ:ページ番号またはラベル(例:iv)を入力して Enter + Zoom — type a percentage and press Enter ズーム — パーセンテージを入力して Enter キーを押します Welcome to PdfCraft PdfCraft へようこそ An open-source PDF workbench — local, private, and scriptable. ローカルで処理し、プライバシーを守る、スクリプト対応のオープンソース PDF ツール。 Join the ArtCraft community ArtCraft コミュニティに参加 diff --git a/crates/ui-egui/src/i18n/kk.tsv b/crates/ui-egui/src/i18n/kk.tsv index 64a4ea26f4..9acb4ec1be 100644 --- a/crates/ui-egui/src/i18n/kk.tsv +++ b/crates/ui-egui/src/i18n/kk.tsv @@ -123,6 +123,7 @@ Next page Келесі бет Previous page Алдыңғы бет Current page — type a page number or label (such as iv) and press Enter Ағымдағы бет — бет нөмірін немесе белгісін (мысалы, iv) теріңіз де, Enter пернесін басыңыз + Zoom — type a percentage and press Enter Масштаб — пайызды енгізіп, Enter пернесін басыңыз Welcome to PdfCraft PdfCraft-ге қош келдіңіз An open-source PDF workbench — local, private, and scriptable. Ашық бастапқы кодты PDF жұмыс орны — жергілікті, жеке әрі скрипттерді қолдайтын. Join the ArtCraft community ArtCraft қауымдастығына қосылыңыз diff --git a/crates/ui-egui/src/i18n/lt.tsv b/crates/ui-egui/src/i18n/lt.tsv index d98befcf47..d1dfc33044 100644 --- a/crates/ui-egui/src/i18n/lt.tsv +++ b/crates/ui-egui/src/i18n/lt.tsv @@ -431,6 +431,7 @@ Current page Dabartinis puslapis Current page ({n}) Dabartinis puslapis ({n}) Current page — type a page number or label (such as iv) and press Enter Dabartinis puslapis — įveskite puslapio numerį arba žymą (pvz., iv) ir paspauskite „Enter“ + Zoom — type a percentage and press Enter Mastelis – įveskite procentus ir paspauskite Enter Custom Pasirinktinis Custom scale: Pasirinktinis mastelis: Custom text: Pasirinktinis tekstas: diff --git a/crates/ui-egui/src/i18n/pl.tsv b/crates/ui-egui/src/i18n/pl.tsv index e8df527941..c432a6a92e 100644 --- a/crates/ui-egui/src/i18n/pl.tsv +++ b/crates/ui-egui/src/i18n/pl.tsv @@ -158,6 +158,7 @@ Next page Następna strona Previous page Poprzednia strona Current page — type a page number or label (such as iv) and press Enter Bieżąca strona — wpisz numer lub etykietę strony (np. iv) i naciśnij Enter + Zoom — type a percentage and press Enter Powiększenie — wpisz wartość procentową i naciśnij Enter Welcome to PdfCraft Witamy w PdfCraft An open-source PDF workbench — local, private, and scriptable. Otwarty zestaw narzędzi do PDF — lokalny, prywatny i obsługujący skrypty. Join the ArtCraft community Dołącz do społeczności ArtCraft diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index dbefb9d04a..89ff80f9dd 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -1067,6 +1067,7 @@ Next page Próxima página Previous page Página anterior Current page — type a page number or label (such as iv) and press Enter Página atual — digite o número da página ou rótulo (como iv) e pressione Enter + Zoom — type a percentage and press Enter Zoom — digite uma porcentagem e pressione Enter Open a PDF ({key}) Abrir um PDF ({key}) Dark gray theme Tema cinza escuro Light theme Tema claro diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index d5cd9d6cba..73f21aa0d8 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -155,6 +155,7 @@ Next page Следующая страница Previous page Предыдущая страница Current page — type a page number or label (such as iv) and press Enter Текущая страница — введите номер или метку страницы (например, iv) и нажмите Enter + Zoom — type a percentage and press Enter Масштаб — введите процент и нажмите Enter Welcome to PdfCraft Добро пожаловать в PdfCraft An open-source PDF workbench — local, private, and scriptable. Открытый PDF-редактор — локальный, приватный и программируемый. Join the ArtCraft community Присоединяйтесь к сообществу ArtCraft diff --git a/crates/ui-egui/src/i18n/sl.tsv b/crates/ui-egui/src/i18n/sl.tsv index 560450c256..dd169bc961 100644 --- a/crates/ui-egui/src/i18n/sl.tsv +++ b/crates/ui-egui/src/i18n/sl.tsv @@ -519,6 +519,7 @@ Current page Trenutna stran Current page ({n}) Trenutna stran ({n}) Current page — type a page number or label (such as iv) and press Enter Trenutna stran — vnesite številko ali oznako strani (npr. iv) in pritisnite Enter + Zoom — type a percentage and press Enter Povečava – vnesite odstotek in pritisnite Enter Custom Po meri Custom scale: Merilo po meri: Custom stamps arrive on the web with file pickers for images Lastni žigi bodo v spletni različici na voljo z izbirniki slikovnih datotek diff --git a/crates/ui-egui/src/i18n/te.tsv b/crates/ui-egui/src/i18n/te.tsv index c3900c825d..47aee36f8b 100644 --- a/crates/ui-egui/src/i18n/te.tsv +++ b/crates/ui-egui/src/i18n/te.tsv @@ -139,6 +139,7 @@ comment menu Edit సవరించు Next page తదుపరి పేజీ Previous page మునుపటి పేజీ Current page — type a page number or label (such as iv) and press Enter ప్రస్తుత పేజీ — పేజీ సంఖ్యను లేదా లేబుల్‌ను (iv వంటిది) టైప్ చేసి Enter నొక్కండి + Zoom — type a percentage and press Enter జూమ్ — శాతాన్ని టైప్ చేసి Enter నొక్కండి Welcome to PdfCraft PdfCraftకు స్వాగతం An open-source PDF workbench — local, private, and scriptable. ఓపెన్-సోర్స్ PDF వర్క్‌బెంచ్ — మీ కంప్యూటర్‌లోనే నడుస్తుంది, గోప్యంగా ఉంటుంది, స్క్రిప్ట్‌లతో ఆటోమేట్ చేయవచ్చు. Join the ArtCraft community ArtCraft కమ్యూనిటీలో చేరండి diff --git a/crates/ui-egui/src/i18n/tr.tsv b/crates/ui-egui/src/i18n/tr.tsv index bec543b02e..95ca0b608c 100644 --- a/crates/ui-egui/src/i18n/tr.tsv +++ b/crates/ui-egui/src/i18n/tr.tsv @@ -120,6 +120,7 @@ Next page Sonraki sayfa Previous page Önceki sayfa Current page — type a page number or label (such as iv) and press Enter Geçerli sayfa — bir sayfa numarası veya etiketi (örneğin iv) yazıp Enter'a basın + Zoom — type a percentage and press Enter Yakınlaştırma — bir yüzde yazıp Enter tuşuna basın Welcome to PdfCraft PdfCraft'a Hoş Geldiniz An open-source PDF workbench — local, private, and scriptable. Açık kaynaklı bir PDF çalışma alanı — yerel, gizli ve komut dosyalarıyla yönetilebilir. Join the ArtCraft community ArtCraft topluluğuna katılın diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index 379e47317b..7993a06185 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -160,6 +160,7 @@ Next page Наступна сторінка Previous page Попередня сторінка Current page — type a page number or label (such as iv) and press Enter Поточна сторінка — введіть номер або позначку сторінки (наприклад iv) і натисніть Enter + Zoom — type a percentage and press Enter Масштаб — введіть відсоток і натисніть Enter Welcome to PdfCraft Вітаємо в PdfCraft An open-source PDF workbench — local, private, and scriptable. Відкритий інструментарій для PDF — локальний, приватний і з підтримкою сценаріїв. Join the ArtCraft community Приєднатися до спільноти ArtCraft diff --git a/crates/ui-egui/src/i18n/vi.tsv b/crates/ui-egui/src/i18n/vi.tsv index b68d6f61e2..bae599bf6a 100644 --- a/crates/ui-egui/src/i18n/vi.tsv +++ b/crates/ui-egui/src/i18n/vi.tsv @@ -15,6 +15,7 @@ Zoom Thu phóng Actual size Kích thước thực Zoom to page level Thu phóng vừa trang + Zoom — type a percentage and press Enter Thu phóng — nhập tỷ lệ phần trăm rồi nhấn Enter Fit to width Vừa chiều rộng Display theme Chủ đề hiển thị Side panels Bảng bên diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index 1f37b431d8..1ddecc655f 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -1130,6 +1130,7 @@ Next page 下一页 Previous page 上一页 Current page — type a page number or label (such as iv) and press Enter 当前页 — 输入页码或标签(如 iv)后按 Enter + Zoom — type a percentage and press Enter 缩放 — 输入百分比并按 Enter Open a PDF ({key}) 打开 PDF ({key}) Dark gray theme 深灰色主题 Light theme 浅色主题 diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index ded4ad9048..bb5ec3649b 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -1074,6 +1074,7 @@ Next page 下一頁 Previous page 上一頁 Current page — type a page number or label (such as iv) and press Enter 目前頁面:輸入頁碼或頁面標籤(例如 iv),再按 Enter + Zoom — type a percentage and press Enter 縮放 — 輸入百分比並按 Enter Open a PDF ({key}) 開啟 PDF({key}) Dark gray theme 深灰色主題 Light theme 淺色主題 diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index e173b93d4a..725bcad4b0 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -2565,7 +2565,7 @@ impl PdfCraftApp { self.palette_query = value.to_string(); } ("page", Some(v)) => v.go_to_page(value.parse::().map_err(|e| e.to_string())?.saturating_sub(1)), - ("zoom", Some(v)) => v.set_zoom(value.trim_end_matches('%').parse::().map_err(|e| e.to_string())? / 100.0), + ("zoom", Some(v)) => v.set_zoom(DocView::parse_zoom_percent(value).ok_or("zoom must be a percentage above 0")?), ("layout", Some(v)) => { let (layout, continuous) = canvas::parse_display_token(value).ok_or("layout must be single, continuous, two-up or two-page")?; v.set_display(layout, continuous); diff --git a/crates/ui-egui/tests/view.rs b/crates/ui-egui/tests/view.rs index 5ea5452fa7..c30f081e4a 100644 --- a/crates/ui-egui/tests/view.rs +++ b/crates/ui-egui/tests/view.rs @@ -617,6 +617,35 @@ fn default_zoom_is_used_for_new_documents() { assert_eq!(fit_zoom(&fresh, 0), (Fit::None, 1.5)); } +#[test] +fn the_rail_zoom_field_sets_a_typed_zoom() { + use egui::accesskit::Role; + use egui_kittest::kittest::Queryable; + use pdfcraft_ui_egui::canvas::Fit; + let mut h = harness(&[]); + let type_zoom = |h: &mut Harness<'static, PdfCraftApp>, typed: &str, key: Key| { + h.get_by_role_and_label(Role::TextInput, "Zoom").click(); + h.run_steps(2); + h.get_by_role_and_label(Role::TextInput, "Zoom").type_text(typed); + h.run_steps(1); + h.key_press(key); + h.run_steps(3); + }; + type_zoom(&mut h, "150", Key::Enter); + assert_eq!((h.state().views[0].fit, h.state().views[0].zoom), (Fit::None, 1.5)); + type_zoom(&mut h, "62.5 %", Key::Enter); + assert_eq!(h.state().views[0].zoom, 0.625); + for (typed, key) in [("abc", Key::Enter), ("0", Key::Enter), ("300", Key::Escape)] { + type_zoom(&mut h, typed, key); + assert_eq!(h.state().views[0].zoom, 0.625, "{typed}"); + } + type_zoom(&mut h, "9000", Key::Enter); + assert_eq!(h.state().views[0].zoom, 64.0); + h.state_mut().views[0].set_zoom(2.0); + h.run_steps(2); + assert_eq!(h.get_by_role_and_label(Role::TextInput, "Zoom").value().as_deref(), Some("200%")); +} + #[test] fn preferences_set_the_default_zoom() { use egui_kittest::kittest::Queryable; diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index 47ea3f4e19..3a25698829 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -950,8 +950,8 @@ milestone = "M3" status = "shipped" commands = [] tools = [] -evidence = ["crates/ui-egui/tests/ui.rs::selection_works_in_every_view_rotation"] -notes = "PdfCraft draws a percentage on its fixed 96-dpi basis: 96/72 logical pixels per PDF point at 100%. Acrobat draws it at its Page Display resolution, with logical pixels per PDF point = ppi / 72 × zoom (checked black-box with Custom resolutions of 96, 110 and 120 ppi on one display), so the same percentage can differ in size. Acrobat's Use System Setting resolution is not reproduced. Tracked with misc.prefs-page-display" +evidence = ["crates/ui-egui/tests/ui.rs::selection_works_in_every_view_rotation", "crates/ui-egui/tests/view.rs::the_rail_zoom_field_sets_a_typed_zoom"] +notes = "Typed into the right rail's zoom field (150, 150% or 62.5 %, Enter applies, Escape discards, clamped to 8–6400%) or set through the control channel's zoom option; no preset dropdown in the field yet. PdfCraft draws a percentage on its fixed 96-dpi basis: 96/72 logical pixels per PDF point at 100%. Acrobat draws it at its Page Display resolution, with logical pixels per PDF point = ppi / 72 × zoom (checked black-box with Custom resolutions of 96, 110 and 120 ppi on one display), so the same percentage can differ in size. Acrobat's Use System Setting resolution is not reproduced. Tracked with misc.prefs-page-display" [[feature]] id = "view.zoom-actual-size" From 1952a88801252fe4d9ff106a780d9e86930b9088 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?enes=20y=C4=B1ld=C4=B1z?= <121250962+enesoglu@users.noreply.github.com> Date: Sun, 18 Oct 2026 16:54:00 +0300 Subject: [PATCH 103/105] fix(cli): use laid-out pages for dynamic XFA forms (#2813) Fixes #1645. For a dynamic XFA form, `info` reported the placeholder page and field counts, and `text` and `render` read the placeholder pages, while the app and the automation tools lay the template out. The CLI now opens dynamic XFA forms through the engine session and uses its laid-out document for page and field metadata, text and pixels. `info` keeps the input's file size and also reports the original counts as `pdf_pages` and `acroform_fields`; `text` checks the copy permission before extracting. The input file is never written. Checks run (rustc 1.99.0): cargo fmt --check; cargo clippy -p pdfcraft-cli --all-targets -- -D warnings; cargo test -p pdfcraft-cli; cargo xtask layers; cargo xtask assets. Co-authored-by: Claude Opus 5.5 Co-authored-by: Brandon Thomas --- ROADMAP.md | 1 + apps/pdfcraft-cli/src/main.rs | 48 ++++++++---- apps/pdfcraft-cli/tests/xfa.rs | 136 +++++++++++++++++++++++++++++++++ docs/roadmap.md | 2 +- docs/target-app-parity.md | 2 +- parity/acrobat-features.toml | 4 +- 6 files changed, 173 insertions(+), 20 deletions(-) create mode 100644 apps/pdfcraft-cli/tests/xfa.rs diff --git a/ROADMAP.md b/ROADMAP.md index 0d1cfee90b..a8aefb06d2 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -166,6 +166,7 @@ Ranked; detail and the full milestone table in [docs/roadmap.md](docs/roadmap.md ## Progress log - **2026-10-15 (M7, Edit text caret):** In a text region that mixes sizes (a 24 pt title over 12 pt lines), the inline editor's caret on a small row was as tall as the region's first size and reached into the row below: egui draws the caret at least one row of the text field's font tall, and that font had the paragraph's first size. The field's font is now half the region's smallest size, so each row's own height decides. Kittest regression; checked in the app on a real document. Estimates unchanged. - **2026-10-16 (M3, #2781):** The Pages panel follows the current page, as Acrobat's page thumbnails do: going to a page whose thumbnail is out of sight (scrolling, Page Down, Go to page, a bookmark) scrolls the panel just enough to show it. A thumbnail already in view, or clicked, leaves the panel where it is, and a page change while the user scrolls the panel or drags thumbnails does not move it. Estimates unchanged. +- **2026-10-16 (M12, #1645):** CLI info, text and render use the engine's dynamic XFA layout rather than placeholder pages. Synthetic two-page and encrypted-form regressions cover counts, text, pixels, copy permissions and unchanged input bytes. Estimates unchanged. - **2026-10-18 (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-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-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. diff --git a/apps/pdfcraft-cli/src/main.rs b/apps/pdfcraft-cli/src/main.rs index 0e24800d39..119aaf4acb 100644 --- a/apps/pdfcraft-cli/src/main.rs +++ b/apps/pdfcraft-cli/src/main.rs @@ -298,14 +298,21 @@ fn info(args: &[String]) -> Result<(), CliError> { let path = *positional(args).first().ok_or("info: missing file")?; let bytes = read(path)?; let password = flag(args, "--password"); - let info = inspect(bytes.clone(), password).map_err(|e| e.to_string())?; + let mut info = inspect(bytes.clone(), password).map_err(|e| e.to_string())?; + let pdf_pages = info.pages.len(); + let acroform_fields = info.fields.len(); // Dynamic XFA forms: say what laying the template out gives (what the app shows). let xfa_layout = (info.xfa == Some(pdfcraft_render::Xfa::Dynamic)) .then(|| { let mut s = pdfcraft_engine::Session::new(); let id = s.open("info.pdf", None, bytes.clone(), password).ok()?; let d = s.get(id)?; - d.xfa.as_ref().map(|x| serde_json::json!({ "pages": x.pages, "fields": x.fields, "warnings": x.warnings })) + d.xfa.as_ref().map(|x| { + info = d.info.clone(); + // The in-memory layout is an appended revision, not the file being inspected. + info.file_size = bytes.len(); + serde_json::json!({ "pages": x.pages, "fields": x.fields, "warnings": x.warnings }) + }) }) .flatten(); let json = serde_json::json!({ @@ -313,6 +320,7 @@ fn info(args: &[String]) -> Result<(), CliError> { "pdf_version": info.pdf_version, "file_size": info.file_size, "pages": info.pages.len(), + "pdf_pages": pdf_pages, "acroform_fields": acroform_fields, "first_page_pt": info.pages.first().map(|p| [p.width, p.height]), "title": info.title, "author": info.author, "producer": info.producer, "creator": info.creator, "encrypted": info.encrypted, "tagged": info.tagged, "javascript": info.has_javascript, @@ -348,7 +356,19 @@ fn text(args: &[String]) -> Result<(), CliError> { } } let password = flag(args, "--password"); - let bytes = read(path)?; + let mut bytes = read(path)?; + // Printing the text is copying it, so ask what `text_extract` asks (#1645): without the + // permissions password, a document that doesn't allow copying gives no text. + let mut session = pdfcraft_engine::Session::new(); + if let Some(doc) = session.open(path, None, bytes.clone(), password).ok().and_then(|id| session.get(id)) { + // The engine's hint is about editing in the app; on the command line the way in is + // `--password`. + doc.check_copying() + .map_err(|_| "text: the document's permissions don't allow copying its text; pass the owner password with --password to extract it")?; + if doc.xfa.is_some() { + bytes = doc.display.clone(); + } + } let mut r = PageRenderer::new(bytes.clone(), RenderConfig { password: password.map(Arc::from), ..Default::default() }); // A document the renderer could not open (wrong or missing password, unparseable file) has // no pages, and "extract every page" of nothing would print nothing and exit 0 (#132). Ask @@ -359,15 +379,6 @@ fn text(args: &[String]) -> Result<(), CliError> { return Err("text: the document could not be parsed".into()); } } - // Printing the text is copying it, so ask what `text_extract` asks (#1645): without the - // permissions password, a document that doesn't allow copying gives no text. - let mut session = pdfcraft_engine::Session::new(); - if let Some(doc) = session.open(path, None, bytes, password).ok().and_then(|id| session.get(id)) { - // The engine's hint is about editing in the app; on the command line the way in is - // `--password`. - doc.check_copying() - .map_err(|_| "text: the document's permissions don't allow copying its text; pass the owner password with --password to extract it")?; - } let pages: Vec = match selected_page { Some(page) => vec![page], None => (0..r.page_count()).collect(), @@ -588,10 +599,15 @@ fn render(args: &[String]) -> Result<(), CliError> { // The app renders a display copy that draws comments missing their /AP streams; the CLI // must agree with it (and with independent viewers), while keeping the file it reads intact. let bytes = read(path)?; - let mut r = PageRenderer::new( - pdfcraft_engine::display_bytes_for(bytes.clone(), flag(args, "--password")), - RenderConfig { password: flag(args, "--password").map(Arc::from), reject_oversize: true, ..Default::default() }, - ); + let password = flag(args, "--password"); + let display = if inspect(bytes.clone(), password).is_ok_and(|info| info.xfa == Some(pdfcraft_render::Xfa::Dynamic)) { + let mut session = pdfcraft_engine::Session::new(); + let id = session.open(path, None, bytes, password).map_err(|e| e.to_string())?; + session.get(id).ok_or("render: the document could not be opened")?.display.clone() + } else { + pdfcraft_engine::display_bytes_for(bytes, password) + }; + let mut r = PageRenderer::new(display, RenderConfig { password: password.map(Arc::from), reject_oversize: true, ..Default::default() }); let p = r.render(RenderRequest { page: page_index, kind: RequestKind::Pixels, tile: None, scale: dpi / 72.0, tag: 0, preview: false }); if let Some(e) = p.error { return Err(e.into()); diff --git a/apps/pdfcraft-cli/tests/xfa.rs b/apps/pdfcraft-cli/tests/xfa.rs new file mode 100644 index 0000000000..1b5ff7bc9d --- /dev/null +++ b/apps/pdfcraft-cli/tests/xfa.rs @@ -0,0 +1,136 @@ +//! CLI page commands must use the same dynamic XFA layout as the app and tools (#1645). + +use std::path::PathBuf; +use std::process::{Command, Output}; +use std::sync::Arc; + +const BIN: &str = env!("CARGO_BIN_EXE_pdfcraft-cli"); + +fn fixture(test: &str) -> PathBuf { + let xdp = r#" +"#; + let objects = [ + "<< /Type /Catalog /Pages 2 0 R /NeedsRendering true /AcroForm << /XFA 5 0 R /Fields [] >> >>".to_string(), + "<< /Type /Pages /Kids [3 0 R] /Count 1 >>".into(), + "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] /Contents 4 0 R >>".into(), + "<< /Length 0 >>\nstream\n\nendstream".into(), + format!("<< /Length {} >>\nstream\n{xdp}\nendstream", xdp.len()), + ]; + let mut bytes = b"%PDF-1.7\n".to_vec(); + let mut offsets = Vec::new(); + for (i, object) in objects.iter().enumerate() { + offsets.push(bytes.len()); + bytes.extend_from_slice(format!("{} 0 obj\n{object}\nendobj\n", i + 1).as_bytes()); + } + let xref = bytes.len(); + bytes.extend_from_slice(b"xref\n0 6\n0000000000 65535 f \n"); + for offset in offsets { + bytes.extend_from_slice(format!("{offset:010} 00000 n \n").as_bytes()); + } + bytes.extend_from_slice(format!("trailer\n<< /Size 6 /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n").as_bytes()); + let dir = std::env::temp_dir().join(format!("pdfcraft-cli-xfa-{test}-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let path = dir.join("form.pdf"); + std::fs::write(&path, bytes).unwrap(); + path +} + +fn success(output: &Output) { + assert!(output.status.success(), "{}", String::from_utf8_lossy(&output.stderr)); +} + +#[test] +fn info_reports_the_laid_out_pages_and_fields() { + let path = fixture("info"); + let original = std::fs::read(&path).unwrap(); + let output = Command::new(BIN).arg("info").arg(&path).output().unwrap(); + success(&output); + let info: serde_json::Value = serde_json::from_slice(&output.stdout).unwrap(); + assert_eq!(info["xfa_layout"]["pages"], 2, "the synthetic form lays out into two pages"); + assert_eq!(info["pages"], 2, "top-level page count must agree with the app"); + assert_eq!(info["fields"], 1); + assert_eq!(info["pdf_pages"], 1); + assert_eq!(info["acroform_fields"], 0); + assert_eq!(info["first_page_pt"], serde_json::json!([200.0, 300.0])); + assert_eq!(info["file_size"], original.len()); + assert_eq!(std::fs::read(path).unwrap(), original, "inspection must not write the layout to the input"); +} + +#[test] +fn text_reads_the_layout_including_a_page_absent_from_the_placeholder() { + let path = fixture("text"); + let output = Command::new(BIN).arg("text").arg(&path).output().unwrap(); + success(&output); + let text = String::from_utf8(output.stdout).unwrap(); + assert!(text.contains("First XFA page") && text.contains("Second XFA page"), "{text:?}"); + let output = Command::new(BIN).arg("text").arg(&path).args(["--page", "2"]).output().unwrap(); + success(&output); + let text = String::from_utf8(output.stdout).unwrap(); + assert!(text.contains("Second XFA page") && !text.contains("First XFA page"), "{text:?}"); +} + +#[test] +fn render_matches_the_engine_layout_on_the_second_page() { + use pdfcraft_render::{PageRenderer, RenderConfig, RenderRequest, RequestKind}; + let path = fixture("render"); + let original = std::fs::read(&path).unwrap(); + let mut session = pdfcraft_engine::Session::new(); + let id = session.open("form.pdf", None, Arc::new(original.clone()), None).unwrap(); + let doc = session.get(id).unwrap(); + assert_eq!(doc.info.pages.len(), 2); + assert!(doc.text_lines(1).iter().any(|line| line.text.contains("Second XFA page"))); + let mut renderer = PageRenderer::new(doc.display.clone(), RenderConfig::default()); + let expected = renderer.render(RenderRequest { page: 1, kind: RequestKind::Pixels, tile: None, scale: 1.0, tag: 0, preview: false }); + assert!(expected.error.is_none(), "{:?}", expected.error); + assert!(expected.rgba.as_chunks::<4>().0.iter().filter(|[r, _, _, a]| *r < 128 && *a > 0).count() > 10, "the layout has visible ink"); + let out = path.with_extension("pam"); + let output = Command::new(BIN).arg("render").arg(&path).args(["--page", "2", "--dpi", "72", "--out"]).arg(&out).output().unwrap(); + success(&output); + let actual = std::fs::read(out).unwrap(); + let header = format!("P7\nWIDTH {}\nHEIGHT {}\nDEPTH 4\nMAXVAL 255\nTUPLTYPE RGB_ALPHA\nENDHDR\n", expected.width, expected.height); + assert!(actual.starts_with(header.as_bytes())); + assert_eq!(&actual[header.len()..], &expected.rgba[..]); + assert_eq!(std::fs::read(path).unwrap(), original); +} + +#[test] +fn a_laid_out_form_keeps_the_original_copy_permissions() { + let path = fixture("permissions"); + let mut doc = pdfcraft_cos::Document::open(Arc::new(std::fs::read(&path).unwrap())).unwrap(); + doc.set_encryption(&pdfcraft_cos::NewEncryption { + algorithm: pdfcraft_cos::Algorithm::Aes256, + user_password: "openme", + owner_password: "boss", + permissions: -17, + encrypt_metadata: true, + seed: [17; 32], + }) + .unwrap(); + let original = pdfcraft_cos::write_full(&doc, &Default::default()).unwrap(); + std::fs::write(&path, &original).unwrap(); + let output = Command::new(BIN).arg("text").arg(&path).args(["--password", "openme"]).output().unwrap(); + assert!(!output.status.success()); + assert!(output.stdout.is_empty()); + assert!(String::from_utf8_lossy(&output.stderr).contains("don't allow copying")); + let output = Command::new(BIN).arg("text").arg(&path).args(["--password", "boss", "--page", "2"]).output().unwrap(); + success(&output); + assert!(String::from_utf8_lossy(&output.stdout).contains("Second XFA page")); + let output = Command::new(BIN).arg("info").arg(&path).args(["--password", "openme"]).output().unwrap(); + success(&output); + let info: serde_json::Value = serde_json::from_slice(&output.stdout).unwrap(); + assert_eq!(info["pages"], 2); + assert_eq!(info["encrypted"], true); + assert_eq!(info["file_size"], original.len()); + assert_eq!(std::fs::read(path).unwrap(), original); +} diff --git a/docs/roadmap.md b/docs/roadmap.md index 7980337939..91742b2c47 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -138,7 +138,7 @@ total matches the parity estimate. | M11 | Optimize, preflight, PDF/A/X/UA, print production | 200–350 | 18% | 80–140 | Done: new `optimize` crate: Reduce File Size and the PDF Optimizer (images measured where drawn, bicubic downsampling, JPEG/ZIP recompression only when smaller, discard objects and user data, Flate clean-up, resource merging, object streams). Missing: fonts and transparency panels, space audit, preflight, PDF/A/X/UA | | M12 | Accessibility, compare, measure, search, XFA | 200–380 | 30% | 90–160 | Done: new `a11y` crate with the Accessibility Checker (all 32 rules, report, Fix/Skip/Explain, options dialog and results panel, agent tools). Locatable figure, annotation and form findings highlight their page bounds.
Done since: 2D distance, perimeter and area measurements with persistent viewport calibration, snapping, live information and CSV export; a bounded, text-only auto-tag proposal tool for one page. Marking a figure decorative rewrites only that stream (#1324). An XFA radio item whose value contains a space is written back unchanged (#1319). Missing: writing proposed tags and parent tree, field autotag, visual Tags/Order/Content panels, Reading Order tool, compare, geospatial/3D measurement, search index, XFA | | M13 | Automation (MCP, Action Wizard, CLI) + AI providers | 60–120 | 45% | 25–45 | Done: MCP resources (document info, text, page images); headless tool table (123 tools incl. signing, optimizing, initial view, links, stamps, data exchange, comment review, forms authoring and scripts, redaction, sanitize, print, add content), opt-in MCP server over stdio, CLI `run`/`tools` (closed stdout pipes exit cleanly), UI control channel with drag. Missing: Action Wizard, AI providers
Done since: CLI run and MCP reject missing, empty, repeated and option-valued --root arguments before any tool/script execution or MCP initialization. A single-hyphen directory name remains valid; synthetic CLI regressions preserve a would-be overwritten file. `text_find` also caps the per-page match ranges it materializes at the remaining result limit plus one continuation match. CLI robustness scans visit each directory once through overlapping roots and symlink aliases. | -| M12 | Accessibility, compare, measure, search, XFA | 200–380 | 30% | 90–160 | Done: new `a11y` crate with the Accessibility Checker (all 32 rules, report, Fix/Skip/Explain, options dialog and results panel, agent tools). Locatable figure, annotation and form findings highlight their page bounds.
Done since: 2D distance, perimeter and area measurements with persistent viewport calibration, snapping, live information and CSV export; a bounded text-only auto-tag proposal tool; text-only Apply writes marked content, paragraph tags and a parent tree, then opens the Tags panel for review. Missing: mixed-content and field autotag, heading/list/table/figure classification, Order/Content panels, Reading Order tool, compare, geospatial/3D measurement, search index, XFA | +| M12 | Accessibility, compare, measure, search, XFA | 200–380 | 30% | 90–160 | Done: new `a11y` crate with the Accessibility Checker (all 32 rules, report, Fix/Skip/Explain, options dialog and results panel, agent tools). Locatable figure, annotation and form findings highlight their page bounds.
Done since: 2D distance, perimeter and area measurements with persistent viewport calibration, snapping, live information and CSV export; a bounded text-only auto-tag proposal tool; text-only Apply writes marked content, paragraph tags and a parent tree, then opens the Tags panel for review. Missing: mixed-content and field autotag, heading/list/table/figure classification, Order/Content panels, Reading Order tool, compare, geospatial/3D measurement, search index, XFA
Done since: CLI info, text and render use dynamic XFA layout pages, with synthetic count/text/pixel and copy-permission regressions (#1645). | | M13 | Automation (MCP, Action Wizard, CLI) + AI providers | 60–120 | 45% | 25–45 | Done: MCP resources (document info, text, page images); headless tool table (123 tools incl. signing, optimizing, initial view, links, stamps, data exchange, comment review, forms authoring and scripts, redaction, sanitize, print, add content), opt-in MCP server over stdio, CLI `run`/`tools` (closed stdout pipes exit cleanly), UI control channel with drag. Missing: Action Wizard, AI providers
Done since: CLI run and MCP reject missing, empty, repeated and option-valued --root arguments before any tool/script execution or MCP initialization. A single-hyphen directory name remains valid; synthetic CLI regressions preserve a would-be overwritten file. `text_find` also caps the per-page match ranges it materializes at the remaining result limit plus one continuation match. | | M14 | 1.0 polish: performance, localization, installers | 120–250 | 15% | 80–150 | Done: PhotoCraft's translation system (`i18n/`: TSV catalogs, `tl!`, command-id and plural entries, system-language detection, strict catalog tests); every dialog, panel and notice goes through `tl!`. Catalogs of ≈ 2,000–2,140 entries for Japanese, Simplified and Traditional Chinese, Russian, Bulgarian, German, Spanish, French, Telugu, Hungarian, Ukrainian, Brazilian Portuguese and Arabic (logical-order catalog put into display order; layout not yet mirrored); Czech covers the menus. Italian has 2,528 entries: every key of the other catalogs that the code looks up. Signed and notarized macOS builds, signed Windows installers (x64, x86, ARM64), AppImage, deb, rpm, Flatpak and FreeBSD packages. #307 (bounded large-PDF memory): shared resource indexes and parser metadata, lazy inspection, bounded caches; a 9,156-page file inspects in ≈ 1.8 s at ≈ 500 MiB instead of exceeding 10 GiB. Edits let go of the previous working file before writing the next (a 1 GiB file peaks at two copies while editing, not three; gap 13). Shortcut display uses localized modifier/key names across menus, toolbar, palette, Help and tooltips (#496). Lithuanian adds a 2,578-entry catalog and date names, reviewed by a native-speaking contributor. Validated candidate (macOS raster probe; Windows runtime unverified): the Windows policy changes only embedded JetBrains Mono; horizontal alpha-edge energy per ink improves 2.78%, 3.87% and 1.59% at 100%, 125% and 150%, and is down 0.79% at 200%. Inter is pixel-identical; layout hashes, glyph coverage and the 8 MiB atlas are unchanged. Timing and RSS ranges overlap, with no improvement claimed. Estimates unchanged. Candidate #2383 clamps GPU requests to supported limits while retaining the adapter texture extent; synthetic V3D/downlevel and Windows GPU-required tests pass, but Raspberry Pi startup is unverified. Estimates unchanged. Missing: a right-to-left layout, a Chinese UI face in releases, Hindi/Indonesian/Korean/Vietnamese, file-backed input and global memory budgets, performance budgets, keyboard-only operation Linux/Unix UI fallback fonts also use Fontconfig directories, including Nix store paths, with bounded discovery and the existing candidate allowlist; relative includes and digit-prefixed config filtering have isolated regressions. | | M14 | 1.0 polish: performance, localization, installers | 120–250 | 15% | 80–150 | Done: PhotoCraft's translation system (`i18n/`: TSV catalogs, `tl!`, command-id and plural entries, system-language detection, strict catalog tests); every dialog, panel and notice goes through `tl!`. Catalogs of ≈ 2,000–2,140 entries for Japanese, Simplified and Traditional Chinese, Russian, Bulgarian, German, Spanish, French, Telugu, Hungarian, Ukrainian, Brazilian Portuguese and Arabic (logical-order catalog put into display order; layout not yet mirrored); Czech covers the menus. Italian has 2,528 entries: every key of the other catalogs that the code looks up. Signed and notarized macOS builds, signed Windows installers (x64, x86, ARM64), AppImage, deb, rpm, Flatpak and FreeBSD packages. #307 (bounded large-PDF memory): shared resource indexes and parser metadata, lazy inspection, bounded caches; a 9,156-page file inspects in ≈ 1.8 s at ≈ 500 MiB instead of exceeding 10 GiB. Edits let go of the previous working file before writing the next (a 1 GiB file peaks at two copies while editing, not three; gap 13). Shortcut display uses localized modifier/key names across menus, toolbar, palette, Help and tooltips (#496). Lithuanian adds a 2,578-entry catalog and date names, reviewed by a native-speaking contributor. Validated candidate (macOS raster probe; Windows runtime unverified): the Windows policy changes only embedded JetBrains Mono; horizontal alpha-edge energy per ink improves 2.78%, 3.87% and 1.59% at 100%, 125% and 150%, and is down 0.79% at 200%. Inter is pixel-identical; layout hashes, glyph coverage and the 8 MiB atlas are unchanged. Timing and RSS ranges overlap, with no improvement claimed. Estimates unchanged. Missing: a right-to-left layout, a Chinese UI face in releases, Hindi/Indonesian/Korean/Vietnamese, file-backed input and global memory budgets, performance budgets, keyboard-only operation XDG desktops use the configuration base for preferences with non-overwriting migration and original-data preservation (#1395). | diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index e7698baeef..9119063e20 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -201,7 +201,7 @@ full parity in that area. | C Content editing | 61 | 29 | 7 | 53.3% | 30% | 100–180 | Edit existing paragraphs (font reused or Helvetica substituted, rewrapped), added text and images stay editable, header/footer, watermarks, backgrounds, Bates, links. Missing: robust editing of real-world text (subset fonts, CJK, RTL #766, rotated), lists, vector/object editing, arrange/align, spell check, multi-select move across pages (same-page shipped, #844) | | D Organize pages | 76 | 45 | 3 | 61.2% | 70% | 12–25 | Organize grid, insert/extract/replace/split/combine with links, fields, layers and bookmarks carried over; page labels, boxes, bookmarks from structure. Missing: transitions, attachments editing, portfolios, struct-tree merge, field-name conflicts (destination and annotation fixes #817, #818, #821 shipped) | | E Comments and review | 65 | 46 | 9 | 77.7% | 70% | 12–25 | Every markup type with appearances, replies, status, filters, stamps (custom, dynamic), XFDF/FDF, summaries, flatten, 2D measuring. Missing: rich-text runs, popup windows, summary layouts with pages, per-type hiding (XFDF fidelity fixes #807, #809, #819 shipped) | -| F Forms and JavaScript | 79 | 58 | 8 | 78.5% | 65% | 40–75 | Fill and author every AcroForm field type, Acrobat's AF functions in Acrobat's event order, sandboxed JavaScript (boa), data exchange, Fill & Sign; XFA static/dynamic layout, FormCalc and XFA data (partial). Missing: wider JavaScript object model, document actions, submit, debugger, barcode fields, XFA flattening and full dynamic fidelity | +| F Forms and JavaScript | 79 | 58 | 8 | 78.5% | 65% | 40–75 | Fill and author every AcroForm field type, Acrobat's AF functions in Acrobat's event order, sandboxed JavaScript (boa), data exchange, Fill & Sign; XFA static/dynamic layout, FormCalc and XFA data (partial). CLI info, text and render use the laid-out dynamic pages (#1645). Missing: wider JavaScript object model, document actions, submit, debugger, barcode fields, XFA flattening and full dynamic fidelity | | G Protect, redact, sanitize | 41 | 23 | 2 | 58.5% | 55% | 15–30 | Passwords and permissions, Remove security, redaction that removes glyphs, pixels, vectors, annotations and tags, with verification; word lists, patterns, FOIA/Privacy Act codes; sanitize. Missing: certificate security, pattern locales, custom code sets, partial-word redaction, folder redaction, protected view | | H Digital signatures | 45 | 23 | 10 | 62.2% | 45% | 40–70 | PAdES B-B signing and validation (PKCS #12, macOS Keychain, Windows store with PIN prompts), certification, encrypted documents, certificate usage checks; B-T, LTV, OCSP/CRL with caller-supplied evidence. Missing: PKCS #11 and smart cards outside Windows, timestamp-server preferences, online revocation fetching, FieldMDP and field locking, OS trust store, EU Trusted Lists, compare signed version | | I Scan and OCR | 26 | 5 | 2 | 23.1% | 15% | 40–75 | Searchable image (ocrs, Latin script without accents) on pages, ranges and many files. Missing: every other language (#744 Dutch; accents, CJK, Cyrillic, Arabic), editable-text output, deskew, rotation, despeckle, camera images beyond straightening (found or placed corners): orientation, clean-up, MRC compression, scanners | diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index 3a25698829..3e2b2efaef 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -5220,8 +5220,8 @@ milestone = "M12" status = "partial" commands = ["file.open"] tools = ["doc_open", "doc_info", "form_fields", "form_fill", "js_run"] -evidence = ["crates/xfa/src/tests.rs::layout_places_captions_fields_and_sizes_containers_to_their_content", "crates/xfa/src/tests.rs::tables_break_across_pages_and_repeat_their_header_row", "crates/xfa/src/tests.rs::rendering_into_the_pdf_replaces_the_placeholder_page_and_adds_fields", "crates/engine/src/tests.rs::dynamic_xfa_forms_are_laid_out_on_open_filled_and_saved_incrementally", "crates/automation/tests/automation.rs::dynamic_xfa_forms_open_render_fill_and_save_through_tools", "crates/ui-egui/tests/ui_group/xfa.rs::a_dynamic_xfa_form_is_laid_out_and_filled", "crates/xfa/src/tests.rs::nested_width_less_subforms_lay_out_in_polynomial_time", "crates/js/src/tests.rs::instance_managers_add_remove_and_count", "crates/xfa/src/tests.rs::the_form_tree_carries_values_presence_instances_and_the_events", "crates/engine/src/tests.rs::xfa_scripts_initialize_calculate_validate_toggle_and_add_rows", "crates/automation/tests/automation.rs::xfa_scripts_run_for_buttons_and_field_changes_through_tools", "crates/ui-egui/tests/ui_group/xfa.rs::xfa_buttons_run_their_scripts_in_the_app", "crates/engine/src/tests.rs::hostile_xfa_open_scripts_are_merged_capped_kept_from_printing_and_noted", "crates/engine/src/tests.rs::runaway_xfa_calculations_at_open_end_quickly_with_a_report", "crates/ui-egui/tests/ui_group/xfa.rs::a_form_whose_scripts_were_turned_off_at_open_says_so", "crates/ui-egui/tests/ui_group/xfa.rs::scripts_turned_off_by_a_click_bring_a_closed_notice_bar_back", "crates/automation/tests/automation.rs::doc_info_says_when_a_runaway_script_turned_the_forms_scripts_off", "crates/xfa/src/tests.rs::the_live_form_of_nested_repeating_subforms_stops_at_its_node_budget", "crates/xfa/src/tests.rs::data_ops_of_one_event_are_one_datasets_rewrite", "crates/xfa/src/tests.rs::choice_lists_signatures_passwords_and_pictures_become_fields_and_images", "crates/xfa/src/tests.rs::choice_values_come_from_the_data_as_saved_values_and_several_for_lists", "crates/xfa/src/tests.rs::pictures_are_sized_from_their_headers_and_hostile_ones_are_refused_unallocated", "crates/engine/src/tests.rs::xfa_choice_lists_signature_and_picture_fields_open_fill_script_and_save", "crates/xfa/src/tests.rs::repeating_subforms_inside_positioned_content_get_their_instances", "crates/engine/src/tests.rs::xfa_rows_in_areas_are_laid_out_read_calculated_and_written_where_adobe_binds_them"] -notes = "laid out from the template on open by the xfa crate (positioned, tb, lr-tb, table/row, page masters, breaks, repeated header rows, captions, rich text, JPEG/PNG/GIF images) into real pages and AcroForm fields (text, password, date with AFDate formats, check boxes, radio groups, drop-downs and list boxes with shown/saved item pairs and multi-select, signature fields, image fields showing their picture (pictures decoded once per document, at most 20 million pixels, and reused across re-layouts), buttons with reset/print/save/URL), filled from the datasets packet (including rows another viewer added), saved as an incremental revision with the values merged into the datasets so Adobe's viewers show them; doc_info reports xfa_layout. XFA JavaScript and FormCalc (form.xfa-formcalc) run in the js crate's XFA object model (xfa.form, SOM resolution, rawValue, presence, access, instance managers, xfa.host, xfa.layout, xfa.event): initialize and calculate on open, exit, validate and calculate after a field changes, click for buttons (through js_run and the app), with rows added and removed, subforms shown and hidden, and the form laid out again, all as one undo step and kept in the file. All the scripts of one event run on one thread, each in a fresh context on the form as the engine holds it then (nothing shared between scripts; the live form kept up to date in place for values, rebuilt only when the structure changed), so hundreds of calculations run within the open budget. Hostile scripts are bounded per event (merged and capped effects, messages and console lines, lower loop limits and a time budget on open and calculate, one datasets rewrite per event, print/save/links/focus from clicks only, a runaway script abandoned and the form's scripts turned off, which the app says in a toast and on the notice bar, with a button to the JavaScript console, and doc_info reports as xfa_scripts_off) and what open-time scripts changed is noted. Missing: loops inside nested calls are only stopped by abandoning their thread, and script memory is not capped; the change event per keystroke; xfa.template, connections and data descriptions; a hidden instance of a repeating subform hides them all; barcodes are drawn as their text, not bars; keep rules; metrics are approximate and Acrobat fidelity is eyeballed on real forms, not measured (#60)" +evidence = ["crates/xfa/src/tests.rs::layout_places_captions_fields_and_sizes_containers_to_their_content", "crates/xfa/src/tests.rs::tables_break_across_pages_and_repeat_their_header_row", "crates/xfa/src/tests.rs::rendering_into_the_pdf_replaces_the_placeholder_page_and_adds_fields", "crates/engine/src/tests.rs::dynamic_xfa_forms_are_laid_out_on_open_filled_and_saved_incrementally", "crates/automation/tests/automation.rs::dynamic_xfa_forms_open_render_fill_and_save_through_tools", "crates/ui-egui/tests/ui_group/xfa.rs::a_dynamic_xfa_form_is_laid_out_and_filled", "crates/xfa/src/tests.rs::nested_width_less_subforms_lay_out_in_polynomial_time", "crates/js/src/tests.rs::instance_managers_add_remove_and_count", "crates/xfa/src/tests.rs::the_form_tree_carries_values_presence_instances_and_the_events", "crates/engine/src/tests.rs::xfa_scripts_initialize_calculate_validate_toggle_and_add_rows", "crates/automation/tests/automation.rs::xfa_scripts_run_for_buttons_and_field_changes_through_tools", "crates/ui-egui/tests/ui_group/xfa.rs::xfa_buttons_run_their_scripts_in_the_app", "crates/engine/src/tests.rs::hostile_xfa_open_scripts_are_merged_capped_kept_from_printing_and_noted", "crates/engine/src/tests.rs::runaway_xfa_calculations_at_open_end_quickly_with_a_report", "crates/ui-egui/tests/ui_group/xfa.rs::a_form_whose_scripts_were_turned_off_at_open_says_so", "crates/ui-egui/tests/ui_group/xfa.rs::scripts_turned_off_by_a_click_bring_a_closed_notice_bar_back", "crates/automation/tests/automation.rs::doc_info_says_when_a_runaway_script_turned_the_forms_scripts_off", "crates/xfa/src/tests.rs::the_live_form_of_nested_repeating_subforms_stops_at_its_node_budget", "crates/xfa/src/tests.rs::data_ops_of_one_event_are_one_datasets_rewrite", "crates/xfa/src/tests.rs::choice_lists_signatures_passwords_and_pictures_become_fields_and_images", "crates/xfa/src/tests.rs::choice_values_come_from_the_data_as_saved_values_and_several_for_lists", "crates/xfa/src/tests.rs::pictures_are_sized_from_their_headers_and_hostile_ones_are_refused_unallocated", "crates/engine/src/tests.rs::xfa_choice_lists_signature_and_picture_fields_open_fill_script_and_save", "crates/xfa/src/tests.rs::repeating_subforms_inside_positioned_content_get_their_instances", "crates/engine/src/tests.rs::xfa_rows_in_areas_are_laid_out_read_calculated_and_written_where_adobe_binds_them", "apps/pdfcraft-cli/tests/xfa.rs::info_reports_the_laid_out_pages_and_fields", "apps/pdfcraft-cli/tests/xfa.rs::text_reads_the_layout_including_a_page_absent_from_the_placeholder", "apps/pdfcraft-cli/tests/xfa.rs::render_matches_the_engine_layout_on_the_second_page", "apps/pdfcraft-cli/tests/xfa.rs::a_laid_out_form_keeps_the_original_copy_permissions"] +notes = "laid out from the template on open by the xfa crate (positioned, tb, lr-tb, table/row, page masters, breaks, repeated header rows, captions, rich text, JPEG/PNG/GIF images) into real pages and AcroForm fields (text, password, date with AFDate formats, check boxes, radio groups, drop-downs and list boxes with shown/saved item pairs and multi-select, signature fields, image fields showing their picture (pictures decoded once per document, at most 20 million pixels, and reused across re-layouts), buttons with reset/print/save/URL), filled from the datasets packet (including rows another viewer added), saved as an incremental revision with the values merged into the datasets so Adobe's viewers show them; doc_info reports xfa_layout. CLI info reports the laid-out page/field counts and dimensions while retaining the input file size and raw counts as pdf_pages/acroform_fields; text and render use those pages too, preserving copy permissions and leaving the source file unchanged (#1645). XFA JavaScript and FormCalc (form.xfa-formcalc) run in the js crate's XFA object model (xfa.form, SOM resolution, rawValue, presence, access, instance managers, xfa.host, xfa.layout, xfa.event): initialize and calculate on open, exit, validate and calculate after a field changes, click for buttons (through js_run and the app), with rows added and removed, subforms shown and hidden, and the form laid out again, all as one undo step and kept in the file. All the scripts of one event run on one thread, each in a fresh context on the form as the engine holds it then (nothing shared between scripts; the live form kept up to date in place for values, rebuilt only when the structure changed), so hundreds of calculations run within the open budget. Hostile scripts are bounded per event (merged and capped effects, messages and console lines, lower loop limits and a time budget on open and calculate, one datasets rewrite per event, print/save/links/focus from clicks only, a runaway script abandoned and the form's scripts turned off, which the app says in a toast and on the notice bar, with a button to the JavaScript console, and doc_info reports as xfa_scripts_off) and what open-time scripts changed is noted. Missing: loops inside nested calls are only stopped by abandoning their thread, and script memory is not capped; the change event per keystroke; xfa.template, connections and data descriptions; a hidden instance of a repeating subform hides them all; barcodes are drawn as their text, not bars; keep rules; metrics are approximate and Acrobat fidelity is eyeballed on real forms, not measured (#60)" [[feature]] id = "form.xfa-formcalc" From ecffce69eeb3f102206f142f38f3ee4e66058933 Mon Sep 17 00:00:00 2001 From: mtlcl410 <2368091+mtlcl410@users.noreply.github.com> Date: Sun, 18 Oct 2026 22:47:28 +0800 Subject: [PATCH 104/105] fix(engine): Save writes a repaired file in full even without edits (#1937) (#2029) * fix(engine): Save writes a repaired file in full even without edits (#1937) A damaged file is repaired on open (its cross-reference index rebuilt from object headers, as Document Properties reports), but Save wrote it back as it was: `Session::save_bytes` returned the bytes as opened whenever nothing was edited, and a repair is not an edit. The "repaired" file on disk was still the damaged one. `save_bytes` now writes a reconstructed document in full even with no edits. `Document::reconstructed()` in cos names the condition (no revision chain: rebuilt, or built from scratch), and `write_incremental` uses it too, so the writer and the engine share one definition. Tests: `engine::tests::a_repaired_file_is_rewritten_by_save_even_without_edits` (table offsets shifted; no table at all) and `automation::saving_a_repaired_file_puts_the_repair_on_disk` (doc_open, doc_info, doc_save over the same path, reopen). Both fail with the condition removed: the saved bytes equal the damaged file. parity: core.repair-broken-files cites them and notes that a repair still does not count as an unsaved change. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01RUNQSPVXx3dDBabRZHAnjz * fix(engine): a signed repaired file keeps its bytes on Save without edits (#1937) Review follow-up: writing a reconstructed file in full on Save puts the repair on disk, but for a signed file it would also invalidate its signatures without warning. Save now skips the early return only when the document is reconstructed and not signed. A regression test signs a document, appends a `startxref` that points nowhere (the signed bytes stay intact, the cross-reference data must be rebuilt) and checks that Save without edits returns those bytes. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01RUNQSPVXx3dDBabRZHAnjz --------- Co-authored-by: Claude Fable 5.1 --- crates/automation/tests/automation.rs | 24 +++++++++++ crates/cos/src/document.rs | 8 ++++ crates/cos/src/writer.rs | 2 +- crates/engine/src/lib.rs | 7 +++- crates/engine/src/tests.rs | 57 +++++++++++++++++++++++++++ parity/acrobat-features.toml | 4 +- 6 files changed, 98 insertions(+), 4 deletions(-) diff --git a/crates/automation/tests/automation.rs b/crates/automation/tests/automation.rs index 30658fdfd7..683b626862 100644 --- a/crates/automation/tests/automation.rs +++ b/crates/automation/tests/automation.rs @@ -214,6 +214,30 @@ fn page_transitions_are_set_and_removed_through_the_tools() { ); } +/// #1937 through the tools: a damaged file is repaired on open (`doc_info` lists the repair) and +/// `doc_save` over it writes the rebuilt file, so opening it again needs no repair. +#[test] +fn saving_a_repaired_file_puts_the_repair_on_disk() { + let dir = workdir("repaired-save"); + let good = fixture(2); + let mut broken = b"%PDF-1.7\n%shifted\n".to_vec(); + broken.extend_from_slice(&good[b"%PDF-1.7\n".len()..]); + std::fs::write(dir.join("broken.pdf"), &broken).unwrap(); + let mut a = auto(&dir); + let doc = ok(&mut a, "doc_open", json!({ "path": "broken.pdf" }))["doc"].as_u64().unwrap(); + let info = ok(&mut a, "doc_info", json!({ "doc": doc })); + assert!(info["repairs"].as_array().unwrap().iter().any(|r| r.as_str().unwrap_or("").contains("rebuilt")), "{}", info["repairs"]); + let saved = ok(&mut a, "doc_save", json!({ "doc": doc })); + assert_eq!(saved["incremental"], false, "{saved}"); + let bytes = std::fs::read(dir.join("broken.pdf")).unwrap(); + assert_ne!(bytes, broken, "the damaged bytes were written back"); + let reopened = pdfcraft_cos::Document::open(std::sync::Arc::new(bytes)).unwrap(); + assert!(reopened.repair_log().is_empty(), "{:?}", reopened.repair_log()); + let info = ok(&mut a, "doc_info", json!({ "doc": doc })); + assert_eq!(info["repairs"].as_array().unwrap().len(), 0, "{}", info["repairs"]); + assert_eq!(info["pages"].as_array().unwrap().len(), 2); +} + #[test] fn autotag_preview_proposes_text_without_changing_the_pdf() { let dir = workdir("autotag-preview"); diff --git a/crates/cos/src/document.rs b/crates/cos/src/document.rs index e943555905..a8a561e7d5 100644 --- a/crates/cos/src/document.rs +++ b/crates/cos/src/document.rs @@ -857,6 +857,14 @@ impl Document { &self.revisions } + /// There is no cross-reference chain to append to: the index was rebuilt from object + /// headers because the file's own was unusable (see [`Self::repair_log`]), or the document + /// was built from scratch ([`Self::new_empty`]). A save writes such a document in full, even + /// when nothing was edited: for a repaired file that is what puts the repair on disk. + pub fn reconstructed(&self) -> bool { + self.revisions.is_empty() + } + /// Where each revision of the file ends (oldest first): the byte after the `%%EOF` (and its /// end-of-line) that closes its cross-reference section. `data[..end]` is that revision as it /// was saved. A linearized file's first-page section belongs to the revision it precedes, so diff --git a/crates/cos/src/writer.rs b/crates/cos/src/writer.rs index f8d8fd3d80..ecd5dc2bc8 100644 --- a/crates/cos/src/writer.rs +++ b/crates/cos/src/writer.rs @@ -183,7 +183,7 @@ pub fn write_incremental(doc: &Document, opts: &SaveOptions) -> Result, } // Reconstructed files have no chain to append to; added or removed encryption must // rewrite every object; redaction must not leave the old revision behind. All need a full save. - if doc.revisions().is_empty() || doc.encryption_changed() || doc.full_save_required() { + if doc.reconstructed() || doc.encryption_changed() || doc.full_save_required() { return write_full(doc, opts); } let mut doc = doc.clone(); diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 147fc634ec..07ba5503ca 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -4078,11 +4078,16 @@ impl Session { /// The bytes to write for Save: an incremental update of the file as opened/last saved, /// with `/ModDate` stamped. Call `mark_saved` after writing them successfully. + /// + /// A file whose cross-reference data had to be rebuilt is written in full even when nothing + /// was edited: its bytes as opened are the damaged file, and this save is how the repair the + /// user was told about reaches the disk (#1937). Not a signed one: a full rewrite would + /// break its signatures, so with no edits it keeps its bytes, damage and all. pub fn save_bytes(&self, id: DocId) -> Result>, EditError> { let doc = self.get(id).ok_or(EditError::NoDocument)?; let Some(editor) = doc.editor.as_ref() else { return Ok(doc.bytes.clone()) }; let on_saved = Arc::ptr_eq(editor.cos.bytes(), &editor.saved); - if !editor.cos.is_modified() && on_saved { + if !editor.cos.is_modified() && on_saved && (!editor.cos.reconstructed() || doc.is_signed()) { return Ok(editor.saved.clone()); } let opts = SaveOptions { mod_date: self.now().map(pdfcraft_cos::pdf_date), ..SaveOptions::default() }; diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 1c473b7807..74f4e51c5e 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -519,6 +519,63 @@ fn save_is_incremental_stamps_mod_date_and_rebases() { assert_eq!(reopened.revisions().len(), 3); } +/// #1937: a file whose cross-reference data had to be rebuilt saved as its original bytes when +/// nothing was edited, so the damage Document Properties reported as repaired was still in the +/// file. Save writes the rebuilt file in full; reopened, it needs no repair. +#[test] +fn a_repaired_file_is_rewritten_by_save_even_without_edits() { + let good = fixture(2); + // Every offset in the table is off by the inserted line: the chain reads but leads nowhere. + let mut shifted = b"%PDF-1.7\n%shifted\n".to_vec(); + shifted.extend_from_slice(&good[b"%PDF-1.7\n".len()..]); + // No cross-reference table at all. + let mut lost = good.clone(); + lost.truncate(good.windows(9).rposition(|w| w == b"startxref").unwrap()); + lost.extend_from_slice(b"startxref\n999999\n%%EOF\n"); + for (name, damaged) in [("shifted", shifted), ("lost", lost)] { + let mut s = Session::new().with_clock(|| 1_700_000_000); + let id = s.open("broken.pdf", Some("/tmp/broken.pdf".into()), Arc::new(damaged.clone()), None).unwrap(); + let doc = s.get(id).unwrap(); + assert!(doc.repair_log().iter().any(|l| l.contains("rebuilt cross-reference index")), "{name}: {:?}", doc.repair_log()); + assert_eq!(doc.info.pages.len(), 2, "{name}"); + assert!(!doc.dirty, "{name}: a repair is not an edit"); + let saved = s.save_bytes(id).unwrap(); + assert_ne!(&saved[..], &damaged[..], "{name}: the damaged bytes are not what Save writes"); + let reopened = pdfcraft_cos::Document::open(saved.clone()).unwrap(); + assert!(reopened.repair_log().is_empty(), "{name}: {:?}", reopened.repair_log()); + assert_eq!(reopened.revisions().len(), 1, "{name}"); + s.mark_saved(id, saved.clone(), None).unwrap(); + let doc = s.get(id).unwrap(); + assert!(doc.repair_log().is_empty(), "{name}: {:?}", doc.repair_log()); + assert_eq!(doc.info.pages.len(), 2, "{name}"); + // Sound now: saving again without edits writes the same bytes. + assert_eq!(s.save_bytes(id).unwrap(), saved, "{name}"); + } +} + +/// A damaged file that is signed keeps its bytes on a Save without edits: rewriting it in full +/// to put the repair on disk would invalidate its signatures without a word. +#[test] +fn a_signed_repaired_file_keeps_its_bytes_when_saved_without_edits() { + let p12 = std::fs::read(concat!(env!("CARGO_MANIFEST_DIR"), "/../sign/tests/data/ec-p256.p12")).unwrap(); + let digital_id = sign::pkcs12::open(&p12, "test").unwrap(); + // After the test certificate's start date (2026-10-02). + let mut s = Session::new().with_clock(|| 1_800_000_000); + let id = s.open("fixture.pdf", None, Arc::new(fixture(1)), None).unwrap(); + let opts = SignOptions { page: 0, rect: Some([20.0, 20.0, 180.0, 60.0]), ..SignOptions::default() }; + let signed = s.sign(id, &digital_id, opts).unwrap(); + // A trailing `startxref` that points nowhere: the signed revision's bytes are untouched, but + // the file's cross-reference data has to be rebuilt. + let mut damaged = signed.to_vec(); + damaged.extend_from_slice(b"\nstartxref\n999999\n%%EOF\n"); + let mut t = Session::new().with_clock(|| 1_800_000_000); + let tid = t.open("damaged.pdf", Some("/tmp/damaged.pdf".into()), Arc::new(damaged.clone()), None).unwrap(); + let doc = t.get(tid).unwrap(); + assert!(doc.repair_log().iter().any(|l| l.contains("rebuilt cross-reference index")), "{:?}", doc.repair_log()); + assert!(doc.is_signed()); + assert_eq!(&t.save_bytes(tid).unwrap()[..], &damaged[..], "a signed file is not rewritten without edits"); +} + #[test] fn undo_still_works_after_save() { let (mut s, id) = session_with(2); diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index 3e2b2efaef..deb0d9471d 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -46,8 +46,8 @@ milestone = "M1" status = "shipped" commands = ["file.open"] tools = ["doc_open"] -evidence = ["crates/cos/src/document.rs::damaged_xref_is_reconstructed", "crates/cos/src/parser.rs::indirect_stream_with_wrong_length_recovers", "crates/cos/src/document.rs::self_referential_length_does_not_overflow"] -notes = "" +evidence = ["crates/cos/src/document.rs::damaged_xref_is_reconstructed", "crates/cos/src/parser.rs::indirect_stream_with_wrong_length_recovers", "crates/cos/src/document.rs::self_referential_length_does_not_overflow", "crates/engine/src/tests.rs::a_repaired_file_is_rewritten_by_save_even_without_edits", "crates/automation/tests/automation.rs::saving_a_repaired_file_puts_the_repair_on_disk", "crates/engine/src/tests.rs::a_signed_repaired_file_keeps_its_bytes_when_saved_without_edits"] +notes = "Save and doc_save write a reconstructed file in full even when nothing was edited, so the repair reaches the disk and the saved file opens without one (#1937); a signed one keeps its bytes, so its signatures stay valid. A repair does not mark the document as having unsaved changes, so closing it does not offer to save the repaired file" [[feature]] id = "core.malformed-input-safety" From a12fe8f4116984ebbc870eb514346ea922233316 Mon Sep 17 00:00:00 2001 From: mtlcl410 <2368091+mtlcl410@users.noreply.github.com> Date: Sun, 18 Oct 2026 22:49:30 +0800 Subject: [PATCH 105/105] M4: custom document properties through the engine and the tools (#2076) * M4: custom document properties through the engine and the tools (#organize.custom-properties) /Info entries beyond the ones the spec names are custom properties: organize lists them (custom_info) and set_info accepts any such name after Acrobat's checks (no standard name, no empty name, no spaces, delimiters, / or #, at most 127 bytes); inspection lists them too (DocInfo::custom); doc_info reports them under custom and doc_set_info sets or clears them. The Custom tab in Document Properties and the XMP mirror stay planned. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01RUNQSPVXx3dDBabRZHAnjz * M4: custom document properties list the same before and after an edit Review follow-up. The open-time reader (render/inspect.rs) now uses the engine's rule (pdfcraft_organize::custom_info, trimmed, empty values dropped): text strings only, NUL/whitespace trimmed, empty values left out, and the 256 cap counting listed entries. Before, it also listed name values (`/Dept /RnD`) and capped before filtering, so the list could change after the first unrelated edit. An automation test opens a file with a name value, blank, NUL-padded, empty and numeric entries and checks doc_info's custom list is the same before and after setting the Title. Drops the ROADMAP.md log line, which conflicted with main. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01RUNQSPVXx3dDBabRZHAnjz * M4: custom properties drop NUL/whitespace padding in one pass, before the cap Review follow-up. custom_info now trims NULs and whitespace in any mix (" \0Lab\0 " is "Lab"), leaves out values that are only padding, and does both before taking 256, so blank entries can't crowd real ones out. It is the one rule: the engine uses it as it is, and the open-time reader trims the same way. Tests: 300 blank entries before a real one, and mixed padding, in organize and through doc_info. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01RUNQSPVXx3dDBabRZHAnjz --------- Co-authored-by: Claude Fable 5.1 Co-authored-by: Brandon Thomas --- apps/pdfcraft-cli/src/main.rs | 1 + crates/automation/src/lib.rs | 3 ++ crates/automation/src/tools.rs | 2 +- crates/automation/tests/automation.rs | 68 +++++++++++++++++++++++++++ crates/engine/src/lib.rs | 6 +++ crates/organize/src/lib.rs | 51 ++++++++++++++++++++ crates/organize/src/tests.rs | 53 +++++++++++++++++++++ crates/render/src/inspect.rs | 41 ++++++++++++++++ parity/acrobat-features.toml | 8 ++-- 9 files changed, 228 insertions(+), 5 deletions(-) diff --git a/apps/pdfcraft-cli/src/main.rs b/apps/pdfcraft-cli/src/main.rs index 119aaf4acb..be2df77355 100644 --- a/apps/pdfcraft-cli/src/main.rs +++ b/apps/pdfcraft-cli/src/main.rs @@ -329,6 +329,7 @@ fn info(args: &[String]) -> Result<(), CliError> { "bookmarks": info.outline.len(), "annotations": info.annotations.len(), "fields": info.fields.len(), "links": info.links.len(), "layers": info.layers.len(), "attachments": info.attachments.len(), "transitions": info.transitions.len(), + "custom_properties": info.custom.len(), "page_labels": info.pages.iter().take(8).map(|p| p.label.clone()).collect::>(), "warnings": info.warnings, }); diff --git a/crates/automation/src/lib.rs b/crates/automation/src/lib.rs index 8a4dcec16c..a27b447131 100644 --- a/crates/automation/src/lib.rs +++ b/crates/automation/src/lib.rs @@ -2401,6 +2401,9 @@ fn info(d: &Document) -> Value { "pdf_version": i.pdf_version, "file_size": i.file_size, "title": i.title, "author": i.author, "subject": i.subject, "keywords": i.keywords, + // Custom document properties (Document Properties ▸ Custom): /Info entries beyond the + // ones the spec names, set and cleared with doc_set_info. + "custom": i.custom.iter().map(|(name, value)| json!({ "name": name, "value": value })).collect::>(), "creator": i.creator, "producer": i.producer, "tagged": i.tagged, "has_javascript": i.has_javascript, diff --git a/crates/automation/src/tools.rs b/crates/automation/src/tools.rs index 6b7b8a306a..69d1eda217 100644 --- a/crates/automation/src/tools.rs +++ b/crates/automation/src/tools.rs @@ -156,7 +156,7 @@ pub fn tools() -> Vec { }), &["doc"], )), - t("doc_set_info", "Set document metadata", "Set a document information entry such as Title, Author, Subject or Keywords; an empty value removes it. When the file has XMP metadata, the matching XMP property (dc:title, dc:creator, dc:description, pdf:Keywords, xmp:CreatorTool, pdf:Producer) is changed too, so viewers that read XMP first show the new value. Undoable.") + t("doc_set_info", "Set document metadata", "Set a document information entry such as Title, Author, Subject or Keywords, or a custom property under any other name (no spaces, delimiters, / or #; at most 127 bytes; doc_info lists them under custom); an empty value removes it. When the file has XMP metadata, the matching XMP property (dc:title, dc:creator, dc:description, pdf:Keywords, xmp:CreatorTool, pdf:Producer) is changed too, so viewers that read XMP first show the new value. Undoable.") .with(schema(json!({ "doc": doc(), "key": { "type": "string" }, "value": { "type": "string" } }), &["doc", "key", "value"])), t("page_render", "Render a page", "Render one page to a PNG image (default 96 dpi, at most 600).") .ro() diff --git a/crates/automation/tests/automation.rs b/crates/automation/tests/automation.rs index 683b626862..d541e5ba83 100644 --- a/crates/automation/tests/automation.rs +++ b/crates/automation/tests/automation.rs @@ -238,6 +238,74 @@ fn saving_a_repaired_file_puts_the_repair_on_disk() { assert_eq!(info["pages"].as_array().unwrap().len(), 2); } +/// Custom document properties through the tools: set and cleared with doc_set_info under any +/// name the spec does not use, listed by doc_info apart from the standard entries, bad names +/// refused without a change. +#[test] +fn custom_document_properties_are_set_listed_and_cleared_through_the_tools() { + let dir = workdir("custom-info"); + std::fs::write(dir.join("a.pdf"), fixture(1)).unwrap(); + let mut a = auto(&dir); + let doc = ok(&mut a, "doc_open", json!({ "path": "a.pdf" }))["doc"].as_u64().unwrap(); + ok(&mut a, "doc_set_info", json!({ "doc": doc, "key": "Title", "value": "Report" })); + ok(&mut a, "doc_set_info", json!({ "doc": doc, "key": "Project", "value": "Apollo" })); + ok(&mut a, "doc_set_info", json!({ "doc": doc, "key": "Revision", "value": "7" })); + let info = ok(&mut a, "doc_info", json!({ "doc": doc })); + assert_eq!(info["title"], "Report"); + let mut custom: Vec<(String, String)> = + info["custom"].as_array().unwrap().iter().map(|c| (c["name"].as_str().unwrap().into(), c["value"].as_str().unwrap().into())).collect(); + custom.sort(); + assert_eq!(custom, [("Project".to_string(), "Apollo".to_string()), ("Revision".to_string(), "7".to_string())], "{}", info["custom"]); + assert!(a.call("doc_set_info", &json!({ "doc": doc, "key": "Bad Name", "value": "x" })).is_err(), "a name with a space is refused"); + assert!(a.call("doc_set_info", &json!({ "doc": doc, "key": "", "value": "x" })).is_err()); + ok(&mut a, "doc_set_info", json!({ "doc": doc, "key": "Project", "value": "" })); + let info = ok(&mut a, "doc_info", json!({ "doc": doc })); + assert_eq!(info["custom"].as_array().unwrap().len(), 1, "{}", info["custom"]); + assert_eq!(info["custom"][0]["name"], "Revision"); + ok(&mut a, "doc_save", json!({ "doc": doc, "path": "out/custom.pdf" })); + let mut b = auto(&dir); + let re = ok(&mut b, "doc_open", json!({ "path": "out/custom.pdf" }))["doc"].as_u64().unwrap(); + let info = ok(&mut b, "doc_info", json!({ "doc": re })); + assert_eq!(info["custom"][0]["value"], "7", "{}", info["custom"]); +} + +/// The custom list `doc_info` shows when a file opens is the one it shows after an unrelated +/// edit: a name value, a blank or NUL-padded string and a number are judged the same way by the +/// open-time reader and by the engine's refresh. +#[test] +fn custom_document_properties_do_not_change_after_an_unrelated_edit() { + let dir = workdir("custom-info-stable"); + let objs = [ + "<< /Type /Catalog /Pages 2 0 R >>", + "<< /Type /Pages /Kids [3 0 R] /Count 1 >>", + "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 200 200] >>", + "<< /Producer (Test) /Project (Apollo) /Dept /RnD /Blank ( ) /Padded (Lab\\000\\000) /Revision 7 /Empty () /Mixed ( \\000Lab\\000 ) /Pad ( \\000 ) >>", + ]; + let mut pdf = b"%PDF-1.7\n".to_vec(); + let mut offsets = Vec::new(); + for (i, o) in objs.iter().enumerate() { + offsets.push(pdf.len()); + pdf.extend_from_slice(format!("{} 0 obj\n{o}\nendobj\n", i + 1).as_bytes()); + } + let xref = pdf.len(); + pdf.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objs.len() + 1).as_bytes()); + for o in offsets { + pdf.extend_from_slice(format!("{o:010} 00000 n \n").as_bytes()); + } + pdf.extend_from_slice(format!("trailer\n<< /Size {} /Root 1 0 R /Info 4 0 R >>\nstartxref\n{xref}\n%%EOF\n", objs.len() + 1).as_bytes()); + std::fs::write(dir.join("custom.pdf"), pdf).unwrap(); + let mut a = auto(&dir); + let doc = ok(&mut a, "doc_open", json!({ "path": "custom.pdf" }))["doc"].as_u64().unwrap(); + let opened = ok(&mut a, "doc_info", json!({ "doc": doc }))["custom"].clone(); + let names: Vec<&str> = opened.as_array().unwrap().iter().map(|c| c["name"].as_str().unwrap()).collect(); + assert_eq!(names, ["Project", "Padded", "Mixed"], "{opened}"); + assert_eq!(opened[1]["value"], "Lab", "{opened}"); + assert_eq!(opened[2]["value"], "Lab", "{opened}"); + ok(&mut a, "doc_set_info", json!({ "doc": doc, "key": "Title", "value": "Report" })); + let edited = ok(&mut a, "doc_info", json!({ "doc": doc }))["custom"].clone(); + assert_eq!(edited, opened); +} + #[test] fn autotag_preview_proposes_text_without_changing_the_pdf() { let dir = workdir("autotag-preview"); diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 07ba5503ca..dbeb52e7c7 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -831,6 +831,11 @@ impl Document { self.editor.as_ref().and_then(|e| pdfcraft_organize::info(&e.cos, key)) } + /// The custom document properties as they are now (name, text), Document Properties ▸ Custom. + pub fn custom_info(&self) -> Vec<(String, String)> { + self.editor.as_ref().map(|e| pdfcraft_organize::custom_info(&e.cos)).unwrap_or_default() + } + /// The name shown on the tab and window: the document title when the document asks for it /// (Initial View ▸ Show: Document Title) and has a real one, else the file name. pub fn display_name(&self) -> String { @@ -3902,6 +3907,7 @@ impl Session { doc.info.keywords = info("Keywords"); doc.info.creator = info("Creator"); doc.info.producer = info("Producer"); + doc.info.custom = pdfcraft_organize::custom_info(&editor.cos); doc.info.file_size = bytes.len(); if !doc.signatures.is_empty() { let signatures = signatures_of(&editor.cos, &bytes, &doc.trust, &doc.sig_cache); diff --git a/crates/organize/src/lib.rs b/crates/organize/src/lib.rs index 133bc03ecf..25decd767b 100644 --- a/crates/organize/src/lib.rs +++ b/crates/organize/src/lib.rs @@ -556,6 +556,54 @@ pub fn insert_blank_page(doc: &mut Document, at: usize, width: f64, height: f64) /// Document information keys editable in Document Properties ▸ Description. pub const INFO_KEYS: [&str; 4] = ["Title", "Author", "Subject", "Keywords"]; +/// The document-information entries the spec names (ISO 32000-2 table 349). Any other key in +/// `/Info` is a custom property (Document Properties ▸ Custom). +pub const STANDARD_INFO_KEYS: [&str; 9] = ["Title", "Author", "Subject", "Keywords", "Creator", "Producer", "CreationDate", "ModDate", "Trapped"]; + +/// Longest custom property name accepted, in bytes: the name length every reader is asked to +/// accept (§7.3.5, Annex C). +pub const MAX_CUSTOM_KEY_LEN: usize = 127; + +/// The custom document properties: every `/Info` entry whose key the spec does not name and whose +/// value is text, in the dictionary's order, as (name, text), the text without NUL or whitespace +/// padding and entries that are only padding left out. At most 256 are listed. +pub fn custom_info(doc: &Document) -> Vec<(String, String)> { + let Some(info) = doc.trailer().get(b"Info").map(|o| doc.resolve(o)) else { return Vec::new() }; + let Some(d) = info.as_dict() else { return Vec::new() }; + d.iter() + .filter(|(k, _)| !STANDARD_INFO_KEYS.iter().any(|s| s.as_bytes() == k.as_slice())) + .filter_map(|(k, v)| { + // Padding (\0s and whitespace, in any mix) is not part of the value, and a value that + // is only padding is no property. Filtered before the cap, so blank entries can't + // crowd real ones out. + let text = doc.resolve(v).as_string()?.to_text(); + let text = text.trim_matches(|c: char| c == '\0' || c.is_whitespace()); + (!text.is_empty()).then(|| (String::from_utf8_lossy(k).into_owned(), text.to_owned())) + }) + .take(256) + .collect() +} + +/// Whether `name` may be a custom property's key: not one the spec names (those are the +/// Description fields), not empty, at most [`MAX_CUSTOM_KEY_LEN`] bytes, and without the +/// characters a name cannot carry unescaped (whitespace, the delimiters `()<>[]{}/%` and `#`), +/// so every reader shows the same name. Acrobat refuses the standard and empty names too. +pub fn check_custom_key(name: &str) -> Result<(), OrganizeError> { + if name.is_empty() { + return Err(OrganizeError::Invalid("a custom property needs a name".into())); + } + if STANDARD_INFO_KEYS.contains(&name) { + return Err(OrganizeError::Invalid(format!("{name} is a standard document property, not a custom one"))); + } + if name.len() > MAX_CUSTOM_KEY_LEN { + return Err(OrganizeError::Invalid(format!("a custom property name is at most {MAX_CUSTOM_KEY_LEN} bytes"))); + } + if let Some(c) = name.chars().find(|c| c.is_whitespace() || c.is_control() || "()<>[]{}/%#".contains(*c)) { + return Err(OrganizeError::Invalid(format!("a custom property name can't contain {c:?}"))); + } + Ok(()) +} + /// Read a document-information entry as text. pub fn info(doc: &Document, key: &str) -> Option { let info = doc.trailer().get(b"Info").map(|o| doc.resolve(o))?; @@ -568,6 +616,9 @@ pub fn info(doc: &Document, key: &str) -> Option { /// in the XMP packet, and viewers that read XMP first show its value: the packet's property is /// changed (or removed) to match (see `xmp`). pub fn set_info(doc: &mut Document, key: &str, value: &str) -> Result<(), OrganizeError> { + if !STANDARD_INFO_KEYS.contains(&key) { + check_custom_key(key)?; + } let value = value.trim(); let entry = (!value.is_empty()).then(|| Object::String(PdfString::text(value))); set_info_entry(doc, key.as_bytes(), entry)?; diff --git a/crates/organize/src/tests.rs b/crates/organize/src/tests.rs index c857cd4da7..b5f85386c0 100644 --- a/crates/organize/src/tests.rs +++ b/crates/organize/src/tests.rs @@ -336,6 +336,59 @@ fn page_transitions_are_set_removed_and_checked() { assert_eq!(page_dict(&doc, 2).get(b"Dur").and_then(Object::as_f64), Some(5.0)); } +/// Custom document properties (Document Properties ▸ Custom): any `/Info` key the spec does not +/// name, set and cleared like the standard ones, listed apart from them, and with the names +/// Acrobat refuses refused here too. +#[test] +fn custom_document_properties_are_listed_set_removed_and_their_names_checked() { + let mut doc = open(fixture()); + assert!(custom_info(&doc).is_empty()); + set_info(&mut doc, "Title", "Report").unwrap(); + set_info(&mut doc, "Project", "Apollo").unwrap(); + set_info(&mut doc, "Revision", "7").unwrap(); + let mut listed = custom_info(&doc); + listed.sort(); + assert_eq!(listed, [("Project".to_string(), "Apollo".to_string()), ("Revision".to_string(), "7".to_string())], "standard keys are not custom"); + assert_eq!(info(&doc, "Project").as_deref(), Some("Apollo")); + set_info(&mut doc, "Project", "").unwrap(); + let doc = save_and_reopen(&doc); + assert_eq!(custom_info(&doc), [("Revision".to_string(), "7".to_string())], "cleared, and the rest survives a save"); + assert_eq!(info(&doc, "Title").as_deref(), Some("Report")); + + for bad in ["", "Title", "ModDate", "Bad Name", "a/b", "tab\tbed", "hash#tag", "(x)"] { + assert!(check_custom_key(bad).is_err(), "{bad:?} must be refused"); + } + let long = "x".repeat(MAX_CUSTOM_KEY_LEN + 1); + assert!(check_custom_key(&long).is_err()); + for good in ["Project", "Révision", "部門", "key-2.0", &"y".repeat(MAX_CUSTOM_KEY_LEN)] { + assert!(check_custom_key(good).is_ok(), "{good:?} is a usable name"); + } + let mut doc = open(fixture()); + assert!(set_info(&mut doc, "Bad Name", "x").is_err(), "set_info checks custom names"); + assert!(custom_info(&doc).is_empty()); +} + +/// Padding is not part of a custom value, in any mix of NULs and whitespace, a value that is only +/// padding is not listed, and such entries don't count towards the 256 listed: a real property +/// after 300 blank ones is still there. +#[test] +fn custom_document_properties_drop_padding_before_the_cap() { + let mut doc = open(fixture()); + set_info(&mut doc, "Title", "Report").unwrap(); + let Some(Object::Ref(info_ref)) = doc.trailer().get(b"Info").cloned() else { panic!("an /Info reference") }; + let text = |s: &str| Object::String(pdfcraft_cos::PdfString { bytes: s.as_bytes().to_vec(), hex: false }); + doc.update_dict(info_ref, |d| { + d.set(b"Mixed".to_vec(), text(" \0Lab\0 ")); + d.set(b"Pad".to_vec(), text(" \0 \0")); + for i in 0..300 { + d.set(format!("Blank{i}").into_bytes(), text(" ")); + } + d.set(b"Keep".to_vec(), text("yes")); + }) + .unwrap(); + assert_eq!(custom_info(&doc), [("Mixed".to_string(), "Lab".to_string()), ("Keep".to_string(), "yes".to_string())]); +} + /// A one-page document whose catalog has `xmp` as its (Flate-compressed) XMP packet, and the /// same title and author in `/Info`, as LibreOffice writes them. fn doc_with_xmp(xmp: &[u8]) -> Document { diff --git a/crates/render/src/inspect.rs b/crates/render/src/inspect.rs index 3c3988b7b5..12db8abc3a 100644 --- a/crates/render/src/inspect.rs +++ b/crates/render/src/inspect.rs @@ -56,6 +56,9 @@ pub struct DocInfo { pub keywords: Option, pub creator: Option, pub producer: Option, + /// Custom document properties: `/Info` entries with a key the spec does not name and a text + /// value, as (name, text), in the dictionary's order (at most 256). + pub custom: Vec<(String, String)>, pub pdf_version: String, pub file_size: usize, pub encrypted: bool, @@ -616,6 +619,23 @@ impl<'a> Inspector<'a> { info.keywords = self.text(d, b"Keywords"); info.creator = self.text(d, b"Creator"); info.producer = self.text(d, b"Producer"); + const STANDARD: [&[u8]; 9] = + [b"Title", b"Author", b"Subject", b"Keywords", b"Creator", b"Producer", b"CreationDate", b"ModDate", b"Trapped"]; + // The same rule as the engine's list after an edit (`pdfcraft_organize::custom_info`, + // trimmed and without empty values): text strings only, so a name value is not a + // custom property, and the cap counts listed entries. + info.custom = d + .iter() + .filter(|(k, _)| !STANDARD.contains(&k.as_slice())) + .filter_map(|(k, v)| match self.resolve(v) { + Object::String(bytes, _) => { + let v = text_string(bytes).trim_matches(|c: char| c == '\0' || c.is_whitespace()).to_string(); + (!v.is_empty()).then(|| (String::from_utf8_lossy(k).into_owned(), v)) + } + _ => None, + }) + .take(256) + .collect(); } let Ok(catalog) = self.doc.catalog() else { return }; info.tagged = catalog @@ -2302,6 +2322,27 @@ trailer << /Root 1 0 R >> ); } + const CUSTOM_INFO: &[u8] = b"%PDF-1.7 +1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj +2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 200 200] >> endobj +3 0 obj << /Type /Page /Parent 2 0 R >> endobj +4 0 obj << /Title (Report) /Producer (Test) /Project (Apollo) /Revision 7 /Dept (R&D) /Trapped /False /Note >> endobj +trailer << /Root 1 0 R /Info 4 0 R >> +%%EOF"; + + #[test] + fn custom_document_properties_are_listed_apart_from_the_standard_ones() { + let info = inspect(Arc::new(CUSTOM_INFO.to_vec()), None).expect("opens"); + assert_eq!((info.title.as_deref(), info.producer.as_deref()), (Some("Report"), Some("Test"))); + let mut custom = info.custom.clone(); + custom.sort(); + // Revision is a number, not text, so it is not listed; Trapped is a standard entry. + assert_eq!( + custom, + [("Dept".to_string(), "R&D".to_string()), ("Note".to_string(), "部".to_string()), ("Project".to_string(), "Apollo".to_string())] + ); + } + #[test] fn radio_button_layer_groups_are_read() { let info = inspect(Arc::new(LAYERS.to_vec()), None).expect("opens"); diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index deb0d9471d..de2ca11f43 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -3481,11 +3481,11 @@ title = "Custom document properties" area = "D" tier = "P2" milestone = "M4" -status = "planned" +status = "partial" commands = [] -tools = [] -evidence = [] -notes = "" +tools = ["doc_info", "doc_set_info"] +evidence = ["crates/organize/src/tests.rs::custom_document_properties_are_listed_set_removed_and_their_names_checked", "crates/render/src/inspect.rs::custom_document_properties_are_listed_apart_from_the_standard_ones", "crates/automation/tests/automation.rs::custom_document_properties_are_set_listed_and_cleared_through_the_tools"] +notes = "Through the tools: doc_set_info sets or clears an /Info entry under any name the spec does not use (names checked as Acrobat does: no standard name, no empty name, no spaces or delimiters, at most 127 bytes), and doc_info lists them under custom, apart from the standard entries. Not yet: the Custom tab in Document Properties, and mirroring them into XMP (pdfx:) as Acrobat does" [[feature]] id = "organize.xmp-editor"