diff --git a/Cargo.lock b/Cargo.lock index 53e0f097d5..68a758c2fb 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4263,9 +4263,11 @@ dependencies = [ name = "pdfcraft-preflight" version = "0.9.0" dependencies = [ + "flate2", "pdfcraft-content", "pdfcraft-cos", "pdfcraft-model", + "roxmltree", ] [[package]] diff --git a/ROADMAP.md b/ROADMAP.md index bad75fc3be..b1283e7751 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 (M5, PR #1971: bounded nested FDF export excludes source page/page-tree dictionaries; refreshed against upstream 4db6ac4a; estimates unchanged) > **Last reviewed:** 2026-10-15 · **Last updated:** 2026-10-15 · **Change:** minor (M7: the Edit text caret is as tall as the row it is on; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS) > **Last reviewed:** 2026-10-15 · **Last updated:** 2026-10-15 · **Change:** minor (M6: CSV tab-containing headers; estimates unchanged) > **Last reviewed:** 2026-10-18 · **Last updated:** 2026-10-18 · **Change:** minor (M3, #3149: discard render demand after page geometry changes and check the GPU texture limit before upload; estimates unchanged) @@ -171,6 +172,16 @@ Ranked; detail and the full milestone table in [docs/roadmap.md](docs/roadmap.md ## Progress log +- **2026-10-19 (M14, #3373):** The main menu and its long submenus scroll in short windows, keeping their commands reachable. Estimates unchanged. + +- **2026-10-19 (M3, #3387):** Save-prompt shortcuts yield to later consent and utility dialogs; a synthetic UI regression checks that an edited document stays open. Estimates unchanged. + +- **2026-10-18 (M10, #3120):** Export to Word, PowerPoint, HTML and RTF reads text from form XObjects nested as deep as the renderer draws them (50 levels), and up to 50 000 forms a page (the renderer's budget for nested ones); it stopped at 12 levels and 4 096 forms and left the rest out silently. A page past those limits is listed in `doc_export_office`'s `truncated_pages`, with a warning. Estimates unchanged. + +- **2026-10-18 (M5, PR #1971 review):** Nested FDF annotation values that identify a source `/Page` or `/Pages` dictionary become null at every copy depth. A synthetic three-page PDF saved and reopened before export verifies that destination actions cannot pull source page content into the FDF, while unrelated nested metadata and shared references remain intact. FDF import semantics, copied-graph limits and estimates are unchanged. +- **2026-10-15 (M5, PR #1971 refresh):** Nested FDF annotation values retain shared or cyclic copied graphs. Discovery borrows direct values and records annotation positions with lazy array owners; retained entries are budgeted before cloning. Synthetic stress fixtures include repeated aliases and saved-PDF cache eviction. Limits cover the copied graph; source parsing and page discovery retain their existing limits, with no total source-memory bound. FDF import semantics and estimates are unchanged. +- **2026-10-13 (M5, nested FDF values):** Comment export copies and renumbers nested indirect values, retaining geometry, colours, borders and shared graphs. Graph/data limits return an error before the destination is written; synthetic saved-PDF and automation exchange regressions. Estimates unchanged. + - **2026-10-18 (M6, #2892):** Push-button normal labels are editable per widget through Options and form_set_props, independently of field names; rendering, undo/redo and incremental/full save/reopen are covered. Estimates unchanged. - **2026-10-18 (M7, #3212):** Headers, Bates numbers, watermarks and added text stay visible after unfinished marked content, with original content preserved. @@ -181,6 +192,7 @@ Ranked; detail and the full milestone table in [docs/roadmap.md](docs/roadmap.md - **2026-10-18 (M4, PR #1919 review):** repeated page indices apply each rotation to the latest page-local value, preserving indirect/inherited angles. Synthetic adjacent/interleaved duplicates retain siblings and page contents through incremental and full saves. Estimates unchanged. - **2026-10-15 (M4, #1919 refresh):** Integrated upstream `99941daa` while retaining the indirect page rotation correction and all upstream progress. Only independent documentation conflicts required resolution; focused production and test files are unchanged from the previously green CI head. Estimates unchanged. - **2026-10-18 (M3, #3149):** Page replacement cancels render demand collected for the previous geometry; all viewer raster upload paths reject empty or oversized textures before cache eviction. Synthetic regressions reproduce the reported 1880×2350 upload at a 2048 limit, retain cached images and unsaved edits, and resume bounded backdrops and tiles. Estimates unchanged. +- **2026-10-19 (M3, #3360):** Switching between Read and the docking workspace retains the current document’s scroll position at fixed zoom, including within-page position; separate tabs retain their own offsets. Estimates unchanged. - **2026-10-18 (M8, #1350):** Integrated current main while preserving content-stream comment boundaries. Hidden-text cleanup retires unused named replacement descriptions on rewritten pages and forms, including inherited/shared resource holders, without removing descriptions still used by surviving content. Synthetic saved-byte and preservation regressions cover the cleanup. Estimates unchanged. - **2026-10-17 (M4, #2997):** Dragging page thumbnails in the Organize grid with Ctrl held (Option on macOS) drops copies at the gap instead of moving the pages: a "+" badge sits by the pointer, the copies land at the gap selected, and one undo step ("Duplicate pages") takes them out again. `page_duplicate` takes `at` (1-based, where the first copy goes; left out, after the last of them) so agents can drive the same thing, and the UI control channel's `ui.drag` modifiers now reach gestures that read the input state. Estimates unchanged. - **2026-10-15 (M6, AcroForm XML text):** Plain AcroForm XML imports retain all direct text around comments and processing instructions, including CDATA and character references. Synthetic regressions verify exact values and unknown catalog data after incremental and full save/reopen. Nested-element and XFA interpretation are unchanged. Estimates unchanged. @@ -188,9 +200,11 @@ Ranked; detail and the full milestone table in [docs/roadmap.md](docs/roadmap.md - **2026-10-15 (M1, #2267 refresh):** Integrated upstream `99941daa` while retaining the raw-deflate fallback correction and all upstream progress. Only independent documentation conflicts required resolution; focused production and test files are unchanged from the previously green CI head. Estimates unchanged. - **2026-10-16 (M10, #1201 partial):** Lossless image export applies each component's Decode pair. Indexed values round and clip before palette lookup. Color-key masks keep raw samples. The existing 16-bit precision limit remains. JPEG and mask Decode, ICC transforms, and calibrated colors remain outside this fix. Estimates unchanged. - **2026-10-16 (M3, #2739):** Wrapped revision buttons stay above Binding in Document Properties. A synthetic regression checks control bounds and opens a saved revision at two viewport widths. Estimates unchanged. +- **2026-10-19 (M7, tracked text):** Editable paragraphs and object labels distinguish repeated glyph tracking from word gaps in TJ arrays and separately positioned glyphs. Synthetic regressions cover character spacing, horizontal scaling, rotated baselines, larger word gaps, and rows of one-glyph words or cells (inferred tracking is capped at half an em, so `a b c` and digit or Y/N columns keep their spaces). Estimates unchanged. - **2026-10-15 (M2, gap 12):** `cargo xtask text-oracle` scores reading order as well as word F1: the longest common subsequence of the two word sequences over the longer one. F1 is a bag of words and cannot see order at all -- measuring #2431, a change that moves text and nothing else, it called 26 of the 29 files whose output changed identical, where the order score separated 18 improvements from 6 regressions. Reports, does not gate; a disagreement with Poppler, not a verdict. Estimates unchanged. - **2026-10-15 (M2, #2305 follow-up):** WinAnsiEncoding 0xA0 and 0xAD and MacRomanEncoding 0xCA extract as the ordinary space and hyphen Annex D.2 names them, in the vendored hayro tables (patch 30) as well as `crates/fonts`, so search, copy and redaction see characters that were hidden behind U+00A0 and U+00AD. Across the pdf.js corpus the soft hyphens we extract fall 117 to 13 and the non-breaking spaces 30 to 26; the rest come from `/ToUnicode`. An explicit `/Differences` name keeps its glyph list value, as pdftotext does. Estimates unchanged. -- **2026-10-15 (M12, XFA, #2574):** A `pageArea` with no `` repeats without limit instead of taking a subform's default of one use, so a later page area with `occur max="1"` is no longer drawn (with a copy of its fields) over every body page. A page area's `occur min` is honoured too: one the body never reached gets the pages it still needs after the body (the once-only closing page of the public ISCIII form, with its button, is page 7 of 7). Acrobat Pro shows the form as 7 pages with that page last, as here; when printing, Acrobat keeps that page but leaves it blank (`relevant="-print"` on the page area), which PdfCraft does not do yet. Synthetic regression tests. Estimates unchanged. +- **2026-10-15 (M12, XFA, #2574):** A `pageArea` with no `` repeats without limit instead of taking a subform's default of one use, so a later page area with `occur max="1"` is no longer drawn (with a copy of its fields) over every body page. A page area's `occur min` is honoured too: one the body never reached gets the pages it still needs after the body (the once-only closing page of a form, with its button, comes last). Acrobat Pro also puts that page last, and when printing keeps it but leaves it blank (`relevant="-print"` on the page area), which PdfCraft does not do yet; both re-observed on a synthetic fixture, see the 2026-10-19 line. Synthetic regression tests. Estimates unchanged. +- **2026-10-19 (M12, XFA, #2574):** The Acrobat observations behind the line above were first made on a public real-world form, which the Privacy rule does not allow; they are replaced by ones made on a synthetic form (three `breakBefore pageArea` sections, then a page area with `occur min="1" max="1"`, with and without `relevant="-print"`). Acrobat Pro shows the closing page last in both and prints it in both, blank only with `relevant="-print"`: confirmed. The synthetic form also shows a difference not fixed yet: Acrobat opens it as 5 pages, with an empty first body page before the first section, where PdfCraft lays out 4. Two more synthetic forms narrow it down: with `breakBefore targetType="pageArea"` on the first section, with or without `startNew`, Acrobat opens an empty body page first; without a break on the first section it lays out 4, as PdfCraft does (so `startNew` is not the cause). Not fixed yet: `layout.rs` skips a break while the page is still empty. Estimates unchanged. - **2026-10-15 (M8, #2447):** Removing links or comments (Remove Hidden Information, Sanitize) also drops the tagged-PDF structure tree's object references to them, so the full rewrite no longer keeps a removed link's URL or a comment's text. Regression seen to fail without the fix. Estimates unchanged. - **2026-10-15 (M6, CSV tab-containing headers):** CSV export and spreadsheet merging quote tabs, and import distinguishes quoted tabs from TSV delimiters without changing field identities. Synthetic export/import and incremental/full save regressions cover header tabs, quotes and line breaks, plus ordinary TSV controls. Formula-cell policy is unchanged. Estimates unchanged. - **2026-10-15 (M6, AcroForm XML text):** Plain AcroForm XML imports retain all direct text around comments and processing instructions, including CDATA and character references. Synthetic regressions verify exact values and unknown catalog data after incremental and full save/reopen. Nested-element and XFA interpretation are unchanged. Estimates unchanged. @@ -538,6 +552,8 @@ Newest first: the date, what moved, and the new overall estimate. | Date | Change | Summary | |---|---|---| +| 2026-10-18 | minor | M5, PR #1971: nested source `/Page` and `/Pages` dictionaries export as null without traversing page content or the page tree. Synthetic saved/reopened three-page regression retains unrelated metadata and aliases. FDF import semantics, copied-graph limits and estimates unchanged. | +| 2026-10-15 | minor | M5: budget retained FDF entries before cloning; discovery keeps lazy array owners and annotation positions, with alias/cache-eviction stress fixtures. Limits cover the copied graph; source parsing/page discovery retain existing COS/parser limits, with no total source-memory bound. FDF import semantics and estimates unchanged. | | 2026-10-15 | minor | M7: the Edit text caret is as tall as the row it is on in a region with several sizes; estimates unchanged. | | 2026-10-18 | minor | M4, PR #1919 review: repeated page indices apply each rotation to the latest page-local value, preserving indirect/inherited angles. Synthetic adjacent/interleaved duplicates retain siblings and page contents through incremental and full saves. Estimates unchanged. | | 2026-10-18 | minor | M3, #3149: page replacement cancels render demand collected for the previous geometry; all viewer raster upload paths reject empty or oversized textures before cache eviction. Synthetic regressions reproduce the reported 1880×2350 upload at a 2048 limit, retain cached images and unsaved edits, and resume bounded backdrops and tiles. Estimates unchanged. | diff --git a/apps/pdfcraft/Cargo.toml b/apps/pdfcraft/Cargo.toml index a27401c700..3ce1c96ef3 100644 --- a/apps/pdfcraft/Cargo.toml +++ b/apps/pdfcraft/Cargo.toml @@ -54,8 +54,9 @@ winit = { version = "0.30", default-features = false, features = ["wayland"] } [target.'cfg(target_os = "windows")'.dependencies] # Which GPU drives which display (EnumDisplayDevices), so the window is drawn on the GPU that -# shows it (issue #378). Safe wrappers over Win32; only the `user` feature (which brings `kernel` with it). -winsafe = { version = "0.0.29", default-features = false, features = ["user"] } +# shows it (issue #378). Safe wrappers over Win32; only the `user` feature (which brings `kernel` with it), +# and `advapi` for the session id that names the single-instance socket (#3338). +winsafe = { version = "0.0.29", default-features = false, features = ["user", "advapi"] } # Settings ▸ Accessibility ▸ Visual effects ▸ Animation effects (src/motion.rs), through WinRT's # `UISettings`, whose bindings are safe to call (winsafe's `SystemParametersInfo` is an unsafe # fn). Already in the tree through wgpu's DX12 backend; this adds one small namespace to it. diff --git a/apps/pdfcraft/src/single_instance.rs b/apps/pdfcraft/src/single_instance.rs index ecfa12f5c3..751785d0c5 100644 --- a/apps/pdfcraft/src/single_instance.rs +++ b/apps/pdfcraft/src/single_instance.rs @@ -153,7 +153,7 @@ pub fn claim(files: &[String], may_hand_off: bool) -> Claim { /// `%LOCALAPPDATA%` is unset. #[cfg(windows)] fn socket_path() -> Option { - let name = socket_name(std::env::var("SESSIONNAME").ok().as_deref()); + let name = socket_name(session_id(), std::env::var("SESSIONNAME").ok().as_deref()); // %LOCALAPPDATA% is private to the user's sign-in, so other accounts can't receive this // user's file paths or send it files to open. A portable copy uses it too (#889), under its // own name so it never hands files to an installed copy (which keeps its settings elsewhere). @@ -184,13 +184,31 @@ fn display_socket_name(display: Option<&str>) -> String { if display.is_empty() { "instance.sock".to_string() } else { format!("instance-{display}.sock") } } -/// The socket's file name for a Windows session (`SESSIONNAME`: `Console`, `RDP-Tcp#3`…). The -/// same user signed in twice (at the console and over Remote Desktop) gets one socket per session, -/// so a file never opens in a window on the other screen. Only letters, digits and `-` are kept. +/// The Windows session this process runs in, from its access token: the console, each Remote +/// Desktop sign-in and session 0 (services) each have their own. `None` if Windows won't say. +#[cfg(windows)] +fn session_id() -> Option { + use winsafe::{HACCESSTOKEN, TokenInfo, co::TOKEN_INFORMATION_CLASS}; + match HACCESSTOKEN::GetCurrentProcessToken().GetTokenInformation(TOKEN_INFORMATION_CLASS::SessionId) { + Ok(TokenInfo::SessionId(id)) => Some(*id), + _ => None, + } +} + +/// The socket's file name for a Windows session. The same user signed in twice (at the console +/// and over Remote Desktop) gets one socket per session, so a file never opens in a window on the +/// other screen. The session is the token's session id (`session`): a launch from Task Scheduler, +/// a service or a remote shell has no `SESSIONNAME` but still runs in a session, so it finds the +/// window of the session it runs in (#3338), and never one in another session (review on #3340). +/// Only if the id can't be read does `SESSIONNAME` (`Console`, `RDP-Tcp#3`…) name it, letters, +/// digits and `-` kept. #[cfg_attr(not(windows), allow(dead_code))] -fn socket_name(session: Option<&str>) -> String { - let session: String = session.unwrap_or_default().chars().filter(|c| c.is_ascii_alphanumeric() || *c == '-').take(32).collect(); - if session.is_empty() { "instance.sock".to_string() } else { format!("instance-{session}.sock") } +fn socket_name(session: Option, session_name: Option<&str>) -> String { + if let Some(id) = session { + return format!("instance-s{id}.sock"); + } + let name: String = session_name.unwrap_or_default().chars().filter(|c| c.is_ascii_alphanumeric() || *c == '-').take(32).collect(); + if name.is_empty() { "instance.sock".to_string() } else { format!("instance-{name}.sock") } } /// Held while a launch finds out whether it is the first, so launches that start together (several @@ -359,12 +377,20 @@ mod tests { #[test] fn each_windows_session_gets_its_own_socket_name() { - assert_eq!(socket_name(Some("Console")), "instance-Console.sock"); - assert_eq!(socket_name(Some("RDP-Tcp#3")), "instance-RDP-Tcp3.sock"); - assert_ne!(socket_name(Some("RDP-Tcp#3")), socket_name(Some("RDP-Tcp#4"))); - assert_eq!(socket_name(Some(r"..\x/y:z")), "instance-xyz.sock", "never a path"); - assert_eq!(socket_name(None), "instance.sock"); - assert_eq!(socket_name(Some("")), "instance.sock"); + // The session id decides: #3338, a launch from Task Scheduler (no SESSIONNAME) in the + // console's session finds the window opened from Explorer there... + assert_eq!(socket_name(Some(1), None), socket_name(Some(1), Some("Console"))); + // ...and (review on #3340) one in a Remote Desktop session never reaches the console's. + assert_ne!(socket_name(Some(2), None), socket_name(Some(1), Some("Console"))); + assert_ne!(socket_name(Some(2), Some("RDP-Tcp#3")), socket_name(Some(3), Some("RDP-Tcp#3"))); + assert_eq!(socket_name(Some(4_000_000_000), None), "instance-s4000000000.sock"); + // Only without an id does SESSIONNAME name it. + assert_eq!(socket_name(None, Some("Console")), "instance-Console.sock"); + assert_eq!(socket_name(None, Some("RDP-Tcp#3")), "instance-RDP-Tcp3.sock"); + assert_ne!(socket_name(None, Some("RDP-Tcp#3")), socket_name(None, Some("RDP-Tcp#4"))); + assert_eq!(socket_name(None, Some(r"..\x/y:z")), "instance-xyz.sock", "never a path"); + assert_eq!(socket_name(None, None), "instance.sock"); + assert_eq!(socket_name(None, Some("")), "instance.sock"); } #[test] @@ -394,11 +420,21 @@ mod tests { assert_eq!(display_socket_name(Some("")), "instance.sock"); } + /// The socket is named by the session the process runs in, which Windows always gives. + #[cfg(windows)] + #[test] + fn this_process_has_a_session_id() { + let id = super::session_id(); + assert!(id.is_some(), "the access token names the session"); + let name = socket_name(id, None); + assert!(socket_path().is_none_or(|p| p.file_name().is_some_and(|f| f.to_string_lossy().ends_with(&name))), "{name}"); + } + #[cfg(any(windows, all(unix, not(target_os = "macos"))))] fn socket(test: &str) -> std::path::PathBuf { let dir = std::env::temp_dir().join(format!("pdfcraft-instance-{}-{test}", std::process::id())); let _ = std::fs::remove_dir_all(&dir); - dir.join(socket_name(None)) + dir.join(socket_name(None, None)) } #[cfg(any(windows, all(unix, not(target_os = "macos"))))] diff --git a/contributors/people.toml b/contributors/people.toml index cd8ef99f72..f7cb392924 100644 --- a/contributors/people.toml +++ b/contributors/people.toml @@ -148,3 +148,8 @@ sync_github_name = false [people.MortenHusted] sync_github_name = true + +[people.EricKaden] +real_name = "Eric Kaden" +display_name = "Eric Kaden" +sync_github_name = false diff --git a/crates/annot/src/appearance.rs b/crates/annot/src/appearance.rs index 0f0706428f..3e1dd4a6b6 100644 --- a/crates/annot/src/appearance.rs +++ b/crates/annot/src/appearance.rs @@ -174,7 +174,7 @@ fn interior_color(d: &Dict, fallback: Option) -> Option { color(d, b"IC").flatten().or(fallback) } -fn border_width(d: &Dict) -> f64 { +pub(crate) fn border_width(d: &Dict) -> f64 { if let Some(w) = d.get(b"BS").and_then(|b| b.as_dict()).and_then(|b| b.get(b"W")).and_then(|w| w.as_f64()) { return w.max(0.0); } diff --git a/crates/annot/src/lib.rs b/crates/annot/src/lib.rs index b7f9d62697..587cd4466f 100644 --- a/crates/annot/src/lib.rs +++ b/crates/annot/src/lib.rs @@ -1987,13 +1987,12 @@ pub fn set_font_size(doc: &mut Document, page: usize, index: usize, size: f64, m result } -/// Whether a comment of this subtype has an interior that can be filled (`/IC`: rectangles, -/// ovals, polygons and clouds, §12.5.6.8–9). +/// Whether a comment has a fill: `/IC` for shapes, `/C` for FreeText (§12.5.6). pub fn fillable(subtype: &str) -> bool { - matches!(subtype, "Square" | "Circle" | "Polygon") + matches!(subtype, "Square" | "Circle" | "Polygon" | "FreeText") } -/// Fill a rectangle, oval or polygon comment with `fill`, or remove its fill (`None`), and +/// Fill a shape or FreeText comment with `fill`, or remove its fill (`None`), and /// redraw its appearance (Acrobat: Properties ▸ Fill Color). pub fn set_fill(doc: &mut Document, page: usize, index: usize, fill: Option, meta: &Meta) -> Result<(), AnnotError> { let (_, r) = annot_ref(doc, page, index)?; @@ -2001,7 +2000,7 @@ pub fn set_fill(doc: &mut Document, page: usize, index: usize, fill: Option let d = annot_dict(doc, r); let subtype = String::from_utf8_lossy(d.name(b"Subtype").unwrap_or_default()).into_owned(); if !fillable(&subtype) { - return Err(AnnotError::Invalid("only rectangles, ovals and polygons can be filled".into())); + return Err(AnnotError::Invalid("only rectangles, ovals, polygons and text boxes can be filled".into())); } // Check before changing anything: a stale appearance would contradict the new fill. if appearance::build(&d).is_none() { @@ -2013,10 +2012,11 @@ pub fn set_fill(doc: &mut Document, page: usize, index: usize, fill: Option refuse_foreign_text(doc, &d)?; let before = d; doc.update_dict(r, |d| { + let key: &[u8] = if subtype == "FreeText" { b"C" } else { b"IC" }; match fill { - Some(c) => d.set(b"IC".to_vec(), rgb(c)), + Some(c) => d.set(key.to_vec(), rgb(c)), None => { - d.remove(b"IC"); + d.remove(key); } } touch(d, meta); @@ -2205,7 +2205,7 @@ pub struct Props { pub subject: String, pub color: Option, pub opacity: f64, - /// Border / line width, for shapes, lines and drawings. + /// Border / line width, for shapes, lines, drawings and text boxes. pub width: Option, /// Notes only. pub icon: Option, @@ -2217,9 +2217,9 @@ pub struct Props { pub locked: bool, /// `/LE`: two names for a line or polyline (`None` when unset), one for a callout. pub endings: Option>, - /// Rectangles, ovals and polygons: their interior can be filled (see [`fillable`]). + /// Whether the comment has a fill control (see [`fillable`]). pub fillable: bool, - /// `/IC`, the interior colour (`None`: no fill). + /// The interior colour (`None`: no fill). pub fill: Option, /// Text boxes, typewriter text and callouts: the `/DA` font size in points. pub font_size: Option, @@ -2234,8 +2234,13 @@ pub fn props(doc: &Document, page: usize, index: usize) -> Option { let subtype = String::from_utf8_lossy(d.name(b"Subtype")?).into_owned(); let c: Vec = d.get(b"C").and_then(|o| o.as_array().map(|a| a.iter().filter_map(|x| x.as_f64()).collect())).unwrap_or_default(); let color = if subtype == "FreeText" { Some(appearance::parse_da(d).0) } else { (c.len() == 3).then(|| [c[0], c[1], c[2]]) }; - let width = matches!(subtype.as_str(), "Square" | "Circle" | "Line" | "Ink" | "Polygon" | "PolyLine") - .then(|| d.get(b"BS").and_then(|b| b.as_dict()).and_then(|b| b.get(b"W")).and_then(|w| w.as_f64()).unwrap_or(1.0)); + let width = matches!(subtype.as_str(), "Square" | "Circle" | "Line" | "Ink" | "Polygon" | "PolyLine" | "FreeText").then(|| { + if subtype == "FreeText" { + if !d.contains(b"BS") && !d.contains(b"Border") { 0.0 } else { appearance::border_width(d) } + } else { + d.get(b"BS").and_then(|b| b.as_dict()).and_then(|b| b.get(b"W")).and_then(|w| w.as_f64()).unwrap_or(1.0) + } + }); Some(Props { author: text_value(doc, d, b"T").unwrap_or_default(), subject: text_value(doc, d, b"Subj").unwrap_or_default(), @@ -2254,9 +2259,10 @@ pub fn props(doc: &Document, page: usize, index: usize) -> Option { }) } -/// `/IC` as an RGB colour. An empty array means no fill; grey and CMYK fills are converted. +/// A shape’s `/IC` or FreeText `/C` as RGB. Grey and CMYK fills are converted. fn fill_of(d: &Dict) -> Option { - let c: Vec = d.get(b"IC")?.as_array()?.iter().filter_map(|x| x.as_f64()).collect(); + let key: &[u8] = if d.name(b"Subtype") == Some(b"FreeText") { b"C" } else { b"IC" }; + let c: Vec = d.get(key)?.as_array()?.iter().filter_map(|x| x.as_f64()).collect(); let rgb = match c.as_slice() { [g] => [*g; 3], [r, g, b] => [*r, *g, *b], diff --git a/crates/annot/src/tests.rs b/crates/annot/src/tests.rs index d3d448e520..4a4dfb1064 100644 --- a/crates/annot/src/tests.rs +++ b/crates/annot/src/tests.rs @@ -1335,3 +1335,50 @@ fn text_box_font_size_changes_the_da_and_grows_the_box() { assert_eq!(props(&doc, 0, i).unwrap().font_size, None); } } + +#[test] +fn text_boxes_and_callouts_can_change_fill_and_border() { + let mut doc = fixture(); + let shapes = [ + Shape::TextBox { rect: [300.0, 600.0, 500.0, 660.0], font_size: 12.0 }, + Shape::Callout { + rect: [300.0, 600.0, 500.0, 660.0], + knee: [260.0, 560.0], + point: [200.0, 520.0], + font_size: 12.0, + ending: LineEnding::OpenArrow, + }, + ]; + for shape in shapes { + let index = add_annotation(&mut doc, &new(0, shape), &meta("test")).unwrap(); + assert!(props(&doc, 0, index).unwrap().fillable); + set_fill(&mut doc, 0, index, Some([1.0, 1.0, 1.0]), &meta("test")).unwrap(); + set_style(&mut doc, 0, index, None, None, Some(3.0), None, &meta("test")).unwrap(); + let saved = reopen(&doc); + let properties = props(&saved, 0, index).unwrap(); + assert_eq!(properties.fill, Some([1.0; 3])); + assert_eq!(properties.width, Some(3.0)); + let ap = ap_content(&saved, &list(&saved, 0)[index]); + assert!(ap.contains("1 1 1 rg") && ap.contains(" re f") && ap.contains("3 w"), "{ap}"); + set_fill(&mut doc, 0, index, None, &meta("test")).unwrap(); + assert_eq!(props(&doc, 0, index).unwrap().fill, None); + } +} + +#[test] +fn free_text_properties_show_legacy_or_absent_border_width() { + let mut doc = fixture(); + let index = add_annotation(&mut doc, &new(0, Shape::TextBox { rect: [100.0, 100.0, 200.0, 140.0], font_size: 12.0 }), &meta("test")).unwrap(); + let (_, reference) = annot_ref(&mut doc, 0, index).unwrap(); + doc.update_dict(reference, |d| { + d.remove(b"BS"); + d.remove(b"Border"); + }) + .unwrap(); + assert_eq!(props(&doc, 0, index).unwrap().width, Some(0.0)); + doc.update_dict(reference, |d| { + d.set(b"Border".to_vec(), Object::Array([0.0, 0.0, 4.0].into_iter().map(Object::Real).collect())); + }) + .unwrap(); + assert_eq!(props(&doc, 0, index).unwrap().width, Some(4.0)); +} diff --git a/crates/automation/src/a11y.rs b/crates/automation/src/a11y.rs index 8130ed084d..2124549731 100644 --- a/crates/automation/src/a11y.rs +++ b/crates/automation/src/a11y.rs @@ -710,9 +710,19 @@ impl Automation { let ext = path.extension().map(|e| e.to_string_lossy().into_owned()).unwrap_or_default(); let format = pdfcraft_engine::compare::OfficeFormat::from_extension(&ext) .ok_or_else(|| ToolError::InvalidArgs(format!("unsupported extension {ext:?} (docx, pptx, html or rtf)")))?; - let bytes = self.doc_to_copy(a)?.export_office(format); - write_atomic(&path, &bytes)?; - Ok(json!({ "path": path.to_string_lossy(), "bytes": bytes.len(), "format": format.extension() })) + let export = self.doc_to_copy(a)?.export_office_checked(format); + write_atomic(&path, &export.bytes)?; + // Text left out to keep a hostile page bounded is said, not silent (#3120). + let truncated: Vec = export.truncated_pages.iter().map(|p| p.saturating_add(1)).collect(); + let mut out = json!({ "path": path.to_string_lossy(), "bytes": export.bytes.len(), "format": format.extension(), + "truncated": !truncated.is_empty(), "truncated_pages": truncated }); + if !export.truncated_pages.is_empty() { + use pdfcraft_engine::compare::{MAX_FORM_DEPTH, MAX_FORM_VISITS}; + out["warnings"] = json!([format!( + "forms nested more than {MAX_FORM_DEPTH} deep, or past the first {MAX_FORM_VISITS} forms of a page (forms the page draws itself count too), were left out on the pages in truncated_pages: their text and images are missing from the export, although the viewer may still show them" + )]); + } + Ok(out) } fn pdfa_level(&self, a: &Args) -> Result { diff --git a/crates/automation/src/tools.rs b/crates/automation/src/tools.rs index ba861b467d..16a83db7a8 100644 --- a/crates/automation/src/tools.rs +++ b/crates/automation/src/tools.rs @@ -790,13 +790,13 @@ pub fn tools() -> Vec { json!({ "doc": doc(), "sort": { "type": "string", "enum": ["page", "author", "date", "type"] }, "out": save_out, "open": open }), &["doc"], )), - t("comment_edit", "Edit a comment", "Change a comment's text, colour, opacity, line width, font size (text box/typewriter/callout), fill (rectangle/oval/polygon; \"none\" removes it), rectangle (rectangle/oval/text box/stamp) or position (`move` [dx, dy] in points). Stamps keep their original appearance when resized. One undo step.").with(schema( + t("comment_edit", "Edit a comment", "Change a comment's text, colour, opacity, line width, font size (text box/typewriter/callout), fill (rectangle/oval/polygon/text box/callout; \"none\" removes it), rectangle (rectangle/oval/text box/stamp) or position (`move` [dx, dy] in points). Stamps keep their original appearance when resized. One undo step.").with(schema( comment_ref(json!({ "contents": { "type": "string" }, "color": color(), "opacity": { "type": "number", "minimum": 0, "maximum": 1 }, "width": { "type": "number", "minimum": 0 }, - "fill": { "type": "string", "description": "Interior colour of a rectangle, oval or polygon: #RRGGBB, a colour name, or \"none\" for no fill." }, + "fill": { "type": "string", "description": "Interior colour of a rectangle, oval, polygon, text box or callout: #RRGGBB, a colour name, or \"none\" for no fill." }, "font_size": { "type": "number", "minimum": 1, "maximum": 400, "description": "Font size in points of a text box, typewriter text or callout; the box grows to hold the text." }, "rect": { "type": "array", "items": { "type": "number" }, "minItems": 4, "maxItems": 4 }, "move": point(), @@ -1071,7 +1071,7 @@ pub fn tools() -> Vec { json!({ "doc": doc(), "field": { "type": "string" }, "event": { "type": "string", "enum": ["keystroke", "format", "validate", "calculate", "mouse_up"] }, "script": { "type": "string" } }), &["doc", "field", "event"], )), - t("doc_export_office", "Export to Word, PowerPoint, HTML or RTF", "Export a PDF ▸ Word (.docx), PowerPoint (.pptx, one slide per page), HTML (.html, one file with images inline) or RTF (.rtf), chosen by path's extension: text, headings, bold and italic. Word uses borderless inferred tables and positioned editable shapes on pages with artwork, rotated or large text; each source page retains its size. Font matching and vector graphics remain limited.") + t("doc_export_office", "Export to Word, PowerPoint, HTML or RTF", "Export a PDF ▸ Word (.docx), PowerPoint (.pptx, one slide per page), HTML (.html, one file with images inline) or RTF (.rtf), chosen by path's extension: text, headings, bold and italic. Word uses borderless inferred tables and positioned editable shapes on pages with artwork, rotated or large text; each source page retains its size. Font matching and vector graphics remain limited. Export reads forms 50 deep and up to 50 000 a page, counting forms the page draws itself. Pages that go past either limit are listed in truncated_pages, with a warning; the text and images of the forms left out are missing.") .cmd("export.docx") .with(schema(json!({ "doc": doc(), "path": { "type": "string" } }), &["doc", "path"])), t("pdfa_verify", "Verify PDF/A", "Standards ▸ Verify PDF/A compliance: the PDF/A-2b or 3b rules the document breaks (ISO 19005 clause, message, page, whether Save as PDF/A can fix it), plus what it declares.") diff --git a/crates/automation/tests/automation.rs b/crates/automation/tests/automation.rs index 2e0ee7422e..84f59000a6 100644 --- a/crates/automation/tests/automation.rs +++ b/crates/automation/tests/automation.rs @@ -5282,6 +5282,74 @@ fn exporting_to_word_html_and_rtf() { assert!(a.call("doc_export_office", &json!({ "doc": doc, "path": "a.xyz" })).is_err()); } +/// #3120: a PDF whose pages draw "Head", then a chain of `depths[page]` nested forms whose +/// innermost writes "TAIL". +fn nested_forms_pdf(depths: &[usize]) -> Vec { + let mut objs: Vec = + vec!["<< /Type /Catalog /Pages 2 0 R >>".into(), String::new(), "<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>".into()]; + let mut kids = Vec::new(); + for &depth in depths { + // The object number of D1, D2 and so on. + let first = objs.len() + 1; + let xobjects: String = (1..=depth).map(|i| format!("/D{i} {} 0 R ", first + i - 1)).collect(); + let resources = format!("<< /Font << /F1 3 0 R >> /XObject << {xobjects}>> >>"); + for i in 1..=depth { + let body = if i == depth { "BT /F1 24 Tf 20 150 Td (TAIL) Tj ET".to_string() } else { format!("/D{} Do", i + 1) }; + objs.push(format!( + "<< /Type /XObject /Subtype /Form /BBox [0 0 200 300] /Resources {resources} /Length {} >>\nstream\n{body}\nendstream", + body.len() + )); + } + let body = "BT /F1 24 Tf 20 250 Td (Head) Tj ET /D1 Do"; + objs.push(format!("<< /Length {} >>\nstream\n{body}\nendstream", body.len())); + objs.push(format!("<< /Type /Page /Parent 2 0 R /Contents {} 0 R /Resources {resources} >>", objs.len())); + kids.push(format!("{} 0 R", objs.len())); + } + objs[1] = format!("<< /Type /Pages /Kids [{}] /Count {} /MediaBox [0 0 200 300] >>", kids.join(" "), kids.len()); + let mut out = b"%PDF-1.7\n".to_vec(); + let mut offsets = Vec::new(); + for (i, o) in objs.iter().enumerate() { + offsets.push(out.len()); + out.extend_from_slice(format!("{} 0 obj\n{o}\nendobj\n", i + 1).as_bytes()); + } + let xref = out.len(); + out.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objs.len() + 1).as_bytes()); + for o in offsets { + out.extend_from_slice(format!("{o:010} 00000 n \n").as_bytes()); + } + out.extend_from_slice(format!("trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n", objs.len() + 1).as_bytes()); + out +} + +/// #3120: export reads forms as deeply nested as the renderer draws them (13 used to lose their +/// text), and its result says which pages draw forms past that, whose text is missing. +#[test] +fn exporting_office_files_says_which_pages_left_form_text_out() { + use pdfcraft_engine::compare::{MAX_FORM_DEPTH, MAX_FORM_VISITS}; + let dir = workdir("office-forms"); + std::fs::write(dir.join("deep.pdf"), nested_forms_pdf(&[13, MAX_FORM_DEPTH + 1, MAX_FORM_DEPTH])).unwrap(); + std::fs::write(dir.join("fine.pdf"), nested_forms_pdf(&[13])).unwrap(); + let mut a = auto(&dir); + let doc = ok(&mut a, "doc_open", json!({ "path": "deep.pdf" }))["doc"].as_u64().unwrap(); + for ext in ["html", "docx"] { + let r = ok(&mut a, "doc_export_office", json!({ "doc": doc, "path": format!("deep.{ext}") })); + assert_eq!((&r["truncated"], &r["truncated_pages"]), (&json!(true), &json!([2])), "{r}"); + let warning = r["warnings"][0].as_str().unwrap(); + assert!(warning.contains(&format!("{MAX_FORM_DEPTH} deep")) && warning.contains(&MAX_FORM_VISITS.to_string()), "{warning}"); + assert!(warning.contains("truncated_pages"), "{warning}"); + } + let html = std::fs::read_to_string(dir.join("deep.html")).unwrap(); + let tails: Vec = html.split("
").map(|page| page.matches("TAIL").count()).collect(); + assert_eq!(tails, [1, 0, 1], "{html}"); + assert_eq!(html.matches("Head").count(), 3, "{html}"); + + let fine = ok(&mut a, "doc_open", json!({ "path": "fine.pdf" }))["doc"].as_u64().unwrap(); + let r = ok(&mut a, "doc_export_office", json!({ "doc": fine, "path": "fine.html" })); + assert_eq!((&r["truncated"], &r["truncated_pages"]), (&json!(false), &json!([])), "{r}"); + assert!(r.get("warnings").is_none(), "{r}"); + assert!(std::fs::read_to_string(dir.join("fine.html")).unwrap().contains("TAIL")); +} + /// #773: a diagonal watermark must not enter the body flow or move invoice fields. #[test] fn exporting_an_invoice_to_word_keeps_the_watermark_out_of_flow() { @@ -6737,3 +6805,93 @@ fn attaching_and_deleting_files_through_the_tools() { assert_eq!(ok(&mut a, "doc_info", json!({ "doc": doc }))["attachments"].as_array().unwrap().len(), 3); assert!(a.call("attachment_add", &json!({ "doc": doc, "path": "missing.txt" })).is_err(), "a file that is not there is refused"); } + +#[test] +fn fdf_nested_comment_values_survive_export_import_and_save_through_tools() { + use pdfcraft_cos::{Dict, Document, Object, PdfString, SaveOptions, write_full}; + use std::sync::Arc; + let dir = workdir("fdf-nested-values"); + let mut source = Document::open(Arc::new(fixture(1))).unwrap(); + let page = pdfcraft_model::pages(&source)[0].obj; + let mut note = Dict::new(); + note.set(b"Type".to_vec(), Object::name("Annot")); + note.set(b"Subtype".to_vec(), Object::name("Square")); + note.set(b"NM".to_vec(), PdfString::text("nested-values")); + note.set(b"Rect".to_vec(), Object::Array([10, 20, 30, 40].map(|value| Object::Ref(source.add(Object::Int(value)))).into())); + note.set(b"C".to_vec(), Object::Array([0.25, 0.5, 0.75].map(|value| Object::Ref(source.add(Object::Real(value)))).into())); + let note = source.add(note); + source.update_dict(page, |dict| dict.set(b"Annots".to_vec(), Object::Array(vec![Object::Ref(note)]))).unwrap(); + std::fs::write(dir.join("source.pdf"), write_full(&source, &SaveOptions::default()).unwrap()).unwrap(); + let mut a = auto(&dir); + let source = ok(&mut a, "doc_open", json!({ "path": "source.pdf" }))["doc"].as_u64().unwrap(); + + ok(&mut a, "doc_export_data", json!({ "doc": source, "path": "notes.fdf", "what": "comments" })); + let target = ok(&mut a, "doc_open", json!({ "path": "b.pdf" }))["doc"].as_u64().unwrap(); + ok(&mut a, "doc_import_data", json!({ "doc": target, "path": "notes.fdf" })); + ok(&mut a, "doc_save", json!({ "doc": target, "path": "restored.pdf" })); + let reopened = ok(&mut a, "doc_open", json!({ "path": "restored.pdf" }))["doc"].as_u64().unwrap(); + let after = ok(&mut a, "comment_list", json!({ "doc": reopened })); + // The saved input has legal indirect scalar numbers. The engine's source inspection + // currently reads those as defaults, so compare the restored data to the fixture's + // known values (converted from PDF bottom-left to the tool's top-left coordinates). + assert_eq!(after["comments"][0]["rect"], json!([10.0, 260.0, 30.0, 280.0])); + assert_eq!(after["comments"][0]["color"], "#4080BF"); +} + +#[test] +fn refused_fdf_export_keeps_the_destination_and_source_through_tools() { + use pdfcraft_cos::{Dict, Document, Object, SaveOptions, write_full}; + use std::sync::Arc; + let dir = workdir("fdf-export-limit"); + let mut source = Document::open(Arc::new(fixture(1))).unwrap(); + let page = pdfcraft_model::pages(&source)[0].obj; + let mut note = Dict::new(); + note.set(b"Type".to_vec(), Object::name("Annot")); + note.set(b"Subtype".to_vec(), Object::name("Square")); + note.set(b"Rect".to_vec(), Object::Array(vec![10.into(), 20.into(), 30.into(), 40.into()])); + note.set(b"PCData".to_vec(), Object::Array(vec![Object::Null; 100_001])); + let note = source.add(note); + source.update_dict(page, |dict| dict.set(b"Annots".to_vec(), Object::Array(vec![Object::Ref(note)]))).unwrap(); + let bytes = write_full(&source, &SaveOptions::default()).unwrap(); + std::fs::write(dir.join("source.pdf"), &bytes).unwrap(); + std::fs::write(dir.join("notes.fdf"), b"existing destination").unwrap(); + let mut a = auto(&dir); + let doc = ok(&mut a, "doc_open", json!({ "path": "source.pdf" }))["doc"].as_u64().unwrap(); + let before = ok(&mut a, "comment_list", json!({ "doc": doc })); + let error = a.call("doc_export_data", &json!({ "doc": doc, "path": "notes.fdf", "what": "comments" })).unwrap_err(); + assert!(error.to_string().contains("100,000 values"), "{error}"); + assert_eq!(std::fs::read(dir.join("notes.fdf")).unwrap(), b"existing destination"); + assert_eq!(std::fs::read(dir.join("source.pdf")).unwrap(), bytes); + assert_eq!(ok(&mut a, "comment_list", json!({ "doc": doc })), before); +} + +#[test] +fn text_box_and_callout_fill_and_border_through_tools() { + let dir = workdir("textbox-fill"); + let mut a = auto(&dir); + let doc = ok(&mut a, "doc_open", json!({ "path": "a.pdf" }))["doc"].as_u64().unwrap(); + for kind in ["textbox", "callout"] { + let mut args = json!({ "doc": doc, "page": 1, "type": kind, "rect": [80, 40, 180, 100], "contents": "Example" }); + if kind == "callout" { + args["to"] = json!([20, 120]); + } + let added = ok(&mut a, "comment_add", args); + let comment = &added["comment"]; + ok(&mut a, "comment_edit", json!({ "doc": doc, "page": 1, "index": comment["index"], "fill": "white", "width": 3 })); + } + ok(&mut a, "doc_save", json!({ "doc": doc, "path": "filled.pdf" })); + let mut session = pdfcraft_engine::Session::new(); + let bytes = std::sync::Arc::new(std::fs::read(dir.join("filled.pdf")).unwrap()); + let saved = session.open("filled.pdf", None, bytes, None).unwrap(); + for index in 0..2 { + let props = session.get(saved).unwrap().comment_props(0, index).unwrap(); + assert_eq!((props.fill, props.width), (Some([1.0; 3]), Some(3.0))); + ok(&mut a, "comment_edit", json!({ "doc": doc, "page": 1, "index": index + 1, "fill": "none" })); + } + ok(&mut a, "doc_save", json!({ "doc": doc, "path": "transparent.pdf" })); + let bytes = std::sync::Arc::new(std::fs::read(dir.join("transparent.pdf")).unwrap()); + let saved = session.open("transparent.pdf", None, bytes, None).unwrap(); + for index in 0..2 { + assert_eq!(session.get(saved).unwrap().comment_props(0, index).unwrap().fill, None); + } +} diff --git a/crates/cos/src/parser.rs b/crates/cos/src/parser.rs index c9689d7fee..0458055961 100644 --- a/crates/cos/src/parser.rs +++ b/crates/cos/src/parser.rs @@ -404,7 +404,16 @@ fn parse_indirect_in( if let Object::Dict(dict) = &obj && lx.eat_keyword(b"stream") { - // The keyword is followed by CRLF or LF (a lone CR is tolerated). + // The keyword is followed by CRLF or LF (a lone CR is tolerated). Microsoft Reporting + // Services writes a space first (`stream \r\n`); spaces and tabs count only when a line + // break follows, so they are never taken from the data. + let mut eol = lx.pos; + while matches!(data.get(eol), Some(b' ' | b'\t')) { + eol += 1; + } + if matches!(data.get(eol), Some(b'\r' | b'\n')) { + lx.pos = eol; + } match (data.get(lx.pos), data.get(lx.pos + 1)) { (Some(b'\r'), Some(b'\n')) => lx.pos += 2, (Some(b'\n'), _) | (Some(b'\r'), _) => lx.pos += 1, @@ -554,6 +563,25 @@ mod tests { assert_eq!(s.dict.int(b"Length"), Some(5)); } + #[test] + fn indirect_stream_with_a_space_before_the_line_break() { + // `stream \r\n` as Microsoft Reporting Services writes it: the data starts after the + // line break, so the /Length is still right and the data keeps no leading space. + for eol in [&b" \r\n"[..], b" \n", b"\t \r"] { + let mut data = b"7 0 obj\n<< /Length 5 >>\nstream".to_vec(); + data.extend_from_slice(eol); + data.extend_from_slice(b"HELLO\r\nendstream\nendobj\n"); + let (_, o) = parse_indirect(&data, 0, &|_| None).unwrap(); + let Object::Stream(s) = o else { panic!("stream expected") }; + assert_eq!(&s.raw[..], b"HELLO", "{eol:?}"); + } + // Spaces with no line break after them are data, not a keyword terminator. + let data = b"7 0 obj\n<< /Length 7 >>\nstream HELLO\nendstream\nendobj\n"; + let (_, o) = parse_indirect(data, 0, &|_| None).unwrap(); + let Object::Stream(s) = o else { panic!("stream expected") }; + assert_eq!(&s.raw[..], b" HELLO"); + } + #[test] fn indirect_length_is_resolved() { let data = b"7 0 obj\n<< /Length 8 0 R >>\nstream\r\nAB\r\nendstream\nendobj\n"; diff --git a/crates/edit/src/images.rs b/crates/edit/src/images.rs index 79f2c2cd2a..e90d8e4f15 100644 --- a/crates/edit/src/images.rs +++ b/crates/edit/src/images.rs @@ -300,7 +300,7 @@ pub fn reading_images(doc: &Document, page: usize) -> Result, Edi /// The forms being read (cycle guard) and how many were entered (the page's budget). struct Forms { path: Vec, - visits: usize, + budget: crate::text::FormBudget, } fn walk( doc: &Document, @@ -317,7 +317,7 @@ pub fn reading_images(doc: &Document, page: usize) -> Result, Edi let ctm = st.placement.matrix; if op.is("Do") { let Some(name) = op.name(0) else { continue }; - if let Some(form) = crate::text::form_call(doc, resources, name, ctm, &forms.path, &mut forms.visits) { + if let Some(form) = crate::text::form_call(doc, resources, name, ctm, &forms.path, &mut forms.budget) { if let Some(r) = form.obj { forms.path.push(r); } @@ -367,7 +367,7 @@ pub fn reading_images(doc: &Document, page: usize) -> Result, Edi let res = p.dict.get(b"Resources").map(|r| doc.resolve(r)).and_then(|r| r.as_dict().cloned()).unwrap_or_default(); let mut out = Vec::new(); let mut st = ImageState::new(Matrix::IDENTITY, p.crop(doc), Vec::new()); - let mut forms = Forms { path: Vec::new(), visits: 0 }; + let mut forms = Forms { path: Vec::new(), budget: crate::text::FormBudget::default() }; let pieces = joined(&streams(doc, &p.dict)); walk(doc, &pieces.parse().ops, &res, &mut st, &|op| pieces.piece_of(op.span.start), &mut forms, &mut out)?; Ok(out) diff --git a/crates/edit/src/lib.rs b/crates/edit/src/lib.rs index fcf2f19975..c1062b4105 100644 --- a/crates/edit/src/lib.rs +++ b/crates/edit/src/lib.rs @@ -859,8 +859,8 @@ pub mod indic; mod linebreak; pub mod text; pub use text::{ - BlockStyle, LineEdit, RangeStyle, TextBlock, TextLine, clear_block, reading_blocks, replace_block, replace_line, rewrite_block, text_blocks, - text_lines, + BlockStyle, LineEdit, MAX_FORM_DEPTH, MAX_FORM_VISITS, RangeStyle, ReadingBlocks, TextBlock, TextLine, clear_block, reading_blocks, + reading_blocks_checked, replace_block, replace_line, rewrite_block, text_blocks, text_lines, }; pub mod added; pub use added::{ diff --git a/crates/edit/src/tests.rs b/crates/edit/src/tests.rs index f2def2fd6a..879ebc6c66 100644 --- a/crates/edit/src/tests.rs +++ b/crates/edit/src/tests.rs @@ -904,6 +904,92 @@ fn text_page(content: &str) -> Document { Document::open(Arc::new(out)).unwrap() } +#[test] +fn tracked_text_keeps_words_in_paragraphs_and_objects() { + let expected = "TRACKED SAMPLE TEXT"; + for spacing in [1.5, 2.0001, 3.0] { + for scale in [50.0, 100.0, 150.0] { + for positioned in [false, true] { + let mut show = String::new(); + if !positioned { + show.push('['); + } + for (i, c) in expected.chars().enumerate() { + if positioned { + show.push_str(&format!("({c}) Tj ")); + if i + 1 < expected.len() { + show.push_str(&format!("[{}] TJ ", -spacing * 100.0)); + } + } else { + show.push_str(&format!("({c}) ")); + if i + 1 < expected.len() { + show.push_str(&format!("{} ", -spacing * 100.0)); + } + } + } + if !positioned { + show.push_str("] TJ"); + } + let doc = text_page(&format!("BT /F1 10 Tf {scale} Tz 72 700 Td {show} ET")); + assert_eq!(text::text_lines(&doc, 0).unwrap()[0].text, expected, "{spacing}, {scale}, {positioned}"); + assert_eq!(text::text_blocks(&doc, 0).unwrap()[0].text, expected); + assert_eq!(crate::objects::editable_objects(&doc, 0).unwrap()[0].text.as_deref(), Some(expected)); + } + } + let doc = text_page(&format!("BT /F1 10 Tf {spacing} Tc 72 700 Td ({expected}) Tj ET")); + assert_eq!(text::text_blocks(&doc, 0).unwrap()[0].text, expected); + } +} + +#[test] +fn single_glyph_words_and_cells_are_not_tracking() { + for (content, expected) in [ + ("BT /F1 10 Tf 72 700 Td [(a) -1000 (b) -1000 (c) -1000 (d)] TJ ET", "a b c d"), + ("BT /F1 10 Tf 72 700 Td [(1) -2500 (2) -2500 (3) -2500 (4) -2500 (5)] TJ ET", "1 2 3 4 5"), + ("BT /F1 10 Tf 72 700 Td [(A) -300 (B) -1200 (C) -1200 (D) -1200 (E) -300 (F) -1200 (G)] TJ ET", "A B C D E F G"), + ] { + assert_eq!(text::text_blocks(&text_page(content), 0).unwrap()[0].text, expected, "{content}"); + } + let mut s = String::from("BT /F1 10 Tf "); + for (i, c) in "12345".chars().enumerate() { + s.push_str(&format!("1 0 0 1 {} 700 Tm ({c}) Tj ", 72 + i * 12)); + } + s.push_str("ET"); + assert_eq!(text::text_blocks(&text_page(&s), 0).unwrap()[0].text, "1 2 3 4 5"); +} + +#[test] +fn tracked_text_preserves_larger_word_gaps() { + for show in [ + "[(T) -300 (E) -300 (S) -300 (T) -600 (W) -300 (O) -300 (R) -300 (D)] TJ", + "(T) Tj [-300] TJ (E) Tj [-300] TJ (S) Tj [-300] TJ (T) Tj [-600] TJ (W) Tj [-300] TJ (O) Tj [-300] TJ (R) Tj [-300] TJ (D) Tj", + "[(TEST) -300 (WORD)] TJ", + "(TEST) Tj [-300] TJ (W) Tj [-300] TJ (O) Tj [-300] TJ (R) Tj [-300] TJ (D) Tj", + ] { + let doc = text_page(&format!("BT /F1 10 Tf 72 700 Td {show} ET")); + assert_eq!(text::text_blocks(&doc, 0).unwrap()[0].text, "TEST WORD", "{show}"); + } +} + +#[test] +fn tracked_text_positions_do_not_count_character_spacing_twice() { + let expected = "TRACKED SAMPLE TEXT"; + for char_spacing in [0.0, 2.0] { + for transform in ["1 0 0 1 0 0", "0 1 -1 0 800 0"] { + let mut content = format!("q {transform} cm BT /F1 10 Tf {char_spacing} Tc "); + let mut x = 72.0; + for c in expected.chars() { + content.push_str(&format!("1 0 0 1 {x:.4} 700 Tm ({c}) Tj ")); + x += pdfcraft_fonts::helvetica_width(&c.to_string(), 10.0) + 2.0001; + } + content.push_str("ET Q"); + let doc = text_page(&content); + assert_eq!(text::text_blocks(&doc, 0).unwrap()[0].text, expected); + assert_eq!(crate::objects::editable_objects(&doc, 0).unwrap()[0].text.as_deref(), Some(expected)); + } + } +} + fn descriptor_text_page() -> Document { let content = "BT /F1 12 Tf 72 700 Td (ab) Tj ET"; let objs: Vec = vec![ @@ -2896,6 +2982,88 @@ fn form_xobjects_that_fan_out_stop_at_the_visit_budget() { assert!(start.elapsed() < std::time::Duration::from_secs(30), "took {:?}", start.elapsed()); } +/// #3120: a one-page document whose page draws "Secret", then `content`. `forms` are (name, +/// content) form XObjects, which the page and every form can draw, as they can a 1×1 image `/Im`. +fn page_with_forms(content: &str, forms: &[(String, String)]) -> Document { + let xobjects: String = forms.iter().enumerate().map(|(i, (name, _))| format!("/{name} {} 0 R ", 7 + i)).collect(); + let resources = format!("<< /Font << /F1 5 0 R >> /XObject << /Im 6 0 R {xobjects}>> >>"); + let mut objs = vec![ + "<< /Type /Catalog /Pages 2 0 R >>".to_string(), + "<< /Type /Pages /Kids [3 0 R] /Count 1 >>".to_string(), + format!("<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] /Resources {resources} /Contents 4 0 R >>"), + stream("", &format!("BT /F1 24 Tf 72 700 Td (Secret) Tj ET\n{content}")), + "<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>".to_string(), + stream("/Type /XObject /Subtype /Image /Width 1 /Height 1 /ColorSpace /DeviceGray /BitsPerComponent 8", "A"), + ]; + objs.extend(forms.iter().map(|(_, body)| stream(&format!("/Type /XObject /Subtype /Form /BBox [0 0 612 792] /Resources {resources}"), body))); + build(&objs.iter().map(String::as_str).collect::>()) +} + +/// #3120: export reads text from forms as deep and as often as the renderer draws them, and says +/// when a limit left some out: a text form drawn 5 000 times (past the old budget of 4 096) or +/// forms nested 13 deep (past the old 12) lost the form drawn after them, silently. +#[test] +fn reading_follows_forms_as_far_as_the_renderer_and_says_where_it_stops() { + // Covers "Secret" with a black box and writes "TAIL": drawn last, so a reader that stops + // early misses it. + const TAIL: &str = "0 g 70 692 90 30 re f BT /F1 24 Tf 72 600 Td (TAIL) Tj ET"; + let tail = |doc: &Document| { + let read = text::reading_blocks_checked(doc, 0).unwrap(); + (read.blocks.iter().any(|b| b.text.contains("TAIL")), read.truncated) + }; + let form = |name: &str, body: &str| (name.to_string(), body.to_string()); + + let rows = + |n: usize| page_with_forms(&format!("{}/C Do", "/R Do ".repeat(n)), &[form("R", "BT /F1 6 Tf 300 100 Td (row) Tj ET"), form("C", TAIL)]); + assert_eq!(tail(&rows(5_000)), (true, false)); + + // The innermost form also draws an image: an image at the depth limit is read, and leaves + // nothing out. + let chain = |depth: usize| { + let mut forms: Vec<_> = (1..depth).map(|i| form(&format!("D{i}"), &format!("/D{} Do", i + 1))).collect(); + forms.push(form(&format!("D{depth}"), &format!("{TAIL} q 20 0 0 10 5 5 cm /Im Do Q"))); + page_with_forms("/D1 Do", &forms) + }; + assert_eq!(tail(&chain(13)), (true, false)); + // The renderer's limit, written out so the test does not move with the constant. + assert_eq!(tail(&chain(50)), (true, false)); + assert_eq!(tail(&chain(51)), (false, true)); + let deepest = chain(text::MAX_FORM_DEPTH); + assert_eq!(tail(&deepest), (true, false)); + assert_eq!(images::reading_images(&deepest, 0).unwrap().len(), 1); + let deep = chain(text::MAX_FORM_DEPTH + 1); + assert_eq!(tail(&deep), (false, true)); + assert!(images::reading_images(&deep, 0).unwrap().is_empty()); + + // A wrapper form A draws a dot form B `n` times, then C. A, the dots and C use the page's + // budget up exactly; one dot more leaves C unread. + let fanout = + |n: usize| page_with_forms("/A Do", &[form("A", &format!("{}/C Do", "/B Do ".repeat(n))), form("B", "0 g 10 10 1 1 re f"), form("C", TAIL)]); + assert_eq!(tail(&fanout(text::MAX_FORM_VISITS - 2)), (true, false)); + let many = fanout(text::MAX_FORM_VISITS - 1); + assert_eq!(tail(&many), (false, true)); + assert!(images::reading_images(&many, 0).unwrap().is_empty()); + // Without the flag, reading is unchanged. + let blocks: Vec = text::reading_blocks(&many, 0).unwrap().into_iter().map(|b| b.text).collect(); + assert_eq!(blocks, ["Secret"]); +} + +/// #3120: with the renderer's limits, forms that each draw the next a hundred times, 12 levels +/// deep (100^11 forms) or past the depth cap, still stop at the page's budget quickly, and say +/// that text was left out. +#[test] +fn forms_that_fan_out_a_hundred_ways_stop_quickly_and_say_so() { + for levels in [12, text::MAX_FORM_DEPTH + 1] { + let mut forms: Vec<(String, String)> = (1..levels).map(|i| (format!("N{i}"), format!("/N{} Do ", i + 1).repeat(100))).collect(); + forms.push((format!("N{levels}"), "BT /F1 10 Tf 10 10 Td (leaf) Tj ET".to_string())); + let doc = page_with_forms("/N1 Do", &forms); + let start = std::time::Instant::now(); + assert!(text::reading_blocks_checked(&doc, 0).unwrap().truncated, "{levels} levels"); + assert!(images::reading_images(&doc, 0).unwrap().is_empty()); + assert!(start.elapsed() < std::time::Duration::from_secs(30), "{levels} levels took {:?}", start.elapsed()); + } +} + /// Forms that draw themselves or each other, have no resources, a malformed matrix, or are /// missing are read once (or skipped) without panicking or looping. #[test] @@ -4975,3 +5143,78 @@ fn flattening_comments_keeps_annotations_that_are_not_comments() { let without_widget: Vec<&str> = all.iter().copied().filter(|s| *s != "Widget").collect(); assert_eq!(left(&reopen(&doc)), without_widget, "flattening fields touches no annotation that isn't a widget"); } + +/// A page whose text is a clipping mask (`mode` Tr) with a fill painted through it. +fn clipping_text_page(mode: i64) -> Document { + text_page(&format!("q\nBT {mode} Tr /F1 48 Tf 40 60 Td (MASK) Tj ET\n1 0 0 rg 0 0 300 150 re f\nQ\n")) +} + +/// Rewriting text that is a clipping path destroyed the artwork it masked: the glyphs went, the +/// mask with them, and the fill behind flooded its own rectangle, in a file that still opened +/// without a warning (#3429). Photoshop exports draw every text object this way. The rewrite is +/// refused instead, as `clear_block` already refuses a preview. +#[test] +fn rewriting_clipping_path_text_is_refused_for_every_clipping_mode() { + for mode in [4, 5, 6, 7] { + let before = text::text_lines(&clipping_text_page(mode), 0).unwrap(); + assert_eq!(before.len(), 1, "mode {mode}: the mask is one line"); + + let mut doc = clipping_text_page(mode); + let line = text::replace_line(&mut doc, 0, 0, "EDITED"); + assert!(matches!(&line, Err(EditError::Invalid(m)) if m.contains("clipping path")), "mode {mode}: {line:?}"); + + let mut doc = clipping_text_page(mode); + let block = text::rewrite_block(&mut doc, 0, 0, Some("EDITED"), &BlockStyle::default()); + assert!(matches!(&block, Err(EditError::Invalid(m)) if m.contains("clipping path")), "mode {mode}: {block:?}"); + + // Refused means untouched: the mask is still there to shape what is painted through it. + let after = text::text_lines(&doc, 0).unwrap(); + assert_eq!(after.len(), 1, "mode {mode}"); + assert_eq!(after[0].text, "MASK", "mode {mode}"); + } +} + +/// The modes that paint ink are still editable: only 4 to 7 add to the clip path. +#[test] +fn painting_modes_are_still_rewritten() { + for mode in [0, 1, 2] { + let mut doc = clipping_text_page(mode); + assert!(text::replace_line(&mut doc, 0, 0, "EDITED").is_ok(), "mode {mode}"); + assert_eq!(text::text_lines(&doc, 0).unwrap()[0].text, "EDITED", "mode {mode}"); + } +} + +/// A line's runs need not share a rendering mode: nothing in the join requires it, so a line that +/// starts as ink can continue as a mask. The refusal reads the whole line, not the mode its first +/// run set -- otherwise the rewrite passed the guard and took the mask away with the ink. +#[test] +fn a_line_that_starts_as_ink_and_continues_as_a_mask_is_refused() { + let content = "q\nBT /F1 48 Tf 40 60 Td 0 Tr (TITLE) Tj 7 Tr (MASK) Tj ET\n1 0 0 rg 0 0 300 150 re f\nQ\n"; + let before = text::text_lines(&text_page(content), 0).unwrap(); + assert_eq!(before.len(), 1, "the two runs are one line: {:?}", before.iter().map(|l| &l.text).collect::>()); + assert_eq!(before[0].text, "TITLEMASK", "and the mask is the second half of it"); + + let mut doc = text_page(content); + let line = text::replace_line(&mut doc, 0, 0, "EDITED"); + assert!(matches!(&line, Err(EditError::Invalid(m)) if m.contains("clipping path")), "{line:?}"); + let block = text::rewrite_block(&mut doc, 0, 0, Some("EDITED"), &BlockStyle::default()); + assert!(matches!(&block, Err(EditError::Invalid(m)) if m.contains("clipping path")), "{block:?}"); + assert_eq!(text::text_lines(&doc, 0).unwrap()[0].text, "TITLEMASK", "refused means untouched"); +} + +/// Another drawing of the line on the same baseline goes with it, so that the replacement is all +/// that shows -- and that copy can be the mask while the line being replaced is ink. Dropping it +/// destroys the artwork as surely as rewriting the mask itself, so this is refused too. +#[test] +fn a_clipping_copy_on_the_same_baseline_is_refused() { + let content = "q\nBT 0 Tr /F1 48 Tf 40 60 Td (MASK) Tj ET\nBT 7 Tr /F1 48 Tf 40 60 Td (MASK) Tj ET\n1 0 0 rg 0 0 300 150 re f\nQ\n"; + let before = text::text_lines(&text_page(content), 0).unwrap(); + assert_eq!(before.len(), 2, "two drawings of the same line"); + + let mut doc = text_page(content); + let line = text::replace_line(&mut doc, 0, 0, "EDITED"); + assert!(matches!(&line, Err(EditError::Invalid(m)) if m.contains("clipping path")), "{line:?}"); + let block = text::rewrite_block(&mut doc, 0, 0, Some("EDITED"), &BlockStyle::default()); + assert!(matches!(&block, Err(EditError::Invalid(m)) if m.contains("clipping path")), "{block:?}"); + assert_eq!(text::text_lines(&doc, 0).unwrap().len(), 2, "refused means untouched"); +} diff --git a/crates/edit/src/text.rs b/crates/edit/src/text.rs index 32d63d5c80..2672592178 100644 --- a/crates/edit/src/text.rs +++ b/crates/edit/src/text.rs @@ -68,6 +68,10 @@ pub struct TextLine { /// Some of the line's text is `/ActualText`, which is already in logical order: visual-order /// Indic recovery would scramble it. actual_text: bool, + /// Whether any of the line's runs draws in a clipping mode (`4 Tr` to `7 Tr`). The join below + /// does not require one rendering mode, so a line that starts as ink can continue as a mask; + /// [`refuse_clipping_text`] reads this rather than the origin's mode alone. + clips: bool, } impl TextLine { @@ -635,6 +639,7 @@ struct Shown { glyph_count: usize, /// Advance in text space, including spacing and TJ adjustments. advance: f64, + tracking: f64, /// The text matrix where it starts (text space), and the state there and at its `BT`. tm: Matrix, tlm: Matrix, @@ -702,6 +707,77 @@ impl Pen { } } +/// Use the lower third of letter gaps, not word gaps. A pair of isolated labels is not +/// evidence of tracking; unadjusted neighbours inside a string contribute zero too. +/// The tracking a run of glyphs was set with: the lower third of its gaps, when that is at +/// most half an em (`limit`). A wider "tracking" is word or cell spacing between one-glyph +/// words (`a b c`, a column of digits or Y/N ticks), which must keep its spaces. +fn typical_tracking(gaps: &mut [f64], limit: f64) -> f64 { + if gaps.len() < 3 { + return 0.0; + } + let at = gaps.len() / 3; + gaps.select_nth_unstable_by(at, f64::total_cmp); + gaps.get(at).copied().filter(|n| n.is_finite() && *n <= limit).unwrap_or(0.0).max(0.0) +} + +fn tj_tracking(pieces: &[Object], metrics: &Metrics, size: f64, scale: f64) -> f64 { + let mut gaps = Vec::new(); + let mut previous = false; + let mut gap = 0.0; + for piece in pieces { + if let Object::String(s) = piece { + for (code, len) in metrics.codes(&s.bytes) { + let blank = metrics.is_space(code, len) || metrics.text_of(code).is_some_and(|t| !t.is_empty() && t.chars().all(char::is_whitespace)); + if previous && !blank { + gaps.push(gap); + } + previous = !blank; + gap = 0.0; + } + } else if let Some(n) = piece.as_f64() { + gap += -n / 1000.0 * size * scale; + } + } + typical_tracking(&mut gaps, size * scale.abs() * 0.5) +} + +fn positioned_tracking(shown: &mut [Shown]) { + let mut start = 0; + while start < shown.len() { + let mut end = start.saturating_add(1); + while let (Some(prev), Some(next)) = (shown.get(end.saturating_sub(1)), shown.get(end)) { + if prev.glyph_count != 1 + || next.glyph_count != 1 + || prev.font != next.font + || prev.scope != next.scope + || (prev.size - next.size).abs() > 0.01 + || prev.state.char_spacing != next.state.char_spacing + || prev.state.scale != next.state.scale + || !Pen::after(prev).continues(next, 1.5).0 + { + break; + } + end = end.saturating_add(1); + } + if let Some(run) = shown.get_mut(start..end) { + let mut gaps: Vec = run + .windows(2) + .filter_map(|pair| { + let [prev, next] = pair else { return None }; + (!prev.text.trim().is_empty() && !next.text.trim().is_empty()).then(|| Pen::after(prev).continues(next, 0.0).1) + }) + .collect(); + let limit = run.first().map_or(0.0, |s| s.size * s.state.scale.abs() * 0.5); + let tracking = typical_tracking(&mut gaps, limit); + for s in run.iter_mut().skip(1) { + s.tracking = tracking; + } + } + start = end; + } +} + /// The graphics state carried from one of a page's content streams to the next: the streams /// are one stream in pieces (§7.8.2), so a `cm` (AutoCAD scales the whole page in the first /// stream), an unbalanced `q`, the font and the colour still apply in the streams after it. @@ -883,6 +959,8 @@ fn interpret( b"TJ" => op.operands.first().and_then(Object::as_array).cloned().unwrap_or_default(), _ => op.operands.last().cloned().into_iter().collect(), }; + // Tc is already included in the pen advance; TJ may encode tracking instead. + let tracking = if op.is("TJ") { tj_tracking(&pieces, &m, ts.size, ts.scale) } else { 0.0 }; let trm0 = tm.then(&ts.ctm); let start = trm0.apply(0.0, ts.rise); let mut text = String::new(); @@ -924,12 +1002,12 @@ fn interpret( if let Some(n) = other.as_f64() { let dx = -n / 1000.0 * ts.size * ts.scale; // A large gap inside TJ reads as a space. - if dx > ts.size * 0.2 && !text.ends_with(' ') { + if dx - tracking > (ts.size * ts.scale).abs() * 0.2 && !text.ends_with(' ') { text.push(' '); } - // Kerning keeps a word together; a jump either way of more - // than 0.2 em ends it (whatever the sign of the size or `Tz`). - if dx.abs() > (ts.size * ts.scale).abs() * 0.2 { + // Tracking and kerning keep a word together; a larger jump + // ends it (whatever the sign of the size or `Tz`). + if dx - tracking > (ts.size * ts.scale).abs() * 0.2 || dx < -(ts.size * ts.scale).abs() * 0.2 { in_word = false; } x_text += dx; @@ -976,6 +1054,7 @@ fn interpret( extra_ops: Vec::new(), glyph_count, advance: x_text, + tracking: 0.0, tm: Matrix([tm.0[0], tm.0[1], tm.0[2], tm.0[3], tm.0[4] - x_text * tm.0[0], tm.0[5] - x_text * tm.0[1]]), state: ts.clone(), tlm, @@ -1009,6 +1088,7 @@ fn interpret( carry.ts = ts; carry.stack = stack; carry.marked = marked; + positioned_tracking(&mut out); out } @@ -1057,7 +1137,7 @@ pub fn text_lines(doc: &Document, page: usize) -> Result, EditErro }); if joins && let Some(l) = lines.last_mut() { let gap = last.map_or(0.0, |(_, pen)| pen.continues(&s, 0.0).1); - if gap > l.size * 0.2 && !l.text.ends_with(' ') && !s.text.starts_with(' ') { + if gap - s.tracking > l.size * s.state.scale.abs() * 0.2 && !l.text.ends_with(' ') && !s.text.starts_with(' ') { l.text.push(' '); } l.text.push_str(&s.text); @@ -1069,6 +1149,7 @@ pub fn text_lines(doc: &Document, page: usize) -> Result, EditErro l.decodable &= s.decodable; l.visual_order |= s.visual_order; l.actual_text |= s.actual_text; + l.clips |= s.state.render_mode >= 4; // Spaces may use a separate subset font; they do not change the visible style. } else { lines.push(TextLine { @@ -1089,6 +1170,7 @@ pub fn text_lines(doc: &Document, page: usize) -> Result, EditErro scope: s.scope, logical_order: s.logical_order, actual_text: s.actual_text, + clips: s.state.render_mode >= 4, ops: std::iter::once(s.op).chain(s.extra_ops.iter().copied()).collect(), origin: Origin { tm: s.tm.0, @@ -1147,7 +1229,7 @@ fn add_shown(lines: &mut Vec, last: &mut Option, s: Shown, stream }); if joins && let Some(l) = lines.last_mut() { let gap = last.map_or(0.0, |pen| pen.continues(&s, 0.0).1); - if gap > l.size * 0.2 && !l.text.ends_with(' ') && !s.text.starts_with(' ') { + if gap - s.tracking > l.size * s.state.scale.abs() * 0.2 && !l.text.ends_with(' ') && !s.text.starts_with(' ') { l.text.push(' '); } l.text.push_str(&s.text); @@ -1159,6 +1241,7 @@ fn add_shown(lines: &mut Vec, last: &mut Option, s: Shown, stream l.decodable &= s.decodable; l.visual_order |= s.visual_order; l.actual_text |= s.actual_text; + l.clips |= s.state.render_mode >= 4; } else { lines.push(TextLine { text: s.text.clone(), @@ -1178,6 +1261,7 @@ fn add_shown(lines: &mut Vec, last: &mut Option, s: Shown, stream scope: s.scope, logical_order: s.logical_order, actual_text: s.actual_text, + clips: s.state.render_mode >= 4, ops: std::iter::once(s.op).chain(s.extra_ops.iter().copied()).collect(), origin: Origin { tm: s.tm.0, @@ -1195,13 +1279,27 @@ fn add_shown(lines: &mut Vec, last: &mut Option, s: Shown, stream *last = Some(Pen::after(&s)); } -/// How deep form XObjects nest before reading stops (they may also refer to themselves). -pub(crate) const MAX_FORM_DEPTH: usize = 12; +/// How deep form XObjects nest before reading stops (they may also refer to themselves). The +/// renderer stops at the same depth, so export reads the text the page shows (#3120). +pub const MAX_FORM_DEPTH: usize = 50; /// The most form XObjects one page's walk enters, at any depth. The depth cap alone still lets -/// a form that draws another form many times fan out exponentially (100 `Do`s on each of 12 -/// levels), which would hang export on a hostile PDF. -pub(crate) const MAX_FORM_VISITS: usize = 4096; +/// a form that draws another form many times fan out exponentially (100 `Do`s on each of 50 +/// levels), which would hang export on a hostile PDF. The renderer paints as many forms nested +/// in forms; this budget also counts the forms the page draws itself, so a page that draws one +/// a million times stays bounded too. A label or table cell drawn as a form a few thousand +/// times is read in full (#3120). +pub const MAX_FORM_VISITS: usize = 50_000; + +/// One page's walk through the form XObjects it draws: how many it entered (the page's +/// [`MAX_FORM_VISITS`]), and whether a limit left a form unread. +#[derive(Default)] +pub(crate) struct FormBudget { + visits: usize, + /// A form was left unread because it nests deeper than [`MAX_FORM_DEPTH`] or comes past + /// [`MAX_FORM_VISITS`]: what it draws is missing from the walk. + pub(crate) truncated: bool, +} /// A form XObject a page draws: its object, content, resources and its matrix composed with the /// CTM at the `Do`. @@ -1213,17 +1311,19 @@ pub(crate) struct FormCall { } /// The form XObject `name` in `resources`, drawn with `ctm`. `None` for images, missing names -/// and forms already being read (`path`), nested too deep, or past the page's `visits` budget -/// ([`MAX_FORM_VISITS`]); each form entered counts one visit. +/// and forms already being read (`path`), and for forms nested too deep or past the page's +/// budget, which `budget` records as truncated; each form entered counts one visit. pub(crate) fn form_call( doc: &Document, resources: &Dict, name: &[u8], ctm: Matrix, path: &[pdfcraft_cos::ObjRef], - visits: &mut usize, + budget: &mut FormBudget, ) -> Option { - if path.len() >= MAX_FORM_DEPTH || *visits >= MAX_FORM_VISITS { + let limited = path.len() >= MAX_FORM_DEPTH || budget.visits >= MAX_FORM_VISITS; + // Once a limit has cut the page short, refusing the rest costs nothing more. + if limited && budget.truncated { return None; } let xobjects = resources.get(b"XObject").map(|x| doc.resolve(x)).and_then(|x| x.as_dict().cloned())?; @@ -1236,6 +1336,11 @@ pub(crate) fn form_call( if s.dict.name(b"Subtype") != Some(b"Form") { return None; } + // Only a form left unread loses content; an image at the limit is still read by its caller. + if limited { + budget.truncated = true; + return None; + } let m = s.dict.get(b"Matrix").map(|m| doc.resolve(m)).and_then(|m| { let a = m.as_array()?; let v: Vec = a.iter().filter_map(|x| doc.resolve(x).as_f64()).collect(); @@ -1243,7 +1348,7 @@ pub(crate) fn form_call( }); // A form without its own resources uses the ones it was drawn with (PDF 1.1 files). let own = s.dict.get(b"Resources").map(|r| doc.resolve(r)).and_then(|r| r.as_dict().cloned()); - *visits = visits.saturating_add(1); + budget.visits = budget.visits.saturating_add(1); Some(FormCall { obj, data: s.decoded().unwrap_or_default(), @@ -1254,14 +1359,15 @@ pub(crate) fn form_call( /// The lines a page shows as it reads, including text drawn by form XObjects (which /// [`text_lines`] leaves out because it can only rewrite the page's own streams). Lines from a -/// form carry a stream number past the page's streams, one per form drawn. -fn reading_lines(doc: &Document, page: usize) -> Result, EditError> { +/// form carry a stream number past the page's streams, one per form drawn. The flag is `true` +/// when forms past [`MAX_FORM_DEPTH`] or [`MAX_FORM_VISITS`] were left unread. +fn reading_lines(doc: &Document, page: usize) -> Result<(Vec, bool), EditError> { struct Walk<'a> { doc: &'a Document, lines: Vec, next_stream: usize, path: Vec, - visits: usize, + budget: FormBudget, } /// `stream` gives the stream number of the operator at an index of `ops`. fn walk(w: &mut Walk, ops: &[Op], resources: &Dict, carry: &mut Carry, stream: &dyn Fn(usize) -> usize) { @@ -1291,7 +1397,7 @@ fn reading_lines(doc: &Document, page: usize) -> Result, EditError } } fn enter(w: &mut Walk, resources: &Dict, name: &[u8], ts: Ts) { - let Some(form) = form_call(w.doc, resources, name, ts.ctm, &w.path, &mut w.visits) else { return }; + let Some(form) = form_call(w.doc, resources, name, ts.ctm, &w.path, &mut w.budget) else { return }; let ops = parse(&form.data).ops; // The form starts with the graphics state at its `Do`, its own matrix applied. let mut carry = Carry { ts: Ts { ctm: form.ctm, ..ts }, stack: Vec::new(), marked: Vec::new() }; @@ -1308,7 +1414,7 @@ fn reading_lines(doc: &Document, page: usize) -> Result, EditError let p = page_dict(doc, page)?; let res = p.dict.get(b"Resources").map(|r| doc.resolve(r)).and_then(|r| r.as_dict().cloned()).unwrap_or_default(); let streams = content_streams(doc, &p.dict); - let mut w = Walk { doc, lines: Vec::new(), next_stream: streams.len(), path: Vec::new(), visits: 0 }; + let mut w = Walk { doc, lines: Vec::new(), next_stream: streams.len(), path: Vec::new(), budget: FormBudget::default() }; let mut carry = Carry::new(); // Read the page's streams as the one stream they are, in one pass, as rendering and editing // do: a comment, an operator's operands or a marked-content sequence (its /ActualText too) @@ -1324,7 +1430,7 @@ fn reading_lines(doc: &Document, page: usize) -> Result, EditError l.text = crate::indic::to_logical(&l.text); } lines.retain(|l| !l.text.trim().is_empty()); - Ok(lines) + Ok((lines, w.budget.truncated)) } /// Text → the runs that show it: each run's font resource name and the bytes shown in that font. @@ -1739,11 +1845,33 @@ pub fn replace_line(doc: &mut Document, page: usize, line: usize, text: &str) -> replace_line_with_limit(doc, page, line, text, MAX_TYPE3_GLYPHS) } +/// Refuses a rewrite that would remove text drawn as a clipping path (`4 Tr` to `7 Tr`). Such +/// glyphs are not ink but a mask, and whatever is painted through them takes their shape. +/// Removing the glyphs removes the mask, and the fill or image behind floods its own rectangle +/// instead -- the artwork is destroyed, in a file that still opens without a warning (#3429). +/// `clear_block` already refuses these for the same reason. +/// +/// Every line the rewrite drops is checked, not only the one being replaced: a line's runs need +/// not share a rendering mode (see [`TextLine::clips`]), and [`coincident_ops`] drops a whole +/// overlapping line without regard to its mode -- so a mask could go with text that is ink. +fn refuse_clipping_text(lines: &[TextLine], dropped: &std::collections::HashSet) -> Result<(), EditError> { + if lines.iter().any(|l| l.clips && l.ops.iter().any(|op| dropped.contains(op))) { + return Err(EditError::Invalid("text used as a clipping path cannot be edited: its glyphs mask the artwork behind them".into())); + } + Ok(()) +} + /// [`replace_line`] with the most unique characters one generated font may hold (tests lower it). pub(crate) fn replace_line_with_limit(doc: &mut Document, page: usize, line: usize, text: &str, max_glyphs: usize) -> Result { let text = text.replace(['\n', '\r'], " "); let lines = text_lines(doc, page)?; let target = lines.get(line).cloned().ok_or_else(|| EditError::Invalid(format!("page {} has no line {}", page + 1, line + 1)))?; + // Copies of the line drawn on the same baseline go with it, so the replacement is all that + // shows. Which operators go is settled here, before any font is generated, so that a refusal + // leaves the document untouched. + let mut drops = coincident_ops(&lines, &[&target]); + drops.extend(target.ops.iter().copied()); + refuse_clipping_text(&lines, &drops)?; let p = page_dict(doc, page)?; let mut res = p.dict.get(b"Resources").map(|r| doc.resolve(r)).and_then(|r| r.as_dict().cloned()).unwrap_or_default(); let mut fonts_res = res.get(b"Font").map(|f| doc.resolve(f)).and_then(|f| f.as_dict().cloned()).unwrap_or_default(); @@ -1817,10 +1945,7 @@ pub(crate) fn replace_line_with_limit(doc: &mut Document, page: usize, line: usi } } } - // Rebuild: the line's first operator becomes the replacement, its others go. Copies of the - // line drawn on the same baseline go too, so the replacement is all that shows. - let mut drops = coincident_ops(&lines, &[&target]); - drops.extend(target.ops.iter().copied()); + // Rebuild: the line's first operator becomes the replacement, its others go. let new_data = splice(&pieces, &ops, |i| if i == first { (std::mem::take(&mut replacement), false) } else { (Vec::new(), !drops.contains(&i)) }); let contents = rewritten_contents(doc, &streams, new_data); let page_ref = p.obj; @@ -1940,9 +2065,27 @@ pub fn clear_block(doc: &mut Document, page: usize, block: usize) -> Result<(), /// The paragraphs on a page as a reader sees them, including text drawn by form XObjects /// (Export to Word, HTML and RTF). Read-only: indexes into this list are not /// [`text_blocks`] indexes, so they can't be passed to [`replace_block`] or [`rewrite_block`]. +/// [`reading_blocks_checked`] also says whether text was left out. pub fn reading_blocks(doc: &Document, page: usize) -> Result, EditError> { - let lines = reading_lines(doc, page)?; - Ok(regions::group_reading_blocks(&lines)) + reading_blocks_checked(doc, page).map(|r| r.blocks) +} + +/// A page's paragraphs as a reader sees them ([`reading_blocks_checked`]). +#[derive(Clone, Debug, Default, PartialEq)] +pub struct ReadingBlocks { + /// The paragraphs, as [`reading_blocks`] returns them. + pub blocks: Vec, + /// The page draws forms nested deeper than [`MAX_FORM_DEPTH`], or more of them than + /// [`MAX_FORM_VISITS`]. Reading leaves those out to stay bounded on hostile files, so their + /// text is missing from `blocks`; [`crate::reading_images`] walks the same forms and misses + /// their images too. + pub truncated: bool, +} + +/// [`reading_blocks`], saying whether the limits that bound reading left text out. +pub fn reading_blocks_checked(doc: &Document, page: usize) -> Result { + let (lines, truncated) = reading_lines(doc, page)?; + Ok(ReadingBlocks { blocks: regions::group_reading_blocks(&lines), truncated }) } // An ideographic space carries intentional Japanese spacing, including paragraph indentation. @@ -2063,6 +2206,10 @@ fn rewrite_block_from( let blocks = group_blocks(&lines); let b = blocks.get(block).cloned().ok_or_else(|| EditError::Invalid(format!("page {} has no paragraph {}", page + 1, block + 1)))?; let members: Vec<&TextLine> = b.lines.iter().map(|i| &lines[*i]).collect(); + // One clipping run is enough: rewriting the paragraph would drop its mask with the rest. + let mut dropped = coincident_ops(&lines, &members); + dropped.extend(members.iter().flat_map(|l| l.ops.iter().copied())); + refuse_clipping_text(&lines, &dropped)?; let first = members.first().copied().ok_or_else(|| EditError::Invalid("the paragraph has no source runs".into()))?; let p = page_dict(doc, page)?; let mut res = p.dict.get(b"Resources").map(|r| doc.resolve(r)).and_then(|r| r.as_dict().cloned()).unwrap_or_default(); diff --git a/crates/engine/src/compare.rs b/crates/engine/src/compare.rs index 3c03870f34..f0ed2b4b84 100644 --- a/crates/engine/src/compare.rs +++ b/crates/engine/src/compare.rs @@ -4,6 +4,9 @@ use std::sync::Arc; pub use pdfcraft_compare::{Change, Comparison, Kind, Side}; +// How far Office export reads into form XObjects; `OfficeExport::truncated_pages` lists the pages +// that draw more. +pub use pdfcraft_edit::{MAX_FORM_DEPTH, MAX_FORM_VISITS}; use crate::{DocId, Edit, EditError, Markup, NewAnnotation, NoteIcon, Session, Shape}; @@ -151,12 +154,31 @@ impl OfficeFormat { } } +/// A Word, PowerPoint, HTML or RTF export ([`crate::Document::export_office_checked`]). +#[derive(Clone, Debug, Default, PartialEq)] +pub struct OfficeExport { + /// The exported file. + pub bytes: Vec, + /// Pages (0-based) that draw forms nested deeper than [`MAX_FORM_DEPTH`] or more of them + /// than [`MAX_FORM_VISITS`]: export stops reading there to stay bounded on hostile files, so + /// the text and images of those forms are missing from `bytes`. + pub truncated_pages: Vec, +} + impl crate::Document { /// The pages as paragraphs and images (for Word, HTML and RTF export), including what /// form XObjects draw. pub fn export_pages(&self) -> Vec { - let Some(cos) = self.editor.as_ref().map(|e| &e.cos) else { return Vec::new() }; - self.info + self.export_pages_checked().0 + } + + /// [`Self::export_pages`], and the pages whose forms were left partly unread + /// ([`OfficeExport::truncated_pages`]). + fn export_pages_checked(&self) -> (Vec, Vec) { + let Some(cos) = self.editor.as_ref().map(|e| &e.cos) else { return (Vec::new(), Vec::new()) }; + let mut truncated = Vec::new(); + let pages = self + .info .pages .iter() .enumerate() @@ -171,8 +193,12 @@ impl crate::Document { let crop_width = f64::from(info.crop[2] - info.crop[0]); let display_width = f64::from(if info.rotation % 180 == 0 { info.width } else { info.height }); let unit = if crop_width > 0.0 && crop_width.is_finite() && display_width.is_finite() { display_width / crop_width } else { 1.0 }; - let blocks = pdfcraft_edit::reading_blocks(cos, i) - .unwrap_or_default() + let reading = pdfcraft_edit::reading_blocks_checked(cos, i).unwrap_or_default(); + if reading.truncated { + truncated.push(i); + } + let blocks = reading + .blocks .into_iter() .filter(|b| !b.text.trim().is_empty()) .map(|b| { @@ -204,18 +230,26 @@ impl crate::Document { .collect(); pdfcraft_export::Page { width: info.width as f64, height: info.height as f64, blocks, images } }) - .collect() + .collect(); + (pages, truncated) } /// The document as a Word, PowerPoint, HTML or RTF file. pub fn export_office(&self, format: OfficeFormat) -> Vec { - let pages = self.export_pages(); + self.export_office_checked(format).bytes + } + + /// [`Self::export_office`], and the pages whose text it had to leave out + /// ([`OfficeExport::truncated_pages`]). + pub fn export_office_checked(&self, format: OfficeFormat) -> OfficeExport { + let (pages, truncated_pages) = self.export_pages_checked(); let title = self.info.title.clone().unwrap_or_else(|| self.name.trim_end_matches(".pdf").to_string()); - match format { + let bytes = match format { OfficeFormat::Docx => pdfcraft_export::docx(&pages, &title), OfficeFormat::Pptx => pdfcraft_export::pptx(&pages, &title), OfficeFormat::Html => pdfcraft_export::html(&pages, &title).into_bytes(), OfficeFormat::Rtf => pdfcraft_export::rtf(&pages).into_bytes(), - } + }; + OfficeExport { bytes, truncated_pages } } } diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 255077ce80..bfd623580c 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -4364,7 +4364,7 @@ impl Session { } guard(|| match format { DataFormat::Xfdf => Ok(pdfcraft_xfdf::export_xfdf(&cos, comments, fields, &doc.name).into_bytes()), - DataFormat::Fdf => Ok(pdfcraft_xfdf::export_fdf(&cos, comments, fields, &doc.name)), + DataFormat::Fdf => pdfcraft_xfdf::export_fdf(&cos, comments, fields, &doc.name).map_err(|e| e.to_string()), DataFormat::Xml | DataFormat::Xdp if xfa => { pdfcraft_xfa::export_data(&cos, format == DataFormat::Xdp, &doc.name).map(String::into_bytes).map_err(|e| e.to_string()) } diff --git a/crates/forms/src/af.rs b/crates/forms/src/af.rs index 129f7fa751..c5b81719ac 100644 --- a/crates/forms/src/af.rs +++ b/crates/forms/src/af.rs @@ -9,6 +9,7 @@ //! writes them back when a form is authored. Other scripts are kept untouched and reported as //! unsupported; they need the JavaScript engine (M6.4). +use std::collections::VecDeque; use std::fmt::Write as _; /// The Format tab. @@ -545,24 +546,55 @@ fn weekday(y: i32, m: u32, d: u32) -> usize { (y + y / 4 - y / 100 + y / 400 + t + i64::from(d)).rem_euclid(7) as usize } -/// The order of the year/month/day fields in a format, as letters. A run of literal digits -/// (the fixed year in "dd.mm.26") is `'#'` with its value; the others carry 0. -fn order(fmt: &str) -> Vec<(char, u32)> { - let mut out: Vec<(char, u32)> = Vec::new(); +/// One field of a date format, in the order the format has them. +struct Part { + /// `'y'`, `'m'`, `'d'`, `'H'`, `'M'` or `'s'`; a run of literal digits (the fixed year in + /// "dd.mm.26") is `'#'`. + kind: char, + /// The value of a `'#'` run; 0 for the others. + literal: u32, + /// The digits the field takes when typed run together with the next one: 4 for "yyyy", 2 for + /// "yy", "mm", "dd", "HH", "MM" and "ss". 0 when the width varies ("m", "d") or the field is + /// a name ("mmm"). + width: usize, + /// The next field follows with nothing between them ("yyyymmdd"), so one run of typed digits + /// can hold both. + joined: bool, +} + +/// The fields of a format (year, month, day, hours, minutes, seconds and literal digits), in +/// order. +fn order(fmt: &str) -> Vec { + let mut out: Vec = Vec::new(); // Weekday names (ddd, dddd) are shown, not typed: leave them out. let mut cleaned = fmt.replace("dddd", " "); cleaned = cleaned.replace("ddd", " "); let mut prev = ' '; + // The letter of the last field's current run and its length so far. + let mut run: Option<(char, usize)> = None; + let fixed = |k: char, n: usize| match (k, n) { + ('y', 2 | 4) | ('m' | 'd' | 'H' | 'M' | 's', 2) => n, + _ => 0, + }; for c in cleaned.chars() { + let continues = run.is_some_and(|(r, _)| r == c); + if let Some((r, n)) = run + && !continues + { + // The run ended; it was the last field's width. + if let Some(last) = out.last_mut().filter(|p| p.kind != '#') { + last.width = fixed(if r == 'h' { 'H' } else { r }, n); + } + run = None; + } if let Some(digit) = c.to_digit(10) { match out.last_mut() { - Some(('#', n)) if prev.is_ascii_digit() => *n = n.saturating_mul(10).saturating_add(digit), - _ => out.push(('#', digit)), + Some(p) if p.kind == '#' && prev.is_ascii_digit() => p.literal = p.literal.saturating_mul(10).saturating_add(digit), + _ => out.push(Part { kind: '#', literal: digit, width: 0, joined: false }), } prev = c; continue; } - prev = c; let k = match c { 'y' => 'y', 'm' => 'm', @@ -570,11 +602,28 @@ fn order(fmt: &str) -> Vec<(char, u32)> { 'H' | 'h' => 'H', 'M' => 'M', 's' => 's', - _ => continue, + _ => { + prev = c; + continue; + } }; - if out.last().map(|&(l, _)| l) != Some(k) { - out.push((k, 0)); + if continues { + run = run.map(|(r, n)| (r, n.saturating_add(1))); + } else { + // A field starting right where the last one's letters stopped is joined to it. + let adjacent = matches!(prev, 'y' | 'm' | 'd' | 'H' | 'h' | 'M' | 's'); + if out.last().map(|p| p.kind) != Some(k) { + if let Some(last) = out.last_mut().filter(|p| p.kind != '#') { + last.joined = adjacent; + } + out.push(Part { kind: k, literal: 0, width: 0, joined: false }); + } + run = Some((c, 1)); } + prev = c; + } + if let (Some((r, n)), Some(last)) = (run, out.last_mut().filter(|p| p.kind != '#')) { + last.width = fixed(if r == 'h' { 'H' } else { r }, n); } out } @@ -591,16 +640,17 @@ pub fn parse_date(text: &str, fmt: &str) -> Option { let bare_p = lower.strip_suffix('p').is_some_and(|rest| rest.trim_end().ends_with(|c: char| c.is_ascii_digit())); let pm = lower.contains("pm") || bare_p; let am = lower.contains("am"); - // Tokens: numbers and month names. - let mut nums: Vec = Vec::new(); + // Tokens: runs of digits (kept as text, so a run can be split between fields typed without + // separators) and month names. + let mut nums: VecDeque = VecDeque::new(); let mut month_name: Option = None; let mut cur = String::new(); - let flush = |cur: &mut String, nums: &mut Vec, month: &mut Option| { + let flush = |cur: &mut String, nums: &mut VecDeque, month: &mut Option| { if cur.is_empty() { return; } - if let Ok(n) = cur.parse::() { - nums.push(n); + if cur.starts_with(|c: char| c.is_ascii_digit()) { + nums.push_back(std::mem::take(cur)); } else if cur.len() >= 3 && let Some(i) = MONTHS.iter().position(|m| m.to_lowercase().starts_with(&cur.to_lowercase())) { @@ -622,34 +672,76 @@ pub fn parse_date(text: &str, fmt: &str) -> Option { } flush(&mut cur, &mut nums, &mut month_name); let mut dt = DateTime { y: 0, m: 1, d: 1, hh: 0, mm: 0, ss: 0 }; - let mut it = nums.into_iter().peekable(); + // The next number for a field. A field joined to the next one in the format ("yyyymmdd") + // takes only its own width from a longer run of digits and leaves the rest for the next. + // A run typed on its own is split only when fewer runs were typed than the fields of its kind + // still to fill (`need`, this one included: date fields for a date field, time fields for a + // time field, since times may be left out), and only when its rest can fill the next field + // (`next_width`): with separators each run is one field's number, so "2026-10-18" keeps its + // four-digit year in a yymmdd or yymmddHHMM field, and "2026 10 018" is the 18th. What is left + // of a split run (`tail`: the front of `nums` is one) goes on being split along the fields + // written side by side, and must fill its last field exactly, so "202610018" leaves "018" for + // dd and is refused. `None` when there is no number left, it doesn't fit a u32, or such a + // rest has the wrong width. + let next_number = |nums: &mut VecDeque, tail: &mut bool, part: &Part, need: usize, next_width: usize| -> Option { + let run = nums.pop_front()?; + let was_tail = std::mem::replace(tail, false); + let short = nums.len() < need.saturating_sub(1); + let digits = match (run.get(..part.width), run.get(part.width..)) { + (Some(head), Some(rest)) if part.joined && part.width > 0 && !rest.is_empty() && (was_tail || (short && rest.len() >= next_width)) => { + nums.push_front(rest.to_string()); + *tail = true; + head + } + _ if was_tail && part.width > 0 && run.len() != part.width => return None, + _ => run.as_str(), + }; + digits.parse::().ok() + }; + let parts = order(fmt); + let takes_number = |p: &Part| matches!(p.kind, 'y' | 'd' | 'H' | 'M' | 's') || (p.kind == 'm' && month_name.is_none()); + let is_time = |p: &Part| matches!(p.kind, 'H' | 'M' | 's'); + // The fields of each kind still to fill: [date, time]. + let mut wanted = [parts.iter().filter(|p| takes_number(p) && !is_time(p)).count(), parts.iter().filter(|p| is_time(p)).count()]; + let mut tail = false; let mut have_y = false; - for (k, literal) in order(fmt) { - match k { + for (i, part) in parts.iter().enumerate() { + let kind = usize::from(is_time(part)); + let need = wanted.get(kind).copied().unwrap_or(0); + let next_width = parts.get(i + 1).map_or(0, |p| p.width); + if takes_number(part) + && let Some(left) = wanted.get_mut(kind) + { + *left = left.saturating_sub(1); + } + let take = |nums: &mut VecDeque, tail: &mut bool| next_number(nums, tail, part, need, next_width); + match part.kind { // Literal digits in the format may be typed or left out ("15.05.26" or "15.05" // for "dd.mm.26"); a different number there is left over and rejected below. - '#' if it.peek() == Some(&literal) => { - it.next(); + '#' if nums.front().and_then(|n| n.parse::().ok()) == Some(part.literal) => { + nums.pop_front(); + tail = false; } 'm' => { dt.m = match month_name { Some(m) => m, - None => it.next()?, + None => take(&mut nums, &mut tail)?, } } - 'd' => dt.d = it.next()?, + 'd' => dt.d = take(&mut nums, &mut tail)?, 'y' => { - let y = i32::try_from(it.next()?).ok()?; + let y = i32::try_from(take(&mut nums, &mut tail)?).ok()?; dt.y = if y < 100 { if y < 50 { 2000 + y } else { 1900 + y } } else { y }; have_y = true; } - 'H' => dt.hh = it.next().unwrap_or(0), - 'M' => dt.mm = it.next().unwrap_or(0), - 's' => dt.ss = it.next().unwrap_or(0), + // Times may be left out; a number that is there must be readable. + 'H' => dt.hh = if nums.is_empty() { 0 } else { take(&mut nums, &mut tail)? }, + 'M' => dt.mm = if nums.is_empty() { 0 } else { take(&mut nums, &mut tail)? }, + 's' => dt.ss = if nums.is_empty() { 0 } else { take(&mut nums, &mut tail)? }, _ => {} } } - if it.next().is_some() { + if !nums.is_empty() { return None; } if !have_y { @@ -1309,3 +1401,96 @@ mod pm_marker_tests { assert_eq!(parse_date("05/09/2026 5:30 pm", "mm/dd/yyyy h:MM").map(|d| d.hh), Some(17)); } } + +#[cfg(test)] +mod undelimited_tests { + use super::*; + + fn ymd(dt: Option) -> Option<(i32, u32, u32)> { + dt.map(|d| (d.y, d.m, d.d)) + } + + /// Fields written side by side in the format ("yyyymmdd") take their fixed widths from one + /// run of typed digits. Every such date used to be refused, because the run was read as a + /// single number. + #[test] + fn digits_typed_without_separators_fill_adjacent_fields() { + assert_eq!(ymd(parse_date("20261018", "yyyymmdd")), Some((2026, 10, 18))); + assert_eq!(ymd(parse_date("261018", "yymmdd")), Some((2026, 10, 18))); + assert_eq!(ymd(parse_date("10182026", "mmddyyyy")), Some((2026, 10, 18))); + assert_eq!(ymd(parse_date("18102026", "ddmmyyyy")), Some((2026, 10, 18))); + let f = Format::Date("yyyymmdd".into()); + assert_eq!(keystroke(&f, "Date", "20261018"), Ok("20261018".into())); + assert_eq!(format_value(&f, "20261018"), "20261018"); + } + + #[test] + fn separators_are_still_accepted_in_an_undelimited_format() { + assert_eq!(ymd(parse_date("2026-10-18", "yyyymmdd")), Some((2026, 10, 18))); + assert_eq!(ymd(parse_date("2026 1018", "yyyymmdd")), Some((2026, 10, 18))); + } + + /// Separators mean one number per field, as before: a four-digit year typed with them stays + /// whole in a two-digit-year format. Only a run covering several fields is split. + #[test] + fn a_run_is_split_only_when_it_has_to_fill_several_fields() { + assert_eq!(ymd(parse_date("2026-10-18", "yymmdd")), Some((2026, 10, 18))); + assert_eq!(ymd(parse_date("2026-10", "yymm")), Some((2026, 10, 1))); + assert_eq!(ymd(parse_date("2610", "yymm")), Some((2026, 10, 1))); + assert_eq!(ymd(parse_date("202610 18", "yyyymmdd")), Some((2026, 10, 18))); + assert_eq!(ymd(parse_date("18Oct2026", "ddmmmyyyy")), Some((2026, 10, 18))); + } + + #[test] + fn impossible_undelimited_dates_are_refused() { + let f = Format::Date("yyyymmdd".into()); + assert!(keystroke(&f, "Date", "20261340").is_err(), "no month 13"); + assert!(keystroke(&f, "Date", "20260230").is_err(), "no 30 February"); + assert!(keystroke(&f, "Date", "2026101899").is_err(), "digits left over make no day"); + assert!(keystroke(&f, "Date", "2026").is_err(), "a year alone is not a date"); + } + + /// What is left of a split run fills its field exactly: "202610018" leaves "018" for the + /// day, one digit too many, and used to pass as 18 October. Numbers typed on their own + /// keep their leeway. + #[test] + fn the_rest_of_a_split_run_must_fill_its_field_exactly() { + assert_eq!(parse_date("202610018", "yyyymmdd"), None, "one digit too many"); + assert_eq!(parse_date("2026101", "yyyymmdd"), None, "one digit too few"); + assert_eq!(parse_date("2026101814305", "yyyymmddHHMM"), None, "one digit too many in the time"); + assert_eq!(ymd(parse_date("2026 10 018", "yyyymmdd")), Some((2026, 10, 18)), "typed on its own, 018 is still a day"); + assert_eq!(parse_date("202610181430", "yyyymmddHHMM").map(|d| (d.d, d.hh, d.mm)), Some((18, 14, 30))); + } + + /// Times may be left out, so a number typed on its own isn't split for them: "018" is the + /// day in "2026 10 018" even when the format goes on to HHMM. + #[test] + fn a_number_typed_on_its_own_is_split_only_when_the_rest_fills_the_next_field() { + let dt = parse_date("2026 10 018", "yyyymmddHHMM").expect("a date with the time left out"); + assert_eq!((dt.y, dt.m, dt.d, dt.hh, dt.mm), (2026, 10, 18, 0, 0)); + assert_eq!(ymd(parse_date("20261018", "yyyymmddHHMM")), Some((2026, 10, 18))); + assert_eq!(parse_date("2026 10 18 1430", "yyyymmdd HHMM").map(|d| (d.hh, d.mm)), Some((14, 30))); + } + + /// The date's runs are matched against the date's fields, the time's against the time's: + /// optional times left out don't make a separated four-digit year look like it needs + /// splitting, and a time typed in one run still fills HHMM. + #[test] + fn date_and_time_fields_are_counted_apart() { + let dt = parse_date("2026-10-18", "yymmddHHMM").expect("a date with the time left out"); + assert_eq!((dt.y, dt.m, dt.d, dt.hh, dt.mm), (2026, 10, 18, 0, 0)); + assert_eq!(parse_date("20261018 1430", "yyyymmddHHMM").map(|d| (d.y, d.m, d.d, d.hh, d.mm)), Some((2026, 10, 18, 14, 30))); + assert_eq!(parse_date("2026-10-18 1430", "yyyy-mm-dd HHMM").map(|d| (d.hh, d.mm)), Some((14, 30))); + assert_eq!(parse_date("20261018143005", "yyyymmddHHMMss").map(|d| (d.d, d.hh, d.mm, d.ss)), Some((18, 14, 30, 5))); + } + + /// A run of more digits than fit in a u32 ("yyyymmddHHMMss") is split, not dropped. + #[test] + fn long_runs_of_digits_split_into_dates_and_times() { + let dt = parse_date("20261018143005", "yyyymmddHHMMss").expect("valid date and time"); + assert_eq!((dt.y, dt.m, dt.d, dt.hh, dt.mm, dt.ss), (2026, 10, 18, 14, 30, 5)); + assert_eq!(parse_date("1430", "HHMM").map(|d| (d.hh, d.mm)), Some((14, 30))); + assert_eq!(parse_date("99999999999999999999", "yyyymmdd"), None); + assert_eq!(parse_date("2026 99999999999", "yyyy"), None, "an unreadable number is left over, not ignored"); + } +} diff --git a/crates/oracle/src/lib.rs b/crates/oracle/src/lib.rs index e9e7ae372e..16c7192b17 100644 --- a/crates/oracle/src/lib.rs +++ b/crates/oracle/src/lib.rs @@ -23,6 +23,8 @@ //! This crate is test scaffolding (`Class::Testkit` in `xtask/src/layers.rs`): a dev-dependency //! only, never compiled into the product. +#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] + pub mod pixels; use std::fmt; diff --git a/crates/oracle/src/pixels.rs b/crates/oracle/src/pixels.rs index 2b11719819..4ff0ebaf13 100644 --- a/crates/oracle/src/pixels.rs +++ b/crates/oracle/src/pixels.rs @@ -201,6 +201,10 @@ pub fn decode_pam_gray(bytes: &[u8]) -> Option<(usize, usize, Vec)> { } fn find(haystack: &[u8], needle: &[u8]) -> Option { + // `windows(0)` panics. + if needle.is_empty() { + return None; + } haystack.windows(needle.len()).position(|w| w == needle) } @@ -404,6 +408,14 @@ mod tests { assert_eq!((w, h, px.len()), (4, 2, 8)); } + /// An empty needle is not found, rather than panicking in `windows(0)`. + #[test] + fn find_with_an_empty_needle_is_none() { + assert_eq!(find(b"abc", b""), None); + assert_eq!(find(b"", b""), None); + assert_eq!(find(b"abcabc", b"ca"), Some(2)); + } + /// Malformed or hostile headers return `None` rather than panicking or inventing a size. #[test] fn hostile_pgm_headers_are_rejected() { diff --git a/crates/preflight/Cargo.toml b/crates/preflight/Cargo.toml index b79575fc0b..082e44fc5c 100644 --- a/crates/preflight/Cargo.toml +++ b/crates/preflight/Cargo.toml @@ -14,3 +14,7 @@ workspace = true pdfcraft-content = { workspace = true } pdfcraft-cos = { workspace = true } pdfcraft-model = { workspace = true } +roxmltree = "0.20" + +[dev-dependencies] +flate2 = { workspace = true } diff --git a/crates/preflight/src/lib.rs b/crates/preflight/src/lib.rs index a4d90d874b..2cda29fea1 100644 --- a/crates/preflight/src/lib.rs +++ b/crates/preflight/src/lib.rs @@ -165,7 +165,7 @@ fn metadata(doc: &Document) -> Option { let cat = catalog(doc); let m = doc.resolve(cat.get(b"Metadata")?); match &*m { - Object::Stream(s) => s.decoded().ok().map(|b| String::from_utf8_lossy(&b).into_owned()), + Object::Stream(s) => s.decoded_within(xmp::MAX_XMP_BYTES).ok().map(|b| String::from_utf8_lossy(&b).into_owned()), _ => None, } } @@ -174,10 +174,10 @@ fn metadata(doc: &Document) -> Option { pub fn declared(doc: &Document) -> Declared { let mut d = Declared::default(); if let Some(x) = metadata(doc) { - let part = xmp::value(&x, "pdfaid:part").and_then(|p| p.parse().ok()); - let conf = xmp::value(&x, "pdfaid:conformance").unwrap_or_default(); + let part = xmp::identifier(&x, xmp::PDFA_ID, "part").and_then(|p| p.parse().ok()); + let conf = xmp::identifier(&x, xmp::PDFA_ID, "conformance").unwrap_or_default(); d.pdfa = part.map(|p| (p, conf.to_uppercase())); - d.pdfua = xmp::value(&x, "pdfuaid:part").and_then(|p| p.parse().ok()); + d.pdfua = xmp::identifier(&x, xmp::PDFUA_ID, "part").and_then(|p| p.parse().ok()); } let cat = catalog(doc); if let Some(list) = cat.get(b"OutputIntents").map(|o| doc.resolve(o)).and_then(|o| o.as_array().cloned()) { @@ -423,8 +423,8 @@ pub fn verify(doc: &Document, level: Level) -> Vec { match metadata(doc) { None => issue("6.6.2.1", "The document has no XMP metadata", None, None, true), Some(x) => { - let part = xmp::value(&x, "pdfaid:part"); - let conf = xmp::value(&x, "pdfaid:conformance"); + let part = xmp::identifier(&x, xmp::PDFA_ID, "part"); + let conf = xmp::identifier(&x, xmp::PDFA_ID, "conformance"); if part.as_deref() != Some(&level.part().to_string()) || conf.as_deref().map(str::to_uppercase).as_deref() != Some("B") { issue("6.6.4", "The metadata doesn't identify the file as {}", Some(level.label().to_owned()), None, true); } diff --git a/crates/preflight/src/tests.rs b/crates/preflight/src/tests.rs index 25d06669ed..80b1c63c49 100644 --- a/crates/preflight/src/tests.rs +++ b/crates/preflight/src/tests.rs @@ -371,3 +371,49 @@ fn device_colour_in_forms_patterns_and_glyphs_is_found() { assert!(declared(&doc).output_intents.is_empty(), "{what}: an sRGB output intent was added to a CMYK document: {:?}", r.fixed); } } + +#[test] +fn standards_identification_uses_namespace_not_prefix() { + fn with_xmp(xmp: &str) -> Document { + let stream = format!("<< /Type /Metadata /Subtype /XML /Length {} >>\nstream\n{xmp}\nendstream", xmp.len()); + build(&["<< /Type /Catalog /Pages 2 0 R /Metadata 3 0 R >>", "<< /Type /Pages /Kids [] /Count 0 >>", &stream], "") + } + let attribute = r#""#; + let element = r#"2B"#; + for xml in [attribute, element] { + let doc = with_xmp(xml); + assert_eq!(declared(&doc).pdfa, Some((2, "B".into()))); + assert!(!verify(&doc, Level::A2b).iter().any(|i| i.clause == "6.6.4")); + } + assert_eq!(declared(&with_xmp(attribute)).pdfua, Some(1)); + let wrong = r#""#; + let doc = with_xmp(wrong); + assert_eq!(declared(&doc).pdfa, None); + assert!(verify(&doc, Level::A2b).iter().any(|i| i.clause == "6.6.4")); + let nested = format!("{}{}", "".repeat(1100), "".repeat(1100)); + assert_eq!(declared(&with_xmp(&nested)).pdfa, None); +} + +#[test] +fn oversized_xmp_is_refused_while_decoding() { + let xmp = " ".repeat(xmp::MAX_XMP_BYTES + 1); + let stream = format!("<< /Type /Metadata /Subtype /XML /Length {} >>\nstream\n{xmp}\nendstream", xmp.len()); + let doc = build(&["<< /Type /Catalog /Pages 2 0 R /Metadata 3 0 R >>", "<< /Type /Pages /Kids [] /Count 0 >>", &stream], ""); + assert!(metadata(&doc).is_none()); + assert_eq!(declared(&doc).pdfa, None); + + use std::io::Write as _; + let mut z = flate2::write::ZlibEncoder::new(Vec::new(), flate2::Compression::default()); + z.write_all(xmp.as_bytes()).unwrap(); + let compressed = z.finish().unwrap(); + let hex: String = compressed.iter().map(|byte| format!("{byte:02X}")).collect(); + let data = format!("{hex}>"); + let stream = + format!("<< /Type /Metadata /Subtype /XML /Filter [/ASCIIHexDecode /FlateDecode] /Length {} >>\nstream\n{data}\nendstream", data.len()); + let doc = build(&["<< /Type /Catalog /Pages 2 0 R /Metadata 3 0 R >>", "<< /Type /Pages /Kids [] /Count 0 >>", &stream], ""); + let cat = catalog(&doc); + let raw = doc.resolve(cat.get(b"Metadata").unwrap()); + let Object::Stream(stream) = &*raw else { panic!("fixture has no metadata stream") }; + assert_eq!(stream.decoded().unwrap().len(), xmp::MAX_XMP_BYTES + 1, "fixture must actually decompress"); + assert!(metadata(&doc).is_none(), "compressed metadata must be limited during decoding"); +} diff --git a/crates/preflight/src/xmp.rs b/crates/preflight/src/xmp.rs index 3057213d1a..0da4869cc6 100644 --- a/crates/preflight/src/xmp.rs +++ b/crates/preflight/src/xmp.rs @@ -3,6 +3,116 @@ use pdfcraft_cos::Document; +/// The largest XMP packet read; a larger one is treated as missing. +pub(crate) const MAX_XMP_BYTES: usize = 8 * 1024 * 1024; +/// Namespace URIs of the PDF/A and PDF/UA identification schemas. +pub(crate) const PDFA_ID: &str = "http://www.aiim.org/pdfa/ns/id/"; +pub(crate) const PDFUA_ID: &str = "http://www.aiim.org/pdfua/ns/id/"; + +/// Read an identification property by its expanded XML name, not its arbitrary prefix. A packet +/// that is not well-formed XML (an undeclared prefix, an illegal character) or is too deep or wide +/// to parse safely is read by the conventional prefix (`pdfaid:`, `pdfuaid:`) instead, as before. +pub(crate) fn identifier(xmp: &str, namespace: &str, local: &str) -> Option { + if xmp.len() > MAX_XMP_BYTES { + return None; + } + match parsed_identifier(xmp, namespace, local) { + Ok(found) => found, + Err(()) => { + let prefix = match namespace { + PDFA_ID => "pdfaid", + PDFUA_ID => "pdfuaid", + _ => return None, + }; + value(xmp, &format!("{prefix}:{local}")) + } + } +} + +/// [`identifier`] through an XML parser; `Err` when the packet can't be parsed within the limits. +fn parsed_identifier(xmp: &str, namespace: &str, local: &str) -> Result, ()> { + if !xml_limits_ok(xmp) { + return Err(()); + } + let options = roxmltree::ParsingOptions { allow_dtd: false, nodes_limit: 100_000 }; + let doc = roxmltree::Document::parse_with_options(xmp, options).map_err(|_| ())?; + for node in doc.descendants().filter(roxmltree::Node::is_element) { + if let Some(value) = node.attribute((namespace, local)) { + return Ok(Some(value.trim().to_owned())); + } + if node.tag_name().namespace() == Some(namespace) && node.tag_name().name() == local { + return Ok(node.text().map(|value| value.trim().to_owned())); + } + } + Ok(None) +} + +/// Reject deep XML and start tags with too many attributes before roxmltree checks duplicates. +/// Each unquoted `=` in a valid start tag introduces an attribute, including namespace bindings. +/// Every `b[i]` and `hay[p..]` below is guarded by `i < b.len()` / `p < hay.len()`. +fn xml_limits_ok(text: &str) -> bool { + fn find(hay: &[u8], from: usize, needle: &[u8]) -> Option { + (from..hay.len()).find(|&p| hay[p..].starts_with(needle)) + } + let b = text.as_bytes(); + let (mut i, mut depth, mut total_attributes) = (0usize, 0usize, 0usize); + while i < b.len() { + if b[i] != b'<' { + i += 1; + } else if b[i..].starts_with(b"").map_or(b.len(), |p| p + 3); + } else if b[i..].starts_with(b"").map_or(b.len(), |p| p + 3); + } else if b[i..].starts_with(b"").map_or(b.len(), |p| p + 2); + } else if b[i..].starts_with(b"").map_or(b.len(), |p| p + 1); + } else if b[i..].starts_with(b"").map_or(b.len(), |p| p + 1); + } else { + depth += 1; + if depth > 1024 { + return false; + } + // Skip the rest of the start tag, over any quoted attribute value, and remember its last + // significant byte: a tag that ends in `/` closes itself, so it is one level deep while it + // is read rather than a level left open. + i += 1; + let mut quote = 0u8; + let mut last = 0u8; + let mut attributes = 0usize; + while i < b.len() { + let c = b[i]; + if quote != 0 { + if c == quote { + quote = 0; + } + } else if c == b'"' || c == b'\'' { + quote = c; + } else if c == b'>' { + i += 1; + break; + } else if c == b'=' { + attributes += 1; + total_attributes += 1; + if attributes > 128 || total_attributes > 4096 { + return false; + } + last = c; + } else if !c.is_ascii_whitespace() { + last = c; + } + i += 1; + } + if last == b'/' { + depth -= 1; + } + } + } + true +} + /// The value of a simple XMP property, written either as an attribute (`pdfaid:part="2"`) or /// as an element (`2`, or inside an `rdf:Alt`/`rdf:Seq`). pub fn value(xmp: &str, key: &str) -> Option { @@ -133,3 +243,28 @@ mod tests { assert_eq!(iso_date("junk"), None); } } + +#[cfg(test)] +mod limit_tests { + use super::*; + + #[test] + fn attribute_and_namespace_budgets_bound_parser_work() { + let prefix = r#"(); + let at_limit = format!("{prefix}{}/>", attrs(126)); + assert_eq!(identifier(&at_limit, PDFA_ID, "part").as_deref(), Some("2")); + let over_limit = format!("{prefix}{}/>", attrs(127)); + assert_eq!(identifier(&over_limit, PDFA_ID, "part"), None); + let namespaces = (0..129).map(|i| format!("xmlns:n{i}=\"urn:{i}\" ")).collect::(); + assert_eq!(identifier(&format!(""), PDFA_ID, "part"), None); + let many_elements = format!("{}", r#""#.repeat(4097)); + assert!(!xml_limits_ok(&many_elements)); + // Not well-formed (the prefix is never declared): read by the conventional prefix, as before. + let undeclared = r#""#; + assert_eq!(identifier(undeclared, PDFA_ID, "part").as_deref(), Some("3")); + assert_eq!(identifier(undeclared, PDFA_ID, "conformance").as_deref(), Some("u")); + let over_bytes = format!("{}", " ".repeat(8 * 1024 * 1024)); + assert_eq!(identifier(&over_bytes, PDFA_ID, "part"), None); + } +} diff --git a/crates/redact/src/image.rs b/crates/redact/src/image.rs index 23ef2559a5..9a19ba2f0b 100644 --- a/crates/redact/src/image.rs +++ b/crates/redact/src/image.rs @@ -1,5 +1,5 @@ //! Clearing the pixels of an image under redaction regions. The image is decoded, every pixel -//! whose centre maps into a region is set to zero (no ink for image masks), and the result is +//! whose footprint intersects a region is set to zero (no ink for image masks), and the result is //! written as a new Flate image; the original object is untouched (other pages may use it). //! Images whose codec PdfCraft can't decode (DCT, JPX, JBIG2, CCITT) return `None`, and the //! caller removes the whole image instead; that is fail-closed. @@ -33,7 +33,7 @@ fn components(doc: &Document, s: &Stream) -> Option { }) } -/// `Ok(Some(copy))` with the covered pixels cleared, `Ok(None)` when no pixel centre is covered, +/// `Ok(Some(copy))` with the covered pixels cleared, `Ok(None)` when no pixel intersects, /// `Err(())` when the image can't be cleared (the caller removes it). #[allow(clippy::result_unit_err)] pub(crate) fn clear(doc: &Document, s: &Stream, ctm: &Matrix, rects: &[[f64; 4]]) -> Result, ()> { @@ -91,18 +91,20 @@ fn decode_and_clear(doc: &Document, s: &Stream, ctm: &Matrix, rects: &[[f64; 4]] }; let mut cleared = 0usize; for j in 0..h { - // Image space: (0,0) is the bottom-left of the unit square; row 0 is the top. - let v = 1.0 - (j as f64 + 0.5) / h as f64; - let (ax, ay) = ctm.apply(0.0, v); - let (bx, by) = ctm.apply(1.0, v); - let row_box = [ax.min(bx), ay.min(by), ax.max(bx), ay.max(by)]; - if !rects.iter().any(|r| overlaps(*r, [row_box[0], row_box[1] - 0.01, row_box[2], row_box[3] + 0.01], 0.0)) { + // Image space has its origin at the bottom left; row 0 is at the top. + let v0 = 1.0 - (j + 1) as f64 / h as f64; + let v1 = 1.0 - j as f64 / h as f64; + let row_box = ctm.bbox([0.0, v0, 1.0, v1]); + if !rects.iter().any(|r| overlaps(*r, row_box, 0.0)) { continue; } for i in 0..w { - let u = (i as f64 + 0.5) / w as f64; - let (x, y) = ctm.apply(u, v); - if !rects.iter().any(|r| x >= r[0] && x <= r[2] && y >= r[1] && y <= r[3]) { + let u0 = i as f64 / w as f64; + let u1 = (i + 1) as f64 / w as f64; + // The bounding box may cover extra pixels for a rotated or skewed image; that + // conservative over-clear prevents a touched sample surviving in the file. + let pixel_box = ctm.bbox([u0, v0, u1, v1]); + if !rects.iter().any(|r| overlaps(*r, pixel_box, 0.0)) { continue; } cleared += 1; diff --git a/crates/redact/src/lib.rs b/crates/redact/src/lib.rs index a8dbf64d9b..fdac37dbec 100644 --- a/crates/redact/src/lib.rs +++ b/crates/redact/src/lib.rs @@ -51,6 +51,13 @@ pub enum RedactError { "redaction appearance has invalid geometry or exceeds the 8 MiB budget; reduce the marked area, turn off repeated text or apply fewer pages at once (nothing was redacted)" )] OverlayLimit, + /// The structure tree couldn't be cleaned completely (#3302): an element written in place that + /// would need changing, an element whose inherited page can't be found, or a tree too large to + /// walk. Tags that kept the removed text or annotation would undo the redaction. + #[error( + "the document's tags can't be cleaned completely (an element written in place, or a structure tree too large or too deep to follow); the operation was refused" + )] + Tags, #[error(transparent)] Edit(#[from] pdfcraft_edit::EditError), #[error(transparent)] @@ -305,6 +312,12 @@ fn localize_resources(doc: &mut Document, selected: &[usize]) -> Result<(), pdfc /// Apply every redaction mark (or only those on `pages`, 0-based). Irreversible for the saved /// file; callers keep the previous document for undo. +/// +/// # Errors +/// +/// This operation is not transactional: an error may leave `doc` partially modified, with +/// sensitive content still present. Call this on a working clone and replace the original only +/// on success. On any error, discard the working clone; do not save or reuse it. pub fn apply(doc: &mut Document, pages: Option<&[usize]>) -> Result { let all_marks = marks(doc); let chosen: Vec<&Mark> = all_marks.iter().filter(|m| pages.is_none_or(|p| p.contains(&m.page))).collect(); diff --git a/crates/redact/src/sanitize.rs b/crates/redact/src/sanitize.rs index 0c9228ac02..1940e376a8 100644 --- a/crates/redact/src/sanitize.rs +++ b/crates/redact/src/sanitize.rs @@ -261,6 +261,12 @@ pub fn scan(doc: &Document) -> Vec<(Hidden, usize)> { } /// Remove the chosen categories. Returns what was removed per category. +/// +/// # Errors +/// +/// This operation is not transactional: an error may leave `doc` partially modified, with +/// sensitive content still present. Call this on a working clone and replace the original only +/// on success. On any error, discard the working clone; do not save or reuse it. pub fn remove_hidden(doc: &mut Document, which: &[Hidden]) -> Result, RedactError> { let counts = scan(doc); let count = |h: Hidden| counts.iter().find(|c| c.0 == h).map_or(0, |c| c.1); diff --git a/crates/redact/src/tags.rs b/crates/redact/src/tags.rs index b70cf23ab9..ab5735e065 100644 --- a/crates/redact/src/tags.rs +++ b/crates/redact/src/tags.rs @@ -9,6 +9,8 @@ use std::collections::{HashMap, HashSet}; use pdfcraft_cos::{Dict, Document, ObjRef, Object}; +use crate::RedactError; + /// Each MCID's operators (serialized), and whether anything is still drawn inside it. fn by_mcid(doc: &Document, resources: &Dict, data: &[u8]) -> HashMap, bool)> { let mut out: HashMap, bool)> = HashMap::new(); @@ -103,7 +105,23 @@ pub(crate) fn clean( stream: Option<(ObjRef, ObjRef)>, changed: &HashSet, empty: &HashSet, -) -> Result { +) -> Result { + clean_within(doc, page, stream, changed, empty, MAX_STRUCT_REFS) +} + +/// [`clean`], visiting at most `limit` structure-tree references. Fail-closed (#3302): when the +/// cleanup can't be shown complete, it is [`RedactError::Tags`] and the redaction isn't made. That +/// is a tree too large to walk in full (it could hide an element that repeats the redacted text), +/// an element whose inherited page can't be found (its ancestry too deep, or looping), and an +/// element written in place that would need changing (only the object holding it could be). +pub(crate) fn clean_within( + doc: &mut Document, + page: ObjRef, + stream: Option<(ObjRef, ObjRef)>, + changed: &HashSet, + empty: &HashSet, + limit: usize, +) -> Result { if changed.is_empty() && stream.is_none() { return Ok(0); } @@ -114,17 +132,44 @@ pub(crate) fn clean( else { return Ok(0); }; - // Indirect elements with the page they inherit. - let mut todo: Vec<(Object, Option)> = root.get(b"K").map(|k| vec![(k.clone(), None)]).unwrap_or_default(); + // Indirect elements with the page they inherit. Elements are reached from the root's /K, and + // also from the parent tree and the ID tree: one listed only there still owns marked content + // (#3302). The stack pops /K first, so an element reached through /K gets the page it inherits + // from its ancestors before a tree reaches it without one. + let mut todo: Vec<(Object, Option)> = + [&b"IDTree"[..], b"ParentTree", b"K"].iter().filter_map(|key| root.get(key).cloned()).map(|k| (k, None)).collect(); let mut seen = HashSet::new(); + let mut pages = HashMap::new(); let mut hits: Vec<(ObjRef, Dict, Option, bool)> = Vec::new(); + // Whether marked content `x` (a kid on page `pg`) is one this cleanup must change. + let touches = |doc: &Document, x: &Object, pg: Option| { + mcid_of(doc, x, pg, page, stream).is_some_and(|m| stream.is_some() || changed.contains(&m) || empty.contains(&m)) + }; while let Some((k, pg)) = todo.pop() { match k { Object::Array(a) => todo.extend(a.into_iter().map(|x| (x, pg))), + // A number or name tree node written in place. + Object::Dict(d) if !d.contains(b"S") => todo.extend(tree_node_entries(&d).into_iter().map(|x| (x, pg))), + // An element written in place: its kids are followed, but if it owns marked content + // this cleanup changes, only rewriting what holds it could change it. + Object::Dict(d) => { + let pg = match d.get(b"Pg").and_then(Object::as_ref).or(pg) { + Some(p) => Some(p), + None => inherited_page(doc, &d, &mut pages)?, + }; + let kids = kids_of(doc, &d); + if kids.iter().any(|x| touches(doc, x, pg)) { + return Err(RedactError::Tags); + } + todo.extend(kids.into_iter().map(|x| (x, pg))); + } Object::Ref(r) => { - if !seen.insert(r) || seen.len() > 2_000_000 { + if !seen.insert(r) { continue; } + if seen.len() > limit { + return Err(RedactError::Tags); + } let object = doc.get(r); if let Object::Array(a) = &*object { todo.extend(a.iter().cloned().map(|x| (x, pg))); @@ -132,16 +177,15 @@ pub(crate) fn clean( } let Some(d) = object.as_dict().cloned() else { continue }; if !d.contains(b"S") { + // A parent tree or ID tree node: its values may be elements. + todo.extend(tree_node_entries(&d).into_iter().map(|x| (x, pg))); continue; } - let pg = d.get(b"Pg").and_then(Object::as_ref).or(pg); - let kids: Vec = match d.get(b"K").map(|k| doc.resolve(k)) { - Some(o) => match &*o { - Object::Array(a) => a.clone(), - _ => d.get(b"K").cloned().into_iter().collect(), - }, - None => Vec::new(), + let pg = match d.get(b"Pg").and_then(Object::as_ref).or(pg) { + Some(p) => Some(p), + None => inherited_page(doc, &d, &mut pages)?, }; + let kids = kids_of(doc, &d); let sensitive = kids.iter().any(|x| mcid_of(doc, x, pg, page, stream).is_some_and(|m| changed.contains(&m))); let remap = stream.is_some() && kids.iter().any(|x| mcid_of(doc, x, pg, page, stream).is_some()); if sensitive || remap { @@ -203,6 +247,49 @@ pub(crate) fn clean( Ok(n) } +/// The page an element inherits (§14.7.4.4): the nearest `/Pg` among its ancestors, following +/// `/P`. `Ok(None)` when none has one; [`RedactError::Tags`] when the lookup gives up (more than +/// [`MAX_PARENTS`] ancestors, or a loop), which isn't the same as having no page. `pages` keeps +/// each ancestor's answer, so siblings don't climb the same chain again. +fn inherited_page(doc: &Document, d: &Dict, pages: &mut HashMap>) -> Result, RedactError> { + let mut chain: Vec = Vec::new(); + let mut parent = d.get(b"P").and_then(Object::as_ref); + let found = loop { + let Some(r) = parent else { break None }; + if let Some(known) = pages.get(&r) { + break *known; + } + if chain.len() >= MAX_PARENTS || chain.contains(&r) { + return Err(RedactError::Tags); + } + chain.push(r); + let object = doc.get(r); + let Some(p) = object.as_dict() else { break None }; + if let Some(pg) = p.get(b"Pg").and_then(Object::as_ref) { + break Some(pg); + } + parent = p.get(b"P").and_then(Object::as_ref); + }; + for r in chain { + pages.insert(r, found); + } + Ok(found) +} + +/// An element's kids: its `/K` array, or the one kid it has. +fn kids_of(doc: &Document, d: &Dict) -> Vec { + match d.get(b"K").map(|k| doc.resolve(k)) { + Some(o) => match &*o { + Object::Array(a) => a.clone(), + _ => d.get(b"K").cloned().into_iter().collect(), + }, + None => Vec::new(), + } +} + +/// The most ancestors [`inherited_page`] looks through; deeper trees, and cycles, go unanswered. +const MAX_PARENTS: usize = 256; + /// Whether `o` is an object reference (`/Type /OBJR`, direct or indirect) to one of `gone`. fn refers_to(doc: &Document, o: &Object, gone: &HashSet) -> bool { doc.resolve(o) @@ -210,22 +297,34 @@ fn refers_to(doc: &Document, o: &Object, gone: &HashSet) -> bool { .is_some_and(|d| d.name(b"Type") == Some(b"OBJR") && d.get(b"Obj").and_then(Object::as_ref).is_some_and(|r| gone.contains(&r))) } +/// What a number or name tree node leads on to: its `/Nums` or `/Names` array (keys and values +/// alike; the walk ignores numbers and strings) and its `/Kids`. +fn tree_node_entries(d: &Dict) -> Vec { + [&b"Nums"[..], b"Names", b"Kids"].iter().filter_map(|key| d.get(key).cloned()).collect() +} + /// Drop the structure tree's object references (OBJR) to `gone`, annotations removed from their /// pages (#2447). A tagged PDF's tree points at each link and comment it tags; left there, the /// reference keeps the annotation, its URL or text included, in a full rewrite after the page /// no longer lists it. Returns how many references were dropped. -pub(crate) fn drop_object_refs(doc: &mut Document, gone: &HashSet) -> Result { +/// +/// Only elements that are objects of their own are edited: changing a structure element written in +/// place (a direct dictionary in a `/K` array or a tree) means rewriting the object that holds it, +/// so when one of those refers to a removed annotation it is [`RedactError::Tags`] (fail-closed). +/// Producers rarely write elements that way. +pub(crate) fn drop_object_refs(doc: &mut Document, gone: &HashSet) -> Result { drop_object_refs_within(doc, gone, MAX_STRUCT_REFS) } /// The most structure-tree references [`drop_object_refs`] visits. const MAX_STRUCT_REFS: usize = 2_000_000; -/// [`drop_object_refs`], visiting at most `limit` references. A tree too large to walk in full -/// could keep a reference the walk never reached, so then the whole structure tree goes instead -/// (with `/MarkInfo`, which would claim tags there no longer are): fail-closed, at the cost of -/// the document's tags. -pub(crate) fn drop_object_refs_within(doc: &mut Document, gone: &HashSet, limit: usize) -> Result { +/// [`drop_object_refs`], visiting at most `limit` references. Fail-closed: a tree too large to +/// walk in full could keep a reference the walk never reached, and an element written in place +/// can only be changed by rewriting what holds it, so either is [`RedactError::Tags`] rather than +/// a cleanup that leaves the annotation reachable (removing only the tree would not: an outline +/// item's `/SE` or a link's `/SD` still reaches its elements). +pub(crate) fn drop_object_refs_within(doc: &mut Document, gone: &HashSet, limit: usize) -> Result { if gone.is_empty() { return Ok(0); } @@ -236,23 +335,31 @@ pub(crate) fn drop_object_refs_within(doc: &mut Document, gone: &HashSet else { return Ok(0); }; - let mut todo: Vec = root.get(b"K").cloned().into_iter().collect(); + // Elements are reached from the root's /K, and also from the parent tree and the ID tree: + // one listed only there (left behind by an editor, say) still points at its annotation. + let mut todo: Vec = [&b"K"[..], b"ParentTree", b"IDTree"].iter().filter_map(|key| root.get(key).cloned()).collect(); let mut seen = HashSet::new(); let mut hits: Vec = Vec::new(); while let Some(k) = todo.pop() { match k { Object::Array(a) => todo.extend(a), + // A structure element written in place: its kids are followed; one that tags a + // removed annotation can't be changed here (see above). + Object::Dict(d) if d.contains(b"S") => { + let kids = kids_of(doc, &d); + if kids.iter().any(|x| refers_to(doc, x, gone)) { + return Err(RedactError::Tags); + } + todo.extend(kids); + } + // A number or name tree node written in place (§7.9.6, §7.9.7): its values and kids. + Object::Dict(d) => todo.extend(tree_node_entries(&d)), Object::Ref(r) => { if !seen.insert(r) { continue; } if seen.len() > limit { - let Some(catalog) = doc.root() else { return Ok(0) }; - doc.update_dict(catalog, |c| { - c.remove(b"StructTreeRoot"); - c.remove(b"MarkInfo"); - })?; - return Ok(0); + return Err(RedactError::Tags); } let object = doc.get(r); if let Object::Array(a) = &*object { @@ -261,15 +368,11 @@ pub(crate) fn drop_object_refs_within(doc: &mut Document, gone: &HashSet } let Some(d) = object.as_dict() else { continue }; if !d.contains(b"S") { + // A parent tree or ID tree node: its values may be elements. + todo.extend(tree_node_entries(d)); continue; } - let kids: Vec = match d.get(b"K").map(|k| doc.resolve(k)) { - Some(o) => match &*o { - Object::Array(a) => a.clone(), - _ => d.get(b"K").cloned().into_iter().collect(), - }, - None => Vec::new(), - }; + let kids = kids_of(doc, d); if kids.iter().any(|x| refers_to(doc, x, gone)) { hits.push(r); } diff --git a/crates/redact/src/tests.rs b/crates/redact/src/tests.rs index a7e11bcaf1..da1ecf12ab 100644 --- a/crates/redact/src/tests.rs +++ b/crates/redact/src/tests.rs @@ -399,6 +399,25 @@ fn named_image(doc: &Document, page: usize, name: &[u8]) -> Option> { } } +#[test] +fn redaction_clears_pixels_intersected_without_covering_their_centres() { + for (content, rect) in + [(&b"q 200 0 0 100 0 0 cm /Im Do Q"[..], [10.0, 10.0, 20.0, 90.0]), (&b"q 0 100 -200 0 200 0 cm /Im Do Q"[..], [10.0, 5.0, 190.0, 10.0])] + { + let mut doc = one_page( + content, + "/XObject << /Im 7 0 R >>", + vec![stream("/Type /XObject /Subtype /Image /Width 2 /Height 1 /ColorSpace /DeviceGray /BitsPerComponent 8", [123, 234])], + ); + mark(&mut doc, 0, &[rect], ""); + let report = apply(&mut doc, None).unwrap(); + assert_eq!(report.images_cleared, 1); + let saved = Document::open(Arc::new(write_full(&doc, &SaveOptions::default()).unwrap())).unwrap(); + assert_eq!(named_image(&saved, 0, b"Im"), None); + assert_eq!(named_image(&saved, 0, b"PCRedacted1"), Some(vec![0, 234])); + } +} + /// Apply Redactions gives the page its own pruned /Resources, but the dictionary it drew through /// before (inherited from the /Pages node, or shared with another page) still named the removed /// image and form, so a full-rewrite save kept them. @@ -810,11 +829,12 @@ fn flattened_widgets_and_far_pop_ups_leave_the_structure_tree() { } /// #2532 review: a structure tree larger than the walk's limit can't be searched in full, so a -/// reference the walk never reached could keep a removed annotation. Then the whole tree goes -/// (with `/MarkInfo`), rather than reporting success with references left behind; under the -/// limit only the reference goes. +/// reference the walk never reached could keep a removed annotation. Then it is an error, and +/// nothing is changed, rather than success with references left behind (removing only the tree +/// would leave an outline's `/SE` reaching them, review on #3285); under the limit only the +/// reference goes. #[test] -fn a_structure_tree_too_large_to_walk_is_removed_whole() { +fn a_structure_tree_too_large_to_walk_is_refused() { let objs: Vec> = vec![ b"<< /Type /Catalog /Pages 2 0 R /MarkInfo << /Marked true >> /StructTreeRoot 5 0 R >>".to_vec(), b"<< /Type /Pages /Kids [3 0 R] /Count 1 >>".to_vec(), @@ -835,11 +855,219 @@ fn a_structure_tree_too_large_to_walk_is_removed_whole() { assert!(catalog(&doc).contains(b"StructTreeRoot") && catalog(&doc).contains(b"MarkInfo"), "under the limit the tree stays"); let mut doc = pdf(objs); - crate::tags::drop_object_refs_within(&mut doc, &gone, 2).unwrap(); - assert!(!catalog(&doc).contains(b"StructTreeRoot"), "a tree the walk couldn't finish goes whole"); - assert!(!catalog(&doc).contains(b"MarkInfo")); + assert!(matches!(crate::tags::drop_object_refs_within(&mut doc, &gone, 2), Err(RedactError::Tags)), "a tree the walk couldn't finish is refused"); + assert!(catalog(&doc).contains(b"StructTreeRoot"), "nothing changed"); +} + +/// #3302: an element listed only in the parent tree (not under the root's /K) still owns the +/// marked content redaction changed, so its /ActualText and /Alt, which repeat the redacted +/// words, go as they do for an element under /K. +#[test] +fn an_element_listed_only_in_the_parent_tree_loses_its_redacted_text() { + let objs: Vec> = vec![ + b"<< /Type /Catalog /Pages 2 0 R /StructTreeRoot 5 0 R /MarkInfo << /Marked true >> >>".to_vec(), + b"<< /Type /Pages /Kids [3 0 R] /Count 1 >>".to_vec(), + b"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 300 300] /Contents 4 0 R /StructParents 0 /Resources << /Font << /F1 9 0 R >> >> >>".to_vec(), + stream("", b"/P <> BDC BT /F1 14 Tf 20 150 Td (Codename Bluefin) Tj ET EMC"), + // The root's /K reaches only the empty document element 6; the paragraph 7 is in the + // parent tree only. + b"<< /Type /StructTreeRoot /K 6 0 R /ParentTree << /Nums [0 [7 0 R]] >> /ParentTreeNextKey 1 >>".to_vec(), + b"<< /Type /StructElem /S /Document /P 5 0 R >>".to_vec(), + b"<< /Type /StructElem /S /P /P 6 0 R /Pg 3 0 R /K 0 /ActualText (Codename Bluefin) /Alt (Codename Bluefin) >>".to_vec(), + b"<< /Type /StructElem /S /Note /P 6 0 R >>".to_vec(), + b"<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding /WinAnsiEncoding >>".to_vec(), + ]; + let mut doc = pdf(objs); + mark(&mut doc, 0, &[[10.0, 140.0, 290.0, 170.0]], ""); + let r = apply(&mut doc, None).unwrap(); + assert!(r.glyphs > 0 && r.tags >= 1, "{r:?}"); + let element = doc.get(ObjRef::new(7, 0)).as_dict().cloned().unwrap(); + assert!(!element.contains(b"ActualText") && !element.contains(b"Alt"), "{element:?}"); +} + +/// #3302: a structure tree too large to walk in full fails closed: an error, so the redaction +/// isn't made, instead of the walk stopping and reporting success with elements never checked. +#[test] +fn a_tag_walk_cut_short_is_refused() { + let objs: Vec> = vec![ + b"<< /Type /Catalog /Pages 2 0 R /StructTreeRoot 5 0 R >>".to_vec(), + b"<< /Type /Pages /Kids [3 0 R] /Count 1 >>".to_vec(), + b"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 300 300] /StructParents 0 >>".to_vec(), + b"<< >>".to_vec(), + b"<< /Type /StructTreeRoot /K 6 0 R >>".to_vec(), + b"<< /Type /StructElem /S /Document /P 5 0 R /K [7 0 R 8 0 R] >>".to_vec(), + b"<< /Type /StructElem /S /P /P 6 0 R /Pg 3 0 R /K 0 /ActualText (Codename Bluefin) >>".to_vec(), + b"<< /Type /StructElem /S /Figure /P 6 0 R /Alt (A chart) /E (chart) >>".to_vec(), + ]; + let page = ObjRef::new(3, 0); + let changed: std::collections::HashSet = [0].into(); + let mut doc = pdf(objs.clone()); + assert_eq!(crate::tags::clean_within(&mut doc, page, None, &changed, &Default::default(), 100).unwrap(), 1); + let figure = doc.get(ObjRef::new(8, 0)).as_dict().cloned().unwrap(); + assert!(figure.contains(b"Alt"), "a walk that finishes keeps an unrelated element's text"); + + let mut doc = pdf(objs); + assert!(matches!(crate::tags::clean_within(&mut doc, page, None, &changed, &Default::default(), 1), Err(RedactError::Tags))); +} + +/// #2532 follow-up: an element reached only through the parent tree or the ID tree, not from +/// the root's /K, still points at a removed annotation, and the root keeps it reachable in a full +/// rewrite. Both trees are walked too, written in place or as their own objects. +#[test] +fn elements_listed_only_in_the_parent_tree_or_the_id_tree_lose_their_references() { + let objs: Vec> = vec![ + b"<< /Type /Catalog /Pages 2 0 R /MarkInfo << /Marked true >> /StructTreeRoot 5 0 R >>".to_vec(), + b"<< /Type /Pages /Kids [3 0 R] /Count 1 >>".to_vec(), + b"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 300 300] >>".to_vec(), + // 4: the removed link; 8: the removed comment + b"<< /Type /Annot /Subtype /Link /Rect [0 0 10 10] /A << /S /URI /URI (https://secret.example) >> >>".to_vec(), + // 5: the root; its /K reaches only the empty document element 6. The parent tree is + // written in place, the ID tree's kid (9) as its own object. + b"<< /Type /StructTreeRoot /K 6 0 R /ParentTree << /Nums [0 7 0 R] >> /IDTree << /Kids [9 0 R] >> >>".to_vec(), + b"<< /Type /StructElem /S /Document /P 5 0 R >>".to_vec(), + // 7: a link element only the parent tree lists + b"<< /Type /StructElem /S /Link /P 6 0 R /K << /Type /OBJR /Obj 4 0 R >> /Alt (secret.example link) >>".to_vec(), + b"<< /Type /Annot /Subtype /Text /Rect [0 0 10 10] /Contents (hushhush note) >>".to_vec(), + b"<< /Names [(note1) 10 0 R] >>".to_vec(), + // 10: an annotation element only the ID tree lists + b"<< /Type /StructElem /S /Annot /P 6 0 R /ID (note1) /K [<< /Type /OBJR /Obj 8 0 R >>] >>".to_vec(), + ]; + let gone: std::collections::HashSet = [ObjRef::new(4, 0), ObjRef::new(8, 0)].into(); + let mut doc = pdf(objs); + assert_eq!(crate::tags::drop_object_refs(&mut doc, &gone).unwrap(), 2, "both references go"); let bytes = write_full(&doc, &SaveOptions { object_streams: false, ..SaveOptions::default() }).unwrap(); - assert!(!bytes.windows(14).any(|w| w == b"secret.example"), "the removed link can't be reached any more"); + for secret in [&b"secret.example"[..], b"hushhush"] { + assert!(!bytes.windows(secret.len()).any(|w| w == secret), "{} survived the rewrite", String::from_utf8_lossy(secret)); + } +} + +/// #3285 review: an element without its own /Pg takes its ancestor's page, also when the parent +/// tree lists it. One under /K must not be reached first through the parent tree without that +/// page, and one only the parent tree lists finds it through /P. +#[test] +fn an_element_inheriting_its_page_loses_its_redacted_text_when_the_parent_tree_lists_it() { + let objs: Vec> = vec![ + b"<< /Type /Catalog /Pages 2 0 R /StructTreeRoot 5 0 R /MarkInfo << /Marked true >> >>".to_vec(), + b"<< /Type /Pages /Kids [3 0 R] /Count 1 >>".to_vec(), + b"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 300 300] /Contents 4 0 R /StructParents 0 /Resources << /Font << /F1 9 0 R >> >> >>".to_vec(), + stream("", b"/P <> BDC BT /F1 14 Tf 20 150 Td (Codename Bluefin) Tj ET EMC /P <> BDC BT /F1 14 Tf 20 100 Td (Codename Bluefin) Tj ET EMC"), + // The document element 6 carries the page; 7 sits under it, 8 only points at it. + b"<< /Type /StructTreeRoot /K 6 0 R /ParentTree << /Nums [0 [7 0 R 8 0 R]] >> /ParentTreeNextKey 1 >>".to_vec(), + b"<< /Type /StructElem /S /Document /P 5 0 R /Pg 3 0 R /K [7 0 R] >>".to_vec(), + b"<< /Type /StructElem /S /P /P 6 0 R /K 0 /ActualText (Codename Bluefin) >>".to_vec(), + b"<< /Type /StructElem /S /P /P 6 0 R /K 1 /Alt (Codename Bluefin) >>".to_vec(), + b"<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding /WinAnsiEncoding >>".to_vec(), + ]; + let mut doc = pdf(objs); + mark(&mut doc, 0, &[[10.0, 90.0, 290.0, 170.0]], ""); + let r = apply(&mut doc, None).unwrap(); + assert!(r.glyphs > 0, "{r:?}"); + for n in [7, 8] { + let element = doc.get(ObjRef::new(n, 0)).as_dict().cloned().unwrap(); + assert!(!element.contains(b"ActualText") && !element.contains(b"Alt"), "{n}: {element:?}"); + } +} + +/// #3285 review: a structure element written in place, here as an ID tree value, can't be +/// edited without rewriting what holds it; when it tags a removed annotation, removing hidden +/// information is refused instead of keeping the annotation reachable in a full rewrite. +/// Direct callers discard the failed working copy to preserve the original document. +#[test] +fn an_element_written_in_place_that_tags_a_removed_annotation_is_refused() { + let objs: Vec> = vec![ + b"<< /Type /Catalog /Pages 2 0 R /MarkInfo << /Marked true >> /StructTreeRoot 5 0 R >>".to_vec(), + b"<< /Type /Pages /Kids [3 0 R] /Count 1 >>".to_vec(), + b"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 300 300] /Annots [4 0 R] >>".to_vec(), + b"<< /Type /Annot /Subtype /Text /Rect [0 0 10 10] /Contents (hushhush note) >>".to_vec(), + b"<< /Type /StructTreeRoot /K 6 0 R /IDTree << /Names [(note1) << /Type /StructElem /S /Annot /P 6 0 R /K [<< /Type /OBJR /Obj 4 0 R >>] >>] >> >>".to_vec(), + b"<< /Type /StructElem /S /Document /P 5 0 R >>".to_vec(), + ]; + let original = pdf(objs); + let before = write_full(&original, &SaveOptions::default()).unwrap(); + let mut working = original.clone(); + assert!(matches!(sanitize::remove_hidden(&mut working, &[sanitize::Hidden::Comments]), Err(RedactError::Tags))); + assert!(!working.get(ObjRef::new(3, 0)).as_dict().unwrap().contains(b"Annots"), "the failed working copy was partially changed"); + drop(working); + assert_eq!(write_full(&original, &SaveOptions::default()).unwrap(), before, "discarding the working copy preserves the original"); +} + +/// Whether a full rewrite of `doc` still holds the redacted word. +fn bluefin_survives(doc: &Document) -> bool { + let bytes = write_full(doc, &SaveOptions { object_streams: false, ..SaveOptions::default() }).unwrap(); + bytes.windows(7).any(|w| w == b"Bluefin") +} + +/// #3285 review (the maintainer's probes): an element written in place in the parent tree that +/// owns the redacted text can't be changed, so applying is refused; the same element as an object +/// of its own is cleaned, and nothing of the word is left in a full rewrite. +/// Refusal does not promise rollback: direct callers discard the failed working copy. +#[test] +fn an_element_written_in_place_owning_redacted_text_is_refused() { + let objs = |element: &str| -> Vec> { + vec![ + b"<< /Type /Catalog /Pages 2 0 R /StructTreeRoot 5 0 R /MarkInfo << /Marked true >> >>".to_vec(), + b"<< /Type /Pages /Kids [3 0 R] /Count 1 >>".to_vec(), + b"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 300 300] /Contents 4 0 R /StructParents 0 /Resources << /Font << /F1 9 0 R >> >> >>" + .to_vec(), + stream("", b"/P <> BDC BT /F1 14 Tf 20 150 Td (Codename Bluefin) Tj ET EMC"), + format!("<< /Type /StructTreeRoot /K 6 0 R /ParentTree << /Nums [0 [{element}]] >> /ParentTreeNextKey 1 >>").into_bytes(), + b"<< /Type /StructElem /S /Document /P 5 0 R >>".to_vec(), + b"<< /Type /StructElem /S /P /P 6 0 R /Pg 3 0 R /K 0 /ActualText (Codename Bluefin) >>".to_vec(), + b"<< >>".to_vec(), + b"<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding /WinAnsiEncoding >>".to_vec(), + ] + }; + let mut doc = pdf(objs("<< /Type /StructElem /S /P /P 6 0 R /Pg 3 0 R /K 0 /ActualText (Codename Bluefin) >>")); + mark(&mut doc, 0, &[[10.0, 140.0, 290.0, 170.0]], ""); + let before = write_full(&doc, &SaveOptions::default()).unwrap(); + let mut working = doc.clone(); + let error = apply(&mut working, None).unwrap_err(); + assert!(matches!(error, RedactError::Tags)); + assert_eq!( + error.to_string(), + "the document's tags can't be cleaned completely (an element written in place, or a structure tree too large or too deep to follow); the operation was refused" + ); + assert!(!content(&working, 0).contains("Bluefin"), "the failed working copy was partially changed"); + assert!(bluefin_survives(&working), "the failed working copy still holds the word in its tags"); + drop(working); + assert_eq!(write_full(&doc, &SaveOptions::default()).unwrap(), before, "discarding the working copy preserves the original"); + let mut doc = pdf(objs("7 0 R")); + mark(&mut doc, 0, &[[10.0, 140.0, 290.0, 170.0]], ""); + let r = apply(&mut doc, None).unwrap(); + assert!(r.glyphs > 0 && !bluefin_survives(&doc), "{r:?}"); +} + +/// #3285 review: an element whose inherited page can't be found (more ancestors than the lookup +/// follows) isn't taken for one with no page: applying is refused. Within the limit the page is +/// found and the text goes. +#[test] +fn an_element_whose_inherited_page_is_out_of_reach_is_refused() { + let objs = |depth: u32| -> Vec> { + let mut objs: Vec> = vec![ + b"<< /Type /Catalog /Pages 2 0 R /StructTreeRoot 5 0 R /MarkInfo << /Marked true >> >>".to_vec(), + b"<< /Type /Pages /Kids [3 0 R] /Count 1 >>".to_vec(), + b"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 300 300] /Contents 4 0 R /StructParents 0 /Resources << /Font << /F1 9 0 R >> >> >>" + .to_vec(), + stream("", b"/P <> BDC BT /F1 14 Tf 20 150 Td (Codename Bluefin) Tj ET EMC"), + b"<< /Type /StructTreeRoot /K 6 0 R /ParentTree << /Nums [0 [7 0 R]] >> /ParentTreeNextKey 1 >>".to_vec(), + b"<< /Type /StructElem /S /Document /P 5 0 R /Pg 3 0 R >>".to_vec(), + format!("<< /Type /StructElem /S /P /P {} 0 R /K 0 /ActualText (Codename Bluefin) >>", 10 + depth - 1).into_bytes(), + b"<< >>".to_vec(), + b"<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding /WinAnsiEncoding >>".to_vec(), + ]; + for i in 0..depth { + let parent = if i == 0 { 6 } else { 10 + i - 1 }; + objs.push(format!("<< /Type /StructElem /S /Div /P {parent} 0 R >>").into_bytes()); + } + objs + }; + let mut doc = pdf(objs(300)); + mark(&mut doc, 0, &[[10.0, 140.0, 290.0, 170.0]], ""); + assert!(matches!(apply(&mut doc, None), Err(RedactError::Tags))); + let mut doc = pdf(objs(100)); + mark(&mut doc, 0, &[[10.0, 140.0, 290.0, 170.0]], ""); + apply(&mut doc, None).unwrap(); + assert!(!bluefin_survives(&doc)); } /// Issue #1325: removing comments keeps the annotations that are not comments: the multimedia diff --git a/crates/render/src/raster.rs b/crates/render/src/raster.rs index 13cc5ef69b..45641178de 100644 --- a/crates/render/src/raster.rs +++ b/crates/render/src/raster.rs @@ -1334,6 +1334,10 @@ mod tests { assert!(parser.acquire(|| panic!("must not retry"), || false).unwrap().is_none()); } + /// The waits here only guard against a hang; they don't time anything. The initializer is + /// held until the end of the test, so a waiter that can't cancel waits forever and fails the + /// guard however long it is, and a correct one returns as soon as its thread runs. Two + /// seconds was too short for a busy FreeBSD VM in CI to start the threads (#1970). #[cfg(not(target_arch = "wasm32"))] #[test] fn waiting_worker_can_cancel_before_initializer_returns() { @@ -1341,6 +1345,7 @@ mod tests { sync::{atomic::AtomicUsize, mpsc}, time::Duration, }; + const HANG_GUARD: Duration = Duration::from_secs(600); let parser = Arc::new(PoolParser::default()); let (started_tx, started_rx) = mpsc::channel(); let (release_tx, release_rx) = mpsc::channel(); @@ -1350,14 +1355,16 @@ mod tests { parser.acquire( || { started_tx.send(()).unwrap(); - release_rx.recv().unwrap(); + // A failed test drops the sender: that ends the hold, it is not a + // second failure. + let _ = release_rx.recv(); Pdf::new(ONE_PAGE.to_vec()).ok() }, || false, ) }) }; - started_rx.recv_timeout(Duration::from_secs(2)).unwrap(); + started_rx.recv_timeout(HANG_GUARD).expect("the initializer starts"); let cancelled = Arc::new(AtomicBool::new(false)); let checks = Arc::new(AtomicUsize::new(0)); let (done_tx, done_rx) = mpsc::channel(); @@ -1374,13 +1381,13 @@ mod tests { done_tx.send(result.unwrap().is_none()).unwrap(); }) }; - let deadline = std::time::Instant::now() + Duration::from_secs(2); + let deadline = std::time::Instant::now() + HANG_GUARD; while checks.load(Ordering::Relaxed) == 0 { - assert!(std::time::Instant::now() < deadline); - std::thread::yield_now(); + assert!(std::time::Instant::now() < deadline, "the waiter never checked for cancellation"); + std::thread::sleep(Duration::from_millis(1)); } cancelled.store(true, Ordering::Release); - assert!(done_rx.recv_timeout(Duration::from_secs(2)).unwrap()); + assert!(done_rx.recv_timeout(HANG_GUARD).expect("the waiter cancels while the initializer is held")); waiter.join().unwrap(); release_tx.send(()).unwrap(); assert!(initializer.join().unwrap().unwrap().is_some()); @@ -2881,6 +2888,33 @@ trailer << /Root 1 0 R >> assert_eq!(px(20, 80), vec![255, 0, 0, 255], "checkbox must show the /Yes appearance, not the decoy stream"); } + /// Microsoft Reporting Services (bank statements, invoices) writes `stream \r\n`, a space + /// before the line break. The content stream was then taken to start at the space and the + /// page rendered blank, while Preview, Chrome and Poppler showed it. Vendored hayro-syntax + /// patch: spaces and tabs before the line break are skipped, with the right `/Length` and + /// with a wrong one. Spaces with no line break after them never start stream data. + #[test] + fn space_before_the_line_break_after_stream_still_renders() { + let pdf = |eol: &str, length: u32| { + format!( + "%PDF-1.3\r\n1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj\r\n\ + 2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 >> endobj\r\n\ + 3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 100 100] /Contents 4 0 R >> endobj\r\n\ + 4 0 obj << /Length {length} >> stream{eol}0 0 0 rg 20 20 60 60 re f\r\nendstream\r\nendobj\r\n\ + trailer << /Root 1 0 R >>\r\n%%EOF" + ) + .into_bytes() + }; + for (eol, length) in [(" \r\n", 25), (" \n", 25), ("\t \r", 25), (" \r\n", 999), ("\r\n", 25)] { + let mut r = PageRenderer::new(Arc::new(pdf(eol, length)), RenderConfig::default()); + let p = r.render(RenderRequest { page: 0, kind: RequestKind::Pixels, tile: None, scale: 1.0, tag: 0, ..Default::default() }); + assert!(p.error.is_none(), "{eol:?}: {:?}", p.error); + let px = |x: u32, y: u32| p.rgba[((y * p.width + x) * 4) as usize..][..4].to_vec(); + assert_eq!(px(50, 50), vec![0, 0, 0, 255], "stream{eol:?} (/Length {length}) must draw the square"); + assert_eq!(px(5, 5), vec![255, 255, 255, 255], "outside the square stays white"); + } + } + /// An appearance whose /BBox is a line or a point, or whose /Matrix collapses it to one, has /// no mapping onto the annotation's /Rect: the scale (/Rect size over transformed box size) /// divided by zero, and the infinite or NaN matrix reached the device with everything the @@ -3392,6 +3426,36 @@ trailer << /Root 1 0 R >> } } + /// A shaded Type 3 glyph must use bounds in device coordinates at every position. + #[test] + fn shaded_type3_glyph_draws_at_multiple_positions() { + let content = "BT /F 30 Tf 1 0 0 1 50 70 Tm (A) Tj 1 0 0 1 220 70 Tm (A) Tj ET"; + let glyph = "100 0 d0 0 0 100 100 re W n /S sh"; + let pdf = format!( + "%PDF-1.7 +1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj +2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 >> endobj +3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 300 150] /Resources << /Font << /F 5 0 R >> >> /Contents 4 0 R >> endobj +4 0 obj << /Length {} >> stream +{content} +endstream endobj +5 0 obj << /Type /Font /Subtype /Type3 /FontBBox [0 0 100 100] /FontMatrix [0.01 0 0 0.01 0 0] /CharProcs << /A 6 0 R >> /Encoding << /Differences [65 /A] >> /FirstChar 65 /LastChar 65 /Widths [100] /Resources << /Shading << /S 7 0 R >> >> >> endobj +6 0 obj << /Length {} >> stream +{glyph} +endstream endobj +7 0 obj << /ShadingType 2 /ColorSpace /DeviceRGB /Coords [0 0 100 0] /Function << /FunctionType 2 /Domain [0 1] /C0 [1 0 0] /C1 [0 0 1] /N 1 >> /Extend [true true] >> endobj +trailer << /Root 1 0 R >> +%%EOF", content.len(), glyph.len() + ); + let mut renderer = PageRenderer::new(Arc::new(pdf.into_bytes()), RenderConfig::default()); + let page = renderer.render(RenderRequest { page: 0, kind: RequestKind::Pixels, tile: None, scale: 1.0, tag: 0, ..Default::default() }); + assert!(page.error.is_none(), "{:?}", page.error); + for x in [65usize, 235] { + let pixel = &page.rgba[(65 * 300 + x) * 4..][..4]; + assert!(pixel[0] < 240 && pixel[2] < 240 && pixel[1] < 10, "glyph at {x}: {pixel:?}"); + } + } + /// From `cargo xtask fuzz`: a Type 3 font without /Resources inherits the page's, where its /// own name is defined, and its glyph shows text in itself (vendored patch: /// `MAX_PAINT_NESTING`). Must terminate; a normal Type 3 glyph must still paint. @@ -3462,6 +3526,141 @@ trailer << /Root 1 0 R >> assert_eq!(&page.rgba[((38 * 40 + 1) * 4)..][..4], &[255, 0, 0, 255]); } + /// One page `w` × `h` points whose content `content` paints with pattern `/P1`: a 10 × 10 cell + /// of blue with `/XStep step /YStep step` and pattern matrix `matrix`. Its RGBA pixels. + fn tiling_page(w: usize, h: usize, content: &str, step: &str, matrix: &str) -> Vec { + let cell = "0 0 1 rg 0 0 10 10 re f"; + let pdf = format!( + "%PDF-1.7 +1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj +2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 >> endobj +3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 {w} {h}] /Contents 4 0 R /Resources << /Pattern << /P1 5 0 R >> >> >> endobj +4 0 obj << /Length {} >> stream +{content} +endstream endobj +5 0 obj << /Type /Pattern /PatternType 1 /PaintType 1 /TilingType 3 /BBox [0 0 10 10] /XStep {step} /YStep {step} /Matrix [{matrix}] /Length {} >> stream +{cell} +endstream endobj +trailer << /Root 1 0 R >> +%%EOF", + content.len(), + cell.len() + ); + let mut r = PageRenderer::new(Arc::new(pdf.into_bytes()), RenderConfig::default()); + let page = r.render(RenderRequest { page: 0, kind: RequestKind::Pixels, tile: None, scale: 1.0, tag: 0, ..Default::default() }); + assert!(page.error.is_none(), "{:?}", page.error); + page.rgba.to_vec() + } + + /// #3349: PowerPoint (PDFMaker) writes a picture fill as a tiling pattern drawn once, with + /// `/XStep 2147480100 /YStep 2147480100` and a matrix that flips y. Fitting such a step into + /// a cell pixmap shrank the picture below a pixel, so it vanished. The cell's `/BBox` is drawn + /// at full scale, once, filled or stroked: where it is it shows, and around it (left, right, + /// above, below) nothing repeats or smears. + #[test] + fn a_tiling_pattern_drawn_once_shows_its_cell() { + let at = |px: &[u8], w: usize, x: usize, y: usize| px[(y * w + x) * 4..][..3].to_vec(); + let (blue, white) = (vec![0, 0, 255], vec![255, 255, 255]); + // The cell at user x 10..20 and, flipped, y 10..20: device rows 20..30. + let once = tiling_page(40, 40, "/Pattern cs /P1 scn 0 0 40 40 re f", "2147480100", "1 0 0 -1 10 20"); + assert_eq!(at(&once, 40, 15, 25), blue, "the cell"); + for (x, y, side) in [(5, 25, "left"), (25, 25, "right"), (15, 15, "above"), (15, 35, "below")] { + assert_eq!(at(&once, 40, x, y), white, "nothing {side} of it"); + } + // Stroked: a wide line across the cell shows it too. + let stroked = tiling_page(40, 40, "/Pattern CS /P1 SCN 20 w 0 15 m 40 15 l S", "2147480100", "1 0 0 -1 10 20"); + assert_eq!(at(&stroked, 40, 15, 25), blue, "a stroke with the pattern"); + assert_eq!(at(&stroked, 40, 25, 25), white); + // An ordinary step still tiles. + let tiled = tiling_page(40, 40, "/Pattern cs /P1 scn 0 0 40 40 re f", "20", "1 0 0 1 0 0"); + assert_eq!(at(&tiled, 40, 25, 35), blue, "a step of 20 repeats the cell"); + } + + /// #3356 review: a `/BBox` too thin to divide by (not zero, which is refused, but 1e-40 + /// wide) makes the cell scale infinite; it keeps the old path, so no infinite or NaN + /// geometry reaches the cell renderer, the page renders and what follows still draws. + #[test] + fn a_tiling_cell_too_thin_to_scale_keeps_the_old_path() { + assert!(hayro::usable_cell_scale(2.0, 1.0)); + for (xs, ys) in [(f32::INFINITY, 1.0), (1.0, f32::NAN), (0.0, 1.0), (1.0, -2.0)] { + assert!(!hayro::usable_cell_scale(xs, ys), "{xs} {ys}"); + } + let thin = "0.0000000000000000000000000000000000000001"; + let content = "/Pattern cs /P1 scn 0 0 40 40 re f 1 0 0 rg 0 0 4 4 re f"; + let cell = "0 0 1 rg 0 0 10 10 re f"; + let pdf = format!( + "%PDF-1.7 +1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj +2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 >> endobj +3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 40 40] /Contents 4 0 R /Resources << /Pattern << /P1 5 0 R >> >> >> endobj +4 0 obj << /Length {} >> stream +{content} +endstream endobj +5 0 obj << /Type /Pattern /PatternType 1 /PaintType 1 /TilingType 3 /BBox [0 0 {thin} 10] /XStep 2147480100 /YStep 2147480100 /Length {} >> stream +{cell} +endstream endobj +trailer << /Root 1 0 R >> +%%EOF", + content.len(), + cell.len() + ); + let mut r = PageRenderer::new(Arc::new(pdf.into_bytes()), RenderConfig::default()); + let page = r.render(RenderRequest { page: 0, kind: RequestKind::Pixels, tile: None, scale: 1.0, tag: 0, ..Default::default() }); + assert!(page.error.is_none(), "{:?}", page.error); + assert_eq!(&page.rgba[((38 * 40 + 1) * 4)..][..4], &[255, 0, 0, 255], "what follows still draws"); + assert_eq!(&page.rgba[((20 * 40 + 20) * 4)..][..3], &[255, 255, 255], "the degenerate cell draws nothing"); + } + + /// #3349: a step too large for a cell pixmap is drawn as one cell only when the painted area + /// can't reach another: a page wide (or tall) enough to show the next cell, or the one before, + /// still tiles them. + #[test] + fn a_large_step_still_tiles_where_the_next_cell_shows() { + let at = |px: &[u8], w: usize, x: usize, y: usize| px[(y * w + x) * 4..][..3].to_vec(); + let blue = vec![0, 0, 255]; + // Steps of 3100 points: over 3000 pixels at this scale, so the cell is shrunk to fit, + // by 3000/3100; a pixel 4 points into a cell is blue either way. + let wide = "/Pattern cs /P1 scn 0 0 3200 20 re f"; + let next = tiling_page(3200, 20, wide, "3100", "1 0 0 1 0 0"); + assert_eq!(at(&next, 3200, 3104, 15), blue, "the cell to the right"); + let before = tiling_page(3200, 20, wide, "3100", "1 0 0 1 3100 0"); + assert_eq!(at(&before, 3200, 4, 15), blue, "the cell to the left"); + let tall = "/Pattern cs /P1 scn 0 0 20 3200 re f"; + let above = tiling_page(20, 3200, tall, "3100", "1 0 0 1 0 0"); + assert_eq!(at(&above, 20, 4, 3200 - 3104), blue, "the cell above"); + let below = tiling_page(20, 3200, tall, "3100", "1 0 0 1 0 3100"); + assert_eq!(at(&below, 20, 4, 3200 - 4), blue, "the cell below"); + // A stroke counts with its width: this line ends at x 3080, short of the next cell at + // 3100, but its square cap reaches 3110. + let stroked = tiling_page(3200, 20, "/Pattern CS /P1 SCN 60 w 2 J 0 10 m 3080 10 l S", "3100", "1 0 0 1 0 0"); + assert_eq!(at(&stroked, 3200, 3104, 15), blue, "the next cell, under the stroke's cap"); + // A miter reaches further than the line width (review on #3356): this join's vertex is + // at x 3085, 15 short of the next cell with a 10-wide line, but its miter tip, 45 long + // under the default limit of 10, reaches 3130. + let mitered = tiling_page(3200, 20, "/Pattern CS /P1 SCN 10 w 0 j 3000 0 m 3085 10 l 3000 20 l S", "3100", "1 0 0 1 0 0"); + // The tip narrows to a point, so this pixel is partly covered: blue, not white. + let tip = at(&mitered, 3200, 3104, 10); + assert!(tip[0] < 160 && tip[1] < 160 && tip[2] > 200, "the next cell, under the miter's tip: {tip:?}"); + // Under a skew, a direction between the axes stretches more than either axis (review on + // #3356). This join's vertex is at device x 2980 and its miter tip, along that direction, + // at 3107: the larger axis stretch (1.118) put the reach at 3092, short of the next cell; + // the most any direction stretches (1.461) puts it at 3126. + let skewed = tiling_page( + 3200, + 20, + "/Pattern CS /P1 SCN 1 0 1 0.5 0 0 cm 20 w 0 j 2783.936 -366.118 m 3033.6 -53.6 l 2721.082 -303.264 l S", + "3100", + "1 0 0 1 0 0", + ); + // The tip narrows to a point, so its pixels are partly covered. + let under_tip = (3100..3108).flat_map(|x| (10..20).map(move |y| (x, y))).map(|(x, y)| at(&skewed, 3200, x, y)); + assert!( + under_tip.clone().any(|px| px[0] < 200 && px[1] < 200 && px[2] > 240), + "the next cell, under the skewed miter's tip: {:?}", + under_tip.collect::>() + ); + } + /// Contributor-original JPEG 2000: one unsigned 8-bit pixel per component, one tile, /// no decomposition, reversible wavelet, and one empty packet per component. Empty /// coefficients decode to the midpoint (128) after the unsigned DC level shift. @@ -3503,6 +3702,60 @@ trailer << /Root 1 0 R >> pdf } + /// [`jpx_midpoint_pdf`]'s codestream (every sample 128) wrapped in a JP2 file whose header + /// has a colour specification naming enumerated colour space `enumerated`. + fn jp2_midpoint_pdf(components: u16, enumerated: u32) -> Vec { + let raw = jpx_midpoint_pdf(8, components); + let start = raw.windows(4).position(|w| w == [0xff, 0x4f, 0xff, 0x51]).unwrap(); + let end = raw.windows(2).rposition(|w| w == [0xff, 0xd9]).unwrap() + 2; + let codestream = &raw[start..end]; + let jp2_box = |kind: &[u8], body: &[u8]| [&((8 + body.len()) as u32).to_be_bytes()[..], kind, body].concat(); + let mut ihdr = Vec::new(); + ihdr.extend_from_slice(&1_u32.to_be_bytes()); // height + ihdr.extend_from_slice(&1_u32.to_be_bytes()); // width + ihdr.extend_from_slice(&components.to_be_bytes()); + ihdr.extend_from_slice(&[7, 7, 0, 0]); // 8 bits, wavelet, colour known, no IPR + let colr = [&[1, 0, 0][..], &enumerated.to_be_bytes()].concat(); + let jp2 = [ + vec![0, 0, 0, 12, b'j', b'P', b' ', b' ', 0x0d, 0x0a, 0x87, 0x0a], + jp2_box(b"ftyp", b"jp2 \0\0\0\0jp2 "), + jp2_box(b"jp2h", &[jp2_box(b"ihdr", &ihdr), jp2_box(b"colr", &colr)].concat()), + jp2_box(b"jp2c", codestream), + ] + .concat(); + let content = "q 20 0 0 20 5 5 cm /Im1 Do Q"; + let mut pdf = format!( + "%PDF-1.7\n\ +1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj\n\ +2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 >> endobj\n\ +3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 40 40] /Contents 4 0 R /Resources << /XObject << /Im1 5 0 R >> >> >> endobj\n\ +4 0 obj << /Length {} >> stream\n{content}\nendstream endobj\n\ +5 0 obj << /Type /XObject /Subtype /Image /Width 1 /Height 1 /BitsPerComponent 8 /Filter /JPXDecode /Length {} >> stream\n", + content.len(), + jp2.len() + ) + .into_bytes(); + pdf.extend_from_slice(&jp2); + pdf.extend_from_slice(b"\nendstream endobj\ntrailer << /Root 1 0 R >>\n%%EOF"); + pdf + } + + /// #3306: a JP2 image whose colour specification names an enumerated colour space the + /// decoder refuses (CIEJab in a veraPDF fixture, or YPbPr, or bilevel for one component) is + /// drawn as if it had none, by its component count, as MuPDF and Poppler draw it: the + /// midpoint image shows mid-grey instead of nothing. A space the decoder knows (sRGB) is + /// drawn as before. + #[test] + fn a_jp2_image_in_a_refused_enumerated_colour_space_is_drawn() { + for (components, enumerated) in [(3, 19), (3, 22), (1, 15), (3, 16)] { + let mut r = PageRenderer::new(Arc::new(jp2_midpoint_pdf(components, enumerated)), RenderConfig::default()); + let page = r.render(RenderRequest { page: 0, kind: RequestKind::Pixels, tile: None, scale: 1.0, tag: 0, ..Default::default() }); + assert!(page.error.is_none(), "{enumerated}: {:?}", page.error); + let px = &page.rgba[((40 - 15) * 40 + 15) * 4..][..3]; + assert!(px.iter().all(|&v| (120..=136).contains(&v)), "enum {enumerated}, {components} component(s): {px:?}"); + } + } + #[test] fn jpx_stream_bit_depth_boundaries_do_not_panic() { use hayro::hayro_syntax::bit_reader::BitReader; diff --git a/crates/sign/src/windows.rs b/crates/sign/src/windows.rs index 3974aff8ca..275bdb52c8 100644 --- a/crates/sign/src/windows.rs +++ b/crates/sign/src/windows.rs @@ -2,7 +2,7 @@ //! Private keys remain in CNG; Windows may ask permission to use a key. //! //! Certificates that can't sign through PdfCraft (no usable private key, a key type it doesn't -//! sign with) are reported in [`Listing::unusable`] with the reason rather than left out +//! sign with, a key usage or extended key usage that doesn't allow signing documents) are reported in [`Listing::unusable`] with the reason rather than left out //! silently, so a missing identity can be explained (issue #179). use std::sync::Arc; @@ -161,6 +161,13 @@ fn entries() -> Result { continue; } } + // A smart card holds encryption and logon certificates beside the signing one, all under + // the same name; a signature made with them doesn't validate, so they aren't offered. + // Checked after the key, so a certificate without one stays out of the list entirely. + if let Some(reason) = certificate.signing_problem_clause() { + skip(certificate.display_name(), reason, false); + continue; + } let public = certificate.public_key.clone(); let key = PrivateKey::external(public.clone(), Arc::new(WindowsKey { context: context.clone(), public })); let friendly_name = Some(certificate.display_name()); diff --git a/crates/sign/src/x509.rs b/crates/sign/src/x509.rs index 1a0ef58ade..6f4a731e7a 100644 --- a/crates/sign/src/x509.rs +++ b/crates/sign/src/x509.rs @@ -384,6 +384,15 @@ impl Certificate { } } + /// [`signing_problem`](Self::signing_problem) as a clause that follows the certificate's + /// name ("it is not issued for signing documents (…)"), for lists of digital IDs. + pub fn signing_problem_clause(&self) -> Option { + self.signing_problem().map(|d| match d.message.strip_prefix("The signer's certificate ") { + Some(rest) => format!("it {}", rest.strip_suffix('.').unwrap_or(rest)), + None => d.message, + }) + } + /// Issued by itself (subject = issuer and its own key verifies it). pub fn is_self_signed(&self) -> bool { self.issuer.raw == self.subject.raw && self.signed_by(&self.public_key) diff --git a/crates/sign/tests/chain.rs b/crates/sign/tests/chain.rs index ece529765a..59834104ee 100644 --- a/crates/sign/tests/chain.rs +++ b/crates/sign/tests/chain.rs @@ -281,6 +281,21 @@ fn a_trusted_signer_whose_certificate_may_not_sign_is_not_valid() { assert_eq!(status, Status::Valid, "{details:?}"); } +#[test] +fn a_smart_cards_certificates_that_may_not_sign_say_why_in_id_lists() { + // A DoD CAC's four certificates, all under the same name; only the signing one may sign. + let r = root(); + let clause = |exts: &[Vec]| signer_with("DOE.JANE.1234567890", &r, exts).cert.signing_problem_clause(); + let encryption = clause(&[key_usage(KEY_ENCIPHERMENT), extended_key_usage(&["1.3.6.1.5.5.7.3.4"])]); + assert_eq!(encryption.as_deref(), Some("it does not allow digital signatures (its key usage has neither digital signature nor non-repudiation)")); + let logon = clause(&[key_usage(DIGITAL_SIGNATURE), extended_key_usage(&["1.3.6.1.4.1.311.20.2.2", "1.3.6.1.5.5.7.3.2"])]); + assert_eq!(logon.as_deref(), Some("it is not issued for signing documents (its extended key usage has no document-signing purpose)")); + let card_auth = clause(&[key_usage(DIGITAL_SIGNATURE), extended_key_usage(&["2.16.840.1.101.3.6.8"])]); + assert_eq!(card_auth, logon); + let signing = clause(&[key_usage(DIGITAL_SIGNATURE | 0x40), extended_key_usage(&["1.3.6.1.5.5.7.3.4", "1.3.6.1.4.1.311.10.3.12"])]); + assert_eq!(signing, None); +} + #[test] fn unreadable_constraint_extensions_fail_closed() { // keyUsage present but not a BIT STRING: no usage at all, so no keyCertSign. diff --git a/crates/testkit/src/lib.rs b/crates/testkit/src/lib.rs index c9f5e55ef2..b4f69a2808 100644 --- a/crates/testkit/src/lib.rs +++ b/crates/testkit/src/lib.rs @@ -25,6 +25,8 @@ //! This crate is test scaffolding (`Class::Testkit` in `xtask/src/layers.rs`): a dev-dependency //! only, never compiled into the product. +#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] + /// A small PDF with a classic cross-reference table, built one object at a time. /// /// Objects are numbered from 1 in the order they are added, all at generation 0, so the first diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index b5ca6abab8..eb9244a284 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -482,6 +482,9 @@ pub struct DocView { /// (#759). The window outlasts a single frame so egui's internal easing tail is suppressed as /// it decays, instead of adding on top of the 1:1 motion. precise_scroll_until: f64, + /// Per-axis line-wheel state; horizontal input cannot extend a vertical burst. + wheel_boost_x: crate::wheel_boost::WheelBoost, + wheel_boost_y: crate::wheel_boost::WheelBoost, /// Touchpad momentum after the fingers lift (#531). fling: crate::fling::Fling, pub(crate) auto_scroll: crate::autoscroll::AutoScroll, @@ -756,6 +759,8 @@ impl DocView { shown: false, wheel: Default::default(), precise_scroll_until: 0.0, + wheel_boost_x: Default::default(), + wheel_boost_y: Default::default(), fling: Default::default(), auto_scroll: Default::default(), selected: BTreeSet::new(), @@ -835,6 +840,7 @@ impl DocView { self.flash = None; // A thumbnail drag holds page indexes from before the change. self.panel_drag = None; + self.reset_coarse_state(); } /// The document was replaced by a newer version of its file (#431). The reader's place @@ -1153,6 +1159,7 @@ impl DocView { .unwrap_or(0.0); self.goto = Some((page, frac)); self.current = page; + self.reset_coarse_state(); } /// Screen position of the centre of glyph `glyph` on `page`, if that page is on screen and its @@ -1174,6 +1181,14 @@ impl DocView { self.thumbs.get(&page).map(|t| &t.tex) } + /// Drops wheel state after a discontinuous jump so the destination cannot + /// inherit momentum from the previous viewport. + fn reset_coarse_state(&mut self) { + self.wheel.clear_motion(); + self.wheel_boost_x.reset(); + self.wheel_boost_y.reset(); + } + pub fn go_to_page(&mut self, page: usize) { let page = page.min(self.page_count.saturating_sub(1)); if page != self.current { @@ -1187,7 +1202,7 @@ impl DocView { self.forward.clear(); } // Partial wheel motion belongs to the old place. - self.wheel.clear_motion(); + self.reset_coarse_state(); self.goto = Some((page, 0.0)); self.current = page; } @@ -1322,6 +1337,7 @@ impl DocView { let page = page.min(self.page_count.saturating_sub(1)); self.goto = Some((page, 0.0)); self.current = page; + self.reset_coarse_state(); true } @@ -1346,12 +1362,14 @@ impl DocView { pub fn rotate_view(&mut self, clockwise: bool) { self.rotation = (self.rotation + if clockwise { 90 } else { 270 }) % 360; self.goto = Some((self.current, 0.0)); + self.reset_coarse_state(); } /// Switch the zoom mode, keeping the current page in view. pub fn set_fit(&mut self, fit: Fit) { self.fit = fit; self.goto = Some((self.current, 0.0)); + self.reset_coarse_state(); } /// Switch the base page display (Single or Two-page), leaving continuous scrolling as it is. @@ -1363,6 +1381,7 @@ impl DocView { } self.layout = layout; self.goto = Some((self.current, 0.0)); + self.reset_coarse_state(); } /// Whether the view scrolls continuously (as opposed to one page or spread at a time). @@ -1376,6 +1395,7 @@ impl DocView { if self.continuous != continuous { self.continuous = continuous; self.goto = Some((self.current, 0.0)); + self.reset_coarse_state(); } } @@ -1391,6 +1411,7 @@ impl DocView { if self.cover != cover { self.cover = cover; self.goto = Some((self.current, 0.0)); + self.reset_coarse_state(); } } @@ -2037,6 +2058,14 @@ fn annotation_hover_text(a: &pdfcraft_render::Annotation) -> Option { Some(if body.is_empty() { who } else { format!("{who}\n{body}") }) } +/// The scroll offset that puts `point` (content coordinates) at `rel` in the viewport, kept within +/// the content as egui keeps it. egui clamps only after drawing, so an offset past the end showed +/// the page misplaced for a frame and then jumped (zooming out beside a page that fits). +fn anchored_scroll(point: Pos2, rel: Vec2, content: Vec2, viewport: Vec2) -> Vec2 { + let max = vec2((content.x - viewport.x).max(0.0), (content.y - viewport.y).max(0.0)); + vec2((point.x - rel.x).clamp(0.0, max.x), (point.y - rel.y).clamp(0.0, max.y)) +} + /// The request tag for a raster at `scale`: equal tags mean the same scale (to 1/65536). fn scale_tag(scale: f32) -> u64 { (f64::from(scale) * 65536.0).round() as u64 @@ -2318,6 +2347,23 @@ pub fn shortcuts(view: &mut DocView, ctx: &egui::Context, speed: crate::scroll_s /// How far ↓ / ↑ scroll at Normal speed (Scroll speed halves or doubles it). Page Down / Page Up /// keep this much of the previous screen in view. const KEY_SCROLL_LINE: f32 = 40.0; +/// Suppression window for egui's eased copy of point input. +/// +/// egui 0.36.2 drains `WheelState::after_events` tail deltas by 90% per 0.1 s. +const SUPPRESS_SECS: f64 = 0.25; +/// Short rolling extension while egui still has an unprocessed wheel tail. +const SUPPRESS_TAIL_SECS: f64 = 0.05; + +// The viewer moves between docking and the central panel as modes change; keep its scroll +// identity tied to the document instead of either parent UI. +fn page_scroll( + ui: &mut egui::Ui, + scroll: egui::ScrollArea, + id: egui::Id, + draw: impl FnOnce(&mut egui::Ui, egui::Rect) -> R, +) -> egui::scroll_area::ScrollAreaOutput { + ui.scope_builder(egui::UiBuilder::new().id(id), |ui| scroll.show_viewport(ui, draw)).inner +} pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) { let t = Tokens::get(ui.ctx()); @@ -2507,36 +2553,25 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) { ui.input_mut(|i| i.smooth_scroll_delta.y = 0.0); } } - // Touchpad scrolling 1:1 (#759). A precision touchpad sends pixel-precise `MouseWheel` - // events; on Windows they carry no `TouchPhase::Start`, so egui never takes its no-smoothing - // path and eases deltas of 8 px or more, leaving the page a frame behind the finger. Apply - // the precise delta ourselves this frame through the un-animated scroll path, and suppress - // egui's eased copy while it decays so it neither doubles nor trails the motion. A coarse - // mouse wheel keeps egui's easing (pleasant for chunky notches). + // Point input bypasses egui smoothing for 1:1 motion. Line input keeps egui's configured, + // smoothed base and adds only the accelerated surplus. While point suppression is active, + // reconstruct line and page input here so current coarse events survive without exposing an + // older eased tail. let now = ui.input(|i| i.time); - let mut precise_scroll = Vec2::ZERO; - if ui.rect_contains_pointer(avail) { - let mut coarse_wheel = false; - ui.input(|i| { - for e in &i.events { - if let egui::Event::MouseWheel { unit, delta, modifiers, .. } = e { - if modifiers.command || modifiers.ctrl { - continue; // Zooming, not scrolling. - } - match unit { - // Shift scrolls sideways, matching egui's own wheel handling. - egui::MouseWheelUnit::Point if modifiers.shift => precise_scroll.x += delta.x + delta.y, - egui::MouseWheelUnit::Point => precise_scroll += *delta, - egui::MouseWheelUnit::Line | egui::MouseWheelUnit::Page => coarse_wheel = true, - } - } - } - }); - if coarse_wheel { - view.precise_scroll_until = 0.0; - } else if precise_scroll != Vec2::ZERO { - view.precise_scroll_until = now + 0.25; - } + let input = if ui.rect_contains_pointer(avail) { + // Match the line distance used by egui's other scroll areas. + let live = ui.ctx().options(|o| o.input_options.line_scroll_speed); + let line_points = if live.is_finite() && live > 0.0 { live } else { crate::scroll_speed::EGUI_LINE_POINTS }; + ui.input(|i| crate::wheel_boost::accumulate(&i.events, line_points, single_paging)) + } else { + crate::wheel_boost::FrameInput::default() + }; + let mut precise_scroll = input.precise; + let mut line_base = input.line; + let mut boost_extra = crate::wheel_boost::boost_extra(input, &mut view.wheel_boost_x, &mut view.wheel_boost_y, now); + let has_page = input.page != Vec2::ZERO; + if now.is_finite() && precise_scroll != Vec2::ZERO && !has_page { + view.precise_scroll_until = now + SUPPRESS_SECS; } if edge_turned { // The turn leaves egui's eased copy of the notch to decay for a few frames; it must not @@ -2549,11 +2584,40 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) { let paging_owns_vertical = single_paging || (!view.continuous && view.wheel.gesture_turned()); if paging_owns_vertical { precise_scroll.y = 0.0; + line_base.y = 0.0; + boost_extra.y = 0.0; } if now < view.precise_scroll_until { + if ui.input(|i| i.is_scrolling()) { + // Keep hiding egui's unprocessed copy until it has drained. The short rolling grace + // avoids a delayed duplicate without prolonging suppression after the tail is gone. + view.precise_scroll_until = view.precise_scroll_until.max(now + SUPPRESS_TAIL_SECS); + } ui.input_mut(|i| i.smooth_scroll_delta = Vec2::ZERO); + // The point path suppresses every eased wheel delta. Restore the line base through the + // same un-eased path as its surplus; paging-owned axes were cleared above. + boost_extra = crate::wheel_boost::cap_frame(boost_extra + line_base); + if has_page { + // Page units normally stay on egui's viewport-sized path. During suppression only, + // reproduce the current event directly so an older hidden tail stays hidden. + let page_scale = ui.input(|i| i.viewport_rect().height()); + let mut page_delta = + if page_scale.is_finite() && page_scale > 0.0 { crate::wheel_boost::cap_frame(input.page * page_scale) } else { Vec2::ZERO }; + if paging_owns_vertical { + page_delta.y = 0.0; + } + boost_extra = crate::wheel_boost::cap_frame(boost_extra + page_delta); + } } else { precise_scroll = Vec2::ZERO; + if line_base != Vec2::ZERO || has_page { + // Selection handling below uses zero to distinguish an inactive window. + view.precise_scroll_until = 0.0; + } + } + if has_page && paging_owns_vertical { + // Page input belongs to egui on the free axis; the pager still owns vertical motion. + ui.input_mut(|i| i.smooth_scroll_delta.y = 0.0); } // Touchpad momentum (#531): a swipe over the page lifted at speed glides on and slows to a // stop, through the same un-eased path. Never while the pager owns the wheel, a gesture or @@ -2631,7 +2695,7 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) { if let Some((page, fx, fy, rel)) = view.zoom_anchor.take() { let r = rects[page.min(rects.len() - 1)]; let point = pos2(r.left() + fx * r.width(), r.top() - y_shift + fy * r.height()); - scroll = scroll.scroll_offset(vec2((point.x - rel.x).max(0.0), (point.y - rel.y).max(0.0))); + scroll = scroll.scroll_offset(anchored_scroll(point, rel, vec2(content_w, content_h), avail.size())); } else if let Some((page, frac)) = view.goto.take() { let page = page.min(rects.len() - 1); let r = rects[page]; @@ -2766,7 +2830,8 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) { scroll = scroll.vertical_scroll_offset((r.top() - y_shift + fy * r.height() - rel.y).clamp(0.0, max_y)); } } - let out = scroll.show_viewport(ui, |ui, viewport| { + let selection_wheel = view.selection_drag && navigation_enabled && unobstructed && !single_paging && ui.rect_contains_pointer(avail); + let out = page_scroll(ui, scroll, egui::Id::new(("page-view-scope", view.id)), |ui, viewport| { // egui's drag responses accept every pointer button. Keep a wheel gesture from also // selecting text, drawing a mark, or panning with the Hand tool. if middle_gesture { @@ -2776,10 +2841,13 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) { ui.set_opacity(opacity); } // Middle-button auto-scroll, the keyboard, precise touchpad input (#759) and its glide - // (#531), and a drawing's edge scroll; positive y moves the content down. All scroll - // without easing, unlike egui's wheel default. - let delta = auto_delta + edge_delta - vec2(0.0, key_scroll) + precise_scroll + glide; - if delta != Vec2::ZERO { + // (#531), accelerated wheel surplus (#1072), and a drawing's edge scroll; positive y + // moves the content down. All scroll without easing, unlike egui's wheel default. + // ScrollArea ignores wheel input during a widget drag. Defer the wheel contribution + // until after the text widgets when a selection owns that drag. + let wheel_delta = if selection_wheel { Vec2::ZERO } else { precise_scroll + boost_extra }; + let delta = auto_delta + edge_delta - vec2(0.0, key_scroll) + wheel_delta + glide; + if delta != Vec2::ZERO && delta.is_finite() { ui.scroll_with_delta_animation(delta, egui::style::ScrollAnimation::none()); } let (resp_rect, resp) = ui.allocate_exact_size(vec2(content_w, content_h), Sense::click_and_drag()); @@ -3512,17 +3580,13 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) { // ScrollArea ignores the wheel while any widget is dragged, so a text-selection drag // scrolls the page itself (#721). After the page loop, so it keeps scrolling once the // pointer leaves the selection's page for the gap or the next page; over the whole - // document area, like #759. A Line/Page event clears precise_scroll_until; a Point - // gesture's eased tail after its suppression deadline is not picked up. - if view.selection_drag - && navigation_enabled - && unobstructed - && !single_paging - && view.precise_scroll_until == 0.0 - && ui.rect_contains_pointer(avail) - { - let delta = ui.input_mut(|input| std::mem::take(&mut input.smooth_scroll_delta)); - if delta != Vec2::ZERO { + // document area, like #759. Combine direct and smoothed motion in one request so mixed + // Point and Line events retain their full contribution. + if selection_wheel { + let smooth = + if view.precise_scroll_until == 0.0 { ui.input_mut(|input| std::mem::take(&mut input.smooth_scroll_delta)) } else { Vec2::ZERO }; + let delta = precise_scroll + boost_extra + smooth; + if delta != Vec2::ZERO && delta.is_finite() { ui.scroll_with_delta_animation(delta, egui::style::ScrollAnimation::none()); } } @@ -4438,11 +4502,8 @@ fn notices( // A form whose scripts were turned off no longer calculates or answers its buttons: that // comes before what else there is to say about the document. let scripts_off = doc.xfa_scripts_off(); - let msg = if scripts_off { - Some(("triangle-alert", crate::js_ui::scripts_off_notice(), true)) - } else if secured { - Some(("lock", tl!("This document is secured. Some changes are restricted by its security settings.").to_string(), false)) - } else if let Some(x) = xfa { + // What the document is as a form, if it is one: what the bar says first, secured or not. + let form = if let Some(x) = xfa { let lang = crate::i18n::current(); let pages = crate::i18n::trn(lang, x.pages as u64, "{n} page", "{n} pages"); let fields = crate::i18n::trn(lang, x.fields as u64, "{n} field", "{n} fields"); @@ -4473,6 +4534,21 @@ fn notices( crate::i18n::fmt(tl!("This document contains {n} interactive form fields."), &[("n", &info.fields.len().to_string())]), true, )) + } else { + None + }; + let msg = if scripts_off { + Some(("triangle-alert", crate::js_ui::scripts_off_notice(), true)) + } else if secured { + // A secured form still says what it is, and keeps Highlight fields: the security + // sentence follows the form's. + let security = tl!("This document is secured. Some changes are restricted by its security settings."); + Some(match form { + Some((icon, text, fields)) => (icon, format!("{text} {security}"), fields), + None => ("lock", security.to_string(), false), + }) + } else if form.is_some() { + form } else if repaired { Some(("bandage", tl!("This file was damaged and has been repaired. Saving keeps the repaired version.").to_string(), false)) } else if !info.warnings.is_empty() { @@ -5240,6 +5316,19 @@ fn grayscale(mut img: egui::ColorImage) -> egui::ColorImage { #[cfg(test)] mod tests { + #[test] + fn zoom_anchor_scroll_stays_within_the_content() { + let (content, viewport) = (vec2(1000.0, 2000.0), vec2(800.0, 600.0)); + // Past the end: clamped to the maximum (200, 1400), not left for egui to snap back. + assert_eq!(anchored_scroll(pos2(900.0, 1900.0), vec2(0.0, 0.0), content, viewport), vec2(200.0, 1400.0)); + // Before the start. + assert_eq!(anchored_scroll(pos2(10.0, 10.0), vec2(300.0, 300.0), content, viewport), Vec2::ZERO); + // Inside: unchanged. + assert_eq!(anchored_scroll(pos2(150.0, 800.0), vec2(100.0, 100.0), content, viewport), vec2(50.0, 700.0)); + // Content smaller than the viewport: no scrolling. + assert_eq!(anchored_scroll(pos2(400.0, 400.0), Vec2::ZERO, vec2(500.0, 500.0), viewport), vec2(0.0, 0.0)); + } + #[test] fn page_size_hint_zone_is_the_bottom_left_quadrant() { let area = Rect::from_min_max(pos2(100.0, 50.0), pos2(500.0, 450.0)); diff --git a/crates/ui-egui/src/chrome.rs b/crates/ui-egui/src/chrome.rs index fb9e0a7c0d..e01d752c40 100644 --- a/crates/ui-egui/src/chrome.rs +++ b/crates/ui-egui/src/chrome.rs @@ -533,88 +533,100 @@ pub fn mode_bar(app: &mut PdfCraftApp, ui: &mut egui::Ui) { }); } +/// Keep menus usable when their contents are taller than the window. +pub(crate) fn scroll_menu(ui: &mut egui::Ui, add: impl FnOnce(&mut egui::Ui)) { + let height = (ui.ctx().content_rect().height() - 24.0).max(40.0); + egui::ScrollArea::vertical().max_height(height).auto_shrink([true, true]).show(ui, add); +} + fn main_menu(app: &mut PdfCraftApp, ui: &mut egui::Ui) { let t = Tokens::get(ui.ctx()); let resp = widgets::ghost_button(ui, "panel-left", tl!("Menu")); egui::Popup::menu(&resp).show(|ui| { - ui.set_min_width(230.0); - ui.menu_button(tl!("File"), |ui| crate::commands::registry_menu(app, ui, "File")); - ui.menu_button(tl!("Edit"), |ui| crate::commands::registry_menu(app, ui, "Edit")); - ui.menu_button(tl!("Pages"), |ui| crate::commands::registry_menu(app, ui, "Pages")); - ui.menu_button(tl!("View"), |ui| { - if let Some(i) = app.active { - let v = &mut app.views[i]; - let ctx = ui.ctx().clone(); - let key = |s| crate::commands::shortcut_label(&ctx, s); - use crate::commands::{ACTUAL_SIZE, FIT_WIDTH, NEXT_VIEW, PAGE_LEVEL, PREV_VIEW, ROTATE_CCW, ROTATE_CW, ZOOM_IN, ZOOM_OUT}; - ui.label(egui::RichText::new(tl!("Zoom")).color(t.text_faint).small()); - if widgets::menu_item(ui, tl!("Actual size"), &key(ACTUAL_SIZE)).clicked() { - v.set_zoom(1.0); - } - if widgets::menu_item(ui, tl!("Zoom to page level"), &key(PAGE_LEVEL)).clicked() { - v.fit = Fit::Page; - } - if widgets::menu_item(ui, tl!("Fit to width"), &key(FIT_WIDTH)).clicked() { - v.fit = Fit::Width; - } - if widgets::menu_item(ui, tl!("Fit to height"), "").clicked() { - v.fit = Fit::Height; - v.goto = Some((v.current, 0.0)); - } - if widgets::menu_item(ui, tl!("Fit visible"), &crate::commands::command_shortcut_label(&ctx, "view.fit_visible")).clicked() { - ui.close(); - app.execute("view.fit_visible"); - return; - } - if widgets::menu_item(ui, tl!("Zoom in"), &key(ZOOM_IN)).clicked() { - v.zoom_step(true); - } - if widgets::menu_item(ui, tl!("Zoom out"), &key(ZOOM_OUT)).clicked() { - v.zoom_step(false); - } - if widgets::menu_item(ui, tl!("Rotate view clockwise"), &key(ROTATE_CW)).clicked() { - v.rotate_view(true); - } - if widgets::menu_item(ui, tl!("Rotate view counterclockwise"), &key(ROTATE_CCW)).clicked() { - v.rotate_view(false); - } - ui.separator(); - ui.label(egui::RichText::new(tl!("Page navigation")).color(t.text_faint).small()); - if ui.add_enabled(!v.back.is_empty(), egui::Button::new(tl!("Previous view")).shortcut_text(key(PREV_VIEW))).clicked() { - v.view_history(false); - } - if ui.add_enabled(!v.forward.is_empty(), egui::Button::new(tl!("Next view")).shortcut_text(key(NEXT_VIEW))).clicked() { - v.view_history(true); - } - ui.separator(); - if let Some(id) = page_display_menu(ui, &app.views[i]) { - app.execute(id); - } - ui.separator(); - } - crate::commands::registry_menu(app, ui, "View"); - ui.menu_button(tl!("Display theme"), |ui| theme_menu(app, ui)); - ui.menu_button(tl!("Side panels"), |ui| { - for (p, label) in [ - (RightPanel::Comments, tl!("Comments")), - (RightPanel::Bookmarks, tl!("Bookmarks")), - (RightPanel::Pages, tl!("Pages")), - (RightPanel::Fields, tl!("Fields")), - (RightPanel::Layers, tl!("Layers")), - (RightPanel::Attachments, tl!("Attachments")), - (RightPanel::Signatures, tl!("Signatures")), - (RightPanel::Accessibility, tl!("Accessibility Checker")), - (RightPanel::Tags, tl!("Tags")), - (RightPanel::Search, tl!("Search")), - (RightPanel::Compare, tl!("Compare")), - ] { - if ui.add(widgets::radio(app.right == Some(p), tl!(label))).clicked() { - app.choose_right_panel(Some(p)); + scroll_menu(ui, |ui| { + ui.set_min_width(230.0); + ui.menu_button(tl!("File"), |ui| scroll_menu(ui, |ui| crate::commands::registry_menu(app, ui, "File"))); + ui.menu_button(tl!("Edit"), |ui| scroll_menu(ui, |ui| crate::commands::registry_menu(app, ui, "Edit"))); + ui.menu_button(tl!("Pages"), |ui| scroll_menu(ui, |ui| crate::commands::registry_menu(app, ui, "Pages"))); + ui.menu_button(tl!("View"), |ui| { + scroll_menu(ui, |ui| { + if let Some(i) = app.active { + let v = &mut app.views[i]; + let ctx = ui.ctx().clone(); + let key = |s| crate::commands::shortcut_label(&ctx, s); + use crate::commands::{ACTUAL_SIZE, FIT_WIDTH, NEXT_VIEW, PAGE_LEVEL, PREV_VIEW, ROTATE_CCW, ROTATE_CW, ZOOM_IN, ZOOM_OUT}; + ui.label(egui::RichText::new(tl!("Zoom")).color(t.text_faint).small()); + if widgets::menu_item(ui, tl!("Actual size"), &key(ACTUAL_SIZE)).clicked() { + v.set_zoom(1.0); + } + if widgets::menu_item(ui, tl!("Zoom to page level"), &key(PAGE_LEVEL)).clicked() { + v.fit = Fit::Page; + } + if widgets::menu_item(ui, tl!("Fit to width"), &key(FIT_WIDTH)).clicked() { + v.fit = Fit::Width; + } + if widgets::menu_item(ui, tl!("Fit to height"), "").clicked() { + v.fit = Fit::Height; + v.goto = Some((v.current, 0.0)); + } + if widgets::menu_item(ui, tl!("Fit visible"), &crate::commands::command_shortcut_label(&ctx, "view.fit_visible")).clicked() { + ui.close(); + app.execute("view.fit_visible"); + return; + } + if widgets::menu_item(ui, tl!("Zoom in"), &key(ZOOM_IN)).clicked() { + v.zoom_step(true); + } + if widgets::menu_item(ui, tl!("Zoom out"), &key(ZOOM_OUT)).clicked() { + v.zoom_step(false); + } + if widgets::menu_item(ui, tl!("Rotate view clockwise"), &key(ROTATE_CW)).clicked() { + v.rotate_view(true); + } + if widgets::menu_item(ui, tl!("Rotate view counterclockwise"), &key(ROTATE_CCW)).clicked() { + v.rotate_view(false); + } + ui.separator(); + ui.label(egui::RichText::new(tl!("Page navigation")).color(t.text_faint).small()); + if ui.add_enabled(!v.back.is_empty(), egui::Button::new(tl!("Previous view")).shortcut_text(key(PREV_VIEW))).clicked() { + v.view_history(false); + } + if ui.add_enabled(!v.forward.is_empty(), egui::Button::new(tl!("Next view")).shortcut_text(key(NEXT_VIEW))).clicked() { + v.view_history(true); + } + ui.separator(); + if let Some(id) = page_display_menu(ui, &app.views[i]) { + app.execute(id); + } + ui.separator(); } - } + crate::commands::registry_menu(app, ui, "View"); + ui.menu_button(tl!("Display theme"), |ui| theme_menu(app, ui)); + ui.menu_button(tl!("Side panels"), |ui| { + scroll_menu(ui, |ui| { + for (p, label) in [ + (RightPanel::Comments, tl!("Comments")), + (RightPanel::Bookmarks, tl!("Bookmarks")), + (RightPanel::Pages, tl!("Pages")), + (RightPanel::Fields, tl!("Fields")), + (RightPanel::Layers, tl!("Layers")), + (RightPanel::Attachments, tl!("Attachments")), + (RightPanel::Signatures, tl!("Signatures")), + (RightPanel::Accessibility, tl!("Accessibility Checker")), + (RightPanel::Tags, tl!("Tags")), + (RightPanel::Search, tl!("Search")), + (RightPanel::Compare, tl!("Compare")), + ] { + if ui.add(widgets::radio(app.right == Some(p), tl!(label))).clicked() { + app.choose_right_panel(Some(p)); + } + } + }) + }); + }) }); - }); - ui.menu_button(tl!("Help"), |ui| crate::commands::registry_menu(app, ui, "Help")); + ui.menu_button(tl!("Help"), |ui| scroll_menu(ui, |ui| crate::commands::registry_menu(app, ui, "Help"))); + }) }); } diff --git a/crates/ui-egui/src/commands.rs b/crates/ui-egui/src/commands.rs index dd4cda9a55..3e3f1e333a 100644 --- a/crates/ui-egui/src/commands.rs +++ b/crates/ui-egui/src/commands.rs @@ -780,17 +780,19 @@ pub(crate) fn registry_menu(app: &mut PdfCraftApp, ui: &mut egui::Ui, menu: &str let mut open: Option = None; let mut clear = false; ui.menu_button(label, |ui| { - for r in &app.recent { - if ui.button(&r.name).on_hover_text(&r.path).clicked() { - open = Some(r.path.clone()); + crate::chrome::scroll_menu(ui, |ui| { + for r in &app.recent { + if ui.button(&r.name).on_hover_text(&r.path).clicked() { + open = Some(r.path.clone()); + ui.close(); + } + } + ui.separator(); + if ui.button(tl!("Clear Recent Files")).clicked() { + clear = true; ui.close(); } - } - ui.separator(); - if ui.button(tl!("Clear Recent Files")).clicked() { - clear = true; - ui.close(); - } + }) }); if let Some(p) = open { app.open_recent(&p); diff --git a/crates/ui-egui/src/comment_props.rs b/crates/ui-egui/src/comment_props.rs index 052fcd55fc..19f7187af6 100644 --- a/crates/ui-egui/src/comment_props.rs +++ b/crates/ui-egui/src/comment_props.rs @@ -109,8 +109,7 @@ impl PdfCraftApp { style.fill = p.fill; } self.comment_prefs.set_style(tool, style); - // Opacity and width go through the clamping setters: the dialog allows 0% opacity, and a - // default that draws invisible comments can't be found again. + // Keep invisible defaults out of tools where a zero stroke would make them disappear. self.comment_prefs.set_opacity(tool, p.opacity); if let Some(w) = p.width { self.comment_prefs.set_width(tool, w); @@ -240,9 +239,10 @@ pub(crate) fn body(ui: &mut egui::Ui, app: &mut PdfCraftApp, t: &Tokens) -> (boo e.opacity = pct / 100.0; } ui.end_row(); + let min_width = if e.font_size.is_some() { 0.0 } else { 0.5 }; if let Some(w) = e.width.as_mut() { ui.label(tl!("Thickness")); - ui.add(egui::Slider::new(w, 0.5..=12.0).step_by(0.5).suffix(" pt")); + ui.add(egui::Slider::new(w, min_width..=12.0).step_by(0.5).suffix(" pt")); ui.end_row(); } if let Some(ends) = e.endings.as_mut() { diff --git a/crates/ui-egui/src/comments.rs b/crates/ui-egui/src/comments.rs index 68b1a72a0c..7fd9b7a622 100644 --- a/crates/ui-egui/src/comments.rs +++ b/crates/ui-egui/src/comments.rs @@ -188,12 +188,17 @@ impl CommentTool { /// Whether the line-thickness control applies. pub fn has_width(self) -> bool { - self.draws() || self.clicks_points() + self.draws() || self.clicks_points() || matches!(self, Self::TextBox | Self::Callout) } - /// Whether the fill-colour control applies: shapes with an interior (#686). + /// Text comments may have no border while retaining visible text or fill. + fn min_width(self) -> f64 { + if matches!(self, Self::TextBox | Self::Callout) { 0.0 } else { 0.5 } + } + + /// Whether the fill-colour control applies to this tool. pub fn has_fill(self) -> bool { - matches!(self, Self::Rectangle | Self::Oval | Self::Polygon | Self::Cloud) + matches!(self, Self::Rectangle | Self::Oval | Self::Polygon | Self::Cloud | Self::TextBox | Self::Callout) } /// A placeholder shape of this kind (for per-tool default styles). @@ -284,7 +289,7 @@ impl CommentPrefs { pub fn set_width(&mut self, tool: CommentTool, w: f64) { if let Some((_, s)) = self.styles.iter_mut().find(|(t, _)| *t == tool) { - s.width = w.clamp(0.5, 12.0); + s.width = w.clamp(tool.min_width(), 12.0); } } @@ -295,7 +300,7 @@ impl CommentPrefs { } } - /// The tool's fill (`None`: no fill). Only shapes with an interior keep one. + /// The tool's fill (`None`: no fill). pub fn set_fill(&mut self, tool: CommentTool, fill: Option) { if let Some((_, s)) = self.styles.iter_mut().find(|(t, _)| *t == tool) { s.fill = if tool.has_fill() { fill.filter(|c| c.iter().all(|x| x.is_finite())).map(|c| c.map(|x| x.clamp(0.0, 1.0))) } else { None }; @@ -1899,7 +1904,7 @@ pub(crate) fn quick_bar_controls(ui: &mut egui::Ui, tool: CommentTool, prefs: &m ui.set_min_width(180.0); let mut w = style.width; ui.label(egui::RichText::new(tl!("Line thickness")).font(theme::semibold(12.0))); - if ui.add(egui::Slider::new(&mut w, 0.5..=12.0).step_by(0.5).suffix(" pt")).changed() { + if ui.add(egui::Slider::new(&mut w, tool.min_width()..=12.0).step_by(0.5).suffix(" pt")).changed() { prefs.set_width(tool, w); } }); diff --git a/crates/ui-egui/src/editing.rs b/crates/ui-egui/src/editing.rs index e554d7a1b0..91480ac7b5 100644 --- a/crates/ui-egui/src/editing.rs +++ b/crates/ui-egui/src/editing.rs @@ -1420,6 +1420,7 @@ mod revert_draft_tests { press: None, calendar: None, calendar_rect: None, + calendar_text: None, } } diff --git a/crates/ui-egui/src/forms_ui.rs b/crates/ui-egui/src/forms_ui.rs index 8020e868b2..612b985e65 100644 --- a/crates/ui-egui/src/forms_ui.rs +++ b/crates/ui-egui/src/forms_ui.rs @@ -38,6 +38,9 @@ pub struct Focus { /// Date fields: the month the calendar shows (year, month 1–12), and where it was drawn. pub calendar: Option<(i32, u32)>, pub calendar_rect: Option, + /// The text the calendar last saw, so it moves to a typed date once, when the typing becomes + /// a whole date, and otherwise keeps the month its arrows turned to. + pub calendar_text: Option, } #[derive(Clone, Debug, Default)] @@ -90,6 +93,7 @@ fn open_focus(f: &FormField, widget: usize) -> Focus { press: None, calendar: None, calendar_rect: None, + calendar_text: None, } } @@ -255,11 +259,19 @@ const MONTHS: [&str; 12] = ["January", "February", "March", "April", "May", "Jun fn calendar(ctx: &egui::Context, view: &mut DocView, field: egui::Rect, fmt: &str, today: (i64, u32, u32)) -> Option { use pdfcraft_engine::form_scripts::{DateTime, format_date, parse_date}; let fx = view.forms.focus.as_mut()?; - let (mut y, mut m) = fx.calendar.unwrap_or_else(|| match parse_date(&fx.text, fmt) { - Some(d) => (d.y, d.m), - None => (today.0 as i32, today.1), - }); let current = parse_date(&fx.text, fmt); + // A whole date typed since the last frame moves the calendar to it; half a date, or text + // that hasn't changed, leaves it on the month it shows (the one the arrows turned to). + let changed = fx.calendar_text.as_deref() != Some(fx.text.as_str()); + let (mut y, mut m) = match (fx.calendar, current) { + (Some(_), Some(d)) if changed => (d.y, d.m), + (Some(shown), _) => shown, + (None, Some(d)) => (d.y, d.m), + (None, None) => (today.0 as i32, today.1), + }; + if changed { + fx.calendar_text = Some(fx.text.clone()); + } let mut picked = None; let area = egui::Area::new(egui::Id::new(("date-picker", view.id.0))) .order(egui::Order::Foreground) diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 6f68ba0dce..153647f44c 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -139,6 +139,7 @@ mod tab_context; pub mod theme; pub mod ui_scale; pub mod updates; +mod wheel_boost; mod wheel_pager; mod widgets; pub mod window_controls; @@ -2832,7 +2833,15 @@ impl PdfCraftApp { // properties; Escape cancels it rather than clearing a selection), and nothing may run // underneath it. They are read here, before the canvas can consume them. if self.close_request.is_some() { - if let Some(choice) = dialogs::save_prompt_key(ctx) { + // These dialogs are drawn after the save prompt and own the keyboard while open. + // Don't let Escape or Return resolve unsaved work underneath one. + let covered = self.pending_link.is_some() + || self.pending_remote_link.is_some() + || self.pending_full_screen.is_some() + || self.updates.open + || self.dialog.is_some() + || self.unlock_prompt.is_some(); + if !covered && let Some(choice) = dialogs::save_prompt_key(ctx) { self.resolve_close(ctx, choice); } return; diff --git a/crates/ui-egui/src/scroll_speed.rs b/crates/ui-egui/src/scroll_speed.rs index a2d857c741..cc3a7e86e5 100644 --- a/crates/ui-egui/src/scroll_speed.rs +++ b/crates/ui-egui/src/scroll_speed.rs @@ -18,7 +18,7 @@ pub enum ScrollSpeed { } /// egui's own `line_scroll_speed`, which its wheel zoom rate is tuned against. -const EGUI_LINE_POINTS: f32 = if cfg!(target_arch = "wasm32") { 8.0 } else { 40.0 }; +pub(crate) const EGUI_LINE_POINTS: f32 = if cfg!(target_arch = "wasm32") { 8.0 } else { 40.0 }; /// Points one wheel line scrolls at Normal speed. const NORMAL_LINE_POINTS: f32 = if cfg!(target_arch = "wasm32") { diff --git a/crates/ui-egui/src/wheel_boost.rs b/crates/ui-egui/src/wheel_boost.rs new file mode 100644 index 0000000000..b9d474565e --- /dev/null +++ b/crates/ui-egui/src/wheel_boost.rs @@ -0,0 +1,395 @@ +//! Bounded acceleration for coarse wheel input in the document viewport. +//! +//! Line-unit events describe notches, not motion over time. A fixed distance per +//! notch makes a fast spin feel slow, so this module derives a bounded multiplier +//! from arrival time and direction. The state is constant-size and leaves isolated +//! notches unchanged. + +use egui::Vec2; + +/// Idle time after which a burst is forgotten, in seconds. +const RESET_GAP_SECS: f64 = 0.3; +/// Notch spacing at or below which input counts as a continuous spin, in seconds. +const FAST_GAP_SECS: f64 = 0.08; +/// Multiplier gained per fast consecutive notch. +const BOOST_PER_TICK: f32 = 0.4; +/// Hard limit for accelerated motion. +const MAX_BOOST: f32 = 6.0; +/// Ticks that affect the output. +const MAX_TICKS: u32 = 13; +/// Extra ticks retained beyond the ceiling. They absorb a few slow events +/// before the visible multiplier starts to fall. +const HYSTERESIS_TICKS: u32 = 4; +/// Maximum scroll applied from one frame of coarse input, in points per axis. +/// Each event is clamped first; this caps the sum. +pub(crate) const MAX_FRAME_POINTS: f32 = 8192.0; +/// Per-event clamp, in native units (lines or pages). Keeps one hostile event +/// bounded before points conversion. +pub(crate) const MAX_EVENT_UNITS: f32 = 10.0; + +/// Constant-size arrival-rate state for line wheel events. +#[derive(Debug, Default)] +pub(crate) struct WheelBoost { + last: f64, + burst: u32, + dir: i8, +} + +impl WheelBoost { + /// Returns the multiplier for one bucket of motion carrying `motion`. + /// + /// `motion` is signed in native units; only its sign matters. Zero means + /// no motion. Non-finite time returns 1.0 and leaves state unchanged; + /// backwards time resets the burst and returns 1.0. + pub(crate) fn multiplier(&mut self, now: f64, motion: f32) -> f32 { + if !now.is_finite() || !motion.is_finite() || motion == 0.0 { + return 1.0; + } + let dir = if motion > 0.0 { 1 } else { -1 }; + let idle = now - self.last; + if !idle.is_finite() || idle < 0.0 { + self.last = now; + self.burst = 0; + self.dir = dir; + return 1.0; + } + if idle > RESET_GAP_SECS || dir != self.dir { + self.burst = 0; + } else if idle <= FAST_GAP_SECS { + self.burst = self.burst.saturating_add(1).min(MAX_TICKS + HYSTERESIS_TICKS); + } else { + // Slow input in the same direction decays rather than extending the burst. + self.burst = self.burst.saturating_sub(1); + } + self.last = now; + self.dir = dir; + let ticks = self.burst.min(MAX_TICKS); + // MAX_TICKS is well within f32's exact integer range. + (1.0 + ticks as f32 * BOOST_PER_TICK).min(MAX_BOOST) + } + + /// Drops burst state. Call after a programmatic scroll jump. + pub(crate) fn reset(&mut self) { + *self = Self::default(); + } +} + +/// Caps one frame of accumulated coarse scroll, per axis. A non-finite axis +/// maps to zero; the other axis is preserved. +pub(crate) fn cap_frame(delta: Vec2) -> Vec2 { + Vec2::new( + if delta.x.is_finite() { delta.x.clamp(-MAX_FRAME_POINTS, MAX_FRAME_POINTS) } else { 0.0 }, + if delta.y.is_finite() { delta.y.clamp(-MAX_FRAME_POINTS, MAX_FRAME_POINTS) } else { 0.0 }, + ) +} + +/// One frame of wheel input, normalized in a single pass. Line motion is in +/// points; page motion remains in native units until the viewport is known. +#[derive(Clone, Copy, Debug, Default, PartialEq)] +pub(crate) struct FrameInput { + pub precise: Vec2, + pub line: Vec2, + pub page: Vec2, +} + +/// Folds raw wheel events into frame totals without consulting time or UI state. +/// Invalid scales contribute no line motion. +pub(crate) fn accumulate(events: &[egui::Event], line_points: f32, single_paging: bool) -> FrameInput { + let line_points = sanitize_scale(line_points); + let mut out = FrameInput::default(); + for event in events { + let egui::Event::MouseWheel { unit, delta, modifiers, .. } = event else { continue }; + if modifiers.command || modifiers.ctrl { + continue; // Zooming, not scrolling. + } + if !delta.is_finite() { + continue; + } + match unit { + egui::MouseWheelUnit::Point if modifiers.shift => { + // Shift scrolls sideways, matching egui's own wheel handling. + let sum = delta.x + delta.y; + if sum.is_finite() { + out.precise.x += sum; + } + } + egui::MouseWheelUnit::Point => out.precise += *delta, + egui::MouseWheelUnit::Line if modifiers.shift => { + let sum = delta.x + delta.y; + if sum.is_finite() { + out.line.x += sum.clamp(-MAX_EVENT_UNITS, MAX_EVENT_UNITS) * line_points; + } + } + egui::MouseWheelUnit::Line => { + out.line.x += delta.x.clamp(-MAX_EVENT_UNITS, MAX_EVENT_UNITS) * line_points; + if !single_paging { + out.line.y += delta.y.clamp(-MAX_EVENT_UNITS, MAX_EVENT_UNITS) * line_points; + } + } + egui::MouseWheelUnit::Page if modifiers.shift => { + let sum = delta.x + delta.y; + if sum.is_finite() { + out.page.x += sum.clamp(-MAX_EVENT_UNITS, MAX_EVENT_UNITS); + } + } + egui::MouseWheelUnit::Page => { + out.page.x += delta.x.clamp(-MAX_EVENT_UNITS, MAX_EVENT_UNITS); + out.page.y += delta.y.clamp(-MAX_EVENT_UNITS, MAX_EVENT_UNITS); + } + } + } + // Bound batched events before acceleration is applied. + out.precise = cap_frame(out.precise); + out.line = cap_frame(out.line); + out.page = cap_frame(out.page); + out +} + +/// Converts the frame total to the extra scroll delta beyond egui's share. +/// +/// egui applies the base distance through its own smoothing. This function +/// returns only the accelerated surplus, so an isolated notch adds nothing. +/// Each axis advances once per frame; events batched into one frame cannot +/// accelerate one another. Output is frame-capped. +pub(crate) fn boost_extra(input: FrameInput, boost_x: &mut WheelBoost, boost_y: &mut WheelBoost, now: f64) -> Vec2 { + let mut extra = Vec2::ZERO; + if input.line.x != 0.0 && input.line.x.is_finite() { + extra.x += input.line.x * (boost_x.multiplier(now, input.line.x) - 1.0); + } + if input.line.y != 0.0 && input.line.y.is_finite() { + extra.y += input.line.y * (boost_y.multiplier(now, input.line.y) - 1.0); + } + cap_frame(extra) +} + +/// Maps a non-finite or non-positive points-per-unit scale to zero. +fn sanitize_scale(points: f32) -> f32 { + if points.is_finite() && points > 0.0 { points } else { 0.0 } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn single_and_slow_notches_stay_unboosted() { + let mut b = WheelBoost::default(); + assert_eq!(b.multiplier(0.0, -1.0), 1.0); + assert_eq!(b.multiplier(0.5, -1.0), 1.0); + assert_eq!(b.multiplier(1.0, -1.0), 1.0); + } + + #[test] + fn fast_spin_accelerates_then_caps() { + let mut b = WheelBoost::default(); + assert_eq!(b.multiplier(1.0, -1.0), 1.0); + let mut last = 1.0; + for (i, t) in (1..=20).map(|i| 1.0 + f64::from(i) * 0.02).enumerate() { + last = b.multiplier(t, -1.0); + if i < 2 { + assert!(last >= 1.0, "early burst must not jump, got {last}"); + } + } + assert!(last > 2.0, "a sustained fast spin must accelerate, got {last}"); + assert!(last <= MAX_BOOST, "boost must be capped, got {last}"); + assert_eq!(last, MAX_BOOST, "a long spin should reach the ceiling"); + for i in 1..=20 { + let m = b.multiplier(1.5 + f64::from(i) * 0.02, -1.0); + assert!(m <= MAX_BOOST, "boost must stay capped, got {m}"); + } + } + + #[test] + fn reversal_and_idle_reset_the_burst() { + let mut b = WheelBoost::default(); + b.multiplier(1.0, -1.0); + for i in 1..=6 { + b.multiplier(1.0 + f64::from(i) * 0.02, -1.0); + } + let boosted = b.multiplier(1.0 + 7.0 * 0.02, -1.0); + assert!(boosted > 1.0, "precondition: burst is boosted, got {boosted}"); + assert_eq!(b.multiplier(1.0 + 8.0 * 0.02, 1.0), 1.0); + for i in 9..=14 { + b.multiplier(1.0 + f64::from(i) * 0.02, 1.0); + } + assert_eq!(b.multiplier(5.0, 1.0), 1.0); + } + + #[test] + fn invalid_input_never_boosts_or_corrupts() { + let mut b = WheelBoost::default(); + for bad_now in [f64::NAN, f64::INFINITY, f64::NEG_INFINITY] { + assert_eq!(b.multiplier(bad_now, -1.0), 1.0); + } + for bad_motion in [0.0, -0.0, f32::NAN, f32::INFINITY, f32::NEG_INFINITY] { + assert_eq!(b.multiplier(1.0, bad_motion), 1.0); + } + assert_eq!(b.multiplier(2.0, -1.0), 1.0); + b.reset(); + assert_eq!(b.multiplier(3.0, -1.0), 1.0); + } + + #[test] + fn same_timestamp_events_count_as_a_burst() { + let mut b = WheelBoost::default(); + assert_eq!(b.multiplier(1.0, -1.0), 1.0); + for _ in 0..6 { + b.multiplier(1.0, -1.0); + } + assert!(b.multiplier(1.0, -1.0) > 1.0); + } + + #[test] + fn fractional_notches_share_the_burst() { + let mut b = WheelBoost::default(); + b.multiplier(1.0, -0.25); + for i in 1..=8 { + b.multiplier(1.0 + f64::from(i) * 0.02, -0.25); + } + assert!(b.multiplier(1.0 + 9.0 * 0.02, -0.25) > 1.0); + } + + #[test] + fn sleep_wake_resets_the_burst() { + let mut b = WheelBoost::default(); + b.multiplier(1.0, -1.0); + for i in 1..=6 { + b.multiplier(1.0 + f64::from(i) * 0.02, -1.0); + } + assert_eq!(b.multiplier(3600.0, -1.0), 1.0); + } + + #[test] + fn backwards_time_resets_without_corrupting() { + let mut b = WheelBoost::default(); + b.multiplier(1.0, -1.0); + b.multiplier(1.02, -1.0); + assert_eq!(b.multiplier(0.5, -1.0), 1.0); + assert_eq!(b.multiplier(0.52, -1.0), 1.4); + } + + #[test] + fn unhurried_spin_decays() { + let mut b = WheelBoost::default(); + b.multiplier(1.0, -1.0); + for i in 1..=8 { + b.multiplier(1.0 + f64::from(i) * 0.02, -1.0); + } + let peak = b.multiplier(1.0 + 9.0 * 0.02, -1.0); + assert!(peak > 1.0); + // Same direction but slower than the fast gap: each event sheds one tick. + let mut prev = peak; + for i in 10..=13 { + let m = b.multiplier(1.0 + f64::from(i) * 0.15, -1.0); + assert!(m <= prev, "decay must not grow: {m} > {prev}"); + prev = m; + } + } + + #[test] + fn hysteresis_holds_the_ceiling_briefly() { + let mut b = WheelBoost::default(); + let mut t = 1.0; + b.multiplier(t, -1.0); + for _ in 0..20 { + t += 0.02; + b.multiplier(t, -1.0); + } + assert_eq!(b.multiplier(t + 0.02, -1.0), MAX_BOOST); + // A couple of unhurried events consume hysteresis before output falls. + t += 0.15; + assert_eq!(b.multiplier(t, -1.0), MAX_BOOST); + t += 0.15; + assert_eq!(b.multiplier(t, -1.0), MAX_BOOST); + } + + #[test] + fn frame_cap_bounds_each_axis() { + use egui::vec2; + assert_eq!(cap_frame(vec2(40.0, -40.0)), vec2(40.0, -40.0)); + assert_eq!(cap_frame(vec2(1e9, -1e9)), vec2(MAX_FRAME_POINTS, -MAX_FRAME_POINTS)); + assert_eq!(cap_frame(vec2(f32::NAN, 40.0)), vec2(0.0, 40.0)); + assert_eq!(cap_frame(vec2(40.0, f32::INFINITY)), vec2(40.0, 0.0)); + } + + fn wheel(unit: egui::MouseWheelUnit, dx: f32, dy: f32) -> egui::Event { + egui::Event::MouseWheel { unit, delta: egui::vec2(dx, dy), phase: egui::TouchPhase::Move, modifiers: egui::Modifiers::NONE } + } + + fn shifted(unit: egui::MouseWheelUnit, dx: f32, dy: f32) -> egui::Event { + egui::Event::MouseWheel { unit, delta: egui::vec2(dx, dy), phase: egui::TouchPhase::Move, modifiers: egui::Modifiers::SHIFT } + } + + #[test] + fn isolated_notch_adds_no_extra() { + use egui::MouseWheelUnit::Line; + for base in [50.0, 100.0, 200.0, 40.0, 8.0] { + let input = accumulate(&[wheel(Line, 0.0, -1.0)], base, false); + assert_eq!(input.line, egui::vec2(0.0, -base)); + let mut bx = WheelBoost::default(); + let mut by = WheelBoost::default(); + assert_eq!(boost_extra(input, &mut bx, &mut by, 1.0), egui::Vec2::ZERO); + } + } + + #[test] + fn fast_spin_adds_a_bounded_extra() { + use egui::MouseWheelUnit::Line; + let mut bx = WheelBoost::default(); + let mut by = WheelBoost::default(); + let mut peak = 0.0f32; + for i in 0..20 { + let input = accumulate(&[wheel(Line, 0.0, -1.0)], 100.0, false); + let extra = boost_extra(input, &mut bx, &mut by, 1.0 + f64::from(i) * 0.02); + assert!(extra.y <= 0.0, "extra follows the spin direction, got {extra:?}"); + peak = peak.min(extra.y); + } + assert!(peak < 0.0, "a sustained spin must add an extra"); + assert!(peak >= -MAX_FRAME_POINTS, "extra stays frame-capped, got {peak}"); + } + + #[test] + fn point_and_page_events_stay_out_of_the_line_boost() { + use egui::{MouseWheelUnit::Line, MouseWheelUnit::Page, MouseWheelUnit::Point}; + let input = accumulate(&[wheel(Page, 0.0, -1.0), wheel(Point, 0.0, -30.0)], 100.0, false); + assert_eq!(input.precise, egui::vec2(0.0, -30.0)); + assert_eq!(input.page, egui::vec2(0.0, -1.0)); + assert_eq!(input.line, egui::Vec2::ZERO); + let input = accumulate(&[wheel(Line, 0.0, -1.0), wheel(Page, 0.0, -1.0)], 100.0, false); + assert_eq!(input.line, egui::vec2(0.0, -100.0)); + assert_eq!(input.page, egui::vec2(0.0, -1.0)); + let mut bx = WheelBoost::default(); + let mut by = WheelBoost::default(); + assert_eq!(boost_extra(input, &mut bx, &mut by, 1.0), egui::Vec2::ZERO); + } + + #[test] + fn single_paging_drops_vertical_keeps_horizontal() { + use egui::MouseWheelUnit::Line; + let input = accumulate(&[wheel(Line, 5.0, -1.0)], 100.0, true); + assert_eq!(input.line, egui::vec2(500.0, 0.0)); + } + + #[test] + fn shift_merges_into_horizontal() { + use egui::MouseWheelUnit::{Line, Page}; + let input = accumulate(&[shifted(Line, 1.0, -2.0)], 100.0, false); + assert_eq!(input.line, egui::vec2(-100.0, 0.0)); + let input = accumulate(&[shifted(Page, 1.0, -2.0)], 100.0, false); + assert_eq!(input.page, egui::vec2(-1.0, 0.0)); + } + + #[test] + fn invalid_scales_and_events_contribute_nothing() { + use egui::MouseWheelUnit::Line; + for bad in [f32::NAN, 0.0, -10.0, f32::INFINITY] { + let input = accumulate(&[wheel(Line, 0.0, -1.0)], bad, false); + assert_eq!(input.line, egui::Vec2::ZERO, "line scale {bad}"); + } + let zoom = + egui::Event::MouseWheel { unit: Line, delta: egui::vec2(0.0, -1.0), phase: egui::TouchPhase::Move, modifiers: egui::Modifiers::COMMAND }; + let key = egui::Event::Key { key: egui::Key::Escape, physical_key: None, pressed: true, repeat: false, modifiers: egui::Modifiers::NONE }; + let input = accumulate(&[zoom, key, wheel(Line, 0.0, f32::NAN)], 100.0, false); + assert_eq!(input, FrameInput::default()); + } +} diff --git a/crates/ui-egui/tests/comments.rs b/crates/ui-egui/tests/comments.rs index 3d53f1e3bb..01166a41d1 100644 --- a/crates/ui-egui/tests/comments.rs +++ b/crates/ui-egui/tests/comments.rs @@ -1571,3 +1571,77 @@ fn text_box_properties_change_the_font_size() { assert_eq!(doc.comment_props(0, 0).unwrap().font_size, Some(30.0)); assert_eq!(doc.can_undo(), Some("Change comment properties")); } + +#[test] +fn text_box_and_callout_properties_include_fill_and_thickness() { + use pdfcraft_engine::{Edit, LineEnding, NewAnnotation, Shape}; + use pdfcraft_ui_egui::comments::CommentTool; + let mut h = harness(|_| {}); + for (tool, shape) in [ + (CommentTool::TextBox, Shape::TextBox { rect: [40.0, 600.0, 240.0, 640.0], font_size: 12.0 }), + ( + CommentTool::Callout, + Shape::Callout { + rect: [40.0, 500.0, 240.0, 540.0], + knee: [30.0, 460.0], + point: [10.0, 440.0], + font_size: 12.0, + ending: LineEnding::OpenArrow, + }, + ), + ] { + let index = comments(&h).len(); + h.state_mut().apply_edit(Edit::AddAnnotation(NewAnnotation { + page: 0, + shape, + style: Default::default(), + contents: "Hello".into(), + author: "Ada".into(), + })); + h.run_steps(2); + h.state_mut().open_comment_props(0, index); + h.run_steps(2); + h.get_by_label("Fill colour"); + { + let draft = h.state_mut().comment_props.as_mut().unwrap(); + assert!(draft.original.fillable && draft.original.width.is_some()); + draft.edited.fill = Some([1.0; 3]); + draft.edited.width = Some(3.0); + } + h.get_by_label("OK").click(); + h.run_steps(3); + let doc = h.state().session.get(h.state().views[0].id).unwrap(); + let p = doc.comment_props(0, index).unwrap(); + assert_eq!((p.fill, p.width), (Some([1.0; 3]), Some(3.0))); + h.state_mut().make_comment_default(0, index); + assert_eq!(h.state().comment_prefs.style(tool).fill, Some([1.0; 3])); + assert_eq!(h.state().comment_prefs.style(tool).width, 3.0); + } +} + +#[test] +fn text_comments_can_save_a_zero_width_default() { + use pdfcraft_engine::{Edit, NewAnnotation, Shape}; + use pdfcraft_ui_egui::comments::CommentTool; + let mut h = harness(|_| {}); + let shape = Shape::TextBox { rect: [40.0, 600.0, 240.0, 640.0], font_size: 12.0 }; + let mut style = pdfcraft_engine::Style::default_for(&shape); + style.width = 0.0; + h.state_mut().apply_edit(Edit::AddAnnotation(NewAnnotation { page: 0, shape, style, contents: "Hello".into(), author: "Ada".into() })); + h.run_steps(2); + h.state_mut().open_comment_props(0, 0); + h.run_steps(2); + assert_eq!(h.state().comment_props.as_ref().unwrap().edited.width, Some(0.0)); + h.get_by_label("OK").click(); + h.run_steps(2); + h.state_mut().make_comment_default(0, 0); + assert_eq!(h.state().comment_prefs.style(CommentTool::TextBox).width, 0.0); + h.state_mut().comment_prefs.set_width(CommentTool::Callout, 0.0); + h.state_mut().comment_prefs.set_width(CommentTool::Rectangle, 0.0); + assert_eq!(h.state().comment_prefs.style(CommentTool::Callout).width, 0.0); + assert_eq!(h.state().comment_prefs.style(CommentTool::Rectangle).width, 0.5); + let mut restored = PdfCraftApp::new(); + restored.restore(&h.state().persist()); + assert_eq!(restored.comment_prefs.style(CommentTool::TextBox).width, 0.0); + assert_eq!(restored.comment_prefs.style(CommentTool::Callout).width, 0.0); +} diff --git a/crates/ui-egui/tests/editing.rs b/crates/ui-egui/tests/editing.rs index 2ef52a4403..d41041e52e 100644 --- a/crates/ui-egui/tests/editing.rs +++ b/crates/ui-egui/tests/editing.rs @@ -2024,7 +2024,12 @@ fn protected(user: &str, owner: &str, permissions: i32) -> Vec { } fn protected_with(algorithm: pdfcraft_cos::Algorithm, user: &str, owner: &str, permissions: i32) -> Vec { - let mut doc = pdfcraft_cos::Document::open(std::sync::Arc::new(fixture(2))).unwrap(); + encrypted(fixture(2), algorithm, user, owner, permissions) +} + +/// `bytes` rewritten encrypted. +fn encrypted(bytes: Vec, algorithm: pdfcraft_cos::Algorithm, user: &str, owner: &str, permissions: i32) -> Vec { + let mut doc = pdfcraft_cos::Document::open(std::sync::Arc::new(bytes)).unwrap(); doc.set_encryption(&pdfcraft_cos::NewEncryption { algorithm, user_password: user, @@ -2146,6 +2151,25 @@ fn remove_security_asks_for_the_permissions_password_first() { assert!(doc.dirty && doc.security_summary().is_none(), "security is removed (written by the next save)"); } +/// A secured form still says it is one and offers Highlight fields: the security notice joins +/// the form's instead of replacing it. +#[test] +fn a_secured_form_shows_its_form_notice_and_its_security() { + // Changing and assembling withheld, filling allowed (as government forms are often set). + let bytes = encrypted(include_bytes!("data/form.pdf").to_vec(), pdfcraft_cos::Algorithm::Aes256, "", "owner", -1036); + let mut h = Harness::builder().with_size(egui::vec2(1600.0, 900.0)).build_eframe(move |_cc| { + let mut app = PdfCraftApp::new(); + app.set_option("language", "en").unwrap(); + app.open_bytes("form.pdf", None, bytes).unwrap(); + app + }); + h.run_steps(4); + h.get_by_label_contains("interactive form fields. This document is secured. Some changes are restricted by its security settings."); + h.get_by_label("Highlight fields"); + h.get_by_label("Security settings"); + h.get_by_label("Unlock…"); +} + #[test] fn restricted_documents_show_a_notice_and_block_page_changes() { let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).build_eframe(|_cc| { diff --git a/crates/ui-egui/tests/forms.rs b/crates/ui-egui/tests/forms.rs index 96a0a6a11e..e153dcca94 100644 --- a/crates/ui-egui/tests/forms.rs +++ b/crates/ui-egui/tests/forms.rs @@ -185,6 +185,70 @@ fn date_fields_offer_a_calendar() { assert_eq!(value(&h, "due"), vec![format!("{nm:02}/15/{ny}")], "picked in the field's format"); } +/// The calendar used to keep the month it opened on, so a date typed while it was open showed +/// only after leaving the field and coming back. +#[test] +fn the_calendar_follows_a_complete_date_typed_while_it_is_open() { + use egui_kittest::kittest::Queryable; + let mut h = harness(); + h.state_mut().apply_edit(pdfcraft_engine::Edit::AddField { + page: 0, + rect: [50.0, 40.0, 200.0, 60.0], + kind: pdfcraft_engine::NewField::Date, + name: Some("due".into()), + }); + h.run_steps(4); + click_field(&mut h, "due", 0); + let (y, m, _) = h.state().session.today(); + let month = + ["January", "February", "March", "April", "May", "June", "July", "August", "September", "October", "November", "December"][(m - 1) as usize]; + h.get_by_label(&format!("{month} {y}")); + // Half a date leaves the calendar where it is. + h.event(egui::Event::Text("03/1".into())); + h.run_steps(2); + h.get_by_label(&format!("{month} {y}")); + // The whole date moves it there. + h.event(egui::Event::Text("4/2025".into())); + h.run_steps(2); + h.get_by_label("March 2025"); + // The arrows still turn the months, and the calendar stays on their month while the text + // doesn't change. + h.get_by_label("›").click(); + h.run_steps(3); + h.get_by_label("April 2025"); +} + +/// A date format with its fields side by side ("yyyymmdd") refused every typed date, and its +/// calendar opened on today's month. +#[test] +fn an_undelimited_date_field_takes_a_typed_date_and_its_calendar_opens_on_it() { + use egui_kittest::kittest::Queryable; + let mut h = harness(); + h.state_mut().apply_edit(pdfcraft_engine::Edit::AddField { + page: 0, + rect: [50.0, 40.0, 200.0, 60.0], + kind: pdfcraft_engine::NewField::Date, + name: Some("due".into()), + }); + assert!(h.state_mut().apply_edit(pdfcraft_engine::Edit::SetFieldProps { + name: "due".into(), + props: Box::new(pdfcraft_engine::FieldProps { + format: Some(pdfcraft_engine::form_scripts::Format::Date("yyyymmdd".into())), + ..Default::default() + }), + })); + h.run_steps(4); + click_field(&mut h, "due", 0); + h.event(egui::Event::Text("20250314".into())); + h.run_steps(2); + h.key_press(egui::Key::Tab); + h.run_steps(4); + assert_eq!(h.state().toast, None, "the typed date was not refused"); + assert_eq!(value(&h, "due"), ["20250314"]); + click_field(&mut h, "due", 0); + h.get_by_label("March 2025"); +} + #[test] fn lithuanian_calendar_localizes_months_and_weekdays_without_changing_field_values() { use egui_kittest::kittest::Queryable; diff --git a/crates/ui-egui/tests/prepare.rs b/crates/ui-egui/tests/prepare.rs index d63df5232c..a2b173c7a1 100644 --- a/crates/ui-egui/tests/prepare.rs +++ b/crates/ui-egui/tests/prepare.rs @@ -254,6 +254,9 @@ fn options_tab_sets_the_check_box_style() { fn button_label_is_editable_without_renaming_the_field() { use pdfcraft_engine::{Edit, NewField}; let mut h = harness(); + // The byte comparisons below must not vary with the save's /ModDate. + let session = &mut h.state_mut().session; + *session = std::mem::take(session).with_clock(|| 1_700_000_000); h.state_mut().apply_edit(Edit::AddField { page: 0, rect: [40.0, 300.0, 190.0, 330.0], diff --git a/crates/ui-egui/tests/ui.rs b/crates/ui-egui/tests/ui.rs index b86f7699f0..d30ab1b8dd 100644 --- a/crates/ui-egui/tests/ui.rs +++ b/crates/ui-egui/tests/ui.rs @@ -354,6 +354,27 @@ fn a_middle_click_closes_the_tab() { assert!(h.state().close_request.is_some()); } +#[test] +fn a_link_prompt_above_a_save_prompt_owns_its_keys() { + for key in [egui::Key::Escape, egui::Key::Enter] { + let mut h = harness(|app| { + app.open_bytes("first.pdf", None, FIXTURE.to_vec()).unwrap(); + app.apply_edit(pdfcraft_engine::Edit::RotatePages { pages: vec![0], degrees: 90 }); + app.close_request = Some(pdfcraft_ui_egui::CloseRequest::Tab(app.views[0].id)); + }); + h.run_steps(3); + h.state_mut().pending_link = + Some(pdfcraft_ui_egui::PendingLink { url: "https://example.org".to_owned(), origin: pdfcraft_ui_egui::LinkOrigin::Link }); + h.run_steps(3); + h.key_press(key); + h.run_steps(3); + assert_eq!(h.state().pending_link.is_some(), key == egui::Key::Enter); + assert!(h.state().close_request.is_some(), "the save prompt stays open after {key:?}"); + assert_eq!(h.state().views.len(), 1, "the edited document remains open"); + assert!(h.state().last_opened_url.is_none()); + } +} + #[test] fn overflowing_tabs_can_be_reached_by_scrolling() { let mut h = harness(|app| { diff --git a/crates/ui-egui/tests/ui_group/xfa.rs b/crates/ui-egui/tests/ui_group/xfa.rs index 2285f36e00..6565a9816f 100644 --- a/crates/ui-egui/tests/ui_group/xfa.rs +++ b/crates/ui-egui/tests/ui_group/xfa.rs @@ -212,3 +212,22 @@ fn scripts_turned_off_by_a_click_bring_a_closed_notice_bar_back() { assert!(h.state().views[0].notice_dismissed); assert!(!says_scripts_are_off(&h.state().toast), "{:?}", h.state().toast); } + +#[test] +fn a_secured_dynamic_xfa_form_shows_its_layout_and_its_security() { + // Changing and assembling withheld, filling allowed. + let mut doc = pdfcraft_cos::Document::open(std::sync::Arc::new(pdfcraft_xfa::fixtures::shell(&pdfcraft_xfa::fixtures::template(2)))).unwrap(); + doc.set_encryption(&pdfcraft_cos::NewEncryption { + algorithm: pdfcraft_cos::Algorithm::Aes256, + user_password: "", + owner_password: "owner", + permissions: -1036, + encrypt_metadata: true, + seed: [4; 32], + }) + .unwrap(); + let h = open(pdfcraft_cos::write_full(&doc, &Default::default()).unwrap()); + h.get_by_label_contains("laid out from its template: 2 pages, 11 fields. This document is secured."); + h.get_by_label("Highlight fields"); + h.get_by_label("Security settings"); +} diff --git a/crates/ui-egui/tests/view.rs b/crates/ui-egui/tests/view.rs index 60150c931c..9cf99021a3 100644 --- a/crates/ui-egui/tests/view.rs +++ b/crates/ui-egui/tests/view.rs @@ -392,10 +392,78 @@ fn precise_touchpad_wheel_scrolls_one_to_one() { assert!(drift.abs() < 2.0, "no easing tail after the swipe: drifted {drift}"); } +#[test] +fn rapid_wheel_notches_accelerate_and_follow_touchpad_input() { + use pdfcraft_ui_egui::scroll_speed::ScrollSpeed; + + let mut h = harness_stepping(1.0 / 60.0, &[("layout", "continuous"), ("zoom", "300")]); + h.state_mut().set_option("animation_effects", "off").unwrap(); + let at = h.state().views[0].viewport_rect().center(); + h.hover_at(at); + h.run_steps(2); + + h.event(wheel(MouseWheelUnit::Line, -1.0, TouchPhase::Move)); + let single = scroll_over(&mut h, 2); + let base = ScrollSpeed::default().line_points(); + assert!((single - base).abs() < 1.0, "one notch keeps the configured base: moved {single}, base {base}"); + + // Let the isolated notch expire, then spin five notches one frame apart. The burst must move + // farther than five base steps without adding work on idle frames. + h.run_steps(30); + let mut burst = 0.0; + for _ in 0..5 { + h.event(wheel(MouseWheelUnit::Line, -1.0, TouchPhase::Move)); + burst += scroll_over(&mut h, 2); + } + assert!(burst > 7.0 * base, "a rapid five-notch spin accelerates: moved {burst}, base {base}"); + + // A line notch inside the precise path's suppression window keeps its base share (#1072): + // suppressing egui's touchpad tail must not swallow mixed-device input. + h.run_steps(30); + h.event(wheel(MouseWheelUnit::Point, -20.0, TouchPhase::Move)); + scroll_over(&mut h, 2); + // Arrive near the old suppression deadline: the line's new easing tail must extend it. + h.run_steps(11); + h.event(wheel(MouseWheelUnit::Line, -1.0, TouchPhase::Move)); + let after_touchpad = scroll_over(&mut h, 2); + assert!(after_touchpad >= base, "a notch after point input is not dropped: moved {after_touchpad}, base {base}"); + let delayed = scroll_over(&mut h, 20); + assert!(delayed.abs() < 2.0, "the reconstructed notch has no delayed easing copy: moved {delayed}"); + + h.event(wheel(MouseWheelUnit::Point, -20.0, TouchPhase::Move)); + scroll_over(&mut h, 2); + h.event(wheel(MouseWheelUnit::Page, -1.0, TouchPhase::Move)); + let page_after_touchpad = scroll_over(&mut h, 2); + assert!(page_after_touchpad > base, "a page event after point input is preserved: moved {page_after_touchpad}"); + let delayed_page = scroll_over(&mut h, 20); + assert!(delayed_page.abs() < 2.0, "the reconstructed page has no delayed easing copy: moved {delayed_page}"); +} + +#[test] +fn changing_continuous_layout_resets_wheel_acceleration() { + use pdfcraft_ui_egui::scroll_speed::ScrollSpeed; + + let mut h = harness_stepping(1.0 / 60.0, &[("layout", "continuous"), ("zoom", "300")]); + h.state_mut().set_option("animation_effects", "off").unwrap(); + let at = h.state().views[0].viewport_rect().center(); + h.hover_at(at); + h.run_steps(2); + for _ in 0..5 { + h.event(wheel(MouseWheelUnit::Line, -1.0, TouchPhase::Move)); + scroll_over(&mut h, 2); + } + + h.state_mut().views[0].set_continuous(false); + h.run_steps(3); + h.event(wheel(MouseWheelUnit::Line, -1.0, TouchPhase::Move)); + let moved = scroll_over(&mut h, 2); + let base = ScrollSpeed::default().line_points(); + assert!((moved - base).abs() < 1.0, "a layout jump clears the old burst: moved {moved}, base {base}"); +} + /// Steps `frames` frames and returns how far the document scrolled down over them, from the /// pages on screen in each pair of consecutive frames (a page can leave the screen on the way). -/// Only the non-macOS glide tests below use it, hence the matching gate. -#[cfg(not(target_os = "macos"))] +/// Used by wheel and non-macOS glide tests below. fn scroll_over(h: &mut Harness<'static, PdfCraftApp>, frames: usize) -> f32 { let tops = |h: &Harness<'static, PdfCraftApp>| (0..3).map(|p| rect(h, p).map(|r| r.min.y)).collect::>(); let mut moved = 0.0; @@ -1498,3 +1566,82 @@ fn two_up_scroll_end_reaches_an_unpaired_final_page() { fn two_up_scroll_end_with_cover_reaches_a_taller_left_page() { assert_final_spread_bottom_reachable(&[400, 400, 400, 400, 100], true); } + +#[test] +fn switching_modes_keeps_the_current_page_at_fixed_zoom() { + use egui_kittest::kittest::Queryable; + let kids = (3..33).map(|i| format!("{i} 0 R")).collect::>().join(" "); + let mut pdf = format!( + "%PDF-1.7\n1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj\n2 0 obj << /Type /Pages /Kids [{kids}] /Count 30 /MediaBox [0 0 300 400] >> endobj\n" + ); + for i in 3..33 { + pdf.push_str(&format!("{i} 0 obj << /Type /Page /Parent 2 0 R >> endobj\n")); + } + pdf.push_str("trailer << /Root 1 0 R >>\n%%EOF"); + let other = pdf.clone().into_bytes(); + 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("pages.pdf", None, pdf.into_bytes()).unwrap(); + app.set_option("zoom", "100").unwrap(); + app + }); + h.run_steps(8); + h.state_mut().views[0].go_to_page(17); + h.run_steps(8); + assert_eq!(h.state().views[0].current, 17); + h.hover_at(h.state().views[0].viewport_rect().center()); + h.event(wheel(MouseWheelUnit::Point, -35.0, TouchPhase::Move)); + h.run_steps(8); + let before = rect(&h, 17).unwrap().top() - h.state().views[0].viewport_rect().top(); + h.get_by_label("Read").click(); + h.run_steps(8); + assert_eq!(h.state().views[0].current, 17, "switching to Read should keep the current page"); + let after = rect(&h, 17).unwrap().top() - h.state().views[0].viewport_rect().top(); + assert!((before - after).abs() < 1.0, "the position within the page moved: {before} to {after}"); + h.state_mut().views[0].go_to_page(5); + h.run_steps(8); + assert_eq!(h.state().views[0].current, 5); + h.get_by_label("All tools").click(); + h.run_steps(8); + assert_eq!(h.state().views[0].current, 5, "switching to All tools should keep the current page"); + h.state_mut().open_bytes("other.pdf", None, other).unwrap(); + h.state_mut().set_option("zoom", "100").unwrap(); + h.run_steps(8); + h.state_mut().views[1].go_to_page(9); + h.run_steps(8); + assert_eq!(h.state().views[1].current, 9); + h.get_by_label("pages.pdf").click(); + h.run_steps(8); + assert_eq!(h.state().views[0].current, 5, "the first tab keeps its own position"); + h.get_by_label("other.pdf").click(); + h.run_steps(8); + assert_eq!(h.state().views[1].current, 9, "the second tab keeps its own position"); +} + +#[test] +fn long_view_menu_fits_a_short_window() { + use egui_kittest::kittest::Queryable; + let mut h = Harness::builder().with_size(egui::vec2(1000.0, 400.0)).build_eframe(|_cc| { + let mut app = PdfCraftApp::new(); + app.set_option("language", "en").unwrap(); + app.open_bytes("pages.pdf", None, PAGES.to_vec()).unwrap(); + app + }); + h.run_steps(8); + h.get_by_label("Menu").click(); + h.run_steps(2); + h.get_by_label("View ⏵").hover(); + h.run_steps(4); + let before = h.get_by_label("Side panels ⏵").rect().bottom(); + h.get_by_label("Fit to height ").hover(); + h.event(wheel(MouseWheelUnit::Point, -1000.0, TouchPhase::Move)); + h.run_steps(6); + let after = h.get_by_label("Side panels ⏵").rect().bottom(); + assert!(after < before && after <= 400.0, "menu did not scroll into view: {before} -> {after}"); + h.get_by_label("Side panels ⏵").hover(); + h.run_steps(4); + h.get_all_by_label("Bookmarks").last().unwrap().click(); + h.run_steps(4); + assert_eq!(h.state().right, Some(pdfcraft_ui_egui::RightPanel::Bookmarks)); +} diff --git a/crates/xfdf/README.md b/crates/xfdf/README.md index f1ca8ac001..c137aabd93 100644 --- a/crates/xfdf/README.md +++ b/crates/xfdf/README.md @@ -4,7 +4,7 @@ Layer L4: comment and form data exchange (execution plan M5.6, M6.7). ```rust let xml = export_xfdf(&doc, true, true, "form.pdf"); // comments and/or field values -let fdf = export_fdf(&doc, false, true, "form.pdf"); +let fdf = export_fdf(&doc, false, true, "form.pdf")?; let txt = export_data(&doc, Format::Txt); // or Xml, Csv let report = import(&mut doc, &bytes)?; // XFDF, FDF, XML, CSV or text, detected ``` @@ -14,7 +14,21 @@ let report = import(&mut doc, &bytes)?; // XFDF, FDF, XML, CSV o name, author, subject, dates, flags, colours, opacity, width, icon, quads, line ends, ink, default appearance, callouts (`callout`, `fringe`, `intent`, `head`), pop-ups and reply threads (`inreplyto`); field values nested by name, with text preserved across XML comments and processing instructions. -- **FDF**: fields (with `/Kids` hierarchies) and comments as direct objects. +- **FDF**: fields (with `/Kids` hierarchies) and indirect comments. Nested annotation values + are copied under FDF object numbers; shared references and cycles remain shared. Document + links and appearances are excluded. Source `/Page` and `/Pages` dictionaries become null + at every copy depth, so nested destination actions cannot carry the source page tree or content. + Type names may be indirect; lookup checks each reference generation, is iterative, stops at + cycles and refuses chains beyond + 64 references. Discovery borrows direct arrays and dictionaries, + retains array owners lazily and records annotation positions instead of resolved bodies. + Retained entries are budgeted before their keys or values are cloned, including when + saved-PDF cache eviction causes annotation bodies to be parsed again. Export returns + `Result` and refuses copied graphs above 100,000 comments/objects/values, 64 direct nesting + levels or a conservative 64 MiB serialization budget instead of exporting partial data. + These are copied-graph limits: source parsing and page discovery retain their existing + COS/parser limits, with no total source-memory bound. Unknown indirect streams retain their + encoded bytes. FDF import semantics are unchanged. - **Form data**: Acrobat's XML (`xfdf:original` keeps names that aren't XML names), CSV and tab-delimited text (a row of names, a row of values). diff --git a/crates/xfdf/src/lib.rs b/crates/xfdf/src/lib.rs index ef5a30a90f..35bc781541 100644 --- a/crates/xfdf/src/lib.rs +++ b/crates/xfdf/src/lib.rs @@ -11,7 +11,7 @@ #![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] -use std::collections::HashMap; +use std::collections::{HashMap, HashSet}; use std::fmt::Write as _; use std::io::Write as _; @@ -29,6 +29,8 @@ pub enum DataError { Malformed(String), #[error("nothing in the file matches this document")] NothingImported, + #[error("FDF export exceeds the {0} limit; no data was exported")] + ExportLimit(&'static str), #[error(transparent)] Cos(#[from] pdfcraft_cos::CosError), } @@ -467,19 +469,223 @@ fn pdf_string(s: &str) -> Vec { v } +// Keep aliases as aliases instead of expanding a shared graph once per use. References are +// queued, not followed recursively, so cycles and long indirect chains cost linear work. +const MAX_FDF_OBJECTS: usize = 100_000; +const MAX_FDF_BYTES: usize = 64 * 1024 * 1024; + +/// Direct values stay borrowed: `Document::resolve` would deep-clone them. Only +/// indirect values need an owner, and annotation bodies are never retained in discovery. +enum FdfValue<'a> { + Borrowed(&'a Object), + Indirect(std::sync::Arc), +} + +impl<'a> FdfValue<'a> { + fn resolve(doc: &Document, value: &'a Object) -> Self { + match value { + Object::Ref(_) => Self::Indirect(doc.resolve(value)), + _ => Self::Borrowed(value), + } + } + + fn object(&self) -> &Object { + match self { + Self::Borrowed(value) => value, + Self::Indirect(value) => value.as_ref(), + } + } +} + +struct FdfCopy<'a> { + doc: &'a Document, + objects: Vec, + numbers: HashMap, + pending: Vec<(ObjRef, usize)>, + nodes: usize, + bytes: usize, +} + +impl<'a> FdfCopy<'a> { + fn new(doc: &'a Document) -> Self { + Self { doc, objects: Vec::new(), numbers: HashMap::new(), pending: Vec::new(), nodes: MAX_FDF_OBJECTS, bytes: MAX_FDF_BYTES } + } + + fn charge(&mut self, bytes: usize) -> Result<(), DataError> { + self.bytes = self.bytes.checked_sub(bytes).ok_or(DataError::ExportLimit("64 MiB data"))?; + Ok(()) + } + + fn slot(&mut self) -> Result<(usize, u32), DataError> { + if self.objects.len() >= MAX_FDF_OBJECTS { + return Err(DataError::ExportLimit("100,000 objects")); + } + // Object 1 is the FDF root; arena indices start at object 2. The arena is capped above. + let index = self.objects.len(); + let number = index as u32 + 2; + self.charge(100)?; // indirect-object and xref overhead + self.objects.push(Object::Null); + Ok((index, number)) + } + + fn dict(&mut self, dict: &Dict, depth: usize) -> Result { + if dict.len() > self.nodes { + return Err(DataError::ExportLimit("100,000 values")); + } + let mut out = Dict::new(); + for (key, value) in dict.iter() { + self.dict_entry(&mut out, key, value, depth + 1)?; + } + Ok(out) + } + + fn dict_entry(&mut self, out: &mut Dict, key: &[u8], value: &Object, depth: usize) -> Result<(), DataError> { + self.charge(key.len().saturating_mul(3).saturating_add(2))?; + let value = self.copy(value, depth)?; + out.set(key.to_vec(), value); + Ok(()) + } + + /// Skip source-document links and appearances without cloning their values. The + /// dictionary and every retained entry are charged before any owned copy is made. + fn annotation_dict(&mut self, dict: &Dict) -> Result { + self.charge_value(0)?; + let mut out = Dict::new(); + for (key, value) in dict.iter() { + if matches!(key.as_slice(), b"P" | b"AP" | b"Popup" | b"IRT" | b"Parent" | b"FS" | b"Sound") { + continue; + } + self.dict_entry(&mut out, key, value, 1)?; + } + Ok(out) + } + + fn charge_value(&mut self, depth: usize) -> Result<(), DataError> { + if depth > MAX_DEPTH { + return Err(DataError::ExportLimit("64 direct nesting levels")); + } + self.nodes = self.nodes.checked_sub(1).ok_or(DataError::ExportLimit("100,000 values"))?; + // Includes delimiters, spacing and even the longest finite f64 in plain decimal form. + self.charge(330)?; + Ok(()) + } + + fn is_source_page(&self, dict: &Dict) -> Result { + let Some(value) = dict.get(b"Type") else { return Ok(false) }; + let mut value = FdfValue::Borrowed(value); + let mut seen = HashSet::new(); + for hops in 0..=MAX_DEPTH { + match value.object() { + Object::Name(name) => return Ok(matches!(name.as_slice(), b"Page" | b"Pages")), + Object::Ref(reference) => { + if self.doc.generation(reference.num) != reference.generation || !seen.insert(*reference) { + return Ok(false); + } + if hops == MAX_DEPTH { + return Err(DataError::ExportLimit("64 Type reference levels")); + } + value = FdfValue::Indirect(self.doc.get(*reference)); + } + _ => return Ok(false), + } + } + Err(DataError::ExportLimit("64 Type reference levels")) + } + + fn copy(&mut self, value: &Object, depth: usize) -> Result { + self.charge_value(depth)?; + Ok(match value { + Object::Ref(source) => { + let number = if let Some(number) = self.numbers.get(source) { + *number + } else { + let (index, number) = self.slot()?; + self.numbers.insert(*source, number); + self.pending.push((*source, index)); + number + }; + Object::Ref(ObjRef::new(number, 0)) + } + Object::Array(values) => { + if values.len() > self.nodes { + return Err(DataError::ExportLimit("100,000 values")); + } + let mut out = Vec::new(); + for value in values { + out.push(self.copy(value, depth + 1)?); + } + Object::Array(out) + } + // Source page links must not drag page contents or the page tree into an FDF. + Object::Dict(dict) if self.is_source_page(dict)? => Object::Null, + Object::Dict(dict) => Object::Dict(self.dict(dict, depth)?), + Object::String(s) => { + self.charge(s.bytes.len().saturating_mul(4))?; + value.clone() + } + Object::Name(name) => { + self.charge(name.len().saturating_mul(3))?; + value.clone() + } + // A PDF stream can only be an indirect object, never a dictionary/array value. + Object::Stream(_) => return Err(DataError::Malformed("FDF export encountered a direct stream".into())), + other => other.clone(), + }) + } + + fn finish(&mut self, doc: &Document) -> Result<(), DataError> { + let mut cursor = 0; + while let Some((source, index)) = self.pending.get(cursor).copied() { + // ISO 32000-2 §7.3.10: an absent or stale reference denotes null. Keep reader + // errors distinct so damaged objects cannot be silently lost during export. + let value = if doc.generation(source.num) != source.generation { + std::sync::Arc::new(Object::Null) + } else { + match doc.try_get(source.num) { + Ok(value) => value, + Err(pdfcraft_cos::CosError::MissingObject(_)) => std::sync::Arc::new(Object::Null), + Err(error) => return Err(error.into()), + } + }; + let copied = match &*value { + Object::Stream(stream) => { + self.charge(stream.raw.len())?; + Object::Stream(pdfcraft_cos::Stream { dict: self.dict(&stream.dict, 0)?, raw: stream.raw.clone() }) + } + other => self.copy(other, 0)?, + }; + let slot = self.objects.get_mut(index).ok_or_else(|| DataError::Malformed("FDF object slot is missing".into()))?; + *slot = copied; + cursor += 1; + } + Ok(()) + } +} + /// FDF with field values and/or comments (appearances are not carried). Each comment is an /// indirect object of the FDF, so a reply's `/IRT` can point at its parent's object there -/// (§12.7.8.3.1); nothing else refers back into the source document. -pub fn export_fdf(doc: &Document, comments: bool, fields: bool, file: &str) -> Vec { +/// (§12.7.8.3.1). Nested values are copied and renumbered, including shared or cyclic graphs. +/// Source `/Page` and `/Pages` dictionaries become null at every copy depth. +/// Refuses exports above the object, value, direct-depth or data budget instead of losing data. +pub fn export_fdf(doc: &Document, comments: bool, fields: bool, file: &str) -> Result, DataError> { + let mut copy = FdfCopy::new(doc); + copy.charge(file.len().saturating_mul(8))?; let mut body: Vec = b"<< /FDF << ".to_vec(); // Comment dictionaries, written after the root as objects 2, 3, … - let mut annots: Vec = Vec::new(); if fields { body.extend_from_slice(b"/Fields ["); for f in pdfcraft_forms::fields(doc) { if matches!(f.kind, FieldKind::PushButton | FieldKind::Signature) { continue; } + // Field dictionary, /T string and /V scalar/array, plus every emitted list + // entry. Other field kinds emit only their first value (or a default). + let field_nodes = if matches!(f.kind, FieldKind::List) && f.value.len() > 1 { f.value.len().saturating_add(3) } else { 3 }; + copy.nodes = copy.nodes.checked_sub(field_nodes).ok_or(DataError::ExportLimit("100,000 values"))?; + copy.charge(f.name.len().saturating_mul(8).saturating_add(100))?; + for value in &f.value { + copy.charge(value.len().saturating_mul(8).saturating_add(100))?; + } body.extend_from_slice(b"<< /T "); body.extend(pdf_string(&f.name)); body.extend_from_slice(b" /V "); @@ -498,44 +704,61 @@ pub fn export_fdf(doc: &Document, comments: bool, fields: bool, file: &str) -> V body.extend_from_slice(b"] "); } if comments { - // Every exported comment with its source reference, numbered in export order. - let mut picked: Vec<(Option, Dict, usize)> = Vec::new(); - for (pi, p) in pdfcraft_model::pages(doc).iter().enumerate() { - for a in p.dict.get(b"Annots").map(|a| doc.resolve(a)).and_then(|a| a.as_array().cloned()).unwrap_or_default() { - let Some(d) = doc.resolve(&a).as_dict().cloned() else { continue }; - if d.name(b"Subtype").is_some_and(|sub| SUBTYPES.iter().any(|(s, _)| s.as_bytes() == sub)) { - picked.push((a.as_ref(), d, pi)); + // Keep the page/array owners once. Handles retain positions, never annotation + // bodies: the document cache can evict and reparse even a repeated source reference. + let pages = pdfcraft_model::pages(doc); + let mut arrays = Vec::new(); + let mut picked: Vec<(Option, usize, usize)> = Vec::new(); + for (pi, page) in pages.iter().enumerate() { + let owner = page.dict.get(b"Annots").map(|value| FdfValue::resolve(doc, value)); + let before = picked.len(); + if let Some(values) = owner.as_ref().and_then(|owner| owner.object().as_array()) { + for (ai, a) in values.iter().enumerate() { + let value = FdfValue::resolve(doc, a); + let Some(d) = value.object().as_dict() else { continue }; + if d.name(b"Subtype").is_some_and(|sub| SUBTYPES.iter().any(|(s, _)| s.as_bytes() == sub)) { + if picked.len() >= MAX_FDF_OBJECTS { + return Err(DataError::ExportLimit("100,000 comments")); + } + picked.push((a.as_ref(), pi, ai)); + } } } + // Empty/unselected arrays need no owner. Resolve subsequent pages only after + // this page passes the cap, rather than loading them all before discovery. + arrays.push(if picked.len() > before { owner } else { None }); } - let numbers: HashMap = - picked.iter().enumerate().filter_map(|(i, (r, _, _))| Some(((*r)?, u32::try_from(i).ok()?.checked_add(2)?))).collect(); - for (_, mut d, pi) in picked { + let mut numbers = HashMap::new(); + for (source, _, _) in &picked { + let (_, number) = copy.slot()?; + if let Some(source) = source { + numbers.insert(*source, number); + } + } + copy.numbers = numbers.clone(); + let count = picked.len(); + for (index, (_, pi, ai)) in picked.into_iter().enumerate() { + let annotation = arrays + .get(pi) + .and_then(Option::as_ref) + .and_then(|owner| owner.object().as_array()) + .and_then(|values| values.get(ai)) + .ok_or_else(|| DataError::Malformed("FDF annotation slot is missing".into()))?; + let value = FdfValue::resolve(doc, annotation); + let d = value.object().as_dict().ok_or_else(|| DataError::Malformed("FDF comment is not a dictionary".into()))?; // A reply points at its parent's object in this FDF; a parent that isn't exported // leaves the reply unthreaded. let irt = d.get(b"IRT").and_then(Object::as_ref).and_then(|r| numbers.get(&r)).copied(); - // Only direct values travel: references into this file would dangle. - for k in [&b"P"[..], b"AP", b"Popup", b"IRT", b"Parent", b"FS", b"Sound"] { - d.remove(k); - } - let keys: Vec> = d.iter().filter(|(_, v)| matches!(v, Object::Ref(_))).map(|(k, _)| k.clone()).collect(); - for k in keys { - let Some(o) = d.get(&k) else { continue }; - let v = (*doc.resolve(o)).clone(); - if matches!(v, Object::Stream(_)) { - d.remove(&k); - } else { - d.set(k, v); - } - } + let mut d = copy.annotation_dict(d)?; if let Some(num) = irt { d.set(b"IRT".to_vec(), Object::Ref(ObjRef::new(num, 0))); } d.set(b"Page".to_vec(), Object::Int(pi as i64)); - annots.push(d); + let slot = copy.objects.get_mut(index).ok_or_else(|| DataError::Malformed("FDF comment slot is missing".into()))?; + *slot = Object::Dict(d); } body.extend_from_slice(b"/Annots ["); - for i in 0..annots.len() { + for i in 0..count { let _ = write!(body, "{} 0 R ", i + 2); } body.extend_from_slice(b"] "); @@ -543,16 +766,17 @@ pub fn export_fdf(doc: &Document, comments: bool, fields: bool, file: &str) -> V body.extend_from_slice(b"/F "); body.extend(pdf_string(file)); body.extend_from_slice(b" >> >>"); + copy.finish(doc)?; let mut out = b"%FDF-1.2\n%\xE2\xE3\xCF\xD3\n".to_vec(); - let mut offsets = Vec::with_capacity(annots.len() + 1); + let mut offsets = Vec::with_capacity(copy.objects.len() + 1); offsets.push(out.len()); out.extend_from_slice(b"1 0 obj\n"); out.extend(body); out.extend_from_slice(b"\nendobj\n"); - for (i, d) in annots.into_iter().enumerate() { + for (i, object) in copy.objects.into_iter().enumerate() { offsets.push(out.len()); let _ = writeln!(out, "{} 0 obj", i + 2); - pdfcraft_cos::serialize(&Object::Dict(d), &mut out); + pdfcraft_cos::serialize(&object, &mut out); out.extend_from_slice(b"\nendobj\n"); } let xref = out.len(); @@ -561,7 +785,7 @@ pub fn export_fdf(doc: &Document, comments: bool, fields: bool, file: &str) -> V let _ = writeln!(out, "{o:010} 00000 n "); } let _ = write!(out, "trailer\n<< /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n"); - out + Ok(out) } /// XML 1.0 fifth-edition NameStartChar, excluding `:` for an unprefixed NCName. /// https://www.w3.org/TR/2008/REC-xml-20081126/#NT-NameStartChar diff --git a/crates/xfdf/src/tests.rs b/crates/xfdf/src/tests.rs index a4f6abb2d3..c33c115e75 100644 --- a/crates/xfdf/src/tests.rs +++ b/crates/xfdf/src/tests.rs @@ -311,7 +311,7 @@ fn xfdf_round_trips_comments_and_values() { #[test] fn fdf_round_trips_values_and_comments() { let src = filled(); - let fdf = export_fdf(&src, true, true, "form.pdf"); + let fdf = export_fdf(&src, true, true, "form.pdf").unwrap(); assert!(fdf.starts_with(b"%FDF-1.2")); let mut dst = blank(); let r = import(&mut dst, &fdf).unwrap(); @@ -353,7 +353,7 @@ fn non_utf8_check_box_states_round_trip() { let mut src = shift_jis_check_box(); set_value(&mut src, "agree", &FieldValue::Check(true)).unwrap(); // FDF writes the state's own bytes, not its #XX text. - let fdf = export_fdf(&src, false, true, "form.pdf"); + let fdf = export_fdf(&src, false, true, "form.pdf").unwrap(); let name: &[u8] = b"/#82#CD#82#A2"; assert!(fdf.windows(name.len()).any(|w| w == name), "{}", String::from_utf8_lossy(&fdf)); for data in [fdf, export_xfdf(&src, false, true, "form.pdf").into_bytes()] { @@ -389,7 +389,7 @@ fn fdf_keeps_replies_and_review_status_on_their_note() { let want = threads(&src); assert_eq!(want.iter().filter(|t| t.1.as_deref() == Some("note")).count(), 3, "{want:?}"); - let fdf = export_fdf(&src, true, false, "notes.pdf"); + let fdf = export_fdf(&src, true, false, "notes.pdf").unwrap(); // Every cross-reference entry points at its object (other readers trust the table). let tail = std::str::from_utf8(&fdf[fdf.windows(5).position(|w| w == b"xref\n").unwrap()..]).unwrap(); let xref: usize = tail.lines().skip_while(|l| *l != "startxref").nth(1).unwrap().parse().unwrap(); @@ -648,7 +648,7 @@ fn bad_data_is_refused_with_a_reason() { fn data_files_merge_into_a_spreadsheet() { let src = filled(); let xfdf = export_xfdf(&src, false, true, "form.pdf"); - let fdf = export_fdf(&src, false, true, "form.pdf"); + let fdf = export_fdf(&src, false, true, "form.pdf").unwrap(); let mut other = blank(); set_value(&mut other, "Name", &FieldValue::Text("Grace \"Amazing\" Hopper, RADM".into())).unwrap(); let pdf = pdfcraft_cos::write_full(&other, &Default::default()).unwrap(); @@ -718,7 +718,7 @@ fn replacing_a_note_keeps_existing_replies_and_review_status() { ) .unwrap(); let original = Arc::new(write_full(&original, &SaveOptions::default()).unwrap()); - for data in [xfdf.as_bytes().to_vec(), export_fdf(&incoming, true, false, "notes.pdf")] { + for data in [xfdf.as_bytes().to_vec(), export_fdf(&incoming, true, false, "notes.pdf").unwrap()] { // Start with the parent, reply and review status in the existing PDF revision. let mut doc = Document::open(original.clone()).unwrap(); let mut want = threads(&doc); @@ -741,7 +741,7 @@ fn replacing_a_note_keeps_existing_replies_and_review_status() { let reopened = Document::open(Arc::new(saved)).unwrap(); let note = summaries(&reopened).into_iter().find(|s| s.name.as_deref() == Some("n1")).unwrap(); assert_eq!(note.contents.as_deref(), Some("Updated note")); - let fdf = export_fdf(&reopened, true, false, "notes.pdf"); + let fdf = export_fdf(&reopened, true, false, "notes.pdf").unwrap(); let mut dst = blank(); import(&mut dst, &fdf).unwrap(); let mut got = threads(&dst); @@ -785,7 +785,7 @@ fn replacing_an_indirectly_named_note_preserves_its_identity_and_thread() { let original = Arc::new(write_full(&original, &SaveOptions::default()).unwrap()); let mut incoming = blank(); import(&mut incoming, update).unwrap(); - for data in [update.to_vec(), export_fdf(&incoming, true, false, "notes.pdf")] { + for data in [update.to_vec(), export_fdf(&incoming, true, false, "notes.pdf").unwrap()] { let mut doc = Document::open(original.clone()).unwrap(); let refs = annotation_refs(&doc); assert_eq!(refs.len(), 3); @@ -848,7 +848,7 @@ fn the_last_same_named_record_controls_its_reply_parent() { refs.push(incoming.add(d)); } incoming.update_dict(page, |p| p.set(b"Annots".to_vec(), Object::Array(refs.into_iter().map(Object::Ref).collect()))).unwrap(); - for data in [export_xfdf(&incoming, true, false, "notes.pdf").into_bytes(), export_fdf(&incoming, true, false, "notes.pdf")] { + for data in [export_xfdf(&incoming, true, false, "notes.pdf").into_bytes(), export_fdf(&incoming, true, false, "notes.pdf").unwrap()] { let mut doc = blank(); import(&mut doc, &data).unwrap(); let notes = summaries(&doc); @@ -1156,6 +1156,238 @@ fn xfdf_import_keeps_text_around_xml_comments_and_instructions() { } } +#[test] +fn fdf_export_keeps_indirect_values_nested_in_annotation_properties() { + let mut src = blank(); + let page = pdfcraft_model::pages(&src)[0].obj; + let mut annotation = Dict::new(); + annotation.set(b"Type".to_vec(), Object::name("Annot")); + annotation.set(b"Subtype".to_vec(), Object::name("Square")); + annotation.set(b"NM".to_vec(), PdfString::text("indirect-values")); + let rect = [50.0, 60.0, 100.0, 120.0].map(|v| Object::Ref(src.add(Object::Real(v)))); + annotation.set(b"Rect".to_vec(), Object::Array(rect.into())); + let color = [0.25, 0.5, 0.75].map(|v| Object::Ref(src.add(Object::Real(v)))); + annotation.set(b"C".to_vec(), Object::Array(color.into())); + let mut border = Dict::new(); + border.set(b"W".to_vec(), Object::Ref(src.add(Object::Real(2.5)))); + border.set(b"S".to_vec(), Object::name("D")); + let dash = [3.0, 2.0].map(|v| Object::Ref(src.add(Object::Real(v)))); + border.set(b"D".to_vec(), Object::Ref(src.add(Object::Array(dash.into())))); + annotation.set(b"BS".to_vec(), Object::Ref(src.add(border))); + let note = src.add(annotation); + src.update_dict(page, |p| p.set(b"Annots".to_vec(), Object::Array(vec![Object::Ref(note)]))).unwrap(); + + // This is an actual saved PDF: indirect scalar numbers, arrays and dictionaries are legal + // PDF objects, and source object numbers have no meaning in an exported FDF. + let src = Document::open(Arc::new(write_full(&src, &SaveOptions::default()).unwrap())).unwrap(); + assert_eq!(summaries(&src)[0].rect, [50.0, 60.0, 100.0, 120.0]); + assert_eq!(summaries(&src)[0].color, Some([0.25, 0.5, 0.75])); + let fdf = export_fdf(&src, true, false, "source.pdf").unwrap(); + let exported = Document::open(Arc::new(fdf.clone())).unwrap(); + let body = fdf_body(&exported).unwrap(); + let annots = exported.resolve(body.get(b"Annots").unwrap()); + let note = exported.resolve(&annots.as_array().unwrap()[0]); + let d = note.as_dict().unwrap(); + assert_eq!(nums_of(&exported, d.get(b"Rect")), [50.0, 60.0, 100.0, 120.0], "{}", String::from_utf8_lossy(&fdf)); + assert_eq!(nums_of(&exported, d.get(b"C")), [0.25, 0.5, 0.75]); + let bs = exported.resolve(d.get(b"BS").unwrap()); + let bs = bs.as_dict().unwrap(); + assert_eq!(exported.resolve(bs.get(b"W").unwrap()).as_f64(), Some(2.5)); + assert_eq!(nums_of(&exported, bs.get(b"D")), [3.0, 2.0]); + + let mut dst = blank(); + import(&mut dst, &fdf).unwrap(); + let dst = Document::open(Arc::new(write_full(&dst, &SaveOptions::default()).unwrap())).unwrap(); + let notes = summaries(&dst); + assert_eq!(notes[0].rect, [50.0, 60.0, 100.0, 120.0]); + assert_eq!(notes[0].color, Some([0.25, 0.5, 0.75])); +} + +/// Put extension data on a real exported comment without changing its normal properties. +fn fdf_note_with_data(doc: &mut Document, data: Object) { + let page = pdfcraft_model::pages(doc)[0].obj; + let mut note = Dict::new(); + note.set(b"Type".to_vec(), Object::name("Annot")); + note.set(b"Subtype".to_vec(), Object::name("Square")); + note.set(b"Rect".to_vec(), Object::Array(vec![10.into(), 20.into(), 30.into(), 40.into()])); + note.set(b"PCData".to_vec(), data); + let note = doc.add(note); + doc.update_dict(page, |dict| dict.set(b"Annots".to_vec(), Object::Array(vec![Object::Ref(note)]))).unwrap(); +} + +fn exported_note_data(doc: &Document) -> Object { + let body = fdf_body(doc).unwrap(); + let annots = doc.resolve(body.get(b"Annots").unwrap()); + let note = doc.resolve(&annots.as_array().unwrap()[0]); + note.as_dict().unwrap().get(b"PCData").unwrap().clone() +} + +#[test] +fn fdf_export_preserves_shared_cycles_and_nested_streams_without_expansion() { + let mut src = blank(); + let leaf = src.add(PdfString::text("shared")); + let mut shared = leaf; + for _ in 0..35 { + shared = src.add(Object::Array(vec![Object::Ref(shared), Object::Ref(shared)])); + } + let cycle = src.add(Object::Null); + src.set(cycle, Object::Array(vec![Object::Ref(cycle)])); + let mut stream_dict = Dict::new(); + stream_dict.set(b"PCMeta".to_vec(), Object::Ref(leaf)); + let stream = src.add(pdfcraft_cos::Stream::from_raw(stream_dict, b"original bytes".to_vec())); + let data = Object::Array(vec![Object::Ref(shared), Object::Ref(shared), Object::Ref(cycle), Object::Ref(stream)]); + fdf_note_with_data(&mut src, data); + let src = Document::open(Arc::new(write_full(&src, &SaveOptions::default()).unwrap())).unwrap(); + let bytes = export_fdf(&src, true, false, "source.pdf").unwrap(); + assert!(bytes.len() < 10_000, "a shared graph must not expand"); + let fdf = Document::open(Arc::new(bytes)).unwrap(); + let data = exported_note_data(&fdf); + let data = data.as_array().unwrap(); + assert_eq!(data[0], data[1]); + let mut current = data[0].clone(); + for _ in 0..35 { + let next = fdf.resolve(¤t); + let values = next.as_array().unwrap(); + assert_eq!(values[0], values[1]); + current = values[0].clone(); + } + assert_eq!(fdf.resolve(¤t).as_string().unwrap().to_text(), "shared"); + let cycle = fdf.resolve(&data[2]); + assert_eq!(cycle.as_array().unwrap()[0], data[2]); + let stream = fdf.resolve(&data[3]); + let Object::Stream(stream) = &*stream else { panic!("copied stream") }; + assert_eq!(&*stream.raw, b"original bytes"); + assert_eq!(fdf.resolve(stream.dict.get(b"PCMeta").unwrap()).as_string().unwrap().to_text(), "shared"); +} + +#[test] +fn fdf_export_walks_long_indirect_chains_and_nulls_absent_or_stale_refs() { + let mut src = blank(); + let live = src.add(PdfString::text("live")); + let mut head = live; + for _ in 0..3_000 { + head = src.add(Object::Array(vec![Object::Ref(head)])); + } + fdf_note_with_data(&mut src, Object::Array(vec![Object::Ref(head), Object::Ref(ObjRef::new(999_999, 0)), Object::Ref(ObjRef::new(live.num, 1))])); + let fdf = Document::open(Arc::new(export_fdf(&src, true, false, "source.pdf").unwrap())).unwrap(); + let data = exported_note_data(&fdf); + let data = data.as_array().unwrap(); + assert_eq!(*fdf.resolve(&data[1]), Object::Null); + assert_eq!(*fdf.resolve(&data[2]), Object::Null); + let mut current = data[0].clone(); + for _ in 0..3_000 { + current = fdf.resolve(¤t).as_array().unwrap()[0].clone(); + } + assert_eq!(fdf.resolve(¤t).as_string().unwrap().to_text(), "live"); +} + +#[test] +fn fdf_export_refuses_excessive_direct_depth_values_and_payload() { + let mut src = blank(); + let mut deep = Object::Null; + for _ in 0..70 { + deep = Object::Array(vec![deep]); + } + fdf_note_with_data(&mut src, deep); + assert_eq!(export_fdf(&src, true, false, "source.pdf"), Err(DataError::ExportLimit("64 direct nesting levels"))); + // Not exporting comments must not inspect their extension data. + assert!(export_fdf(&src, false, true, "source.pdf").is_ok()); + + let mut src = blank(); + fdf_note_with_data(&mut src, Object::Array(vec![Object::Null; MAX_FDF_OBJECTS + 1])); + assert_eq!(export_fdf(&src, true, false, "source.pdf"), Err(DataError::ExportLimit("100,000 values"))); + + let mut src = blank(); + let stream = src.add(pdfcraft_cos::Stream::from_raw(Dict::new(), vec![0; MAX_FDF_BYTES + 1])); + fdf_note_with_data(&mut src, Object::Ref(stream)); + assert_eq!(export_fdf(&src, true, false, "source.pdf"), Err(DataError::ExportLimit("64 MiB data"))); + + let mut src = blank(); + // Strings need up to four serialized bytes per source byte. Refuse before copying + // the direct payload, just as the indirect stream case above refuses its raw data. + fdf_note_with_data(&mut src, Object::String(PdfString::literal(vec![b'x'; MAX_FDF_BYTES / 4 + 1]))); + assert_eq!(export_fdf(&src, true, false, "source.pdf"), Err(DataError::ExportLimit("64 MiB data"))); +} + +#[test] +fn fdf_export_refuses_aliases_before_cloning_large_annotation_values() { + let mut src = blank(); + fdf_note_with_data(&mut src, Object::Array(vec![Object::Null; 1_024])); + let page = pdfcraft_model::pages(&src)[0].obj; + let annots = src.get(page).as_dict().unwrap().get(b"Annots").unwrap().clone(); + let note = annots.as_array().unwrap()[0].as_ref().unwrap(); + // The source holds one 1,024-value dictionary. The aliases must not clone its + // values a hundred thousand times before the comment-count refusal is reached. + src.update_dict(page, |dict| dict.set(b"Annots".to_vec(), Object::Array(vec![Object::Ref(note); MAX_FDF_OBJECTS + 1]))).unwrap(); + assert_eq!(export_fdf(&src, true, false, "source.pdf"), Err(DataError::ExportLimit("100,000 comments"))); +} + +#[test] +fn fdf_export_refuses_repeated_saved_annotations_after_cache_eviction() { + let bytes = { + let mut src = blank(); + let page = pdfcraft_model::pages(&src)[0].obj; + let mut notes = Vec::new(); + // More than the editor cache's 1,024 objects. Four passes force reparsing; + // retaining each resolved body during discovery would pin all four copies. + for _ in 0..1_025 { + let mut note = Dict::new(); + note.set(b"Type".to_vec(), Object::name("Annot")); + note.set(b"Subtype".to_vec(), Object::name("Square")); + note.set(b"Rect".to_vec(), Object::Array(vec![10.into(), 20.into(), 30.into(), 40.into()])); + note.set(b"PCData".to_vec(), Object::Array(vec![Object::Null; 1_024])); + notes.push(Object::Ref(src.add(note))); + } + let aliases = (0..4).flat_map(|_| notes.iter().cloned()).collect(); + src.update_dict(page, |dict| dict.set(b"Annots".to_vec(), Object::Array(aliases))).unwrap(); + write_full(&src, &SaveOptions { object_streams: false, ..SaveOptions::default() }).unwrap() + }; + // Drop the overlay-backed source before reopening: its persistent Arc objects + // would hide the parsed-object cache eviction exercised by this fixture. + let src = Document::open(Arc::new(bytes)).unwrap(); + assert!(src.repair_log().is_empty()); + assert_eq!(export_fdf(&src, true, false, "source.pdf"), Err(DataError::ExportLimit("100,000 values"))); +} + +#[test] +fn fdf_export_counts_emitted_field_values_before_allocating_lists() { + let field_document = |kind: &str, count: usize| { + let mut doc = Document::new_empty(); + let mut field = Dict::new(); + field.set(b"FT".to_vec(), Object::name(kind)); + field.set(b"T".to_vec(), PdfString::text("Pick")); + field.set(b"Ff".to_vec(), Object::Int(1 << 21)); // multi-select for a /Ch list + field.set(b"V".to_vec(), Object::Array(vec![Object::String(PdfString::text("")); count])); + let field = doc.add(field); + let mut form = Dict::new(); + form.set(b"Fields".to_vec(), Object::Array(vec![Object::Ref(field)])); + doc.update_dict(doc.root().unwrap(), |root| root.set(b"AcroForm".to_vec(), Object::Dict(form))).unwrap(); + assert_eq!(fields(&doc)[0].value.len(), count); + doc + }; + + // A field dictionary, /T string and /V array use three nodes before its entries. + let doc = field_document("Ch", MAX_FDF_OBJECTS - 3); + assert_eq!(fields(&doc)[0].kind, FieldKind::List); + let bytes = export_fdf(&doc, false, true, "field.pdf").unwrap(); + assert_eq!(data_values(&bytes).unwrap()[0].1.len(), MAX_FDF_OBJECTS - 3); + for count in [MAX_FDF_OBJECTS - 2, MAX_FDF_OBJECTS + 1] { + let doc = field_document("Ch", count); + assert!(matches!(export_fdf(&doc, false, true, "field.pdf"), Err(DataError::ExportLimit("100,000 values")))); + } + + // A hostile array on a scalar field still emits just its first string. The node + // budget counts output values, rather than charging entries that are not copied. + let doc = field_document("Tx", MAX_FDF_OBJECTS + 1); + assert_eq!(fields(&doc)[0].kind, FieldKind::Text); + let bytes = export_fdf(&doc, false, true, "field.pdf").unwrap(); + let fdf = Document::open(Arc::new(bytes)).unwrap(); + let body = fdf_body(&fdf).unwrap(); + let exported = fdf.resolve(body.get(b"Fields").unwrap()); + let exported = fdf.resolve(&exported.as_array().unwrap()[0]); + assert!(matches!(exported.as_dict().unwrap().get(b"V"), Some(Object::String(_)))); +} + #[test] fn xml_form_import_preserves_text_around_comments_and_instructions() { for (body, expected) in [ @@ -1187,6 +1419,132 @@ fn xml_form_import_preserves_text_around_comments_and_instructions() { } } +#[test] +fn fdf_export_nulls_nested_page_links_without_copying_the_source_page_tree() { + for indirect_type in [false, true] { + let source = { + let mut doc = Document::new_empty(); + let root = doc.root().unwrap(); + let tree = doc.get(root).as_dict().unwrap().reference(b"Pages").unwrap(); + let mut pages = Vec::new(); + for _ in 0..3 { + let contents = doc.add(pdfcraft_cos::Stream::from_raw( + Dict::new(), + b"% source-page-content-must-stay-in-the-PDF\nq 0 g 0 0 10 10 re f Q\n".repeat(1_024), + )); + let mut page = Dict::new(); + let page_type = if indirect_type { Object::Ref(doc.add(Object::name("Page"))) } else { Object::name("Page") }; + page.set(b"Type".to_vec(), page_type); + page.set(b"Parent".to_vec(), Object::Ref(tree)); + page.set(b"MediaBox".to_vec(), Object::Array(vec![0.into(), 0.into(), 600.into(), 800.into()])); + page.set(b"Contents".to_vec(), Object::Ref(contents)); + pages.push(doc.add(page)); + } + let tree_type = if indirect_type { Object::Ref(doc.add(Object::name("Pages"))) } else { Object::name("Pages") }; + doc.update_dict(tree, |dict| { + dict.set(b"Type".to_vec(), tree_type); + dict.set(b"Count".to_vec(), Object::Int(3)); + dict.set(b"Kids".to_vec(), Object::Array(pages.iter().copied().map(Object::Ref).collect())); + }) + .unwrap(); + let mut action = Dict::new(); + action.set(b"S".to_vec(), Object::name("GoTo")); + action.set(b"D".to_vec(), Object::Array(vec![Object::Ref(pages[1]), Object::name("Fit")])); + let mut extension = Dict::new(); + extension.set(b"Type".to_vec(), Object::name("PCMetadata")); + extension.set(b"Target".to_vec(), Object::Ref(pages[1])); + extension.set(b"Label".to_vec(), PdfString::text("retained metadata")); + let extension = doc.add(extension); + let type_cycle = doc.add(Object::Null); + doc.set(type_cycle, Object::Ref(type_cycle)); + let mut cyclic_type = Dict::new(); + cyclic_type.set(b"Type".to_vec(), Object::Ref(type_cycle)); + cyclic_type.set(b"Label".to_vec(), PdfString::text("cyclic type is not a page")); + fdf_note_with_data( + &mut doc, + Object::Array(vec![Object::Dict(action), Object::Ref(extension), Object::Array(vec![Object::Ref(tree)]), Object::Dict(cyclic_type)]), + ); + write_full(&doc, &SaveOptions::default()).unwrap() + }; + let source = Document::open(Arc::new(source)).unwrap(); + assert!(source.repair_log().is_empty()); + assert_eq!(pdfcraft_model::pages(&source).len(), 3); + let bytes = export_fdf(&source, true, false, "source.pdf").unwrap(); + assert!(bytes.len() < 4_096, "a comment export must not carry source pages or their content ({} bytes)", bytes.len()); + let fdf = Document::open(Arc::new(bytes)).unwrap(); + let data = exported_note_data(&fdf); + let data = data.as_array().unwrap(); + let action = data[0].as_dict().unwrap(); + assert_eq!(action.name(b"S"), Some(b"GoTo".as_slice())); + let destination = action.get(b"D").unwrap().as_array().unwrap(); + assert_eq!(*fdf.resolve(&destination[0]), Object::Null); + assert_eq!(destination[1], Object::name("Fit")); + let extension = fdf.resolve(&data[1]); + let extension = extension.as_dict().unwrap(); + assert_eq!(extension.name(b"Type"), Some(b"PCMetadata".as_slice())); + assert_eq!(extension.get(b"Target"), Some(&destination[0]), "the excluded page reference remains aliased"); + assert_eq!(extension.get(b"Label").unwrap().as_string().unwrap().to_text(), "retained metadata"); + let tree = data[2].as_array().unwrap(); + assert_eq!(*fdf.resolve(&tree[0]), Object::Null); + + let cyclic_type = data[3].as_dict().unwrap(); + assert_eq!(cyclic_type.get(b"Label").unwrap().as_string().unwrap().to_text(), "cyclic type is not a page"); + let type_cycle = cyclic_type.get(b"Type").unwrap(); + assert_eq!(&*fdf.resolve(type_cycle), type_cycle); + } +} + +#[test] +fn fdf_export_bounds_indirect_type_alias_lookups() { + for extra_aliases in [MAX_DEPTH - 1, MAX_DEPTH] { + let mut doc = blank(); + let mut page_type = doc.add(Object::name("Page")); + for _ in 0..extra_aliases { + page_type = doc.add(Object::Ref(page_type)); + } + let mut page = Dict::new(); + page.set(b"Type".to_vec(), Object::Ref(page_type)); + fdf_note_with_data(&mut doc, Object::Dict(page)); + let exported = export_fdf(&doc, true, false, ""); + if extra_aliases < MAX_DEPTH { + let fdf = Document::open(Arc::new(exported.unwrap())).unwrap(); + assert_eq!(exported_note_data(&fdf), Object::Null); + } else { + assert!(matches!(exported, Err(DataError::ExportLimit("64 Type reference levels")))); + } + } +} + +#[test] +fn fdf_export_preserves_metadata_with_stale_type_references() { + for alias in [false, true] { + let mut doc = blank(); + let current = doc.add(Object::name("Page")); + doc.update_dict(doc.root().unwrap(), |dict| dict.set(b"PCPageType".to_vec(), Object::Ref(current))).unwrap(); + let mut doc = Document::open(Arc::new(write_full(&doc, &SaveOptions::default()).unwrap())).unwrap(); + let current = doc.get(doc.root().unwrap()).as_dict().unwrap().reference(b"PCPageType").unwrap(); + let stale = ObjRef::new(current.num, current.generation + 1); + let page_type = if alias { doc.add(Object::Ref(stale)) } else { stale }; + let mut metadata = Dict::new(); + metadata.set(b"Type".to_vec(), Object::Ref(page_type)); + metadata.set(b"Label".to_vec(), PdfString::text("preserved despite stale type")); + fdf_note_with_data(&mut doc, Object::Dict(metadata)); + assert_eq!(doc.generation(stale.num), current.generation); + // A warm number-keyed cache must not make the stale Type reference live. + assert_eq!(*doc.try_get(stale.num).unwrap(), Object::name("Page")); + let bytes = export_fdf(&doc, true, false, "").unwrap(); + let fdf = Document::open(Arc::new(bytes)).unwrap(); + let data = exported_note_data(&fdf); + let metadata = data.as_dict().expect("stale Type must not classify unrelated metadata as a source page"); + assert_eq!(metadata.get(b"Label").unwrap().as_string().unwrap().to_text(), "preserved despite stale type"); + let mut page_type = fdf.resolve(metadata.get(b"Type").unwrap()); + if alias { + page_type = fdf.resolve(&page_type); + } + assert_eq!(*page_type, Object::Null); + } +} + #[test] fn csv_headers_with_tabs_survive_export_import_and_save() { for name in ["First\tName", "\tName", "Name\t", "Name\t\"quoted\"", "Name\nwith\ta tab"] { diff --git a/docs/mcp.md b/docs/mcp.md index 96493487c7..77a3c9484d 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -8,6 +8,53 @@ Supply `--root` at most once, followed by a nonempty directory path. Missing ope repeated roots and another long option in place of the directory are refused before any script is read or server is started. Prefix a directory name beginning with `--` with `./`. +## Install and register + +From the repository root, with Rust 1.95+ and a native linker installed +(see [development](development.md) for build and environment notes): + +```sh +cargo install --path apps/pdfcraft-cli --locked +# POSIX shells, for Cargo's default install location: +export PATH="${CARGO_HOME:-$HOME/.cargo}/bin:$PATH" +pdfcraft-cli --help +pdfcraft-cli mcp --help +``` + +The default features include MCP; do not pass `--no-default-features` for this use. +Cargo installs a binary, not a symlink into `target/`, so it survives `cargo clean`. +Re-run the install command after updating the checkout. +By default the executable goes in `$CARGO_HOME/bin` (`~/.cargo/bin` when unset), or +`%USERPROFILE%\.cargo\bin` on Windows. If you set `--root`, `CARGO_INSTALL_ROOT`, or +Cargo's `install.root`, use that installation root's `bin` directory instead. Add it +to the `PATH` inherited by the MCP client, not just an interactive shell, and restart +the client after changing its environment. + +Register the installed command explicitly (replace `/work/project` with an existing folder): + +```sh +claude mcp add pdfcraft -- pdfcraft-cli mcp --root /work/project +``` + +Other MCP clients can use this bare-name configuration: + +```json +{"mcpServers":{"pdfcraft":{"command":"pdfcraft-cli","args":["mcp","--root","/work/project"]}}} +``` + +Linux `.deb` and `.rpm` packages already install `/usr/bin/pdfcraft-cli`; those users +can skip the source install. The [downloads list](../README.md#downloads) also includes +standalone Linux CLI tarballs and a macOS CLI zip; unpack and copy the executable into +a directory on the client's `PATH`. Installing does not start or register a server: +MCP remains opt-in and uses stdio only. + +If a client reports `ENOENT` or "Executable not found in $PATH", verify the install with +`command -v pdfcraft-cli` (PowerShell: `Get-Command pdfcraft-cli`) in the client's +environment. If that environment cannot be changed, set JSON `command` to the absolute +installed binary path, not a literal `~` or `$HOME` expression. Avoid symlinks into +`target/release`: cleaning the build directory breaks them. A source build alone does +not put the CLI on `PATH`. + ## Confining file access ```sh @@ -15,7 +62,7 @@ pdfcraft-cli mcp --automation-read-root /work/project --automation-write-root /w ``` ```json -{"mcpServers": {"pdfcraft": {"command": "/abs/path/target/release/pdfcraft-cli", +{"mcpServers": {"pdfcraft": {"command": "pdfcraft-cli", "args": ["mcp", "--automation-read-root", "/work/project", "--automation-write-root", "/work/project/out"]}}} ``` diff --git a/docs/roadmap.md b/docs/roadmap.md index e1fdce6505..5d73511e9c 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 (M5, PR #1971: bounded nested FDF export excludes source page/page-tree dictionaries; refreshed against upstream 4db6ac4a; estimates unchanged) > **Last reviewed:** 2026-10-15 · **Last updated:** 2026-10-15 · **Change:** minor (M14/#2383 GPU-limit candidate; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS) > **Last reviewed:** 2026-10-15 · **Last updated:** 2026-10-15 · **Change:** minor (M7: the Edit text caret is as tall as the row it is on; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS) > **Last reviewed:** 2026-10-15 · **Last updated:** 2026-10-15 · **Change:** minor (M6: CSV tab-containing headers; estimates unchanged) @@ -113,7 +114,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. #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). Selected-text Copy in Edit and the canvas context menu is covered by focused regressions and native clipboard checks (#1225); document copy restrictions apply to the floating action too. #1364 candidate: absolute local GoToR links ask before target access; headless callers explicitly opt in. Native-opening candidate: chosen tools survive preparation and password retries; startup intake waits for pending preparation. Page-label navigation displays logical labels, retains the current page on unchanged submission and refreshes focused same-document replacements; clean file-watcher reload preserves the chosen scrolling mode, preventing a Single Page reader from jumping to page 1 during replacement. Synthetic control regressions cover these behaviors. #3149: page-list changes discard render demand based on old geometry; actual raster dimensions are checked against the GPU limit before upload. Synthetic crash and continued-rendering regressions cover the replacement path. Save-prompt shortcuts yield to dialogs above them; stacked link-consent UI regression covers Escape and Return. | | M3 | Viewer app (native + web) | 80–150 | 90% | 10–20 | Acrobat-style shell, find, select, panels, tiles, web build, UI control channel for agents (opt-in). Missing: 60 fps test on a 500-page document, snapshot tests of every panel
Done since: fit visible, document title in the window; Linux middle-button scrolling; shared three-state theme preferences in the toolbar, View menu and control channel; numeric Linux portal events and idle wakeups completing #881, with native appearance inheritance retained (#446); late browser startup downloads preserve the current document and Save target (#171). Selected-text Copy in Edit and the canvas context menu is covered by focused regressions and native clipboard checks (#1225); document copy restrictions apply to the floating action too. #1364 candidate: absolute local GoToR links ask before target access; headless callers explicitly opt in. Native-opening candidate: chosen tools survive preparation and password retries; startup intake waits for pending preparation. Page-label navigation displays logical labels, retains the current page on unchanged submission and refreshes focused same-document replacements; clean file-watcher reload preserves the chosen scrolling mode, preventing a Single Page reader from jumping to page 1 during replacement. Synthetic control regressions cover these behaviors. Wrapped revision buttons stay above Binding in Document Properties (#2739); a synthetic UI test checks layout and saved revision content. | | M3 | Viewer app (native + web) | 80–150 | 90% | 10–20 | Acrobat-style shell, find, select, panels, tiles, web build, UI control channel for agents (opt-in). Missing: 60 fps test on a 500-page document, snapshot tests of every panel
Done since: fit visible, document title in the window; Linux middle-button scrolling; shared three-state theme preferences in the toolbar, View menu and control channel; numeric Linux portal events and idle wakeups completing #881, with native appearance inheritance retained (#446); late browser startup downloads preserve the current document and Save target (#171). #1364 candidate: absolute local GoToR links ask before target access; headless callers explicitly opt in. Native-opening candidate: chosen tools survive preparation and password retries; startup intake waits for pending preparation. Later native-open replies stay queued while a modal or picker owns the foreground; regressions cover recipient ownership, completion/cancellation and one-shot wakes. | | M3 | Viewer app (native + web) | 80–150 | 90% | 10–20 | Acrobat-style shell, find, select, panels, tiles, web build, UI control channel for agents (opt-in). Missing: 60 fps test on a 500-page document, snapshot tests of every panel
Done since: fit visible, document title in the window; Linux middle-button scrolling; shared three-state theme preferences in the toolbar, View menu and control channel; numeric Linux portal events and idle wakeups completing #881, with native appearance inheritance retained (#446); late browser startup downloads preserve the current document and Save target (#171). #1364 candidate: absolute local GoToR links ask before target access; headless callers explicitly opt in. Native-opening candidate: chosen tools survive preparation and password retries; startup intake waits for pending preparation. #1980: exact quit modifiers preserve Ctrl+Alt+Q email input; Ctrl+Q/Cmd+Q asks before discarding unsaved documents. | @@ -131,20 +132,20 @@ total matches the parity estimate. | M5 | Comments (all annotation types, XFDF) | 120–200 | 88% | 12–25 | Done: notes, highlight/underline/strikeout/squiggly, text boxes, ink, lines, arrows, rectangles, ovals, stamps (dynamic, Sign Here, Standard Business), with appearance streams; replies, status, checkmarks, locking, move/resize/restyle/delete; properties dialog; panel filter and sort; hide all; summaries (comments only); XFDF/FDF import and export (partial imports preserve existing reply and review-status links); flatten; Fill & Sign with adjustable text/date size; agent tools.
Done since: stamps placed on a rotated page read upright (#1398); polygons, connected lines, clouds, callouts, inserted text, colour and checkmark filters; image signature pointer previews, selection handles and live image movement/resizing with proportional corners and independent edge handles; Ctrl+Enter submission for highlight comment text and replies, with Shift+Enter newlines (reconciled with upstream main for PR #1457 on 2026-10-14). Missing: custom stamps, replace-text proposals, summary layouts with the page | | M4 | Engine, history, save, organize | 100–180 | 95% | 8–15 | Done:
- command registry (menus, shortcuts and palette all use it);
- undo/redo; incremental, atomic and encrypted saves;
- autosave and crash recovery;
- organize, combine, extract, split and insert-from-file, with identical fonts and images stored once;
- CLI `edit/combine/extract/split`.
Done since: Combine files in its own tab (sortable, resizable columns; folders; early warnings; unlocking protected files), bookmark editing, page labels (Number pages), CLI `run`, Set Page Boxes, Crop tool, Duplicate pages, Ctrl+A / Cmd+A to select all pages in Organize, Replace pages, bookmarks from tagged headings. Missing: page transitions, recovery on the web M4: Insert-from-file preserves source bookmarks; integration tests retained with current main. | | M5 | Comments (all annotation types, XFDF) | 120–200 | 88% | 12–25 | Done: notes, highlight/underline/strikeout/squiggly, text boxes, ink, lines, arrows, rectangles, ovals, stamps (dynamic, Sign Here, Standard Business), with appearance streams; replies, status, checkmarks, locking, move/resize/restyle/delete; properties dialog; panel filter and sort; hide all; summaries (comments only); XFDF/FDF import and export (partial imports preserve existing reply and review-status links); flatten; Fill & Sign with adjustable text/date size; agent tools.
Done since: stamps placed on a rotated page read upright (#1398); polygons, connected lines, clouds, callouts, inserted text, colour and checkmark filters; image signature pointer previews, selection handles and live image movement/resizing with proportional corners and independent edge handles. Missing: custom stamps, replace-text proposals, summary layouts with the page | -| M5 | Comments (all annotation types, XFDF) | 120–200 | 88% | 12–25 | Done: notes, highlight/underline/strikeout/squiggly, text boxes, ink, lines, arrows, rectangles, ovals, stamps (dynamic, Sign Here, Standard Business), with appearance streams; replies, status, checkmarks, locking, move/resize/restyle/delete; properties dialog; panel filter and sort; hide all; summaries (comments only); XFDF/FDF import and export (partial imports preserve existing reply and review-status links); 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 | +| M5 | Comments (all annotation types, XFDF) | 120–200 | 88% | 12–25 | Done: notes, highlight/underline/strikeout/squiggly, text boxes, ink, lines, arrows, rectangles, ovals, stamps (dynamic, Sign Here, Standard Business), with appearance streams; replies, status, checkmarks, locking, move/resize/restyle/delete; properties dialog; panel filter and sort; hide all; summaries (comments only); XFDF/FDF import and export (partial imports preserve existing reply and review-status links; nested FDF export uses lazy array owners and annotation positions, with retained entries budgeted before cloning under copied-graph limits and source `/Page` and `/Pages` dictionaries replaced by null at every copy depth); flatten; Fill & Sign with adjustable text/date size; agent tools.
Done since: polygons, connected lines, clouds, callouts, inserted text, colour and checkmark filters; image signature pointer previews, selection handles and live image movement/resizing with proportional corners and independent edge handles. A refused restyle or resize of a text box another program drew leaves that comment unchanged (#1327). Make Current Properties Default on an arrow updates arrows, not plain lines (#1302). Flattening keeps a visible comment it cannot draw (#1317). Missing: custom stamps, replace-text proposals, summary layouts with the page | | M6 | Forms + JavaScript | 160–320 | 50% | 40–75 | Done: filling all field types with regenerated appearances; Prepare a form (every field type, multi-select/shared properties, move/resize/delete, Field Properties General/Appearance/Position/Options/Format/Validate/Calculate); Acrobat's AF format/keystroke/validate/calculate functions and simplified field notation run natively in Acrobat's event order; tab order; push-button actions; flatten; agent tools. Form data exchange (FDF, XFDF, XML, CSV, text) done; XFDF values retain text across XML comments and processing instructions. Plain AcroForm XML imports retain all direct text around comments and processing instructions, including CDATA and character references. CSV export and spreadsheet merging quote tabs, and import distinguishes quoted tabs from TSV delimiters without changing field identities. XML/XFDF exports preserve tabs and line breaks in names and comment metadata. Refreshed against upstream `99941daa` (2026-10-15).
Done since: JavaScript engine (boa, sandboxed) running custom keystroke/validate/calculate/format and button scripts, document JavaScripts, console. Done since: Push-button normal labels are editable per widget through Options and form_set_props, independently of field names; rendering, undo/redo and incremental/full save/reopen are covered. (#2892). Missing: Actions tab, wider object model (annotations, layers, dialogs), XFA
Done since: Multi-select choice fields write ascending `/I` option indices while preserving `/V` selection order; synthetic forms/engine regressions cover reverse selection and save/reopen. CSV export preserves carriage returns in names and values. | | M6 | Forms + JavaScript | 160–320 | 50% | 40–75 | Done: filling all field types with regenerated appearances; Prepare a form (every field type, multi-select/shared properties, move/resize/delete, Field Properties General/Appearance/Position/Options/Format/Validate/Calculate); Acrobat's AF format/keystroke/validate/calculate functions and simplified field notation run natively in Acrobat's event order; tab order; push-button actions; flatten; agent tools. Form data exchange (FDF, XFDF, XML, CSV, text) done; XFDF values retain text across XML comments and processing instructions. Plain AcroForm XML imports retain all direct text around comments and processing instructions, including CDATA and character references.
Done since: JavaScript engine (boa, sandboxed) running custom keystroke/validate/calculate/format and button scripts, document JavaScripts, console. Missing: Actions tab, wider object model (annotations, layers, dialogs), XFA
Done since: Multi-select choice fields write ascending `/I` option indices while preserving `/V` selection order; synthetic forms/engine regressions cover reverse selection and save/reopen. CSV export preserves carriage returns in names and values. | | M6 | Forms + JavaScript | 160–320 | 50% | 40–75 | Done: filling all field types with regenerated appearances; Prepare a form (every field type, multi-select/shared properties, move/resize/delete, Field Properties General/Appearance/Position/Options/Format/Validate/Calculate); Acrobat's AF format/keystroke/validate/calculate functions and simplified field notation run natively in Acrobat's event order; tab order; push-button actions; flatten; agent tools. Form data exchange (FDF, XFDF, XML, CSV, text) done; XFDF values retain text across XML comments and processing instructions. XML/XFDF exports preserve tabs and line breaks in names and comment metadata. Refreshed against upstream `99941daa` (2026-10-15).
Done since: JavaScript engine (boa, sandboxed) running custom keystroke/validate/calculate/format and button scripts, document JavaScripts, console. Missing: Actions tab, wider object model (annotations, layers, dialogs), XFA
Done since: Multi-select choice fields write ascending `/I` option indices while preserving `/V` selection order; synthetic forms/engine regressions cover reverse selection and save/reopen. CSV export preserves carriage returns in names and values. | | M6 | Forms + JavaScript | 160–320 | 50% | 40–75 | Done: filling all field types with regenerated appearances; Prepare a form (every field type, multi-select/shared properties, move/resize/delete, Field Properties General/Appearance/Position/Options/Format/Validate/Calculate); Acrobat's AF format/keystroke/validate/calculate functions and simplified field notation run natively in Acrobat's event order; tab order; push-button actions; flatten; agent tools. Form data exchange (FDF, XFDF, XML, CSV, text) done; XFDF values retain text across XML comments and processing instructions. XML/XFDF exports preserve tabs and line breaks in names and comment metadata. Plain AcroForm XML imports retain all direct text around comments and processing instructions, including CDATA and character references. CSV export and spreadsheet merging quote tabs, and import distinguishes quoted tabs from TSV delimiters without changing field identities.
Done since: JavaScript engine (boa, sandboxed) running custom keystroke/validate/calculate/format and button scripts, document JavaScripts, console. Missing: Actions tab, wider object model (annotations, layers, dialogs), XFA
Done since: Multi-select choice fields write ascending `/I` option indices while preserving `/V` selection order; synthetic forms/engine regressions cover reverse selection and save/reopen. CSV export preserves carriage returns in names and values. | | M7 | Content editing (text, images, header/footer, watermark) | 250–500 | 20% | 100–180 | Done: header & footer, watermarks, backgrounds, Bates; added text and images that stay editable (move, resize, format, rotate, flip, crop, replace); links (Link tool, Link Properties, create from URLs, remove all). Done since: Edit text preserves Arabic/Hebrew `/ActualText` typing order, with font-independent regressions; single-glyph RTL ligature spans (including lam-alef) join their surrounding word. Done since (#1116): editable text regions grouped across compatible words and subset fonts, one rich-text editor over a background with the source text removed, embedded sfnt faces reused as editor faces, and visible (clipped) bounds for cropped-image selection and resize. Exclusive crops now resize across nested states and content streams, with shared clips fixed.
Done since: #594 ordinary paragraph drags preserve overlapping source text; UI/engine regressions, native save/reopen, independent-reader checks and full CI passed in the Linux test environment. #1200 candidate: local GoToR links open in desktop tabs and through root-confined automation; appearance-only edits preserve existing actions. Named destinations remain unapplied and `/NewWindow` is a tab hint. Missing: editing existing text and images in place (the longest pole), image/PDF watermarks | | M7 | Content editing (text, images, header/footer, watermark) | 250–500 | 20% | 100–180 | Done: header & footer, watermarks, backgrounds, Bates; added text and images that stay editable (move, resize, format, rotate, flip, crop, replace); links (Link tool, Link Properties, create from URLs, remove all). Done since: Edit text preserves Arabic/Hebrew `/ActualText` typing order, with font-independent regressions. Done since (#1116): editable text regions grouped across compatible words and subset fonts, one rich-text editor over a background with the source text removed, embedded sfnt faces reused as editor faces, and visible (clipped) bounds for cropped-image selection and resize. Exclusive crops now resize across nested states and content streams, with shared clips fixed.
Done since: #594 ordinary paragraph drags preserve overlapping source text; UI/engine regressions, native save/reopen, independent-reader checks and full CI passed in the Linux test environment. #1200 candidate: local GoToR links open in desktop tabs and through root-confined automation; appearance-only edits preserve existing actions. Named destinations remain unapplied and `/NewWindow` is a tab hint. #1316: replacing a line or paragraph keeps a neighbouring line whose glyph box overlaps when the baselines differ; a second copy on the same baseline is still replaced. Missing: editing existing text and images in place (the longest pole), image/PDF watermarks | -| M7 | Content editing (text, images, header/footer, watermark) | 250–500 | 20% | 100–180 | Done: header & footer, watermarks, backgrounds, Bates; added text and images that stay editable (move, resize, format, rotate, flip, crop, replace); links (Link tool, Link Properties, create from URLs, remove all). #3212: Headers, Bates numbers, watermarks and added text stay visible after unfinished marked content, without revealing hidden content. Missing: editing existing text and images in place (the longest pole), watermark print/screen visibility and repeat | +| M7 | Content editing (text, images, header/footer, watermark) | 250–500 | 20% | 100–180 | Done: header & footer, watermarks, backgrounds, Bates; added text and images that stay editable (move, resize, format, rotate, flip, crop, replace); links (Link tool, Link Properties, create from URLs, remove all). #3212: Headers, Bates numbers, watermarks and added text stay visible after unfinished marked content, without revealing hidden content. Tracked editable text distinguishes repeated glyph spacing from word gaps, with synthetic paragraph/object regressions. Missing: editing existing text and images in place (the longest pole), watermark print/screen visibility and repeat | | M6 | Forms + JavaScript | 160–320 | 50% | 40–75 | Done: filling all field types with regenerated appearances; Prepare a form (every field type, multi-select/shared properties, move/resize/delete, Field Properties General/Appearance/Position/Options/Format/Validate/Calculate); Acrobat's AF format/keystroke/validate/calculate functions and simplified field notation run natively in Acrobat's event order; tab order; push-button actions; flatten; agent tools. Form data exchange (FDF, XFDF, XML, CSV, text) done; XFDF values retain text across XML comments and processing instructions.
Done since: JavaScript engine (boa, sandboxed) running custom keystroke/validate/calculate/format and button scripts, document JavaScripts, console. Missing: Actions tab, wider object model (annotations, layers, dialogs), XFA
Done since: Existing simple field-font encodings and Differences are respected when generating appearances; an unencodable value uses a separate Helvetica appearance font without changing the source font. Synthetic reopen regressions cover punctuation, subset fallback and multiline text. Exact font metrics/layout and broader Unicode fallback remain incomplete. | | M6 | Forms + JavaScript | 160–320 | 50% | 40–75 | Done: filling all field types with regenerated appearances; Prepare a form (every field type, multi-select/shared properties, move/resize/delete, Field Properties General/Appearance/Position/Options/Format/Validate/Calculate); Acrobat's AF format/keystroke/validate/calculate functions and simplified field notation run natively in Acrobat's event order; tab order; push-button actions; flatten; agent tools. Form data exchange (FDF, XFDF, XML, CSV, text) done; XFDF values retain text across XML comments and processing instructions.
Done since: JavaScript engine (boa, sandboxed) running custom keystroke/validate/calculate/format and button scripts, document JavaScripts, console. Missing: Actions tab, wider object model (annotations, layers, dialogs), XFA
Done since: Tab and Shift-Tab wrapping to the same field or another widget of that field refresh the newly opened editor from the accepted value before Save. Synthetic UI regressions cover both directions, same-frame Save, validation refusal and undo/redo. | | M7 | Content editing (text, images, header/footer, watermark) | 250–500 | 20% | 100–180 | Done: header & footer, watermarks, backgrounds, Bates; added text and images that stay editable (move, resize, format, rotate, flip, crop, replace); links (Link tool, Link Properties, create from URLs, remove all). Missing: editing existing text and images in place (the longest pole), image/PDF watermarks | | M8 | Security + redaction | 100–180 | 68% | 15–30 | Done: opening protected documents, permissions, Protect Using Password, Remove security; redaction (mark text/areas/pages, Search & Redact with patterns, apply removing glyphs, image pixels, vectors, XObject content, annotations and fields, verification, full rewrite on save); Remove hidden information and Sanitize.
Done since: redaction codes (U.S. FOIA and Privacy Act sets as overlay text, in Redaction properties and `redact_mark`). Redaction cleans indirect and form structure references, preserving surviving form tags. Redaction retires inherited resource dictionaries, so a parent `/Pages` can't keep a removed XObject reachable; unredacted pages are left untouched. Image redaction removes alternate images and unsupported masks only on affected images. Changed marked-content spans lose replacement descriptions; untouched spans keep theirs. Hidden-text cleanup retires unused named descriptions across inherited/shared page and form resources (64 focused tests and scoped Rust 1.96 Clippy/wasm checks pass; publication CI pending). Missing: certificate security, custom redaction code sets and pattern locales, DCT re-encoding | | M9 | Signatures (PAdES, validation) | 160–280 | 60% | 40–70 | Done: new `sign` crate (DER, X.509, CMS, PKCS #12 on RustCrypto; aws-lc-rs for RSA private keys); PAdES B-B signing (visible, invisible, existing fields, certification with DocMDP); validation with trust store and changes-after-signing classification; self-signed digital IDs; Signatures panel, message bar, sign dialogs; agent tools. Checked with pdfsig and OpenSSL.
Done since: macOS Keychain and Windows Current User Personal store signing (software-backed CNG keys), certificate viewer, signed documents protected from rewrites, page validation overlays separate from original signature artwork (baked Not Verified captions remain; #79 open), snapshot tabs that reuse instead of multiplying. Missing: timestamps (B-T), LTV (DSS, OCSP, CRL), FieldMDP, smart cards, PKCS #11 | -| M10 | OCR, create, export, print | 200–350 | 33% | 120–215 | Done: create from blank/text/PNG/JPEG/TIFF (multi-page)/GIF/BMP; export PNG/JPEG/TIFF and text; Print (Acrobat's sizing, n-up, booklet, poster, comments & forms, preview, the CUPS and Windows spoolers, print-ready PDF).
Done since: embedded/72/custom DPI choices for image imports; export all images; OCR (searchable image for pages, ranges and multiple files); single-sided cut-and-stack imposition with cut marks, through Print and doc_print; architectural/ANSI/large ISO sheet presets, Match page at 100% and PDF-scale preview (#599 follow-up); Create PDF from multiple files (PDFs, images and text, edited as pages in the grid); OCR can also read pictures on pages that have text, without adding that text twice (#1028). Done since: Word invoice export uses borderless inferred tables, floating positioned text/artwork only where text overlaps an image or is rotated, and per-page sizes, covered by synthetic invoice automation regressions (#773); printing on the web through the browser's print dialog, sheets drawn by PdfCraft (gap 4). Lossless image export applies per-component Decode, including Indexed mapping (#1201 partial). Existing 16-bit precision and color-management limits remain. 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. #3120: Office export reads form XObjects 50 levels deep, as the renderer does, and up to 50 000 a page; pages past that are listed in `truncated_pages`. Missing: OCR languages beyond Latin, editable-text OCR output, full Office layout fidelity/import | | M11 | Optimize, preflight, PDF/A/X/UA, print production | 200–350 | 18% | 80–140 | Done: new `optimize` crate: Reduce File Size and the PDF Optimizer (images measured where drawn, bicubic downsampling, JPEG/ZIP recompression only when smaller, discard objects and user data, Flate clean-up, resource merging, object streams). Missing: fonts and transparency panels, space audit, preflight, PDF/A/X/UA | | M12 | Accessibility, compare, measure, search, XFA | 200–380 | 30% | 90–160 | Done: new `a11y` crate with the Accessibility Checker (all 32 rules, report, Fix/Skip/Explain, options dialog and results panel, agent tools). 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. | @@ -153,7 +154,7 @@ total matches the parity estimate. | 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 | +| M14 | 1.0 polish: performance, localization, installers | 120–250 | 15% | 80–150 | Done: PhotoCraft's translation system (`i18n/`: TSV catalogs, `tl!`, command-id and plural entries, system-language detection, strict catalog tests); every dialog, panel and notice goes through `tl!`. Catalogs of ≈ 2,000–2,140 entries for Japanese, Simplified and Traditional Chinese, Russian, Bulgarian, German, Spanish, French, Telugu, Hungarian, Ukrainian, Brazilian Portuguese and Arabic (logical-order catalog put into display order; layout not yet mirrored); Czech covers the menus. Italian has 2,528 entries: every key of the other catalogs that the code looks up. Signed and notarized macOS builds, signed Windows installers (x64, x86, ARM64), AppImage, deb, rpm, Flatpak and FreeBSD packages. #307 (bounded large-PDF memory): shared resource indexes and parser metadata, lazy inspection, bounded caches; a 9,156-page file inspects in ≈ 1.8 s at ≈ 500 MiB instead of exceeding 10 GiB. Edits let go of the previous working file before writing the next (a 1 GiB file peaks at two copies while editing, not three; gap 13). Shortcut display uses localized modifier/key names across menus, toolbar, palette, Help and tooltips (#496). Traditional Chinese accessibility statuses, rule semantics and Taiwan form/table terms have catalog regressions. Long main-menu lists scroll in short windows (#3373). Missing: a right-to-left layout, a Chinese UI face in releases, Hindi/Indonesian/Korean/Vietnamese, file-backed input and global memory budgets, performance budgets, keyboard-only operation | | | **Total** | **2,085–3,840** | **≈ 49% ready** | **≈ 770–1,400** | Area view of the same work: [target-app-parity.md](target-app-parity.md#by-feature-area) | ## Upcoming milestones, ranked @@ -199,7 +200,10 @@ M2 (renderer), M7 (content editing), M10 (OCR and Office), M11 (Preflight and st | Date | Change | Summary | |---|---|---| +| 2026-10-18 | minor | M5, PR #1971: nested source `/Page` and `/Pages` dictionaries export as null without traversing page content or the page tree. Synthetic saved/reopened three-page regression retains unrelated metadata and aliases. FDF import semantics, copied-graph limits and estimates unchanged. | +| 2026-10-15 | minor | M5: budget retained FDF entries before cloning; discovery keeps lazy array owners and annotation positions, with alias/cache-eviction stress fixtures. Limits cover the copied graph; source parsing/page discovery retain existing COS/parser limits, with no total source-memory bound. FDF import semantics and estimates unchanged. | | 2026-10-15 | minor | M7: Edit text caret height follows its row, with a kittest regression; estimates unchanged | +| 2026-10-19 | minor | M3 #3360: retain each document’s scroll position when switching Read and the docking workspace at fixed zoom. Estimates unchanged. | | 2026-10-18 | minor | M4, PR #1919 review: repeated page indices apply each rotation to the latest page-local value, preserving indirect/inherited angles. Synthetic adjacent/interleaved duplicates retain siblings and page contents through incremental and full saves. Estimates unchanged. | | 2026-10-18 | minor | M3, #3149: page replacement cancels render demand collected for the previous geometry; all viewer raster upload paths reject empty or oversized textures before cache eviction. Synthetic regressions reproduce the reported 1880×2350 upload at a 2048 limit, retain cached images and unsaved edits, and resume bounded backdrops and tiles. Estimates unchanged. | | 2026-10-17 | minor | M4 #2997: Ctrl-drag (Option on macOS) in the Organize grid copies the dragged pages into the drop gap (a "+" badge says so; the copies land selected, one undo step); `page_duplicate` takes `at`, and the UI control channel's `ui.drag` modifiers reach the input state. Estimates unchanged. | diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index da412f6e99..0e5191d6aa 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -854,8 +854,8 @@ milestone = "M3" status = "shipped" commands = ["view.continuous"] tools = [] -evidence = ["crates/ui-egui/tests/view.rs::continuous_layout_stacks_pages_vertically", "crates/ui-egui/tests/view.rs::page_display_commands_switch_layouts", "crates/ui-egui/tests/view.rs::default_page_display_is_used_for_new_documents", "crates/ui-egui/src/canvas.rs::fit_page_and_height_hold_still_while_mixed_page_sizes_scroll_past", "crates/ui-egui/tests/view.rs::touchpad_swipe_glides_on_after_the_fingers_lift_and_stops", "crates/ui-egui/tests/view.rs::touching_the_touchpad_again_or_turning_animations_off_stops_the_glide"] -notes = "the default for PDFs that don't ask for a layout (Preferences ▸ Default page display); pages stack with no snapping. Fit page and Fit height fit the largest page, so the zoom holds still across mixed page sizes. A touchpad swipe glides on after the fingers lift on Wayland (#531; macOS glides by itself; Windows and X11 report no lift through winit), unless Animation effects is off; a click, key, zoom or new scroll catches it. Not yet: fingers resting on the touchpad don't catch it (needs Wayland hold gestures, which the vendored winit doesn't bind)" +evidence = ["crates/ui-egui/tests/view.rs::continuous_layout_stacks_pages_vertically", "crates/ui-egui/tests/view.rs::page_display_commands_switch_layouts", "crates/ui-egui/tests/view.rs::default_page_display_is_used_for_new_documents", "crates/ui-egui/src/canvas.rs::fit_page_and_height_hold_still_while_mixed_page_sizes_scroll_past", "crates/ui-egui/tests/view.rs::touchpad_swipe_glides_on_after_the_fingers_lift_and_stops", "crates/ui-egui/tests/view.rs::touching_the_touchpad_again_or_turning_animations_off_stops_the_glide", "crates/ui-egui/tests/view.rs::rapid_wheel_notches_accelerate_and_follow_touchpad_input", "crates/ui-egui/tests/view.rs::changing_continuous_layout_resets_wheel_acceleration"] +notes = "the default for PDFs that don't ask for a layout (Preferences ▸ Default page display); pages stack with no snapping. Fit page and Fit height fit the largest page, so the zoom holds still across mixed page sizes. Rapid line-wheel notches gain a bounded, constant-space arrival-rate boost (#1072); isolated notches still use the chosen Scroll speed, and page units stay unboosted. A touchpad swipe glides on after the fingers lift on Wayland (#531; macOS glides by itself; Windows and X11 report no lift through winit), unless Animation effects is off; a click, key, zoom or new scroll catches it. Not yet: fingers resting on the touchpad don't catch it (needs Wayland hold gestures, which the vendored winit doesn't bind)" [[feature]] id = "view.layout-single-page" @@ -1094,8 +1094,8 @@ milestone = "M3" status = "shipped" commands = ["view.read_mode"] tools = [] -evidence = ["crates/ui-egui/tests/control.rs::inspect_and_click_by_label_and_id", "crates/ui-egui/tests/commands.rs::every_registered_command_is_implemented"] -notes = "" +evidence = ["crates/ui-egui/tests/control.rs::inspect_and_click_by_label_and_id", "crates/ui-egui/tests/commands.rs::every_registered_command_is_implemented", "crates/ui-egui/tests/view.rs::switching_modes_keeps_the_current_page_at_fixed_zoom"] +notes = "Switching between Read and the docking workspace preserves the document’s current scroll position at fixed zoom; each tab keeps its own position" [[feature]] id = "view.full-screen" @@ -1802,8 +1802,8 @@ milestone = "M3" status = "shipped" commands = [] tools = [] -evidence = ["crates/ui-egui/tests/editing.rs::restricted_documents_show_a_notice_and_block_page_changes"] -notes = "" +evidence = ["crates/ui-egui/tests/editing.rs::restricted_documents_show_a_notice_and_block_page_changes", "crates/ui-egui/tests/editing.rs::a_secured_form_shows_its_form_notice_and_its_security", "crates/ui-egui/tests/ui_group/xfa.rs::a_secured_dynamic_xfa_form_shows_its_layout_and_its_security"] +notes = "On a secured form (fields or XFA), the security sentence follows the form's notice, which keeps Highlight fields and the XFA layout notes." [[feature]] id = "view.pdfa-view-mode" @@ -3860,8 +3860,8 @@ milestone = "M5" status = "shipped" commands = ["comment.list"] tools = ["comment_edit"] -evidence = ["crates/ui-egui/tests/comments.rs::comment_properties_change_appearance_and_author", "crates/annot/src/tests.rs::author_subject_and_note_icon_change", "crates/ui-egui/tests/comments.rs::shapes_take_a_fill_colour_from_the_quick_bar_and_properties", "crates/ui-egui/tests/comments.rs::text_box_properties_change_the_font_size", "crates/annot/src/tests.rs::text_box_font_size_changes_the_da_and_grows_the_box", "crates/automation/tests/automation.rs::text_box_font_size_through_the_tool"] -notes = "Appearance (colour, fill colour for rectangles/ovals/polygons, opacity, thickness, note icon, font size of text boxes, typewriter text and callouts: 1-400 pt, written to /DA and the rich-text strings, the box grows to fit and never shrinks; comment_edit font_size), General (author, subject), Review History. Still missing: font family, bold/italic, alignment and per-run formatting of text boxes; the quick bar has no font size for new text boxes (they start at 12 pt, callouts 10 pt)" +evidence = ["crates/ui-egui/tests/comments.rs::comment_properties_change_appearance_and_author", "crates/annot/src/tests.rs::author_subject_and_note_icon_change", "crates/ui-egui/tests/comments.rs::shapes_take_a_fill_colour_from_the_quick_bar_and_properties", "crates/ui-egui/tests/comments.rs::text_box_properties_change_the_font_size", "crates/ui-egui/tests/comments.rs::text_box_and_callout_properties_include_fill_and_thickness", "crates/annot/src/tests.rs::text_boxes_and_callouts_can_change_fill_and_border", "crates/automation/tests/automation.rs::text_box_and_callout_fill_and_border_through_tools", "crates/annot/src/tests.rs::text_box_font_size_changes_the_da_and_grows_the_box", "crates/automation/tests/automation.rs::text_box_font_size_through_the_tool"] +notes = "Appearance (colour, fill colour for rectangles/ovals/polygons/text boxes/callouts, opacity, thickness, note icon, font size of text boxes, typewriter text and callouts: 1-400 pt, written to /DA and the rich-text strings, the box grows to fit and never shrinks; comment_edit font_size), General (author, subject), Review History. Still missing: font family, bold/italic, alignment and per-run formatting of text boxes; the quick bar has no font size for new text boxes (they start at 12 pt, callouts 10 pt)" [[feature]] id = "comment.properties-bar" @@ -4160,8 +4160,8 @@ milestone = "M5" status = "shipped" commands = ["comment.export"] tools = ["doc_export_data"] -evidence = ["crates/xfdf/src/tests.rs::xfdf_round_trips_comments_and_values", "crates/xfdf/src/tests.rs::fdf_round_trips_values_and_comments", "crates/automation/tests/automation.rs::comments_and_form_data_travel_as_xfdf_fdf_and_text", "crates/xfdf/src/tests.rs::xfdf_exports_preserve_whitespace_in_comment_attributes"] -notes = "XFDF exports retain tabs and line breaks in comment identities and metadata." +evidence = ["crates/xfdf/src/tests.rs::fdf_export_nulls_nested_page_links_without_copying_the_source_page_tree", "crates/xfdf/src/tests.rs::fdf_export_bounds_indirect_type_alias_lookups", "crates/xfdf/src/tests.rs::fdf_export_preserves_metadata_with_stale_type_references", "crates/xfdf/src/tests.rs::fdf_export_keeps_indirect_values_nested_in_annotation_properties", "crates/xfdf/src/tests.rs::fdf_export_preserves_shared_cycles_and_nested_streams_without_expansion", "crates/xfdf/src/tests.rs::fdf_export_refuses_excessive_direct_depth_values_and_payload", "crates/xfdf/src/tests.rs::fdf_export_refuses_aliases_before_cloning_large_annotation_values", "crates/xfdf/src/tests.rs::fdf_export_refuses_repeated_saved_annotations_after_cache_eviction", "crates/automation/tests/automation.rs::fdf_nested_comment_values_survive_export_import_and_save_through_tools", "crates/automation/tests/automation.rs::refused_fdf_export_keeps_the_destination_and_source_through_tools", "crates/xfdf/src/tests.rs::xfdf_round_trips_comments_and_values", "crates/xfdf/src/tests.rs::fdf_round_trips_values_and_comments", "crates/automation/tests/automation.rs::comments_and_form_data_travel_as_xfdf_fdf_and_text", "crates/xfdf/src/tests.rs::xfdf_exports_preserve_whitespace_in_comment_attributes"] +notes = "FDF preserves shared/cyclic nested values. Discovery keeps lazy array owners and annotation positions; retained entries are budgeted before cloning, including after saved-PDF cache eviction. Copied-graph limits are 100,000 comments/objects/values, 64 direct levels and a conservative 64 MiB data budget; refusal precedes writing. Source parsing and page discovery retain existing COS/parser limits, with no total source-memory bound. Source /Page and /Pages dictionaries become null at every copy depth; indirect Type names check each reference generation with iterative cycle detection and a 64-reference limit; stale Type references preserve unrelated metadata; a saved/reopened three-page fixture verifies small output and retained unrelated metadata. Appearances and source-document links are not carried; FDF import semantics are unchanged. XFDF exports retain tabs and line breaks in comment identities and metadata." [[feature]] id = "comment.export-selected" @@ -4392,8 +4392,8 @@ milestone = "M3" status = "shipped" commands = [] tools = [] -evidence = ["crates/ui-egui/tests/view.rs::highlight_fields_tints_the_field_area", "crates/ui-egui/tests/view.rs::required_radio_buttons_get_a_round_red_border", "crates/ui-egui/tests/view.rs::field_highlighting_is_remembered"] -notes = "remembered for the next document and session; required fields get a red border (round for radio buttons); the tint is drawn over the field, where Acrobat paints the highlight colour behind it" +evidence = ["crates/ui-egui/tests/view.rs::highlight_fields_tints_the_field_area", "crates/ui-egui/tests/view.rs::required_radio_buttons_get_a_round_red_border", "crates/ui-egui/tests/view.rs::field_highlighting_is_remembered", "crates/ui-egui/tests/editing.rs::a_secured_form_shows_its_form_notice_and_its_security"] +notes = "offered on secured forms too; remembered for the next document and session; required fields get a red border (round for radio buttons); the tint is drawn over the field, where Acrobat paints the highlight colour behind it" [[feature]] id = "form.field-appearance-display" @@ -4524,8 +4524,8 @@ milestone = "M6" status = "shipped" commands = ["form.field.properties"] tools = ["form_set_props", "form_fill"] -evidence = ["crates/forms/src/af.rs::dates_times_and_masks", "crates/forms/src/tests.rs::formats_validation_and_calculations_run_like_acrobat"] -notes = "AFDate_FormatEx/KeystrokeEx, AFDate_Format presets, AFTime_Format(Ex): lenient parsing, month and day names, impossible dates refused" +evidence = ["crates/forms/src/af.rs::dates_times_and_masks", "crates/forms/src/tests.rs::formats_validation_and_calculations_run_like_acrobat", "crates/forms/src/af.rs::digits_typed_without_separators_fill_adjacent_fields"] +notes = "AFDate_FormatEx/KeystrokeEx, AFDate_Format presets, AFTime_Format(Ex): lenient parsing, month and day names, impossible dates refused; digits typed without separators fill fields the format writes side by side by their fixed widths (20261018 for yyyymmdd, 1430 for HHMM). A run of digits is split only where the format itself has no separator: 20261018 typed into a yyyy-mm-dd field is still refused" [[feature]] id = "form.af-special" @@ -4620,8 +4620,8 @@ milestone = "M6" status = "shipped" commands = [] tools = ["form_fill"] -evidence = ["crates/ui-egui/tests/forms.rs::date_fields_offer_a_calendar"] -notes = "a month calendar under date fields; the pick is written in the field's own date format" +evidence = ["crates/ui-egui/tests/forms.rs::date_fields_offer_a_calendar", "crates/ui-egui/tests/forms.rs::the_calendar_follows_a_complete_date_typed_while_it_is_open"] +notes = "a month calendar under date fields; the pick is written in the field's own date format; it opens on the field's date and moves to a whole date typed while it is open, staying on the month its arrows turned to otherwise" [[feature]] id = "form.auto-complete" @@ -4692,8 +4692,8 @@ milestone = "M6" status = "shipped" commands = ["form.export_data"] tools = ["doc_export_data"] -evidence = ["crates/xfdf/src/tests.rs::xfdf_round_trips_comments_and_values", "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"] -notes = "XFDF, FDF; XFA forms also export their data as XML or XDP (form.xfa-data); XML/XFDF exports retain tabs and line breaks in field identities through character references." +evidence = ["crates/xfdf/src/tests.rs::fdf_export_counts_emitted_field_values_before_allocating_lists", "crates/xfdf/src/tests.rs::xfdf_round_trips_comments_and_values", "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"] +notes = "XFDF, FDF; FDF counts emitted field values, including every multi-select list entry, before allocating the output list and refuses exports beyond its shared 100,000-value budget. Existing source-field parsing limits remain unchanged. XFA forms also export their data as XML or XDP (form.xfa-data). XML/XFDF exports retain tabs and line breaks in field identities through character references." [[feature]] id = "form.data-xml-csv-txt" @@ -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", "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 (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. 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: 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)" +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 does on a synthetic 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. 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: a page area's relevant=\"-print\" (on a synthetic form Acrobat still prints the page but leaves it blank; PdfCraft prints its content, #2574); a page break before the first section (Acrobat opens an empty body page first when the first section has breakBefore targetType=pageArea, with or without startNew, as seen on synthetic forms; PdfCraft skips a break on an empty page and lays out one page fewer, #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" @@ -5704,8 +5704,8 @@ milestone = "M8" status = "shipped" commands = ["redact.apply"] tools = ["redact_apply"] -evidence = ["crates/redact/src/tests.rs::images_are_removed_or_have_their_pixels_cleared"] -notes = "covered pixels cleared in a copy; DCT/JPX/JBIG2/CCITT images removed whole" +evidence = ["crates/redact/src/tests.rs::images_are_removed_or_have_their_pixels_cleared", "crates/redact/src/tests.rs::redaction_clears_pixels_intersected_without_covering_their_centres"] +notes = "intersected pixel footprints are cleared conservatively in a copy; DCT/JPX/JBIG2/CCITT images removed whole" [[feature]] id = "protect.redact-vectors" @@ -5912,8 +5912,8 @@ milestone = "M9" status = "shipped" commands = ["sign.digital", "sign.certify"] tools = ["sign_windows_ids", "sign_document"] -evidence = ["crates/sign/tests/pdf.rs::signing_with_windows_store_identities", "crates/sign/tests/pdf.rs::a_windows_store_identity_signs_with_its_issuers", "crates/sign/tests/pdf.rs::windows_store_enumeration_and_missing_identity", "crates/automation/tests/automation.rs::digital_ids_signing_and_validation_through_tools", "crates/ui-egui/tests/signing.rs::windows_store_identity_does_not_ask_for_a_file_password", "crates/ui-egui/tests/signing.rs::an_unusable_id_is_shown_with_its_reason_and_cannot_be_chosen"] -notes = "Current User Personal (My) CNG identities: RSA PKCS #1 v1.5 and DER-encoded ECDSA P-256/P-384. Keys never leave Windows; windows: for agents and Windows store labels in the desktop ID picker. The signature embeds the issuers Windows chains the certificate to. Store certificates that can't sign (no private key, another key type, a key CNG can't open) are reported with the reason and Windows error code (greyed in the picker, `unusable` in sign_windows_ids) rather than left out; keys that need a prompt to open are listed and prompt at signing. Tested with throwaway software-backed RSA-2048, P-256 and P-384 certificates and a key-less certificate (run with --ignored). Smart cards and tokens whose driver registers a CNG key storage provider sign with the provider's own PIN dialog (checked by hand with a SafeNet token only; no hardware in CI); still missing: the Local Machine store, legacy CryptoAPI-only providers, P-521/Brainpool/Ed25519 keys, direct PKCS #11." +evidence = ["crates/sign/tests/pdf.rs::signing_with_windows_store_identities", "crates/sign/tests/pdf.rs::a_windows_store_identity_signs_with_its_issuers", "crates/sign/tests/pdf.rs::windows_store_enumeration_and_missing_identity", "crates/automation/tests/automation.rs::digital_ids_signing_and_validation_through_tools", "crates/ui-egui/tests/signing.rs::windows_store_identity_does_not_ask_for_a_file_password", "crates/ui-egui/tests/signing.rs::an_unusable_id_is_shown_with_its_reason_and_cannot_be_chosen", "crates/sign/tests/chain.rs::a_smart_cards_certificates_that_may_not_sign_say_why_in_id_lists"] +notes = "Current User Personal (My) CNG identities: RSA PKCS #1 v1.5 and DER-encoded ECDSA P-256/P-384. Keys never leave Windows; windows: for agents and Windows store labels in the desktop ID picker. The signature embeds the issuers Windows chains the certificate to. Store certificates that can't sign (no private key, another key type, a key CNG can't open, or a key usage or extended key usage that doesn't allow signing documents, like a CAC's encryption and logon certificates) are reported with the reason and Windows error code (greyed in the picker, `unusable` in sign_windows_ids) rather than left out; keys that need a prompt to open are listed and prompt at signing. Tested with throwaway software-backed RSA-2048, P-256 and P-384 certificates and a key-less certificate (run with --ignored). Smart cards and tokens whose driver registers a CNG key storage provider sign with the provider's own PIN dialog (checked by hand with a SafeNet token only; no hardware in CI); still missing: the Local Machine store, legacy CryptoAPI-only providers, P-521/Brainpool/Ed25519 keys, direct PKCS #11." [[feature]] id = "sign.encrypted-documents" @@ -7088,8 +7088,8 @@ milestone = "M10" status = "partial" commands = ["export.html"] tools = ["doc_export_office"] -evidence = ["crates/export/src/lib.rs::html_has_headings_paragraphs_and_images_in_order", "crates/export/src/lib.rs::text_colour_survives_every_format", "crates/export/src/lib.rs::grid_text_becomes_a_real_table_in_every_format", "crates/automation/tests/automation.rs::exporting_to_word_html_and_rtf", "crates/ui-egui/tests/compare.rs::export_to_word_html_and_rtf"] -notes = "Single HTML file with headings, paragraphs (with text colour), inline images, and detected tables with spans/empty cells. Not yet: multi-page folder output, CSS from fonts." +evidence = ["crates/export/src/lib.rs::html_has_headings_paragraphs_and_images_in_order", "crates/export/src/lib.rs::text_colour_survives_every_format", "crates/export/src/lib.rs::grid_text_becomes_a_real_table_in_every_format", "crates/automation/tests/automation.rs::exporting_to_word_html_and_rtf", "crates/ui-egui/tests/compare.rs::export_to_word_html_and_rtf", "crates/automation/tests/automation.rs::exporting_office_files_says_which_pages_left_form_text_out", "crates/edit/src/tests.rs::reading_follows_forms_as_far_as_the_renderer_and_says_where_it_stops"] +notes = "Single HTML file with headings, paragraphs (with text colour), inline images, and detected tables with spans/empty cells. Text and images in form XObjects are read 50 levels deep, as the renderer does, and up to 50 000 forms a page; the tool lists pages past that in truncated_pages, with a warning (#3120). Not yet: multi-page folder output, CSS from fonts." [[feature]] id = "create.export-text" @@ -7776,7 +7776,7 @@ milestone = "M11" status = "partial" commands = ["standards.pdfa"] tools = ["pdfa_verify"] -evidence = ["crates/preflight/src/tests.rs::verify_reports_what_breaks_pdfa", "crates/preflight/src/tests.rs::convert_fixes_all_but_fonts", "crates/preflight/src/tests.rs::verify_reports_an_output_intent_that_does_not_match_device_colour", "crates/automation/tests/automation.rs::pdfa_through_tools", "crates/ui-egui/tests/compare.rs::pdfa_dialog_verifies_and_converts"] +evidence = ["crates/preflight/src/tests.rs::verify_reports_what_breaks_pdfa", "crates/preflight/src/tests.rs::standards_identification_uses_namespace_not_prefix", "crates/preflight/src/tests.rs::convert_fixes_all_but_fonts", "crates/preflight/src/tests.rs::verify_reports_an_output_intent_that_does_not_match_device_colour", "crates/automation/tests/automation.rs::pdfa_through_tools", "crates/ui-egui/tests/compare.rs::pdfa_dialog_verifies_and_converts"] notes = "PDF/A-2b/3b structural rules: identification, encryption, output intent (present, and RGB/CMYK to match DeviceRGB/DeviceCMYK use), fonts embedded, annotations, actions, images, embedded files. Not yet: content-stream operator checks, transparency details, PDF/A-1 and -4, a veraPDF comparison." [[feature]] diff --git a/vendor/README.md b/vendor/README.md index 3cc6c131eb..824e84419e 100644 --- a/vendor/README.md +++ b/vendor/README.md @@ -7,15 +7,17 @@ the fix. Vendoring copyleft code is never allowed (plan/adr/0001). A PdfCraft patch in `hayro-syntax::Page::base_dimensions` expresses the existing one-point floor as a raw reciprocal of `/UserUnit`, rounded upward only when f32 multiplication would fall below one point. Five `pdfcraft-render` regressions with prefix `raster::tests::raw_crop_` cover the observed dimensions, origins and rotations, units 41/47, bounded vector rasters and unchanged nearly-zero A4 fallback. Accepted page-unit policy and licences are unchanged. +In `hayro-interpret`, Type 3 glyph bounding boxes are transformed to device space before intersecting clip and shading bounds; `raster::tests::shaded_type3_glyph_draws_at_multiple_positions` covers translated glyphs (#3178). + | Crate | Version | Licence | Patches | Test | |---|---|---|---|---| | hayro-interpret | 0.7.0 | Apache-2.0 OR MIT | (1) select `/AP /N` appearance states via `/AS` (checkboxes, radios, stateful icons) and honour the NoView flag; (2) Type3 Unicode fallback to `/Encoding` glyph names, then printable ASCII; (3) `Type3Glyph::advance_width`; (4) `InterpreterSettings::ocg_overrides` for viewer layer toggles; (5) soft masks whose group lacks `/CS` were dropped (Chrome gradient text); (6) paint nesting cap `MAX_PAINT_NESTING` for tiling patterns and Type 3 glyphs (self-referencing content overflowed the stack); (7) CID width ranges in `/W` and `/W2` clamped to `MAX_CID` (a `0 4294967295 w` range hung); (8) `InterpreterSettings::hide_comments` skips markup annotations but keeps widgets and links (View ▸ Hide all comments); (9) `image_size_ok`: images and masks over 2^28 pixels are not decoded (a fuzzed `/W 4294967295` stencil mask allocated 4 GiB); (10) `MAX_NESTED_PAINTS`: nested paints (forms, Type 3 glyphs and tiling patterns inside one another) share a per-page budget of 50,000 (a glyph showing eight glyphs of its own font, sixteen deep, hung); (11) depends on skrifa 0.47 instead of 0.42 (upstream read-fonts fixed a Type 1 tokenizer that recursed through runs of numbers: a fuzzed font hung or overflowed the stack), with the one API change (`Charset::glyph_id` returns `Option`); (12) annotation appearances whose `/BBox` or `/Matrix` collapses to a line or a point are skipped (mapping them onto `/Rect` divided by zero and handed the device infinite or NaN matrices); (13) a Highlight annotation without a usable normal appearance is drawn from its `/QuadPoints`, `/C` and `/CA` with Multiply blending, as pdfcraft-annot draws highlights (it was not drawn at all); (14) simple-font `/Widths` (`read_widths`): entries only for codes 0–255 (one entry per code below `/FirstChar` was allocated: `4294967295` asked for 34 GB), an entry that isn't a number leaves its code missing instead of ending the array, and a `/LastChar` below `/FirstChar` gives no widths instead of discarding the font for a standard one; (15) the appearance box is mapped onto `/Rect` corner to corner: the translation now accounts for the scale (an appearance whose box didn't start at the origin and differed in size from `/Rect` was drawn shifted); (16) a clip set by `W`/`W*` takes effect after whichever path-painting operator ends the path (`S`, `f`, `B`, … as well as `n`; ISO 32000-2 §8.5.4): it was applied only on `n`, so `re W* S` clipped nothing and the clip stayed pending for a later, unrelated `n`; (17) `InterpreterSettings::cancelled`: once set, interpretation stops before the next content operator (a render pool sets it for every worker when dropped, after an edit or on closing, and for one worker when the watchdog gives up on its render, so renders whose answers nobody can receive stop instead of running to the end); (18) `MAX_PAGE_CONTENT`: the content a page holds at once (its contents, and the forms, tiling patterns and Type 3 glyph procedures being painted) stays within 512 MiB (`decode_content`; once it runs out, `InterpreterWarning::ContentTruncated` is reported (once per page by pdfcraft-render) so a truncated page is not a clean render; each level gives its bytes back when it finishes): forms nest fifty deep and each level kept its content while the next ran, so a form painting itself around a stream inflating to 256 MiB would have held over 12 GB; and Type 0 sampled functions take at most `MAX_SAMPLE_ENTRIES` (2^21) table points and `MAX_SAMPLE_VALUES` (2^23) samples, read no further than `/Size` asks (256 MiB of 1-bit samples asked for over 8 GB); (19) mesh shadings (types 4–7) are sampled only inside the area being drawn (`ShadingPattern::encode_within`, which the vendored hayro renderer calls with that area plus a pixel), within 8 visits per pixel of it plus 4 per triangle (`TriangleSampler`), and patches are triangulated one at a time: points far off the page were sampled over their whole bounding box, up to 65536 × 65536 entries (a fuzzed 147-byte patch stream; a synthetic page passed 6 GB in six seconds), ten thousand overlapping triangles ran for minutes, and every patch became 722 triangles before any limit; meshes hold at most `MAX_MESH_PATCHES` (2^16) patches and `MAX_MESH_TRIANGLES` (2^20) triangles, a lattice needs two to 2^19 vertices per row (`/VerticesPerRow 0` pushed empty rows for ever; one huge row read the whole stream), and a page samples at most `MAX_PAGE_MESH_TRIANGLES` (2^25) mesh triangles in all (a mesh painted a thousand times was triangulated a thousand times); each cut logs a warning; (20) PDF function construction is limited to 64 levels and 10,000 total nodes with cycle detection; calculator parsing and evaluation share procedure depth/work budgets, integer division/bitshift/rotation/index are panic-free, and fixed-size stack errors propagate; (21) a Type 0 font drawn with a substitute that isn't CID-keyed (it isn't embedded, or its program doesn't load) draws `.notdef` for a CID of an Adobe character collection (Japan1, GB1, CNS1, Korea1) that it can't find by Unicode: hayro took the CID for a glyph index of the substitute, which numbers its glyphs its own way, so the character was drawn as an unrelated one (𠮷 as "Q" in BIZ UDPGothic); (22) a Type 0 font drawn with a substitute finds a character given with a variation selector (`/ToUnicode`, or Adobe-Japan1-UCS2, which maps the JIS X 0208 forms of the kanji JIS X 0213:2004 redrew that way: CID 1247 is 噂 U+E0100) through the substitute's variation sequences, else as the character alone: such a CID found no glyph and was drawn as an unrelated one; (23) backport of hayro 0.8's `gradient` module: `EncodedShadingPattern::as_svg_gradient` turns an axial or radial shading into stops of an sRGB gradient (within 0.01 per channel, a stop at every stitching-function bound via `Function::stitching_bounds`) when a native gradient draws it exactly (extended past the box drawn in; a radial one with no background colour); (24) `DecodedImageCache`: decoded image pixels shared across renders through the interpreter cache within a 64 MiB budget (an image-heavy page decoded its thousands of images again per tile and per scale), keyed by every decode input (`DecodedImageKey`: the stream's place and dictionary bytes, its object, mask/stencil/raster output, interpolation, decode target size, preview or final); images under a transfer function and inline images are not cached, an image over the budget is never kept, and a poisoned lock decodes uncached; (25) `Device::actual_text` hands the device a marked content sequence's `/ActualText` (inline or named in `/Properties`), so text extraction keeps the Thai, Indic and Arabic clusters Chrome marks this way (their shaped tone marks and vowels have no Unicode of their own); (26) non-embedded simple fonts drawn with a standard-font stand-in build the Latin Extended-A letters the stand-in lacks (`/Gbreve`, `/Idotaccent`, `/Scedilla`, `/aogonek`, `/ccaron`…) from the base letter and accent it has (`StandardKind::composite_glyph`, `ACCENTED_LETTERS`): PDFium's Foxit stand-ins hold only the Standard/WinAnsi/Mac Roman glyphs, so iText letters with Times New Roman unembedded showed blanks for Ğ, İ, Ş, ğ and ş; (27) an embedded TrueType font without a base encoding reads its codes as WinAnsi for text extraction (`EmbeddedKind::code_to_unicode_name`), matching pdftotext's output, instead of Standard: OCR text layers of scans write WinAnsi codes with no `/Encoding`, and Standard has no ü, ö or ß and reads é as Ø (glyph selection unchanged); (28) an image whose pixels hold at most 16 bits of samples (an 8-bit Separation or DeviceN image, a 1-, 2- or 4-bit gray or indexed one) is converted once per possible pixel value through a lookup table instead of once per pixel (`rgb_via_lookup`): a spot-colour tint transform is often a PostScript calculator program, run for every pixel, so a fillable character sheet's 3304 × 4105 DeviceN background took four seconds and 430 MB to draw a page; each entry is converted on its own (`convert_each`), so a tint transform giving the wrong number of components for a value the image never uses cannot shift the colours of the values it does; (29) `Device::begin_annotations`/`end_annotations` bracket the page's annotations, so a device can tell comment paints from document paints (reading colours recolour the document, never a comment, #1548); (30) the Annex D.2 alias codes read as the ink they draw: WinAnsiEncoding 0xA0 and 0xAD, and MacRomanEncoding 0xCA, carry the glyph names `space` and `hyphen` -- the names those tables also give 0x20 and 0x2D -- not `nbspace` and `sfthyphen`, whose glyph list values U+00A0 and U+00AD are invisible formatting characters. Extraction hid drawn ink behind them, so search and redaction matched neither (`XiaoBiaoSong.pdf` in the pdf.js corpus drew 104 soft hyphens; across the 989 files the soft hyphens we extract fall from 117 to 13 and the non-breaking spaces from 30 to 26, what remains coming from `/ToUnicode` maps, which say so explicitly). The base table only, in the text direction only: a `/Differences` name keeps its glyph list value and glyph lookup is untouched, both as pdftotext does; (31) a Type 0 sampled function keeps its samples in one flat array indexed by strides (`Type0::entry`) instead of a hash map keyed by each grid point: the map was built again, one hashed insert per sample, every time a shading using the function was painted (a 2,097,152-sample ramp painted 30 times took 38 s; `bug1721218` in the pdf.js corpus spent most of a 1.5 s page on it); the interpolation is unchanged, so results are bit for bit the same, a sample missing from a short stream still gives no value, and the maximum sample value is computed without overflowing at 32 bits per sample; (32) the decoded-image cache (24) keys a decode by the size the image is drawn at only when a JPX stream decodes it or its soft mask or mask (`decode_reads_target`): JPX is the only filter that decodes a reduced resolution for a size, so a JPEG or Flate scan was decoded again at every zoom step and kept once per zoom level; one decode now serves every size; `InterpreterCache::decoded_image_decodes` counts the cache's decodes; (33) Coons and tensor-product patches copy their vertices' colours less: each point of a patch's 20 × 20 triangle grid is interpolated once (`generate_patch_triangles`; each cell interpolated its four corners itself, 1,444 interpolations a patch for 400 points), a cell's second triangle takes the vertices its first copied, and the mesh sampler moves a patch's triangles into device space instead of copying each vertex (`TriangleSampler::sample_owned`); the same values, 10–16% faster on mesh-gradient pages; (34) `color::tint_transform_runs`: how many colours the calling thread has run through a Separation or DeviceN tint transform (counted once per batch in `convert_f32`), so a test counts patch (28)'s once-per-sample-value runs instead of timing them (its 10 s deadline failed on a busy FreeBSD VM in CI); (41) a transparency group that is neither isolated nor knockout, entered at full opacity with no soft mask and Normal blending, is drawn straight onto the page instead of into a layer of its own (§11.4.7: its elements composite with the backdrop; the layer made a `/BM /Multiply` highlight appearance an opaque block, #1968); (42) text in a Type 3 font is drawn filled whatever the text rendering mode, except mode 3 (invisible): only that mode has any effect on Type 3 fonts (ISO 32000-1 §9.3.6). Stroking ran the glyph procedure a second time, so `2 Tr` made bitmap fonts look bold, `1 Tr` painted an uncoloured glyph in the stroking colour, and `7 Tr` hid it; (43) Lab colour spaces honour their own `/WhitePoint`: L\*a\*b\* goes to XYZ relative to that white, is Bradford-adapted to D65 and converted to sRGB (ISO 32000-2 §8.6.5.4) instead of through `LAB.icc` as if every white were D50 (a D65 blue came out 45 levels too red; the profile is removed); components normalised to [0, 1] (`manual_scale`, an `/Indexed` palette over Lab) map onto L\* 0..100 and the a\*/b\* `/Range` | (1) `raster::tests::widget_appearance_states`; (2, 3) `xtask text-oracle` on corpus/pdfjs issue918; (4) `raster::tests::layer_override_hides_content`; (5) `raster::tests::soft_mask_without_group_colour_space_is_applied`; (6) `raster::tests::self_referencing_tiling_pattern_terminates`, `raster::tests::self_referencing_type3_glyph_terminates`; (7) `raster::tests::huge_cid_width_ranges_terminate`; (8) `raster::tests::hiding_comments_keeps_fields`; (9) `raster::tests::absurd_mask_and_fax_sizes_are_refused_before_decoding`; (10) `raster::tests::type3_glyphs_that_fan_out_into_themselves_finish`; (11) `raster::tests::type1_font_programs_with_long_runs_of_numbers_load_quickly`; (12) `raster::tests::degenerate_annotation_appearances_are_skipped`; (13) `raster::tests::highlights_without_an_appearance_are_drawn`; (14) `raster::tests::malformed_simple_font_widths_keep_later_widths_and_the_font`, `raster::tests::huge_simple_font_first_char_terminates`; (15) `raster::tests::annotation_appearance_box_maps_onto_rect`; (16) `raster::tests::clipping_path_applies_after_any_painting_operator`; (17) `raster::tests::dropping_the_pool_cancels_its_renders`, `raster::tests::the_watchdog_stops_the_render_it_gives_up_on`; (18) `raster::tests::page_contents_and_nested_forms_share_one_budget`, `raster::tests::content_painted_again_and_again_keeps_drawing`, `raster::tests::sampled_functions_with_billions_of_samples_are_refused`; (19) `raster::tests::mesh_shadings_far_off_the_page_render`, `raster::tests::many_overlapping_mesh_triangles_finish`, `raster::tests::mesh_shadings_with_many_patches_or_no_rows_finish`; (20) `raster::tests::function_limits_calculator_arithmetic_and_depth`, `raster::tests::function_limits_public_construction_work_and_depth`, `raster::tests::function_limits_stitching_cycles_are_refused_and_shared_children_work`, `raster::tests::function_limits_invalid_calculator_keeps_the_page_renderable`; (21) `raster::tests::cids_a_substitute_lacks_are_not_drawn_as_other_glyphs`; (26) `raster::tests::accented_letters_the_standard_stand_ins_lack_are_built_from_their_parts`; (22) `raster::tests::variation_sequences_draw_their_kanji_in_a_substitute`; (23) `raster::tests::axial_and_radial_shadings_draw_as_native_gradients`; (24) `raster::tests::tiles_match_full_render`, `raster::tests::skipping_paths_off_the_canvas_changes_no_pixel` (both decode through the cache), `raster::tests::decoded_images_with_identical_dictionaries_keep_their_own_pixels`, `raster::tests::decoded_images_under_a_transfer_function_are_not_shared`; (25) `text::tests::actual_text_replaces_the_glyphs_it_marks_in_every_writing_direction`, `text::tests::actual_text_follows_the_pdf_rules_for_marked_content`; (27) `text::tests::truetype_without_an_encoding_reads_its_codes_as_win_ansi`; (28) `raster::tests::spot_colour_images_run_their_tint_transform_once_per_sample_value`, `raster::tests::images_converted_through_a_table_keep_every_samples_colour`, `raster::tests::a_malformed_tint_for_an_unused_sample_keeps_the_used_colours`; (29) `raster::tests::reading_colors_keep_highlight_annotations_readable`; (30) `text::tests::the_annex_d2_alias_codes_read_as_an_ordinary_hyphen_and_space`; (31) `raster::tests::shadings_with_large_sampled_functions_paint_quickly`, `raster::tests::sampled_functions_interpolate_their_grid_as_before`; (32) `raster::tests::decoded_images_serve_every_size_they_are_drawn_at`; (33) `raster::tests::patch_meshes_keep_their_corner_colours`; (34) `raster::tests::spot_colour_images_run_their_tint_transform_once_per_sample_value`; (41) `raster::tests::a_multiply_highlight_inside_a_transparency_group_keeps_the_text`; (42) `raster::tests::text_rendering_modes_other_than_invisible_draw_type3_glyphs_as_filled`; (43) `raster::tests::lab_honours_the_space_white_point`, `raster::tests::indexed_palettes_over_lab_keep_their_colours` | -| hayro | 0.7.1 | Apache-2.0 OR MIT | (1) `RenderSettings::{x_offset, y_offset}` to render one tile of a large page; (2) images over `MAX_IMAGE_PIXELS` (2^28) are skipped (a fuzzed `/W 4294967295` hung in resampling); (3) `tiling_cell_scale` keeps a tiling pattern's cell pixmap within 3000 px a side whatever its `/XStep`/`/YStep` (a fuzzed step far beyond `/BBox` allocated 2 GB); (4) stroke widths clamped to 4 × (canvas width + height) device pixels (a fuzzed `/LW 9223372036854775807` allocated 10 GB in stroke expansion); (5) `usable_dash_pattern`: a stroke whose dash pattern could cut it into more than `MAX_DASHES_PER_STROKE` (250,000) pieces, counting kurbo's restart of the pattern at every subpath and `ClosePath`, is drawn solid (a fuzzed `/D [[11] 0]` on a line from x = 92234775807 allocated over 5 GB, `[0.000001] 0 d` on a 20 pt line over 10 GB); (6) a dash pattern with a negative or non-finite entry, or a period that isn't positive, is drawn solid (`[-1 -1] 0 d` looped for ever in kurbo); (7) `render_size` and `render_into`: render into a buffer the caller allocates (PdfCraft renders into pixel-aligned memory that the GUI takes over as texture data); `render` is `render_into` on a new pixmap; (8) checked image source/target/RGBA, alpha-premultiplication scratch and intermediate sizes before resampling, finite transforms and exact source/mask buffers; sources wider than u16 may shrink before backend limits, with a 2^20 source-axis cap and conservative 128 MiB filter-planning estimate; an over-limit or failed resize keeps the original image resolution and geometry when the source fits the backend, otherwise skips it, with a logged warning; staging, resampling output/scratch, RGBA conversion and padding buffers use fallible reserves; (9) `may_paint_canvas`: a fill or stroke whose control-point box, grown by how far its paint reaches past the path (half the stroke width times the larger of the miter limit and √2) plus 0.5 user units and 2 pixels, misses the canvas is not drawn, so a tile no longer strokes and fills every path of its page (item 5 of #623); `set_skip_offscreen_paths` turns this off for the regression test; (10) backport of hayro 0.8: shadings that `as_svg_gradient` can represent are painted as a `vello_cpu` linear or two-point radial gradient instead of a texture sampled pixel by pixel through the shading function (InDesign fades are luminosity soft masks of one full-page axial `sh`: 40 on a report page took 15 s at 144 dpi, now under 1 s); without upstream's half-pixel shift, which suits vello_cpu 0.3 but moves 0.0.8's gradients off pixel centres; (11) `may_paint_rect`: an image whose stream-size box, mapped to the device, misses the canvas is skipped before decoding (decoded data can only shrink against that box), so a tile of an image-heavy page no longer decodes, resamples and rasterizes every image on it; (12) preview renders (`InterpreterSettings::preview`: backdrops, thumbnails, grid cells, signature-drag backgrounds) skip images under `PREVIEW_MIN_IMAGE_AREA` device pixels, while final renders always draw them; (13) reading colours (#897): `RenderSettings::color_replacement` takes a `ColorReplacement` (text colour, background, `only_black`, `line_art`) that remaps solid text and line-art paint while rendering, glyph outlines counting as text and stencil-mask ink as line art; images, image alpha masks, soft masks and pattern cells keep their colours; `render_pdf` gains a `color_replacement: Option` argument (pass `None` for true colours); the upstream-only `[dev-dependencies.log]` entry is removed from `Cargo.toml` (log is a normal dependency since the logging patch), so `cargo test -p hayro` runs this crate's unit tests; (14) a shrunk, interpolated, axis-aligned image is snapped to whole device pixels: shrunk once to its rounded device rectangle and drawn 1:1 at the rounded corner, instead of a ceil-sized raster resampled again at a fractional scale and offset (`snap_minified_image`; scans looked soft next to Acrobat); (15) a soft mask of another size than its image is filtered when shrunk, whatever its `/Interpolate` (#1649): such a mask carries the shape on its own (Word saves text as a tiny black image with a glyph-bitmap mask), and nearest-neighbour dropped every stroke thinner than the shrink factor; (16) under a soft mask or a blend mode, axial and radial shadings are sampled instead of drawn as native gradients (#2128): vello_cpu 0.0.8 copies an opaque gradient over fully covered tiles without the mask or blend mode, so PowerPoint's faded gradient fills came out solid; (17) the renderer keeps annotation paints out of the reading-colours mapping between `begin_annotations` and `end_annotations` (#1548); (18) the page renders as one transparency group with its background painted inside it as the initial backdrop, so a Multiply highlight blends with a dark reading-colours background instead of staying an opaque block (identity on white pages) | (1) `raster::tests::tiles_match_full_render`; (2) `raster::tests::absurd_image_dimensions_are_skipped`; (3) `raster::tests::tiling_cells_stay_small_whatever_the_step`; (4) `raster::tests::absurd_predictor_columns_and_line_widths_render`; (5) `raster::tests::dash_patterns_that_cut_a_stroke_into_billions_of_pieces_render`; (6) `raster::tests::invalid_dash_patterns_terminate_and_are_drawn_solid`; (7) `raster::tests::pixels_are_a_plain_renders_bytes_in_a_buffer_a_gui_can_take_over`; (8) `raster::tests::image_resampling_rejects_anisotropic_target_growth`, `raster::tests::image_resampling_bounds_intermediate_before_planning`, `raster::tests::image_resampling_rejects_invalid_sources_and_scales`, `raster::tests::image_resampling_keeps_valid_sizes_and_exact_limits`, `raster::tests::resampling_fallback_preserves_original_geometry_and_pixels`, `raster::tests::image_resampling_wide_sources_shrink_before_backend_side_limits`, `raster::tests::image_resampling_wide_transparent_sources_still_shrink`, `raster::tests::image_resampling_padded_backend_dimensions_stay_checked`, `raster::tests::image_resampling_mismatched_alpha_mask_keeps_pixels_and_placement`; (9) `raster::tests::skipping_paths_off_the_canvas_changes_no_pixel`; (10) `raster::tests::axial_and_radial_shadings_draw_as_native_gradients`; (11) `raster::tests::skipping_paths_off_the_canvas_changes_no_pixel`; (12) `raster::tests::preview_renders_skip_tiny_images_but_finals_draw_everything`; (13) `color_replacement_tests::{black_maps_to_text_colour, white_maps_to_background, midgrey_interpolates, alpha_copied_verbatim, only_black_threshold, line_art_false_skips_paths_but_not_text}`, `color_replacement_render_tests::{color_replacement_maps_text_and_line_art, color_replacement_maps_stencil_mask_ink}`; (14) `raster::tests::interpolated_minified_image_edges_snap_to_whole_pixels` (`cargo test -p hayro`); (15) `raster::tests::shrunk_soft_mask_larger_than_its_image_keeps_thin_strokes`; (16) `raster::tests::soft_mask_applies_to_a_shading_pattern_fill`; (17), (18) `raster::tests::reading_colors_keep_highlight_annotations_readable` | -| hayro | 0.7.1 | Apache-2.0 OR MIT | (1) `RenderSettings::{x_offset, y_offset}` to render one tile of a large page; (2) images over `MAX_IMAGE_PIXELS` (2^28) are skipped (a fuzzed `/W 4294967295` hung in resampling); (3) `tiling_cell_scale` keeps a tiling pattern's cell pixmap within 3000 px a side whatever its `/XStep`/`/YStep` (a fuzzed step far beyond `/BBox` allocated 2 GB); (4) stroke widths clamped to 4 × (canvas width + height) device pixels (a fuzzed `/LW 9223372036854775807` allocated 10 GB in stroke expansion); (5) `usable_dash_pattern`: a stroke whose dash pattern could cut it into more than `MAX_DASHES_PER_STROKE` (250,000) pieces, counting kurbo's restart of the pattern at every subpath and `ClosePath`, is drawn solid (a fuzzed `/D [[11] 0]` on a line from x = 92234775807 allocated over 5 GB, `[0.000001] 0 d` on a 20 pt line over 10 GB); (6) a dash pattern with a negative or non-finite entry, or a period that isn't positive, is drawn solid (`[-1 -1] 0 d` looped for ever in kurbo); (7) `render_size` and `render_into`: render into a buffer the caller allocates (PdfCraft renders into pixel-aligned memory that the GUI takes over as texture data); `render` is `render_into` on a new pixmap; (8) checked image source/target/RGBA, alpha-premultiplication scratch and intermediate sizes before resampling, finite transforms and exact source/mask buffers; sources wider than u16 may shrink before backend limits, with a 2^20 source-axis cap and conservative 128 MiB filter-planning estimate; an over-limit or failed resize keeps the original image resolution and geometry when the source fits the backend, otherwise skips it, with a logged warning; staging, resampling output/scratch, RGBA conversion and padding buffers use fallible reserves; (9) `may_paint_canvas`: a fill or stroke whose control-point box, grown by how far its paint reaches past the path (half the stroke width times the larger of the miter limit and √2) plus 0.5 user units and 2 pixels, misses the canvas is not drawn, so a tile no longer strokes and fills every path of its page (item 5 of #623); `set_skip_offscreen_paths` turns this off for the regression test; (10) `/Interpolate` governs the pre-resize too (#624): a minified image that does not request interpolation is pre-resized with nearest-neighbour, planned without the Catmull-Rom filter limits (`image_resampling_size` takes the flag); a Type 3 glyph image is sampled bicubically only when interpolated; a minified image with a matching soft mask is nearest-sampled straight into premultiplied RGBA; (11) `may_paint_rect`: an image whose stream-size box, mapped to the device, misses the canvas is skipped before decoding (decoded data can only shrink against that box), so a tile of an image-heavy page no longer decodes, resamples and rasterizes every image on it; (12) preview renders (`InterpreterSettings::preview`: backdrops, thumbnails, grid cells, signature-drag backgrounds) skip images under `PREVIEW_MIN_IMAGE_AREA` device pixels, while final renders always draw them; (13) reading colours (#897): `RenderSettings::color_replacement` takes a `ColorReplacement` (text colour, background, `only_black`, `line_art`) that remaps solid text and line-art paint while rendering, glyph outlines counting as text and stencil-mask ink as line art; images, image alpha masks, soft masks and pattern cells keep their colours; `render_pdf` gains a `color_replacement: Option` argument (pass `None` for true colours); the upstream-only `[dev-dependencies.log]` entry is removed from `Cargo.toml` (log is a normal dependency since the logging patch), so `cargo test -p hayro` runs this crate's unit tests; (14) a shrunk, interpolated, axis-aligned image is snapped to whole device pixels: shrunk once to its rounded device rectangle and drawn 1:1 at the rounded corner, instead of a ceil-sized raster resampled again at a fractional scale and offset (`snap_minified_image`; scans looked soft next to Acrobat); (15) a soft mask of another size than its image is filtered when shrunk, whatever its `/Interpolate` (#1649): such a mask carries the shape on its own (Word saves text as a tiny black image with a glyph-bitmap mask), and nearest-neighbour dropped every stroke thinner than the shrink factor; (16) under a soft mask or a blend mode, axial and radial shadings are sampled instead of drawn as native gradients (#2128): vello_cpu 0.0.8 copies an opaque gradient over fully covered tiles without the mask or blend mode, so PowerPoint's faded gradient fills came out solid; (17) the renderer keeps annotation paints out of the reading-colours mapping between `begin_annotations` and `end_annotations` (#1548); (18) the page renders as one transparency group with its background painted inside it as the initial backdrop, so a Multiply highlight blends with a dark reading-colours background instead of staying an opaque block (identity on white pages) | (1) `raster::tests::tiles_match_full_render`; (2) `raster::tests::absurd_image_dimensions_are_skipped`; (3) `raster::tests::tiling_cells_stay_small_whatever_the_step`; (4) `raster::tests::absurd_predictor_columns_and_line_widths_render`; (5) `raster::tests::dash_patterns_that_cut_a_stroke_into_billions_of_pieces_render`; (6) `raster::tests::invalid_dash_patterns_terminate_and_are_drawn_solid`; (7) `raster::tests::pixels_are_a_plain_renders_bytes_in_a_buffer_a_gui_can_take_over`; (8) `raster::tests::image_resampling_rejects_anisotropic_target_growth`, `raster::tests::image_resampling_bounds_intermediate_before_planning`, `raster::tests::image_resampling_rejects_invalid_sources_and_scales`, `raster::tests::image_resampling_keeps_valid_sizes_and_exact_limits`, `raster::tests::resampling_fallback_preserves_original_geometry_and_pixels`, `raster::tests::image_resampling_wide_sources_shrink_before_backend_side_limits`, `raster::tests::image_resampling_wide_transparent_sources_still_shrink`, `raster::tests::image_resampling_padded_backend_dimensions_stay_checked`, `raster::tests::image_resampling_mismatched_alpha_mask_keeps_pixels_and_placement`; (9) `raster::tests::skipping_paths_off_the_canvas_changes_no_pixel`; (10) `raster::tests::non_interpolated_images_are_not_smoothed_when_minified`, `raster::tests::non_interpolated_gray_images_are_not_smoothed_when_minified`, `raster::tests::non_integer_minification_samples_the_same_columns`, `raster::tests::non_integer_minification_samples_colour_and_mask_from_the_same_columns`, `raster::tests::non_interpolated_alpha_images_keep_their_colours_and_mask_when_minified`, `raster::tests::non_interpolated_type3_glyph_images_are_not_smoothed_when_minified`, `raster::tests::image_resampling_nearest_accepts_wide_sources_the_filter_refuses`, `raster::tests::wide_non_interpolated_sources_draw_after_shrinking`; (11) `raster::tests::skipping_paths_off_the_canvas_changes_no_pixel`; (12) `raster::tests::preview_renders_skip_tiny_images_but_finals_draw_everything`; (13) `color_replacement_tests::{black_maps_to_text_colour, white_maps_to_background, midgrey_interpolates, alpha_copied_verbatim, only_black_threshold, line_art_false_skips_paths_but_not_text}`, `color_replacement_render_tests::{color_replacement_maps_text_and_line_art, color_replacement_maps_stencil_mask_ink}`; (14) `raster::tests::interpolated_minified_image_edges_snap_to_whole_pixels` (`cargo test -p hayro`); (15) `raster::tests::shrunk_soft_mask_larger_than_its_image_keeps_thin_strokes`; (16) `raster::tests::soft_mask_applies_to_a_shading_pattern_fill`; (17), (18) `raster::tests::reading_colors_keep_highlight_annotations_readable` | +| hayro | 0.7.1 | Apache-2.0 OR MIT | (1) `RenderSettings::{x_offset, y_offset}` to render one tile of a large page; (2) images over `MAX_IMAGE_PIXELS` (2^28) are skipped (a fuzzed `/W 4294967295` hung in resampling); (3) `tiling_cell_scale` keeps a tiling pattern's cell pixmap within 3000 px a side whatever its `/XStep`/`/YStep` (a fuzzed step far beyond `/BBox` allocated 2 GB); (4) stroke widths clamped to 4 × (canvas width + height) device pixels (a fuzzed `/LW 9223372036854775807` allocated 10 GB in stroke expansion); (5) `usable_dash_pattern`: a stroke whose dash pattern could cut it into more than `MAX_DASHES_PER_STROKE` (250,000) pieces, counting kurbo's restart of the pattern at every subpath and `ClosePath`, is drawn solid (a fuzzed `/D [[11] 0]` on a line from x = 92234775807 allocated over 5 GB, `[0.000001] 0 d` on a 20 pt line over 10 GB); (6) a dash pattern with a negative or non-finite entry, or a period that isn't positive, is drawn solid (`[-1 -1] 0 d` looped for ever in kurbo); (7) `render_size` and `render_into`: render into a buffer the caller allocates (PdfCraft renders into pixel-aligned memory that the GUI takes over as texture data); `render` is `render_into` on a new pixmap; (8) checked image source/target/RGBA, alpha-premultiplication scratch and intermediate sizes before resampling, finite transforms and exact source/mask buffers; sources wider than u16 may shrink before backend limits, with a 2^20 source-axis cap and conservative 128 MiB filter-planning estimate; an over-limit or failed resize keeps the original image resolution and geometry when the source fits the backend, otherwise skips it, with a logged warning; staging, resampling output/scratch, RGBA conversion and padding buffers use fallible reserves; (9) `may_paint_canvas`: a fill or stroke whose control-point box, grown by how far its paint reaches past the path (half the stroke width times the larger of the miter limit and √2) plus 0.5 user units and 2 pixels, misses the canvas is not drawn, so a tile no longer strokes and fills every path of its page (item 5 of #623); `set_skip_offscreen_paths` turns this off for the regression test; (10) backport of hayro 0.8: shadings that `as_svg_gradient` can represent are painted as a `vello_cpu` linear or two-point radial gradient instead of a texture sampled pixel by pixel through the shading function (InDesign fades are luminosity soft masks of one full-page axial `sh`: 40 on a report page took 15 s at 144 dpi, now under 1 s); without upstream's half-pixel shift, which suits vello_cpu 0.3 but moves 0.0.8's gradients off pixel centres; (11) `may_paint_rect`: an image whose stream-size box, mapped to the device, misses the canvas is skipped before decoding (decoded data can only shrink against that box), so a tile of an image-heavy page no longer decodes, resamples and rasterizes every image on it; (12) preview renders (`InterpreterSettings::preview`: backdrops, thumbnails, grid cells, signature-drag backgrounds) skip images under `PREVIEW_MIN_IMAGE_AREA` device pixels, while final renders always draw them; (13) reading colours (#897): `RenderSettings::color_replacement` takes a `ColorReplacement` (text colour, background, `only_black`, `line_art`) that remaps solid text and line-art paint while rendering, glyph outlines counting as text and stencil-mask ink as line art; images, image alpha masks, soft masks and pattern cells keep their colours; `render_pdf` gains a `color_replacement: Option` argument (pass `None` for true colours); the upstream-only `[dev-dependencies.log]` entry is removed from `Cargo.toml` (log is a normal dependency since the logging patch), so `cargo test -p hayro` runs this crate's unit tests; (14) a shrunk, interpolated, axis-aligned image is snapped to whole device pixels: shrunk once to its rounded device rectangle and drawn 1:1 at the rounded corner, instead of a ceil-sized raster resampled again at a fractional scale and offset (`snap_minified_image`; scans looked soft next to Acrobat); (15) a soft mask of another size than its image is filtered when shrunk, whatever its `/Interpolate` (#1649): such a mask carries the shape on its own (Word saves text as a tiny black image with a glyph-bitmap mask), and nearest-neighbour dropped every stroke thinner than the shrink factor; (16) under a soft mask or a blend mode, axial and radial shadings are sampled instead of drawn as native gradients (#2128): vello_cpu 0.0.8 copies an opaque gradient over fully covered tiles without the mask or blend mode, so PowerPoint's faded gradient fills came out solid; (17) the renderer keeps annotation paints out of the reading-colours mapping between `begin_annotations` and `end_annotations` (#1548); (18) the page renders as one transparency group with its background painted inside it as the initial backdrop, so a Multiply highlight blends with a dark reading-colours background instead of staying an opaque block (identity on white pages); (20) a tiling pattern whose step is too large for a cell pixmap, where what is painted (a fill, or a stroke with its width) can only show the first cell (PowerPoint's `/XStep 2147480100` for a picture fill drawn once), has its `/BBox` drawn at full scale with a transparent border and padded instead of tiled (`shows_one_cell`; a cell whose scale isn't finite and positive, from a `/BBox` too thin to divide by, keeps the old path: `usable_cell_scale`); shrinking the cell to fit the 3000 px limit of (3) left the picture under a pixel, so network diagrams lost their pictures (#3349) | (1) `raster::tests::tiles_match_full_render`; (2) `raster::tests::absurd_image_dimensions_are_skipped`; (3) `raster::tests::tiling_cells_stay_small_whatever_the_step`; (4) `raster::tests::absurd_predictor_columns_and_line_widths_render`; (5) `raster::tests::dash_patterns_that_cut_a_stroke_into_billions_of_pieces_render`; (6) `raster::tests::invalid_dash_patterns_terminate_and_are_drawn_solid`; (7) `raster::tests::pixels_are_a_plain_renders_bytes_in_a_buffer_a_gui_can_take_over`; (8) `raster::tests::image_resampling_rejects_anisotropic_target_growth`, `raster::tests::image_resampling_bounds_intermediate_before_planning`, `raster::tests::image_resampling_rejects_invalid_sources_and_scales`, `raster::tests::image_resampling_keeps_valid_sizes_and_exact_limits`, `raster::tests::resampling_fallback_preserves_original_geometry_and_pixels`, `raster::tests::image_resampling_wide_sources_shrink_before_backend_side_limits`, `raster::tests::image_resampling_wide_transparent_sources_still_shrink`, `raster::tests::image_resampling_padded_backend_dimensions_stay_checked`, `raster::tests::image_resampling_mismatched_alpha_mask_keeps_pixels_and_placement`; (9) `raster::tests::skipping_paths_off_the_canvas_changes_no_pixel`; (10) `raster::tests::axial_and_radial_shadings_draw_as_native_gradients`; (11) `raster::tests::skipping_paths_off_the_canvas_changes_no_pixel`; (12) `raster::tests::preview_renders_skip_tiny_images_but_finals_draw_everything`; (13) `color_replacement_tests::{black_maps_to_text_colour, white_maps_to_background, midgrey_interpolates, alpha_copied_verbatim, only_black_threshold, line_art_false_skips_paths_but_not_text}`, `color_replacement_render_tests::{color_replacement_maps_text_and_line_art, color_replacement_maps_stencil_mask_ink}`; (14) `raster::tests::interpolated_minified_image_edges_snap_to_whole_pixels` (`cargo test -p hayro`); (15) `raster::tests::shrunk_soft_mask_larger_than_its_image_keeps_thin_strokes`; (16) `raster::tests::soft_mask_applies_to_a_shading_pattern_fill`; (17), (18) `raster::tests::reading_colors_keep_highlight_annotations_readable`; (20) `raster::tests::a_tiling_pattern_drawn_once_shows_its_cell`, `raster::tests::a_large_step_still_tiles_where_the_next_cell_shows`, `raster::tests::a_tiling_cell_too_thin_to_scale_keeps_the_old_path` | +| hayro | 0.7.1 | Apache-2.0 OR MIT | (1) `RenderSettings::{x_offset, y_offset}` to render one tile of a large page; (2) images over `MAX_IMAGE_PIXELS` (2^28) are skipped (a fuzzed `/W 4294967295` hung in resampling); (3) `tiling_cell_scale` keeps a tiling pattern's cell pixmap within 3000 px a side whatever its `/XStep`/`/YStep` (a fuzzed step far beyond `/BBox` allocated 2 GB); (4) stroke widths clamped to 4 × (canvas width + height) device pixels (a fuzzed `/LW 9223372036854775807` allocated 10 GB in stroke expansion); (5) `usable_dash_pattern`: a stroke whose dash pattern could cut it into more than `MAX_DASHES_PER_STROKE` (250,000) pieces, counting kurbo's restart of the pattern at every subpath and `ClosePath`, is drawn solid (a fuzzed `/D [[11] 0]` on a line from x = 92234775807 allocated over 5 GB, `[0.000001] 0 d` on a 20 pt line over 10 GB); (6) a dash pattern with a negative or non-finite entry, or a period that isn't positive, is drawn solid (`[-1 -1] 0 d` looped for ever in kurbo); (7) `render_size` and `render_into`: render into a buffer the caller allocates (PdfCraft renders into pixel-aligned memory that the GUI takes over as texture data); `render` is `render_into` on a new pixmap; (8) checked image source/target/RGBA, alpha-premultiplication scratch and intermediate sizes before resampling, finite transforms and exact source/mask buffers; sources wider than u16 may shrink before backend limits, with a 2^20 source-axis cap and conservative 128 MiB filter-planning estimate; an over-limit or failed resize keeps the original image resolution and geometry when the source fits the backend, otherwise skips it, with a logged warning; staging, resampling output/scratch, RGBA conversion and padding buffers use fallible reserves; (9) `may_paint_canvas`: a fill or stroke whose control-point box, grown by how far its paint reaches past the path (half the stroke width times the larger of the miter limit and √2) plus 0.5 user units and 2 pixels, misses the canvas is not drawn, so a tile no longer strokes and fills every path of its page (item 5 of #623); `set_skip_offscreen_paths` turns this off for the regression test; (10) `/Interpolate` governs the pre-resize too (#624): a minified image that does not request interpolation is pre-resized with nearest-neighbour, planned without the Catmull-Rom filter limits (`image_resampling_size` takes the flag); a Type 3 glyph image is sampled bicubically only when interpolated; a minified image with a matching soft mask is nearest-sampled straight into premultiplied RGBA; (11) `may_paint_rect`: an image whose stream-size box, mapped to the device, misses the canvas is skipped before decoding (decoded data can only shrink against that box), so a tile of an image-heavy page no longer decodes, resamples and rasterizes every image on it; (12) preview renders (`InterpreterSettings::preview`: backdrops, thumbnails, grid cells, signature-drag backgrounds) skip images under `PREVIEW_MIN_IMAGE_AREA` device pixels, while final renders always draw them; (13) reading colours (#897): `RenderSettings::color_replacement` takes a `ColorReplacement` (text colour, background, `only_black`, `line_art`) that remaps solid text and line-art paint while rendering, glyph outlines counting as text and stencil-mask ink as line art; images, image alpha masks, soft masks and pattern cells keep their colours; `render_pdf` gains a `color_replacement: Option` argument (pass `None` for true colours); the upstream-only `[dev-dependencies.log]` entry is removed from `Cargo.toml` (log is a normal dependency since the logging patch), so `cargo test -p hayro` runs this crate's unit tests; (14) a shrunk, interpolated, axis-aligned image is snapped to whole device pixels: shrunk once to its rounded device rectangle and drawn 1:1 at the rounded corner, instead of a ceil-sized raster resampled again at a fractional scale and offset (`snap_minified_image`; scans looked soft next to Acrobat); (15) a soft mask of another size than its image is filtered when shrunk, whatever its `/Interpolate` (#1649): such a mask carries the shape on its own (Word saves text as a tiny black image with a glyph-bitmap mask), and nearest-neighbour dropped every stroke thinner than the shrink factor; (16) under a soft mask or a blend mode, axial and radial shadings are sampled instead of drawn as native gradients (#2128): vello_cpu 0.0.8 copies an opaque gradient over fully covered tiles without the mask or blend mode, so PowerPoint's faded gradient fills came out solid; (17) the renderer keeps annotation paints out of the reading-colours mapping between `begin_annotations` and `end_annotations` (#1548); (18) the page renders as one transparency group with its background painted inside it as the initial backdrop, so a Multiply highlight blends with a dark reading-colours background instead of staying an opaque block (identity on white pages); (20) a tiling pattern whose step is too large for a cell pixmap, where what is painted (a fill, or a stroke with its width) can only show the first cell (PowerPoint's `/XStep 2147480100` for a picture fill drawn once), has its `/BBox` drawn at full scale with a transparent border and padded instead of tiled (`shows_one_cell`; a cell whose scale isn't finite and positive, from a `/BBox` too thin to divide by, keeps the old path: `usable_cell_scale`); shrinking the cell to fit the 3000 px limit of (3) left the picture under a pixel, so network diagrams lost their pictures (#3349) | (1) `raster::tests::tiles_match_full_render`; (2) `raster::tests::absurd_image_dimensions_are_skipped`; (3) `raster::tests::tiling_cells_stay_small_whatever_the_step`; (4) `raster::tests::absurd_predictor_columns_and_line_widths_render`; (5) `raster::tests::dash_patterns_that_cut_a_stroke_into_billions_of_pieces_render`; (6) `raster::tests::invalid_dash_patterns_terminate_and_are_drawn_solid`; (7) `raster::tests::pixels_are_a_plain_renders_bytes_in_a_buffer_a_gui_can_take_over`; (8) `raster::tests::image_resampling_rejects_anisotropic_target_growth`, `raster::tests::image_resampling_bounds_intermediate_before_planning`, `raster::tests::image_resampling_rejects_invalid_sources_and_scales`, `raster::tests::image_resampling_keeps_valid_sizes_and_exact_limits`, `raster::tests::resampling_fallback_preserves_original_geometry_and_pixels`, `raster::tests::image_resampling_wide_sources_shrink_before_backend_side_limits`, `raster::tests::image_resampling_wide_transparent_sources_still_shrink`, `raster::tests::image_resampling_padded_backend_dimensions_stay_checked`, `raster::tests::image_resampling_mismatched_alpha_mask_keeps_pixels_and_placement`; (9) `raster::tests::skipping_paths_off_the_canvas_changes_no_pixel`; (10) `raster::tests::non_interpolated_images_are_not_smoothed_when_minified`, `raster::tests::non_interpolated_gray_images_are_not_smoothed_when_minified`, `raster::tests::non_integer_minification_samples_the_same_columns`, `raster::tests::non_integer_minification_samples_colour_and_mask_from_the_same_columns`, `raster::tests::non_interpolated_alpha_images_keep_their_colours_and_mask_when_minified`, `raster::tests::non_interpolated_type3_glyph_images_are_not_smoothed_when_minified`, `raster::tests::image_resampling_nearest_accepts_wide_sources_the_filter_refuses`, `raster::tests::wide_non_interpolated_sources_draw_after_shrinking`; (11) `raster::tests::skipping_paths_off_the_canvas_changes_no_pixel`; (12) `raster::tests::preview_renders_skip_tiny_images_but_finals_draw_everything`; (13) `color_replacement_tests::{black_maps_to_text_colour, white_maps_to_background, midgrey_interpolates, alpha_copied_verbatim, only_black_threshold, line_art_false_skips_paths_but_not_text}`, `color_replacement_render_tests::{color_replacement_maps_text_and_line_art, color_replacement_maps_stencil_mask_ink}`; (14) `raster::tests::interpolated_minified_image_edges_snap_to_whole_pixels` (`cargo test -p hayro`); (15) `raster::tests::shrunk_soft_mask_larger_than_its_image_keeps_thin_strokes`; (16) `raster::tests::soft_mask_applies_to_a_shading_pattern_fill`; (17), (18) `raster::tests::reading_colors_keep_highlight_annotations_readable`; (20) `raster::tests::a_tiling_pattern_drawn_once_shows_its_cell`, `raster::tests::a_large_step_still_tiles_where_the_next_cell_shows`, `raster::tests::a_tiling_cell_too_thin_to_scale_keeps_the_old_path` | | hayro-jbig2 | 0.3.0 | Apache-2.0 OR MIT | (1) at most 2^28 pixels per bitmap, page bitmap, halftone grid and grayscale image (each side could be 65535: 4.3 billion pixels decoded one by one); (2) at most 2^26 symbol pixels per symbol dictionary (a fuzzed dictionary decoded for minutes); (3) a symbol dictionary's height classes are capped at one per new symbol and its export runs at `2 × symbols + 1`, each plus `EXTRA_EMPTY_STEPS` (1024): an empty height class or a zero-length export run doesn't advance, and past the end of its data the arithmetic decoder can decode them for ever (one changed byte hung the renderer) | (1) `raster::tests::jbig2_regions_of_billions_of_pixels_are_refused`; (2) the nightly `cargo xtask fuzz` finding, which now finishes in about a second; (3) `raster::tests::jbig2_symbol_dictionaries_that_never_advance_terminate` | | lopdf | 0.45.0 | MIT | (1) PNG predictor rows longer than the data are refused before their buffers are allocated (a fuzzed `/Columns 4294967295` allocated two 4 GiB rows), and empty frames return without row buffers; (2) predictor row widths use saturating arithmetic or reject overflow; (3) sub-byte TIFF Predictor 2 scratch is bounded by the samples present in the input instead of the declared `/Colors`, aligned with [lopdf#589](https://github.com/J-F-Liu/lopdf/pull/589) | (1) `inspect::tests::png_predictor_rows_longer_than_the_data_are_refused_before_allocating`, `inspect::tests::empty_png_predictor_frames_do_not_allocate_rows`; (2) `inspect::tests::tiff_subbyte_predictor_row_width_overflow_is_refused`; (3) `inspect::tests::tiff_subbyte_predictor_scratch_is_bounded_by_available_samples`, `inspect::tests::tiff_subbyte_predictors_keep_components_rows_and_padding` | -| hayro-syntax | 0.7.2 | Apache-2.0 OR MIT | (1) Page-tree cycle guard in `resolve_pages` (visited set + depth cap): a `/Kids` loop among compressed objects overflowed the stack and aborted the process; (2) `ccitt_size_ok`: CCITT images wider than 2^20 columns or over 2^28 pixels are not decoded (a fuzzed `/Columns 4294967295` allocated 4 GiB); (3) Flate/LZW predictor row widths are saturating `usize`, a zero width is refused and a row longer than the data is not allocated (a fuzzed `/Columns 9223372036854775807` wrapped to a 2^61-byte allocation and aborted); (4) the inline-image end-of-data check (`EI`) doesn't nest inside its own look-ahead and examines at most 256 candidates and steps (50 images with "EI" in their data ran for over ten minutes); (5) a stream whose `/Length` is wrong is recovered only when `stream` is the next token — scanning ahead to a later object's `stream` made an indirect appearance-state dictionary (Quartz checkboxes: `/AP /N` → `<< /Yes … /Off … >>`) render as a non-form, so the widget was skipped; (6) JPX rescaling uses an exact integer sample range for 1–32-bit components (32 shifted past u32, and floating-point maxima at 25–31 bits wrapped to zero), and refuses zero-sized rows; (7) `decode_limit`: Flate (through flate2 and the fallback inflater), LZW and RunLength never write past `MAX_DECODED_STREAM` (256 MiB), or, for the last filter of an image of known size, past twice its pixel bytes (a predictor tag byte per byte), at most 1 GiB; `Stream::decoded_within` for budgeted callers; a page's `/Contents` array shares one `MAX_DECODED_STREAM` (a 2 MB page whose content stream or 8 × 8 image inflated to 2 GB took the renderer past 3 GB in under a second; naming the stream twenty times in `/Contents`, 5 GB); (8) high-precision numbers (jsPDF MediaBox coordinates with over 19 digits) parse without overflowing the u64 mantissa: fractional digits past it are dropped, integer digits past it scale the value (a real, kept finite); (9) large dictionary indexes are built on first use and shared across repeated reads within each PDF (#307); the validated-byte cache is bounded, reader contexts remain separate, repairs and inline/content dictionaries bypass sharing, and small dictionaries stay eager; (10) decoded object-stream slots publish once under contention, cold loading rejects recursive dependencies, and xref repair serializes without holding recursive read guards; this permits one parser per native render pool with private worker caches; (11) `sync.rs`: a poisoned lock is recovered instead of unwrapped (a worker panic no longer cascades into every reader of the same PDF), and xref repair takes a blocking write lock instead of asserting a non-blocking one; (12) `SegmentList` (`util.rs`) returns `None` for an out-of-range index instead of panicking; (13) a page's `/UserUnit` scales `render_dimensions` and `initial_transform`, so the page and everything on it are sized in points, as Acrobat shows them; it is read as Acrobat reads it (the page's own entry, not inherited; a number from 1 up, at most 75,000; anything else counts as 1), and a page without it keeps exactly the transform it had; (14) revision 2–4 passwords are tried as given and then in PDFDocEncoding (`legacy_passwords` in `crypto/mod.rs`, which resolves the upstream `TODO: Convert to PDFDocEncoding`): passwords arrive as UTF-8 text, so a password with characters outside ASCII (`Çok güzel`, `ılık€`) written in PDFDocEncoding, as ISO 32000-2 §7.6.4.3.2 asks and qpdf and Acrobat write it, was always wrong; (15) page content is read a piece at a time (`page::ContentReader`, `Page::content_reader`): plain streams and Flate streams without a predictor are decoded as they are read, with a budget of 64 times their compressed size and never less than `MAX_DECODED_STREAM` (a stream that expands past it is cut and reported); other streams are decoded whole, as before, and share `MAX_DECODED_STREAM`. The content tokenizer reads a window at a time (`UntypedIter::new_window`, `TypedIter::needs_more`, `TypedIter::committed`): an instruction that runs past the window is not decided until more data follows, which `Reader` records by flagging reads and `at_end` past the end of the data, including through the clones the parser makes. The Flate decoder keeps its own 32 KiB history, so it pauses at any output size. `ContentReader::new` reuses raw/decrypted stream bytes for budget accounting and piece creation, and `decoded_raw_within` applies the existing Whole-stream filters and limits to those bytes without decrypting twice; (16) revision 5–6 (AES-256) passwords are tried in their SASLprep form (RFC 4013, then UTF-8, at most 127 bytes, as ISO 32000-2 §7.6.4.3.3 Algorithm 2.A asks and pdfcraft-crypt writes them) and then, if that differs, as given (`aes256_passwords` in `crypto/mod.rs`, which resolves the upstream `TODO: Do the above.`; `stringprep`, already in the tree through pdfcraft-crypt, is an optional dependency enabled by `std`, and without `std` passwords are used as given): the renderer used the password as typed, so one that SASLprep changes (a decomposed `café`, a no-break space, the fullwidth `ab` a Japanese IME types in full-width mode) was wrong, although pdfcraft-cos accepted it: protecting a document with such an open password was rolled back ("the password is incorrect"), and a file with such a password did not open with it.; (17) CCITT `/Rows 0` means the height is not predetermined (ISO 32000-2 Table 11), not zero rows: scanned Group 4 pages written that way rendered blank; (18) The streaming Flate decoder (`lzw_flate::flate::fallback`) copies a back-reference in runs of up to its distance (`extend_from_within`), not byte by byte. The copy reads only bytes already in the output, and a copy whose source does not exist still writes nothing, so the bytes are those a byte-by-byte copy writes. Inflating the A1 sample's content stream (278.6 MB) takes about 14% less time (0.66 s to 0.56 s); the output is identical; (19) CCITT `/Rows` below the image's `/Height` decodes to the height: a plotter driver wrote `/Rows` equal to `/Columns`, so only the first rows were decoded and the rest drawn black (#3308) | (1) `cos/tests/writer.rs::page_tree_loops_in_object_streams_do_not_crash_readers`; (2) `raster::tests::absurd_mask_and_fax_sizes_are_refused_before_decoding`; (3) `raster::tests::absurd_predictor_columns_and_line_widths_render`; (4) `raster::tests::inline_images_with_ei_in_their_data_parse_in_linear_time`; (5) `raster::tests::indirect_appearance_state_dict_is_not_another_stream`; (6) `raster::tests::jpx_stream_bit_depth_boundaries_do_not_panic`, `raster::tests::jpx_32_bit_image_does_not_prevent_other_page_content_from_rendering`, `raster::tests::jpx_gray_rgb_and_alpha_keep_eight_bit_samples`, `raster::tests::jpx_streams_reject_out_of_range_bit_depths`; (7) `raster::tests::decompression_bombs_stop_at_the_stream_limit`, `raster::tests::page_contents_and_nested_forms_share_one_budget`; (8) `inspect::tests::jspdf_high_precision_mediabox_does_not_overflow_dimensions`, `object::number::tests::high_precision_float_does_not_overflow_mantissa` (hayro-syntax); (9) `object::dict::lazy_index_tests`, `raster::tests::large_resource_indexes_preserve_pixels_and_text`; (10) `data::tests`, `xref::tests::concurrent_cold_object_stream_reads_all_succeed`, `raster::tests::shared_parser_is_lazy_and_initializes_once_under_contention`; (11) `data::tests::panicking_initializer_wakes_waiters_and_prevents_retries`, `xref::tests::concurrent_cold_object_stream_reads_all_succeed`; (12) `util::tests::segment_list_rejects_out_of_range_indices`; (13) `raster::tests::user_unit_scales_the_page_and_everything_on_it`; (14) `crypto::pdfcraft_password_tests`, `pdfcraft-engine` `tests::legacy_passwords_with_turkish_characters_open`; (15) `content::tests::windows_keep_every_instruction_at_every_split`, `content::tests::windows_keep_every_instruction_in_pieces_of_any_size`, `content::tests::windows_match_the_slice_on_random_content`, `content::tests::windows_drop_blanks_and_keep_a_comment_that_may_go_on`, `page::content_reader_tests::content_past_the_cap_is_read_to_the_end`, `page::content_reader_tests::a_stream_that_expands_past_the_budget_is_cut_there`, `page::content_reader_tests::a_small_expansion_under_the_cap_is_not_cut`, `raster::tests::page_content_past_the_old_cap_renders`, `raster::tests::content_reader_returns_the_same_bytes_in_any_pieces`, `raster::tests::content_bombs_stop_at_the_budget_and_say_so`, `page::content_reader_tests::plain_and_whole_pieces_keep_their_bytes`, `page::content_reader_tests::repeated_content_reference_counts_compressed_bytes_once`, `page::content_reader_tests::whole_cap_still_counts_later_unique_streams`; (16) `crypto::pdfcraft_password_tests::aes256_passwords_are_tried_in_their_saslprep_form_then_as_given`, `crypto::pdfcraft_password_tests::aes256_passwords_open_as_typed_and_in_their_saslprep_form`, `pdfcraft-engine` `tests::aes256_open_passwords_that_saslprep_changes_protect_and_reopen_as_typed`, `tests::aes256_files_whose_passwords_saslprep_changes_open_as_typed`; (17) `raster::tests::group4_fax_with_rows_zero_decodes_to_the_image_height`; (18) `filter::lzw_flate::tests::back_reference_longer_than_its_distance_repeats_its_source`, `back_reference_cut_by_the_limit_resumes_in_the_next_call` and `back_reference_reads_bytes_written_by_an_earlier_call`; each fails when the copy returns the wrong remaining count; (19) `raster::tests::group4_fax_with_rows_below_its_height_decodes_to_the_image_height` +| hayro-syntax | 0.7.2 | Apache-2.0 OR MIT | (1) Page-tree cycle guard in `resolve_pages` (visited set + depth cap): a `/Kids` loop among compressed objects overflowed the stack and aborted the process; (2) `ccitt_size_ok`: CCITT images wider than 2^20 columns or over 2^28 pixels are not decoded (a fuzzed `/Columns 4294967295` allocated 4 GiB); (3) Flate/LZW predictor row widths are saturating `usize`, a zero width is refused and a row longer than the data is not allocated (a fuzzed `/Columns 9223372036854775807` wrapped to a 2^61-byte allocation and aborted); (4) the inline-image end-of-data check (`EI`) doesn't nest inside its own look-ahead and examines at most 256 candidates and steps (50 images with "EI" in their data ran for over ten minutes); (5) a stream whose `/Length` is wrong is recovered only when `stream` is the next token — scanning ahead to a later object's `stream` made an indirect appearance-state dictionary (Quartz checkboxes: `/AP /N` → `<< /Yes … /Off … >>`) render as a non-form, so the widget was skipped; (6) JPX rescaling uses an exact integer sample range for 1–32-bit components (32 shifted past u32, and floating-point maxima at 25–31 bits wrapped to zero), and refuses zero-sized rows; (7) `decode_limit`: Flate (through flate2 and the fallback inflater), LZW and RunLength never write past `MAX_DECODED_STREAM` (256 MiB), or, for the last filter of an image of known size, past twice its pixel bytes (a predictor tag byte per byte), at most 1 GiB; `Stream::decoded_within` for budgeted callers; a page's `/Contents` array shares one `MAX_DECODED_STREAM` (a 2 MB page whose content stream or 8 × 8 image inflated to 2 GB took the renderer past 3 GB in under a second; naming the stream twenty times in `/Contents`, 5 GB); (8) high-precision numbers (jsPDF MediaBox coordinates with over 19 digits) parse without overflowing the u64 mantissa: fractional digits past it are dropped, integer digits past it scale the value (a real, kept finite); (9) large dictionary indexes are built on first use and shared across repeated reads within each PDF (#307); the validated-byte cache is bounded, reader contexts remain separate, repairs and inline/content dictionaries bypass sharing, and small dictionaries stay eager; (10) decoded object-stream slots publish once under contention, cold loading rejects recursive dependencies, and xref repair serializes without holding recursive read guards; this permits one parser per native render pool with private worker caches; (11) `sync.rs`: a poisoned lock is recovered instead of unwrapped (a worker panic no longer cascades into every reader of the same PDF), and xref repair takes a blocking write lock instead of asserting a non-blocking one; (12) `SegmentList` (`util.rs`) returns `None` for an out-of-range index instead of panicking; (13) a page's `/UserUnit` scales `render_dimensions` and `initial_transform`, so the page and everything on it are sized in points, as Acrobat shows them; it is read as Acrobat reads it (the page's own entry, not inherited; a number from 1 up, at most 75,000; anything else counts as 1), and a page without it keeps exactly the transform it had; (14) revision 2–4 passwords are tried as given and then in PDFDocEncoding (`legacy_passwords` in `crypto/mod.rs`, which resolves the upstream `TODO: Convert to PDFDocEncoding`): passwords arrive as UTF-8 text, so a password with characters outside ASCII (`Çok güzel`, `ılık€`) written in PDFDocEncoding, as ISO 32000-2 §7.6.4.3.2 asks and qpdf and Acrobat write it, was always wrong; (15) page content is read a piece at a time (`page::ContentReader`, `Page::content_reader`): plain streams and Flate streams without a predictor are decoded as they are read, with a budget of 64 times their compressed size and never less than `MAX_DECODED_STREAM` (a stream that expands past it is cut and reported); other streams are decoded whole, as before, and share `MAX_DECODED_STREAM`. The content tokenizer reads a window at a time (`UntypedIter::new_window`, `TypedIter::needs_more`, `TypedIter::committed`): an instruction that runs past the window is not decided until more data follows, which `Reader` records by flagging reads and `at_end` past the end of the data, including through the clones the parser makes. The Flate decoder keeps its own 32 KiB history, so it pauses at any output size. `ContentReader::new` reuses raw/decrypted stream bytes for budget accounting and piece creation, and `decoded_raw_within` applies the existing Whole-stream filters and limits to those bytes without decrypting twice; (16) revision 5–6 (AES-256) passwords are tried in their SASLprep form (RFC 4013, then UTF-8, at most 127 bytes, as ISO 32000-2 §7.6.4.3.3 Algorithm 2.A asks and pdfcraft-crypt writes them) and then, if that differs, as given (`aes256_passwords` in `crypto/mod.rs`, which resolves the upstream `TODO: Do the above.`; `stringprep`, already in the tree through pdfcraft-crypt, is an optional dependency enabled by `std`, and without `std` passwords are used as given): the renderer used the password as typed, so one that SASLprep changes (a decomposed `café`, a no-break space, the fullwidth `ab` a Japanese IME types in full-width mode) was wrong, although pdfcraft-cos accepted it: protecting a document with such an open password was rolled back ("the password is incorrect"), and a file with such a password did not open with it.; (17) CCITT `/Rows 0` means the height is not predetermined (ISO 32000-2 Table 11), not zero rows: scanned Group 4 pages written that way rendered blank; (18) The streaming Flate decoder (`lzw_flate::flate::fallback`) copies a back-reference in runs of up to its distance (`extend_from_within`), not byte by byte. The copy reads only bytes already in the output, and a copy whose source does not exist still writes nothing, so the bytes are those a byte-by-byte copy writes. Inflating the A1 sample's content stream (278.6 MB) takes about 14% less time (0.66 s to 0.56 s); the output is identical; (19) CCITT `/Rows` below the image's `/Height` decodes to the height: a plotter driver wrote `/Rows` equal to `/Columns`, so only the first rows were decoded and the rest drawn black (#3308) (20) `stream` followed by spaces or tabs and then the line break (`stream \r\n`, as Microsoft Reporting Services writes it) starts the data after the line break; the content stream was taken to start at the space and the page rendered blank.; (21) a JP2 colour specification naming an enumerated colour space the decoder refuses (CIEJab in a veraPDF fixture, YCbCr, PhotoYCC, YPbPr, bilevel…) no longer fails the image: it is decoded again from a copy whose `colr` names the space the decoder assumes without one, greyscale, sRGB or CMYK by component count, as MuPDF and Poppler draw it (`without_refused_enumerated_colour`; the page drew blank, #3306) | (1) `cos/tests/writer.rs::page_tree_loops_in_object_streams_do_not_crash_readers`; (2) `raster::tests::absurd_mask_and_fax_sizes_are_refused_before_decoding`; (3) `raster::tests::absurd_predictor_columns_and_line_widths_render`; (4) `raster::tests::inline_images_with_ei_in_their_data_parse_in_linear_time`; (5) `raster::tests::indirect_appearance_state_dict_is_not_another_stream`; (6) `raster::tests::jpx_stream_bit_depth_boundaries_do_not_panic`, `raster::tests::jpx_32_bit_image_does_not_prevent_other_page_content_from_rendering`, `raster::tests::jpx_gray_rgb_and_alpha_keep_eight_bit_samples`, `raster::tests::jpx_streams_reject_out_of_range_bit_depths`; (7) `raster::tests::decompression_bombs_stop_at_the_stream_limit`, `raster::tests::page_contents_and_nested_forms_share_one_budget`; (8) `inspect::tests::jspdf_high_precision_mediabox_does_not_overflow_dimensions`, `object::number::tests::high_precision_float_does_not_overflow_mantissa` (hayro-syntax); (9) `object::dict::lazy_index_tests`, `raster::tests::large_resource_indexes_preserve_pixels_and_text`; (10) `data::tests`, `xref::tests::concurrent_cold_object_stream_reads_all_succeed`, `raster::tests::shared_parser_is_lazy_and_initializes_once_under_contention`; (11) `data::tests::panicking_initializer_wakes_waiters_and_prevents_retries`, `xref::tests::concurrent_cold_object_stream_reads_all_succeed`; (12) `util::tests::segment_list_rejects_out_of_range_indices`; (13) `raster::tests::user_unit_scales_the_page_and_everything_on_it`; (14) `crypto::pdfcraft_password_tests`, `pdfcraft-engine` `tests::legacy_passwords_with_turkish_characters_open`; (15) `content::tests::windows_keep_every_instruction_at_every_split`, `content::tests::windows_keep_every_instruction_in_pieces_of_any_size`, `content::tests::windows_match_the_slice_on_random_content`, `content::tests::windows_drop_blanks_and_keep_a_comment_that_may_go_on`, `page::content_reader_tests::content_past_the_cap_is_read_to_the_end`, `page::content_reader_tests::a_stream_that_expands_past_the_budget_is_cut_there`, `page::content_reader_tests::a_small_expansion_under_the_cap_is_not_cut`, `raster::tests::page_content_past_the_old_cap_renders`, `raster::tests::content_reader_returns_the_same_bytes_in_any_pieces`, `raster::tests::content_bombs_stop_at_the_budget_and_say_so`, `page::content_reader_tests::plain_and_whole_pieces_keep_their_bytes`, `page::content_reader_tests::repeated_content_reference_counts_compressed_bytes_once`, `page::content_reader_tests::whole_cap_still_counts_later_unique_streams`; (16) `crypto::pdfcraft_password_tests::aes256_passwords_are_tried_in_their_saslprep_form_then_as_given`, `crypto::pdfcraft_password_tests::aes256_passwords_open_as_typed_and_in_their_saslprep_form`, `pdfcraft-engine` `tests::aes256_open_passwords_that_saslprep_changes_protect_and_reopen_as_typed`, `tests::aes256_files_whose_passwords_saslprep_changes_open_as_typed`; (17) `raster::tests::group4_fax_with_rows_zero_decodes_to_the_image_height`; (18) `filter::lzw_flate::tests::back_reference_longer_than_its_distance_repeats_its_source`, `back_reference_cut_by_the_limit_resumes_in_the_next_call` and `back_reference_reads_bytes_written_by_an_earlier_call`; each fails when the copy returns the wrong remaining count; (19) `raster::tests::group4_fax_with_rows_below_its_height_decodes_to_the_image_height`; (20) `raster::tests::space_before_the_line_break_after_stream_still_renders`; (21) `raster::tests::a_jp2_image_in_a_refused_enumerated_colour_space_is_drawn`, `filter::jpx::tests::refused_enumerated_colour_spaces_become_the_component_counts_default`, `filter::jpx::tests::what_the_decoder_handles_or_cant_be_read_is_left_alone` (`cargo test --manifest-path vendor/hayro-syntax/Cargo.toml`) | hayro-syntax | 0.7.2 | Apache-2.0 OR MIT | (1) Page-tree cycle guard in `resolve_pages` (visited set + depth cap): a `/Kids` loop among compressed objects overflowed the stack and aborted the process; (2) `ccitt_size_ok`: CCITT images wider than 2^20 columns or over 2^28 pixels are not decoded (a fuzzed `/Columns 4294967295` allocated 4 GiB); (3) Flate/LZW predictor row widths are saturating `usize`, a zero width is refused and a row longer than the data is not allocated (a fuzzed `/Columns 9223372036854775807` wrapped to a 2^61-byte allocation and aborted); (4) the inline-image end-of-data check (`EI`) doesn't nest inside its own look-ahead and examines at most 256 candidates and steps (50 images with "EI" in their data ran for over ten minutes); (5) a stream whose `/Length` is wrong is recovered only when `stream` is the next token — scanning ahead to a later object's `stream` made an indirect appearance-state dictionary (Quartz checkboxes: `/AP /N` → `<< /Yes … /Off … >>`) render as a non-form, so the widget was skipped; (6) JPX rescaling uses an exact integer sample range for 1–32-bit components (32 shifted past u32, and floating-point maxima at 25–31 bits wrapped to zero), and refuses zero-sized rows; (7) `decode_limit`: Flate (through flate2 and the fallback inflater), LZW, RunLength and ASCII85 never write past `MAX_DECODED_STREAM` (256 MiB), or, for the last filter of an image of known size, past twice its pixel bytes (a predictor tag byte per byte), at most 1 GiB; `Stream::decoded_within` for budgeted callers; a page's `/Contents` array shares one `MAX_DECODED_STREAM` (a 2 MB page whose content stream or 8 × 8 image inflated to 2 GB took the renderer past 3 GB in under a second; naming the stream twenty times in `/Contents`, 5 GB); (8) high-precision numbers (jsPDF MediaBox coordinates with over 19 digits) parse without overflowing the u64 mantissa: fractional digits past it are dropped, integer digits past it scale the value (a real, kept finite); (9) large dictionary indexes are built on first use and shared across repeated reads within each PDF (#307); the validated-byte cache is bounded, reader contexts remain separate, repairs and inline/content dictionaries bypass sharing, and small dictionaries stay eager; (10) decoded object-stream slots publish once under contention, cold loading rejects recursive dependencies, and xref repair serializes without holding recursive read guards; this permits one parser per native render pool with private worker caches; (11) `sync.rs`: a poisoned lock is recovered instead of unwrapped (a worker panic no longer cascades into every reader of the same PDF), and xref repair takes a blocking write lock instead of asserting a non-blocking one; (12) `SegmentList` (`util.rs`) returns `None` for an out-of-range index instead of panicking; (13) a page's `/UserUnit` scales `render_dimensions` and `initial_transform`, so the page and everything on it are sized in points, as Acrobat shows them; it is read as Acrobat reads it (the page's own entry, not inherited; a number from 1 up, at most 75,000; anything else counts as 1), and a page without it keeps exactly the transform it had; (14) revision 2–4 passwords are tried as given and then in PDFDocEncoding (`legacy_passwords` in `crypto/mod.rs`, which resolves the upstream `TODO: Convert to PDFDocEncoding`): passwords arrive as UTF-8 text, so a password with characters outside ASCII (`Çok güzel`, `ılık€`) written in PDFDocEncoding, as ISO 32000-2 §7.6.4.3.2 asks and qpdf and Acrobat write it, was always wrong; (15) page content is read a piece at a time (`page::ContentReader`, `Page::content_reader`): plain streams and Flate streams without a predictor are decoded as they are read, with a budget of 64 times their compressed size and never less than `MAX_DECODED_STREAM` (a stream that expands past it is cut and reported); other streams are decoded whole, as before, and share `MAX_DECODED_STREAM`. The content tokenizer reads a window at a time (`UntypedIter::new_window`, `TypedIter::needs_more`, `TypedIter::committed`): an instruction that runs past the window is not decided until more data follows, which `Reader` records by flagging reads and `at_end` past the end of the data, including through the clones the parser makes. The Flate decoder keeps its own 32 KiB history, so it pauses at any output size. `ContentReader::new` reuses raw/decrypted stream bytes for budget accounting and piece creation, and `decoded_raw_within` applies the existing Whole-stream filters and limits to those bytes without decrypting twice; (16) revision 5–6 (AES-256) passwords are tried in their SASLprep form (RFC 4013, then UTF-8, at most 127 bytes, as ISO 32000-2 §7.6.4.3.3 Algorithm 2.A asks and pdfcraft-crypt writes them) and then, if that differs, as given (`aes256_passwords` in `crypto/mod.rs`, which resolves the upstream `TODO: Do the above.`; `stringprep`, already in the tree through pdfcraft-crypt, is an optional dependency enabled by `std`, and without `std` passwords are used as given): the renderer used the password as typed, so one that SASLprep changes (a decomposed `café`, a no-break space, the fullwidth `ab` a Japanese IME types in full-width mode) was wrong, although pdfcraft-cos accepted it: protecting a document with such an open password was rolled back ("the password is incorrect"), and a file with such a password did not open with it. | (1) `cos/tests/writer.rs::page_tree_loops_in_object_streams_do_not_crash_readers`; (2) `raster::tests::absurd_mask_and_fax_sizes_are_refused_before_decoding`; (3) `raster::tests::absurd_predictor_columns_and_line_widths_render`; (4) `raster::tests::inline_images_with_ei_in_their_data_parse_in_linear_time`; (5) `raster::tests::indirect_appearance_state_dict_is_not_another_stream`; (6) `raster::tests::jpx_stream_bit_depth_boundaries_do_not_panic`, `raster::tests::jpx_32_bit_image_does_not_prevent_other_page_content_from_rendering`, `raster::tests::jpx_gray_rgb_and_alpha_keep_eight_bit_samples`, `raster::tests::jpx_streams_reject_out_of_range_bit_depths`; (7) `raster::tests::decompression_bombs_stop_at_the_stream_limit`, `raster::tests::page_contents_and_nested_forms_share_one_budget`; (8) `inspect::tests::jspdf_high_precision_mediabox_does_not_overflow_dimensions`, `object::number::tests::high_precision_float_does_not_overflow_mantissa` (hayro-syntax); (9) `object::dict::lazy_index_tests`, `raster::tests::large_resource_indexes_preserve_pixels_and_text`; (10) `data::tests`, `xref::tests::concurrent_cold_object_stream_reads_all_succeed`, `raster::tests::shared_parser_is_lazy_and_initializes_once_under_contention`; (11) `data::tests::panicking_initializer_wakes_waiters_and_prevents_retries`, `xref::tests::concurrent_cold_object_stream_reads_all_succeed`; (12) `util::tests::segment_list_rejects_out_of_range_indices`; (13) `raster::tests::user_unit_scales_the_page_and_everything_on_it`; (14) `crypto::pdfcraft_password_tests`, `pdfcraft-engine` `tests::legacy_passwords_with_turkish_characters_open`; (15) `content::tests::windows_keep_every_instruction_at_every_split`, `content::tests::windows_keep_every_instruction_in_pieces_of_any_size`, `content::tests::windows_match_the_slice_on_random_content`, `content::tests::windows_drop_blanks_and_keep_a_comment_that_may_go_on`, `page::content_reader_tests::content_past_the_cap_is_read_to_the_end`, `page::content_reader_tests::a_stream_that_expands_past_the_budget_is_cut_there`, `page::content_reader_tests::a_small_expansion_under_the_cap_is_not_cut`, `raster::tests::page_content_past_the_old_cap_renders`, `raster::tests::content_reader_returns_the_same_bytes_in_any_pieces`, `raster::tests::content_bombs_stop_at_the_budget_and_say_so`, `page::content_reader_tests::plain_and_whole_pieces_keep_their_bytes`, `page::content_reader_tests::repeated_content_reference_counts_compressed_bytes_once`, `page::content_reader_tests::whole_cap_still_counts_later_unique_streams`; (16) `crypto::pdfcraft_password_tests::aes256_passwords_are_tried_in_their_saslprep_form_then_as_given`, `crypto::pdfcraft_password_tests::aes256_passwords_open_as_typed_and_in_their_saslprep_form`, `pdfcraft-engine` `tests::aes256_open_passwords_that_saslprep_changes_protect_and_reopen_as_typed`, `tests::aes256_files_whose_passwords_saslprep_changes_open_as_typed` | lopdf | 0.45.0 | MIT | (1) PNG predictor rows longer than the data are refused before their buffers are allocated (a fuzzed `/Columns 4294967295` allocated two 4 GiB rows), and empty frames return without row buffers; (2) predictor row widths use saturating arithmetic or reject overflow; (3) sub-byte TIFF Predictor 2 scratch is bounded by the samples present in the input instead of the declared `/Colors`, aligned with [lopdf#589](https://github.com/J-F-Liu/lopdf/pull/589); (4) the object lookup keeps its cycle history through compressed objects, requires an ordinary in-file container, and caps the chain of indirect lookups at 16 (a chain of new objects, each a `/Length` compressed in a container whose own `/Length` names the next, or a stream whose `/Length` names the next stream, overflowed the stack: a dev build's 2 MiB thread within 800 hops); (5) object-stream members are parsed within their own extent (up to the next distinct offset), an entry whose offset repeats an earlier entry's is skipped (so a repeated object number can resolve to its earlier entry), and offsets are processed in index order | (1) `inspect::tests::png_predictor_rows_longer_than_the_data_are_refused_before_allocating`, `inspect::tests::empty_png_predictor_frames_do_not_allocate_rows`; (2) `inspect::tests::tiff_subbyte_predictor_row_width_overflow_is_refused`; (3) `inspect::tests::tiff_subbyte_predictor_scratch_is_bounded_by_available_samples`, `inspect::tests::tiff_subbyte_predictors_keep_components_rows_and_padding`; (4) `inspect::tests::compressed_object_self_container_does_not_recurse`, `inspect::tests::compressed_object_mutual_containers_do_not_recurse`, `inspect::tests::object_stream_length_cycle_does_not_recurse`, `inspect::tests::compressed_object_container_chain_does_not_recurse`, `inspect::tests::compressed_length_chain_does_not_recurse`, `inspect::tests::plain_stream_length_chain_does_not_recurse`, `inspect::tests::compressed_objects_and_indirect_stream_length_still_load`, `inspect::tests::compressed_length_in_a_container_with_an_indirect_length_still_loads`; (5) `inspect::tests::object_stream_member_extents_bound_owned_strings`, `inspect::tests::object_stream_member_extents_preserve_index_order_and_whitespace` | | hayro-syntax | 0.7.2 | Apache-2.0 OR MIT | (1) Page-tree cycle guard in `resolve_pages` (visited set + depth cap): a `/Kids` loop among compressed objects overflowed the stack and aborted the process; (2) `ccitt_size_ok`: CCITT images wider than 2^20 columns or over 2^28 pixels are not decoded (a fuzzed `/Columns 4294967295` allocated 4 GiB); (3) Flate/LZW predictor row widths are saturating `usize`, a zero width is refused and a row longer than the data is not allocated (a fuzzed `/Columns 9223372036854775807` wrapped to a 2^61-byte allocation and aborted); (4) the inline-image end-of-data check (`EI`) doesn't nest inside its own look-ahead and examines at most 256 candidates and steps (50 images with "EI" in their data ran for over ten minutes); (5) a stream whose `/Length` is wrong is recovered only when `stream` is the next token — scanning ahead to a later object's `stream` made an indirect appearance-state dictionary (Quartz checkboxes: `/AP /N` → `<< /Yes … /Off … >>`) render as a non-form, so the widget was skipped; (6) JPX rescaling uses an exact integer sample range for 1–32-bit components (32 shifted past u32, and floating-point maxima at 25–31 bits wrapped to zero), and refuses zero-sized rows; (7) `decode_limit`: Flate (through flate2 and the fallback inflater), LZW and RunLength never write past `MAX_DECODED_STREAM` (256 MiB), or, for the last filter of an image of known size, past twice its pixel bytes (a predictor tag byte per byte), at most 1 GiB; `Stream::decoded_within` for budgeted callers; a page's `/Contents` array shares one `MAX_DECODED_STREAM` (a 2 MB page whose content stream or 8 × 8 image inflated to 2 GB took the renderer past 3 GB in under a second; naming the stream twenty times in `/Contents`, 5 GB); (8) high-precision numbers (jsPDF MediaBox coordinates with over 19 digits) parse without overflowing the u64 mantissa: fractional digits past it are dropped, integer digits past it scale the value (a real, kept finite); (9) large dictionary indexes are built on first use and shared across repeated reads within each PDF (#307); the validated-byte cache is bounded, reader contexts remain separate, repairs and inline/content dictionaries bypass sharing, and small dictionaries stay eager; (10) decoded object-stream slots publish once under contention, cold loading rejects recursive dependencies, and xref repair serializes without holding recursive read guards; this permits one parser per native render pool with private worker caches; (11) `sync.rs`: a poisoned lock is recovered instead of unwrapped (a worker panic no longer cascades into every reader of the same PDF), and xref repair takes a blocking write lock instead of asserting a non-blocking one; (12) `SegmentList` (`util.rs`) returns `None` for an out-of-range index instead of panicking; (13) a page's `/UserUnit` scales `render_dimensions` and `initial_transform`, so the page and everything on it are sized in points, as Acrobat shows them; it is read as Acrobat reads it (the page's own entry, not inherited; a number from 1 up, at most 75,000; anything else counts as 1), and a page without it keeps exactly the transform it had | (1) `cos/tests/writer.rs::page_tree_loops_in_object_streams_do_not_crash_readers`; (2) `raster::tests::absurd_mask_and_fax_sizes_are_refused_before_decoding`; (3) `raster::tests::absurd_predictor_columns_and_line_widths_render`; (4) `raster::tests::inline_images_with_ei_in_their_data_parse_in_linear_time`; (5) `raster::tests::indirect_appearance_state_dict_is_not_another_stream`; (6) `raster::tests::jpx_stream_bit_depth_boundaries_do_not_panic`, `raster::tests::jpx_32_bit_image_does_not_prevent_other_page_content_from_rendering`, `raster::tests::jpx_gray_rgb_and_alpha_keep_eight_bit_samples`, `raster::tests::jpx_streams_reject_out_of_range_bit_depths`; (7) `raster::tests::decompression_bombs_stop_at_the_stream_limit`, `raster::tests::page_contents_and_nested_forms_share_one_budget`; (8) `inspect::tests::jspdf_high_precision_mediabox_does_not_overflow_dimensions`, `object::number::tests::high_precision_float_does_not_overflow_mantissa` (hayro-syntax); (9) `object::dict::lazy_index_tests`, `raster::tests::large_resource_indexes_preserve_pixels_and_text`; (10) `data::tests`, `xref::tests::concurrent_cold_object_stream_reads_all_succeed`, `raster::tests::shared_parser_is_lazy_and_initializes_once_under_contention`; (11) `data::tests::panicking_initializer_wakes_waiters_and_prevents_retries`, `xref::tests::concurrent_cold_object_stream_reads_all_succeed`; (12) `util::tests::segment_list_rejects_out_of_range_indices`; (13) `raster::tests::user_unit_scales_the_page_and_everything_on_it` | diff --git a/vendor/hayro-interpret/src/font/type3.rs b/vendor/hayro-interpret/src/font/type3.rs index 324fa03dfe..c0e0d0b2df 100644 --- a/vendor/hayro-interpret/src/font/type3.rs +++ b/vendor/hayro-interpret/src/font/type3.rs @@ -168,9 +168,10 @@ impl<'a> Type3<'a> { is_shape_glyph }; + // PdfCraft patch: context clips and shading bounds are in device space; FontBBox is in glyph space. let mut context = Context::new_with( state.ctm, - self.font_bbox, + root_transform.transform_rect_bbox(self.font_bbox), &glyph.cache, glyph.xref, glyph.settings.clone(), diff --git a/vendor/hayro-syntax/src/filter/jpx.rs b/vendor/hayro-syntax/src/filter/jpx.rs index 6a788591e3..74efd8b31e 100644 --- a/vendor/hayro-syntax/src/filter/jpx.rs +++ b/vendor/hayro-syntax/src/filter/jpx.rs @@ -26,7 +26,18 @@ pub(crate) fn decode(data: &[u8], params: &ImageDecodeParams) -> Option image, + Err(_) => { + repaired = without_refused_enumerated_colour(data)?; + hayro_jpeg2000::Image::new(&repaired, &settings).ok()? + } + }; let width = image.width(); let height = image.height(); @@ -87,6 +98,82 @@ pub(crate) fn decode(data: &[u8], params: &ImageDecodeParams) -> Option Option> { + let header = boxes(data).find(|&(kind, _, _)| kind == *b"jp2h")?; + let (_, start, end) = header; + let inner = data.get(start..end)?; + let (_, ih_start, _) = boxes(inner).find(|&(kind, _, _)| kind == *b"ihdr")?; + // ihdr: height (4), width (4), number of components (2). + let components = u16::from_be_bytes(inner.get(ih_start + 8..ih_start + 10)?.try_into().ok()?); + let replacement: u32 = match components { + 1 => 17, + 3 => 16, + 4 => 12, + _ => return None, + }; + // Where each refused enum is, found before anything is copied: a stream the decoder refused + // for another reason isn't copied for nothing. + let mut refused = Vec::new(); + for (kind, body, body_end) in boxes(inner) { + if kind != *b"colr" { + continue; + } + // colr: METH (1), PREC (1), APPROX (1), then EnumCS (4) when METH is 1. + let colr = inner.get(body..body_end)?; + if colr.first() != Some(&1) { + continue; + } + let enumerated = u32::from_be_bytes(colr.get(3..7)?.try_into().ok()?); + if DECODED_ENUMERATED.contains(&enumerated) { + continue; + } + refused.push(start.checked_add(body)?.checked_add(3)?); + } + if refused.is_empty() { + return None; + } + let mut out = data.to_vec(); + for at in refused { + out.get_mut(at..at.checked_add(4)?)?.copy_from_slice(&replacement.to_be_bytes()); + } + Some(out) +} + +/// The boxes in `data` (ISO 15444-1 I.4): each one's type and the start and end of its contents. +/// Stops at the first box whose length doesn't fit. +fn boxes(data: &[u8]) -> impl Iterator + '_ { + let mut at = 0usize; + core::iter::from_fn(move || { + let length = u32::from_be_bytes(data.get(at..at.checked_add(4)?)?.try_into().ok()?); + let kind: [u8; 4] = data.get(at + 4..at.checked_add(8)?)?.try_into().ok()?; + let (header, size) = match length { + // The rest of the data. + 0 => (8, data.len().checked_sub(at)?), + // An 8-byte length follows the type. + 1 => { + let large = u64::from_be_bytes(data.get(at + 8..at.checked_add(16)?)?.try_into().ok()?); + (16, usize::try_from(large).ok()?) + } + n => (8, usize::try_from(n).ok()?), + }; + let end = at.checked_add(size)?; + if size < header || end > data.len() { + return None; + } + let item = (kind, at + header, end); + at = end; + Some(item) + }) +} + fn scale( data: &[u8], bit_per_component: u8, @@ -134,7 +221,7 @@ fn scale( #[cfg(test)] mod tests { - use super::scale; + use super::{scale, without_refused_enumerated_colour}; use crate::bit_reader::{BitReader, bit_mask}; use alloc::vec::Vec; @@ -188,4 +275,87 @@ mod tests { } } } + + /// A JP2 header (`jp2h`) holding `ihdr` for `components` and the given `colr` bodies, with + /// a codestream box after it. + fn jp2(components: u16, colrs: &[&[u8]]) -> Vec { + let jp2_box = |kind: &[u8], body: &[u8]| { + let mut b = ((8 + body.len()) as u32).to_be_bytes().to_vec(); + b.extend_from_slice(kind); + b.extend_from_slice(body); + b + }; + let mut ihdr = Vec::new(); + ihdr.extend_from_slice(&[0, 0, 0, 1, 0, 0, 0, 1]); + ihdr.extend_from_slice(&components.to_be_bytes()); + ihdr.extend_from_slice(&[7, 7, 0, 0]); + let mut header = jp2_box(b"ihdr", &ihdr); + for c in colrs { + header.extend(jp2_box(b"colr", c)); + } + let mut out = jp2_box(b"ftyp", b"jp2 \0\0\0\0jp2 "); + out.extend(jp2_box(b"jp2h", &header)); + out.extend(jp2_box(b"jp2c", &[0xff, 0x4f])); + out + } + + fn enumerated(e: u32) -> Vec { + let mut c = alloc::vec![1, 0, 0]; + c.extend_from_slice(&e.to_be_bytes()); + c + } + + /// The enumerated colour spaces found in the copy's `colr` boxes, in order. + fn enums_in(data: &[u8]) -> Vec { + data.windows(4) + .enumerate() + .filter(|(_, w)| *w == b"colr") + .filter(|(i, _)| data.get(i + 4) == Some(&1)) + .map(|(i, _)| u32::from_be_bytes(data[i + 7..i + 11].try_into().unwrap())) + .collect() + } + + #[test] + fn refused_enumerated_colour_spaces_become_the_component_counts_default() { + // CIEJab (19) and other refused spaces, by component count. + for (components, refused, expected) in [(3, 19, 16), (3, 22, 16), (1, 15, 17), (4, 13, 12), (3, 1, 16)] { + let data = jp2(components, &[&enumerated(refused)]); + let out = without_refused_enumerated_colour(&data).unwrap(); + assert_eq!(enums_in(&out), [expected], "{refused} with {components} component(s)"); + assert_eq!(out.len(), data.len(), "only the enum changes"); + } + // Several colour specifications: each refused one, and only those. + let out = without_refused_enumerated_colour(&jp2(3, &[&enumerated(19), &enumerated(14), &enumerated(24)])).unwrap(); + assert_eq!(enums_in(&out), [16, 14, 16]); + // An 8-byte box length (LBox 1) is read too. + let mut long = jp2(3, &[&enumerated(19)]); + let at = long.windows(4).position(|w| w == b"jp2h").unwrap() - 4; + let len = u32::from_be_bytes(long[at..at + 4].try_into().unwrap()) as u64 + 8; + long.splice(at..at + 4, [0, 0, 0, 1]); + long.splice(at + 8..at + 8, (len).to_be_bytes()); + assert_eq!(enums_in(&without_refused_enumerated_colour(&long).unwrap()), [16]); + } + + #[test] + fn what_the_decoder_handles_or_cant_be_read_is_left_alone() { + for known in [12, 14, 16, 17, 18, 20, 21] { + assert_eq!(without_refused_enumerated_colour(&jp2(3, &[&enumerated(known)])), None, "{known}"); + } + // An ICC profile (METH 2), a component count with no default, no header at all. + // (Its bytes where an enum would be read say 19: only METH 1 is an enum.) + assert_eq!(without_refused_enumerated_colour(&jp2(3, &[&[2, 0, 0, 0, 0, 0, 19, 0, 0]])), None); + assert_eq!(without_refused_enumerated_colour(&jp2(2, &[&enumerated(19)])), None); + assert_eq!(without_refused_enumerated_colour(&[0xff, 0x4f, 0xff, 0x51]), None); + // Damaged lengths: none of these may panic, and none is changed. + let good = jp2(3, &[&enumerated(19)]); + for cut in 0..good.len() { + let _ = without_refused_enumerated_colour(&good[..cut]); + } + for bad in [0x0000_0004_u32, 0x7fff_ffff, 0xffff_ffff] { + let mut data = good.clone(); + let at = data.windows(4).position(|w| w == b"colr").unwrap() - 4; + data[at..at + 4].copy_from_slice(&bad.to_be_bytes()); + assert_eq!(without_refused_enumerated_colour(&data), None, "{bad:#x}"); + } + } } diff --git a/vendor/hayro-syntax/src/object/stream.rs b/vendor/hayro-syntax/src/object/stream.rs index 833610817b..a80f0730cb 100644 --- a/vendor/hayro-syntax/src/object/stream.rs +++ b/vendor/hayro-syntax/src/object/stream.rs @@ -353,14 +353,25 @@ impl FilterResult<'_> { } } +/// PdfCraft patch (20): the end-of-line after the `stream` keyword. Microsoft Reporting Services +/// writes `stream \r\n`, a space before the line break, which the specification does not allow. +/// Without this the content stream was taken to start at the space, failed to inflate, and the +/// page rendered blank (bank statements, invoices) while other viewers showed it. Only spaces and +/// tabs are skipped, and only when a line break follows, so stream data is never swallowed. +fn forward_stream_eol(r: &mut Reader<'_>) -> Option<()> { + r.forward_while(|b| b == b' ' || b == b'\t'); + r.forward_tag(b"\n") + .or_else(|| r.forward_tag(b"\r\n")) + // Technically not allowed, but no reason to not try it. + .or_else(|| r.forward_tag(b"\r")) +} + fn parse_proper<'a>(r: &mut Reader<'a>, dict: &Dict<'a>) -> Option> { let length = dict.get::(LENGTH)?; r.skip_white_spaces_and_comments(); r.forward_tag(b"stream")?; - r.forward_tag(b"\n") - .or_else(|| r.forward_tag(b"\r\n")) - .or_else(|| r.forward_tag(b"\r"))?; + forward_stream_eol(r)?; let data = r.read_bytes(length as usize)?; r.skip_white_spaces(); r.forward_tag(b"endstream")?; @@ -376,11 +387,7 @@ fn parse_fallback<'a>(r: &mut Reader<'a>, dict: &Dict<'a>) -> Option> // A wrong `/Length` still recovers, because that keyword sits directly after the dict. r.skip_white_spaces_and_comments(); r.forward_tag(b"stream")?; - - r.forward_tag(b"\n") - .or_else(|| r.forward_tag(b"\r\n")) - // Technically not allowed, but no reason to not try it. - .or_else(|| r.forward_tag(b"\r"))?; + forward_stream_eol(r)?; let tail = r.tail()?; let endstream_offset = find_needle(tail, b"endstream")?; diff --git a/vendor/hayro/src/lib.rs b/vendor/hayro/src/lib.rs index 43381ab679..6333ed5808 100644 --- a/vendor/hayro/src/lib.rs +++ b/vendor/hayro/src/lib.rs @@ -61,7 +61,7 @@ use vello_cpu::{Level, Pixmap, RenderMode}; mod renderer; /// PdfCraft patch: exported for its regression test. -pub use renderer::{image_resampling_size, tiling_cell_scale}; +pub use renderer::{image_resampling_size, tiling_cell_scale, usable_cell_scale}; /// PdfCraft patch: exported for its regression test. #[doc(hidden)] pub use renderer::set_skip_offscreen_paths; diff --git a/vendor/hayro/src/renderer.rs b/vendor/hayro/src/renderer.rs index 0619b4c6dd..6aa53716ad 100644 --- a/vendor/hayro/src/renderer.rs +++ b/vendor/hayro/src/renderer.rs @@ -526,14 +526,7 @@ impl Renderer { }, }; - // A miter limit that isn't finite leaves miters unbounded: never skipped. - let miter = stroke.miter_limit.abs(); - let reach = stroke.width / 2.0 - * if miter.is_finite() { - miter.max(std::f64::consts::SQRT_2) - } else { - f64::INFINITY - }; + let reach = stroke_reach(&stroke); self.ctx.set_stroke(stroke); reach } @@ -1142,6 +1135,65 @@ impl Renderer { }; xs = xs.max(min_x_scale).min(max_x_scale); ys = ys.max(min_y_scale).min(max_y_scale); + // PdfCraft patch (20): a step too large for a cell pixmap, where what is + // painted can only show the first cell (PowerPoint writes `/XStep + // 2147480100` for a picture fill drawn once, #3349): the cell's `/BBox` is + // drawn at full scale with a transparent border and padded, not tiled. + // Shrinking the cell to fit the step left a picture under one pixel. + // How far a stroke's paint reaches past the path, miters included, in + // device pixels: the most the transform stretches any direction (a skew + // stretches some further than either axis), times the reach. + let reach = if is_stroke { + path_transform.spectral_norm() * stroke_reach(self.ctx.stroke()) + } else { + 0.0 + }; + if usable_cell_scale(xs, ys) + && (xs * t.x_step.abs() > MAX_PIXMAP_SIZE || ys * t.y_step.abs() > MAX_PIXMAP_SIZE) + && shows_one_cell(&t.matrix, path_transform, path, reach, bbox, t.x_step, t.y_step) + { + const BORDER: f64 = 1.0; + let pix_width = ((bbox.width() as f32 * xs).ceil() as u16).saturating_add(2); + let pix_height = ((bbox.height() as f32 * ys).ceil() as u16).saturating_add(2); + let mut renderer = Self { + ctx: RenderContext::new_with( + pix_width, + pix_height, + derive_settings(self.ctx.render_settings()), + ), + color_replacement: None, + in_annotations: false, + cur_mask: None, + inside_pattern: true, + soft_mask_cache: HashMap::default(), + outline_cache: self.outline_cache.clone(), + in_type3_glyph: false, + scaler: self.scaler, + preview: self.preview, + image_transparency_stack: Vec::new(), + }; + let initial_transform = Affine::translate((BORDER, BORDER)) + * Affine::scale_non_uniform(xs as f64, ys as f64) + * Affine::translate((-bbox.x0, -bbox.y0)); + t.interpret(&mut renderer, initial_transform, is_stroke); + let mut pix = Pixmap::new(pix_width, pix_height); + renderer.ctx.flush(); + let mut resources = vello_cpu::Resources::default(); + renderer.ctx.render_to_pixmap(&mut resources, &mut pix); + paint_transform = path_transform.inverse() * t.matrix * initial_transform.inverse(); + let image = Image { + image: ImageSource::Pixmap(Arc::new(pix)), + sampler: ImageSampler { + x_extend: peniko::Extend::Pad, + y_extend: peniko::Extend::Pad, + quality: ImageQuality::Medium, + alpha: 1.0, + }, + }; + self.ctx.set_paint_transform(paint_transform); + self.ctx.set_paint(PaintType::Image(image)); + return clip_path; + } // PdfCraft patch: the cell pixmap is `step × scale` pixels, which only the // u16 cast below bounded (a fuzzed /XStep far beyond /BBox gave 65535² cells). xs = tiling_cell_scale(xs, t.x_step, MAX_PIXMAP_SIZE); @@ -1894,6 +1946,46 @@ fn convert_blend_mode(blend_mode: BlendMode) -> peniko::BlendMode { peniko::BlendMode::new(mix, Compose::SrcOver) } +/// PdfCraft patch: how far a stroke's paint reaches past its path, in user units: half the width +/// times the larger of the miter limit and √2 (a miter join or a square cap). A miter limit that +/// isn't finite leaves miters unbounded, so the reach is too. +fn stroke_reach(stroke: &kurbo::Stroke) -> f64 { + let miter = stroke.miter_limit.abs(); + stroke.width / 2.0 + * if miter.is_finite() { + miter.max(std::f64::consts::SQRT_2) + } else { + f64::INFINITY + } +} + +/// PdfCraft patch (20): whether a cell's scale can draw it at full size: finite and positive. A +/// `/BBox` too thin to divide by (1e-40 wide passes the zero-area check) gives an infinite scale, +/// which would send non-finite geometry to the cell renderer; such a cell keeps the old path. +pub fn usable_cell_scale(xs: f32, ys: f32) -> bool { + xs.is_finite() && ys.is_finite() && xs > 0.0 && ys > 0.0 +} + +/// PdfCraft patch (20): whether `path` (drawn with `path_transform`, its paint reaching `reach` +/// device pixels past it), mapped into the space of a tiling pattern with matrix `matrix`, can +/// only meet the first cell's `/BBox`: every other cell's content lies a whole step or more +/// away. False when the matrix can't be inverted or the bounds aren't finite. +pub(crate) fn shows_one_cell(matrix: &Affine, path_transform: &Affine, path: &BezPath, reach: f64, bbox: Rect, x_step: f32, y_step: f32) -> bool { + if matrix.determinant().abs() < 1e-12 || !matrix.determinant().is_finite() { + return false; + } + let device = (*path_transform * path.clone()).bounding_box().inflate(reach, reach); + let bounds = (matrix.inverse() * device.to_path(0.1)).bounding_box(); + let (sx, sy) = (f64::from(x_step.abs()), f64::from(y_step.abs())); + let finite = [bounds.x0, bounds.y0, bounds.x1, bounds.y1, sx, sy].iter().all(|v| v.is_finite()); + // Cell k's content spans `bbox + k × step`; with k = ±1 out of reach, so is every other. + finite + && bounds.x1 < bbox.x0 + sx + && bounds.x0 > bbox.x1 - sx + && bounds.y1 < bbox.y0 + sy + && bounds.y0 > bbox.y1 - sy +} + /// PdfCraft patch: the scale for a tiling cell, lowered so that the cell pixmap (`|step| × /// scale` pixels a side) stays within `max_pixels`. Geometry is unchanged (the pattern transform /// is derived from the scale); an over-large step only renders at lower resolution.