From a67f5bb35920b127b00560e0927885ef40a57ef5 Mon Sep 17 00:00:00 2001 From: jonathanwxh-cell Date: Mon, 9 Nov 2026 18:14:07 +0800 Subject: [PATCH 01/64] automation: compare canonical root components exactly and test roots-only escape confinement (#4003) (#4569) --- crates/automation/src/file_access.rs | 50 ++++++++++++--- crates/automation/tests/it/hostile_args.rs | 72 ++++++++++++++++------ 2 files changed, 93 insertions(+), 29 deletions(-) diff --git a/crates/automation/src/file_access.rs b/crates/automation/src/file_access.rs index dfc33b4e7d..9ffdea083a 100644 --- a/crates/automation/src/file_access.rs +++ b/crates/automation/src/file_access.rs @@ -15,8 +15,8 @@ //! - A path on another network share or in a device namespace (`\\host\share`, `\\.\pipe\x`) //! than the root's is refused by name, before anything is resolved: resolving it would contact //! the host. -//! - The result must lie inside the root, compared by whole components (`/work2` isn't inside -//! `/work`), without regard to case on Windows. +//! - The result must lie inside the root, compared by whole canonical components (`/work2` isn't +//! inside `/work`), with the drive letter compared without regard to case on Windows. //! - Read and write access are separate: a root given alone grants only its own kind of access. //! //! Without roots nothing is checked. A path is checked, then opened: a process that can change the @@ -204,22 +204,23 @@ pub(crate) fn follow(shown: &str, path: &Path, root: &Path) -> Toward { if inside(&at, root) { Toward::Inside } else { Toward::Outside } } -/// Is `path` (resolved) `root` or inside it, component by component (without regard to case on -/// Windows, whose file systems ignore it)? +/// Is `path` (resolved) `root` or inside it, component by component (the drive letter without +/// regard to case on Windows)? fn inside(path: &Path, root: &Path) -> bool { let mut path = path.components(); root.components().all(|r| path.next().is_some_and(|p| same_component(p, r))) } fn same_component(a: Component, b: Component) -> bool { + use std::path::Prefix; if a == b { return true; } - if !cfg!(windows) { - return false; - } - match (a.as_os_str().to_str(), b.as_os_str().to_str()) { - (Some(a), Some(b)) => a.to_lowercase() == b.to_lowercase(), + match (a, b) { + (Component::Prefix(a), Component::Prefix(b)) => match (a.kind(), b.kind()) { + (Prefix::Disk(a) | Prefix::VerbatimDisk(a), Prefix::Disk(b) | Prefix::VerbatimDisk(b)) => a.eq_ignore_ascii_case(&b), + _ => false, + }, _ => false, } } @@ -466,6 +467,37 @@ mod tests { let _ = std::fs::remove_dir_all(base); } + /// #4003: canonical path components are compared as the file system gave them, with only the + /// Windows drive letter matched ASCII case-insensitively, so a sibling whose name `to_lowercase` + /// folds onto the root's (`K` U+212A → `k`) or a differently-cased canonical component is not + /// taken as inside the root. + #[test] + fn canonical_components_are_compared_exactly() { + let (base, _) = folders("canonical-exact"); + std::fs::create_dir_all(base.join("ink")).unwrap(); + let kelvin = base.join("in\u{212A}"); + std::fs::create_dir_all(&kelvin).unwrap(); + std::fs::write(kelvin.join("secret.pdf"), "x").unwrap(); + // U+212A folds to K. A case-insensitive volume stores both names as one directory, so + // `check` would be asked to reject a path inside its own root. The component comparison + // itself does not need that volume. + let distinct = base.join("ink").canonicalize().ok() != kelvin.canonicalize().ok(); + if distinct { + let roots = AutomationRoots::new(Some(&base.join("ink")), None).unwrap().unwrap(); + let e = roots.check(&s(kelvin.join("secret.pdf")), Access::Read).unwrap_err(); + assert!(e.starts_with("automation path rejected: outside the read root"), "{e}"); + } + assert!(!inside(Path::new("/work/in\u{212A}/secret.pdf"), Path::new("/work/ink"))); + assert!(!inside(Path::new(r"\\?\C:\work\IN\a.pdf"), Path::new(r"\\?\C:\work\in"))); + #[cfg(windows)] + { + assert!(inside(Path::new(r"\\?\c:\work\in\a.pdf"), Path::new(r"\\?\C:\work\in"))); + assert!(inside(Path::new(r"c:\work\in\a.pdf"), Path::new(r"\\?\C:\work\in"))); + assert!(!inside(Path::new(r"\\?\D:\work\in\a.pdf"), Path::new(r"\\?\C:\work\in"))); + } + let _ = std::fs::remove_dir_all(base); + } + /// Every file a tool reads or writes goes through `Automation::resolve`, which holds it to the /// roots. A function that reaches for the file system without resolving a path first would /// bypass them, so each use of `std::fs` or `File::open` must come after a `resolve` call in diff --git a/crates/automation/tests/it/hostile_args.rs b/crates/automation/tests/it/hostile_args.rs index c017ff78d3..9835231c63 100644 --- a/crates/automation/tests/it/hostile_args.rs +++ b/crates/automation/tests/it/hostile_args.rs @@ -11,9 +11,10 @@ //! //! 1. **Nothing panics.** A panic in a tool is a crashed MCP server or CLI (AGENTS.md §4), so //! every call must come back as `Ok` or as a `ToolError` the agent can read. -//! 2. **Nothing escapes `--root`.** Every path argument is tried with `..` traversal, absolute -//! paths, Windows drive and UNC paths, and a planted symbolic link. The call must fail, and -//! afterwards nothing outside the root may have been created. +//! 2. **Nothing escapes `--root` or the automation roots.** Every path argument is tried with `..` +//! traversal, absolute paths, Windows drive and UNC paths, and a planted symbolic link, under +//! both `--root` and `--automation-read-root`/`--automation-write-root` (#4003). The call must +//! fail, and afterwards nothing outside the root may have been created. //! //! The second is the mechanized form of the D3 audit. Reading the code says every handler resolves //! its own paths and that no tool dispatches another internally; this keeps that true as tools are @@ -22,6 +23,7 @@ use std::panic::{AssertUnwindSafe, catch_unwind}; use std::path::{Path, PathBuf}; +use pdfcraft_automation::file_access::AutomationRoots; use pdfcraft_automation::{Automation, ToolError, tools}; use serde_json::{Value, json}; @@ -84,6 +86,7 @@ fn zoo() -> Vec { /// bug can only touch what the test made. `linked`: the root holds `link-out`, a symbolic link /// to `base`. fn escapes(base: &std::path::Path, linked: bool) -> Vec { + let root = base.join("root"); let mut out: Vec = vec![ "../outside.pdf".into(), "../../outside.pdf".into(), @@ -93,13 +96,16 @@ fn escapes(base: &std::path::Path, linked: bool) -> Vec { base.join("outside.pdf").to_string_lossy().into_owned(), base.join("new-outside.pdf").to_string_lossy().into_owned(), base.join("sub").join("new-outside.pdf").to_string_lossy().into_owned(), + root.join("..").join("outside.pdf").to_string_lossy().into_owned(), ]; if linked { out.push("link-out/outside.pdf".into()); + out.push(root.join("link-out").join("outside.pdf").to_string_lossy().into_owned()); } // Backslashes separate only on Windows; elsewhere this is an ordinary file name in the root. if cfg!(windows) { out.push(r"..\..\outside.pdf".into()); + out.push(r"\\.\pipe\pdfcraft-hostile-escape.pdf".into()); } out } @@ -220,6 +226,20 @@ fn empty_dir(dir: &Path) { } } +/// `action_run` keeps going when one input path is refused and reports that refusal on the file. +/// That is still a confinement refusal: every file has an error naming the boundary, and none +/// has an output. +fn per_file_confinement_refusal(content: &[pdfcraft_automation::Content]) -> bool { + let Some(files) = content.iter().find_map(|c| match c { + pdfcraft_automation::Content::Json(v) => v.get("files").and_then(Value::as_array), + pdfcraft_automation::Content::Png { .. } => None, + }) else { + return false; + }; + !files.is_empty() + && files.iter().all(|file| file.get("output").is_none() && file.get("error").and_then(Value::as_str).is_some_and(|e| e.contains("outside"))) +} + /// Real values for the arguments a tool checks before the others, so a hostile value reaches the /// argument it is meant for instead of failing on a placeholder. `sign_document` checks its /// digital ID first and its document second: this ID opens with its password, and each call gets @@ -234,6 +254,12 @@ fn fixture(a: &mut Automation, root: &Path, tool: &str) -> serde_json::Map { + let args = json!({ "steps": [{ "step": "set_title", "arg": "Confined" }] }); + args.as_object().cloned().unwrap_or_default() + } _ => serde_json::Map::new(), } } @@ -313,28 +339,34 @@ fn no_tool_escapes_the_root() { } }; - let mut a = Automation::new().with_root(&root).expect("root"); - - // A real document, so tools reach their path arguments instead of failing on `doc` first. - let doc = blank_doc(&mut a); + let roots = AutomationRoots::new(Some(&root), Some(&root)).expect("roots"); + let modes = [("--root", Automation::new().with_root(&root).expect("root")), ("automation roots", Automation::new().with_automation_roots(roots))]; let mut leaked = Vec::new(); let mut tried = 0usize; let mut refused = 0usize; - for def in tools() { - for (prop, is_list) in path_props(&def) { - for escape in escapes(&base, linked) { - let mut obj = plausible_with(&def, Some(doc)); - obj.insert(prop.clone(), if is_list { json!([escape]) } else { json!(escape) }); - let args = Value::Object(obj); - tried += 1; - match catch_unwind(AssertUnwindSafe(|| a.call(def.name, &args))) { - // A path that leaves the root must be refused. Succeeding means the tool - // either used it or ignored an argument it declares — both are faults. - Ok(Ok(_)) => leaked.push(format!("{} accepted {prop}={escape:?}", def.name)), - Ok(Err(_)) => refused += 1, - Err(_) => leaked.push(format!("{} panicked on {prop}={escape:?}", def.name)), + for (mode, mut a) in modes { + // A real document, so tools reach their path arguments instead of failing on `doc` first. + let doc = blank_doc(&mut a); + + for def in tools() { + for (prop, is_list) in path_props(&def) { + for escape in escapes(&base, linked) { + let mut obj = plausible_with(&def, Some(doc)); + obj.insert(prop.clone(), if is_list { json!([escape]) } else { json!(escape) }); + let args = Value::Object(obj); + tried += 1; + match catch_unwind(AssertUnwindSafe(|| a.call(def.name, &args))) { + // A path that leaves the root must be refused. Succeeding means the tool + // either used it or ignored an argument it declares — both are faults. + // action_run reports a refused input inside an otherwise successful + // result, one error per file, and writes nothing for that file. + Ok(Ok(content)) if per_file_confinement_refusal(&content) => refused += 1, + Ok(Ok(_)) => leaked.push(format!("{mode}: {} accepted {prop}={escape:?}", def.name)), + Ok(Err(_)) => refused += 1, + Err(_) => leaked.push(format!("{mode}: {} panicked on {prop}={escape:?}", def.name)), + } } } } From 27f5537ae56fc6839a689919966b2b13a5279f5c Mon Sep 17 00:00:00 2001 From: jonathanwxh-cell Date: Mon, 9 Nov 2026 18:14:23 +0800 Subject: [PATCH 02/64] export: order multi-column and turned page items in reading order (#3657) (#4580) Co-authored-by: jonathanwxh-cell --- crates/export/src/as_docx.rs | 7 +- crates/export/src/layout.rs | 129 ++++++++++++++++++++++++++++++++--- crates/export/src/lib.rs | 2 +- crates/export/src/pptx.rs | 15 ++-- crates/export/src/tests.rs | 94 +++++++++++++++++++++++++ 5 files changed, 229 insertions(+), 18 deletions(-) diff --git a/crates/export/src/as_docx.rs b/crates/export/src/as_docx.rs index 5cb008a4a0..20aa4174e7 100644 --- a/crates/export/src/as_docx.rs +++ b/crates/export/src/as_docx.rs @@ -1,6 +1,6 @@ use super::Zip; use super::as_html::{esc, language_tag}; -use super::layout::{Image, Item, Page, Table, body_size, items}; +use super::layout::{Image, Item, Page, Table, body_size, items, reading_order}; /// A colour's 8-bit sRGB components (NaN and out-of-range values clamp; NaN reads as 0). pub(crate) fn rgb8(c: [f64; 3]) -> [u8; 3] { @@ -156,7 +156,10 @@ fn positioned_page<'a>(page: &'a Page, media: &mut Vec<(String, &'a Image)>, sha // page-sized watermark cannot push the invoice footer onto another page. let mut s = String::from(""); let body = body_size(std::slice::from_ref(page)).max(1.0); - for b in &page.blocks { + // Rotated pages are positioned shapes, so paragraph flow never orders them. Emit the + // text boxes in reading order; the file's block order is not the reading order. + let blocks = reading_order(page, page.blocks.iter().map(|b| (b.rect, b.size, b)).collect()); + for b in blocks { *shape_id = shape_id.saturating_add(1); let rotation = layout_number(-b.rotation).rem_euclid(360.0); let (sin, cos) = rotation.to_radians().sin_cos(); diff --git a/crates/export/src/layout.rs b/crates/export/src/layout.rs index a4efa9fc00..d222c61843 100644 --- a/crates/export/src/layout.rs +++ b/crates/export/src/layout.rs @@ -173,6 +173,9 @@ fn continued_from(blocks: &[Block]) -> Vec> { .iter() .enumerate() .map(|(bi, b)| { + if b.rotation.abs() > 0.1 { + return None; + } let k = by_end.partition_point(|(end, _)| *end < b.rect[0] - INLINE_GAP * b.size); by_end .get(k..) @@ -181,7 +184,10 @@ fn continued_from(blocks: &[Block]) -> Vec> { .take_while(|(end, _)| *end <= b.rect[0] + overhang(b.size)) .take(CONTINUATION_PROBE) .filter_map(|(_, ai)| blocks.get(*ai).filter(|_| *ai != bi).map(|a| (*ai, a))) - .filter(|(_, a)| a.rect[3].min(b.rect[3]) - a.rect[1].max(b.rect[1]) > 0.5 * (a.rect[3] - a.rect[1]).min(b.rect[3] - b.rect[1])) + .filter(|(_, a)| { + a.rotation.abs() <= 0.1 + && a.rect[3].min(b.rect[3]) - a.rect[1].max(b.rect[1]) > 0.5 * (a.rect[3] - a.rect[1]).min(b.rect[3] - b.rect[1]) + }) .min_by(|(_, a), (_, c)| (a.rect[2] - b.rect[0]).abs().total_cmp(&(c.rect[2] - b.rect[0]).abs())) .map(|(ai, _)| ai) }) @@ -581,6 +587,114 @@ pub(crate) fn tables(blocks: &[Block], rules: &[[f64; 4]]) -> (Vec, Vec f64 { + let mut by: Vec<(i64, f64, usize)> = Vec::new(); + for b in &page.blocks { + if !b.rotation.is_finite() { + continue; + } + let k = b.rotation.round() as i64; + if let Some(e) = by.iter_mut().find(|e| e.0 == k) { + e.2 = e.2.saturating_add(b.text.len()); + } else { + by.push((k, b.rotation, b.text.len())); + } + } + by.into_iter().max_by_key(|e| e.2).filter(|e| e.1.abs() > 0.1).map_or(0.0, |e| e.1) +} + +/// Transform `rect` (`[x0, y0, x1, y1]`, `y` up) into the reading frame of text turned by +/// `rotation` degrees counterclockwise (`[along0, across0, along1, across1]`). For upright text +/// (`0°`), this is `rect` itself; for text turned 90° clockwise (`-90°`), `across` decreases from +/// right to left so lines read in their natural order (#3657). +fn in_reading_frame(rect: [f64; 4], rotation: f64) -> [f64; 4] { + if rotation.abs() <= 0.1 || !rotation.is_finite() { + return rect; + } + let (sin, cos) = rotation.to_radians().sin_cos(); + let corners = [(rect[0], rect[1]), (rect[2], rect[1]), (rect[0], rect[3]), (rect[2], rect[3])]; + let mut out = [f64::MAX, f64::MAX, f64::MIN, f64::MIN]; + for (x, y) in corners { + let along = x * cos + y * sin; + let across = y * cos - x * sin; + out = [out[0].min(along), out[1].min(across), out[2].max(along), out[3].max(across)]; + } + out +} + +/// Put a page's items (`(rect, line_height, item)`) into reading order (#3657): +/// - coordinates are projected into the page's reading frame so turned text reads from first line +/// to last line rather than left-to-right across vertical lines; +/// - a column continues across a paragraph gap (up to 3.5 line heights below the previous item) +/// before jumping to the top of the next column on its right, unless an unread item sits above +/// the continuation in its own horizontal span, sits to its left, or continues the previous +/// item on the same line. +pub(crate) fn reading_order(page: &Page, parts: Vec<([f64; 4], f64, T)>) -> Vec { + let rot = page_rotation(page); + let mut parts: Vec<([f64; 4], f64, T)> = parts.into_iter().map(|(r, h, item)| (in_reading_frame(r, rot), h.clamp(1.0, 200.0), item)).collect(); + if parts.len() <= 1 { + return parts.into_iter().map(|x| x.2).collect(); + } + const MAX_COLUMN_ORDER: usize = 2000; + if parts.len() > MAX_COLUMN_ORDER { + parts.sort_by(|a, b| b.0[3].total_cmp(&a.0[3]).then(a.0[0].total_cmp(&b.0[0]))); + return parts.into_iter().map(|x| x.2).collect(); + } + let beside_left = |cb: &[f64; 4], lb: &[f64; 4]| { + let v = lb[3].min(cb[3]) - lb[1].max(cb[1]); + lb[2] <= cb[0] + COL_TOL && v > 0.5 * (lb[3] - lb[1]).min(cb[3] - cb[1]) + }; + let inline_after = |pb: &[f64; 4], ph: f64, rb: &[f64; 4]| { + let v = rb[3].min(pb[3]) - rb[1].max(pb[1]); + rb[0] >= pb[2] - 1.0 && rb[0] <= pb[2] + INLINE_GAP * ph && v > 0.5 * (rb[3] - rb[1]).min(pb[3] - pb[1]) + }; + let mut remaining: Vec = (0..parts.len()).collect(); + let mut order: Vec = Vec::with_capacity(parts.len()); + while let Some(&top) = remaining.iter().max_by(|&&a, &&b| parts[a].0[3].total_cmp(&parts[b].0[3]).then(parts[b].0[0].total_cmp(&parts[a].0[0]))) { + let next_in_column = order.last().and_then(|&prev| { + let (pb, ph) = (parts[prev].0, parts[prev].1); + if remaining.iter().any(|&r| inline_after(&pb, ph, &parts[r].0)) { + return None; + } + let tol = ph * 0.5; + let c = remaining + .iter() + .copied() + .filter(|&c| { + let cb = parts[c].0; + let overlap = cb[2].min(pb[2]) - cb[0].max(pb[0]); + let minw = (cb[2] - cb[0]).min(pb[2] - pb[0]).max(1.0); + cb[3] <= pb[1] + tol && pb[1] - cb[3] <= ph * 3.5 && overlap > minw * 0.3 + }) + .max_by(|&a, &b| parts[a].0[3].total_cmp(&parts[b].0[3]).then(parts[b].0[0].total_cmp(&parts[a].0[0])))?; + let cb = parts[c].0; + let blocked = remaining.iter().any(|&r| { + if r == c { + return false; + } + let rb = parts[r].0; + (rb[3] > cb[3] + tol && rb[2].min(cb[2]) > rb[0].max(cb[0])) || beside_left(&cb, &rb) + }); + (!blocked).then_some(c) + }); + let pick = next_in_column.unwrap_or_else(|| { + let tb = parts[top].0; + remaining + .iter() + .copied() + .filter(|&c| beside_left(&tb, &parts[c].0)) + .min_by(|&a, &b| parts[a].0[0].total_cmp(&parts[b].0[0]).then(parts[b].0[3].total_cmp(&parts[a].0[3]))) + .unwrap_or(top) + }); + remaining.retain(|&r| r != pick); + order.push(pick); + } + let mut slots: Vec> = parts.into_iter().map(|(_, _, item)| Some(item)).collect(); + order.into_iter().filter_map(|i| slots.get_mut(i).and_then(Option::take)).collect() +} + pub(crate) fn items(pages: &[Page]) -> Vec> { let body = body_size(pages); let mut out = Vec::new(); @@ -589,21 +703,20 @@ pub(crate) fn items(pages: &[Page]) -> Vec> { out.push(Item::PageBreak); } let (tables, consumed) = tables(&p.blocks, &p.rules); - // Blocks and images by their top edge, top to bottom. // A paragraph sits where its first run does: its other runs follow along its line and the // lines it wraps onto. - let mut parts: Vec<(f64, f64, Item)> = flowing_paragraphs(&p.blocks, &consumed, body) + let mut parts: Vec<([f64; 4], f64, Item)> = flowing_paragraphs(&p.blocks, &consumed, body) .into_iter() .filter_map(|runs| { let first = runs.first()?.block; + let x1 = runs.iter().map(|r| r.block.rect[2]).fold(first.rect[2], f64::max); let lvl = para_level(&runs, body); - Some((first.rect[3], first.rect[0], Item::Para(runs, lvl))) + Some(([first.rect[0], first.rect[1], x1, first.rect[3]], first.size, Item::Para(runs, lvl))) }) .collect(); - parts.extend(tables.into_iter().map(|t| (t.rect[3], t.rect[0], Item::Table(t)))); - parts.extend(p.images.iter().map(|im| (im.rect[3], im.rect[0], Item::Img(im)))); - parts.sort_by(|a, b| b.0.total_cmp(&a.0).then(a.1.total_cmp(&b.1))); - out.extend(parts.into_iter().map(|x| x.2)); + parts.extend(tables.into_iter().map(|t| (t.rect, body, Item::Table(t)))); + parts.extend(p.images.iter().map(|im| (im.rect, (im.rect[3] - im.rect[1]).max(body), Item::Img(im)))); + out.extend(reading_order(p, parts)); } out } diff --git a/crates/export/src/lib.rs b/crates/export/src/lib.rs index 885d415fc2..5f50122f90 100644 --- a/crates/export/src/lib.rs +++ b/crates/export/src/lib.rs @@ -42,7 +42,7 @@ use flow::list_item; pub use layout::{Block, Cell, Image, Page, Table}; #[cfg(test)] use layout::{LOOKUP_STEPS, MAX_COLS, body_size}; -pub(crate) use layout::{Run, flowing_paragraphs, tables}; +pub(crate) use layout::{Run, flowing_paragraphs, reading_order, tables}; #[cfg(test)] mod tests; diff --git a/crates/export/src/pptx.rs b/crates/export/src/pptx.rs index c662f9cb0f..ec33137eae 100644 --- a/crates/export/src/pptx.rs +++ b/crates/export/src/pptx.rs @@ -137,20 +137,21 @@ pub fn pptx(pages: &[Page], title: &str) -> Vec { .max_by(|(_, a), (_, b)| a.size.total_cmp(&b.size)) .map(|(i, _)| i); - let mut items: Vec<(f64, f64, SlideBodyItem)> = Vec::new(); + let mut parts: Vec<([f64; 4], f64, SlideBodyItem)> = Vec::new(); // The title is a shape of its own, so it joins no paragraph. let skip: Vec = (0..p.blocks.len()).map(|bi| Some(bi) == title_idx || consumed.get(bi).copied().unwrap_or(false)).collect(); for runs in crate::flowing_paragraphs(&p.blocks, &skip, body) { let Some(first) = runs.first().map(|r| r.block) else { continue }; - items.push((first.rect[3], first.rect[0], SlideBodyItem::Para(runs))); + let x1 = runs.iter().map(|r| r.block.rect[2]).fold(first.rect[2], f64::max); + parts.push(([first.rect[0], first.rect[1], x1, first.rect[3]], first.size, SlideBodyItem::Para(runs))); } for t in tables { - items.push((t.rect[3], t.rect[0], SlideBodyItem::Table(t))); + parts.push((t.rect, 11.0, SlideBodyItem::Table(t))); } for im in &p.images { - items.push((im.rect[3], im.rect[0], SlideBodyItem::Img(im))); + parts.push((im.rect, (im.rect[3] - im.rect[1]).max(11.0), SlideBodyItem::Img(im))); } - items.sort_by(|a, b| b.0.total_cmp(&a.0).then(a.1.total_cmp(&b.1))); + let items = crate::reading_order(p, parts); let mut current_y = my; let mut shapes = String::new(); @@ -170,12 +171,12 @@ pub fn pptx(pages: &[Page], title: &str) -> Vec { } let mut i = 0; - while let Some((_, _, item)) = items.get(i) { + while let Some(item) = items.get(i) { match item { SlideBodyItem::Para(_) => { let mut paras_xml = String::new(); let mut est_h_pt = 0.0; - while let Some((_, _, SlideBodyItem::Para(runs))) = items.get(i) { + while let Some(SlideBodyItem::Para(runs)) = items.get(i) { paras_xml.push_str(""); for r in runs { paras_xml.push_str(&run_drawingml(&r.text, r.block.size, r.block.bold, r.block.italic, r.block.color)); diff --git a/crates/export/src/tests.rs b/crates/export/src/tests.rs index cb520d002e..eeece99949 100644 --- a/crates/export/src/tests.rs +++ b/crates/export/src/tests.rs @@ -922,6 +922,100 @@ fn markdown_escapes_markup_and_keeps_emphasis_and_tables() { assert!(rows.iter().all(|r| r.matches(" | ").count() == 2), "every row has the header's three cells: {md}"); } +/// #3657: two-column text finishes the left column before starting the right column across +/// Word, HTML, RTF, Markdown and PowerPoint, and text turned 90° exports in reading order rather +/// than last line first. +#[test] +fn two_column_and_turned_text_export_in_reading_order() { + let para = |t: &str, x: f64, y0: f64, y1: f64, size: f64| Block { + text: t.into(), + rect: [x, y0, x + 130.0, y1], + size, + bold: false, + italic: false, + color: [0.0; 3], + rotation: 0.0, + space_around: [false; 2], + }; + let columns = Page { + width: 595.0, + height: 842.0, + blocks: vec![ + para("Two columns", 50.0, 790.0, 808.0, 18.0), + para("A1 first paragraph A2 first paragraph A3 first paragraph", 50.0, 720.0, 762.0, 12.0), + para("B1 second paragraph B2 second paragraph", 50.0, 675.0, 702.0, 12.0), + para("B3 second paragraph B4 second paragraph", 320.0, 735.0, 762.0, 12.0), + para("C1 third paragraph C2 third paragraph", 320.0, 695.0, 722.0, 12.0), + ], + images: Vec::new(), + rules: Vec::new(), + background: None, + }; + let expected_cols = ["Two columns", "A1 first paragraph", "B1 second paragraph", "B3 second paragraph", "C1 third paragraph"]; + for (fmt, out) in [ + ("html", html(std::slice::from_ref(&columns), "Columns")), + ("docx", part(&docx(std::slice::from_ref(&columns), "Columns"), "word/document.xml")), + ("rtf", rtf(std::slice::from_ref(&columns))), + ("md", markdown(std::slice::from_ref(&columns))), + ("pptx", part(&pptx(std::slice::from_ref(&columns), "Columns"), "ppt/slides/slide1.xml")), + ] { + let positions: Vec = expected_cols.iter().map(|s| out.find(s).unwrap_or_else(|| panic!("{s} missing in {fmt}: {out}"))).collect(); + assert!(positions.windows(2).all(|w| w[0] < w[1]), "{fmt} out of order: {positions:?}\n{out}"); + } + + // Text turned 90 degrees clockwise (rotation = -90°): lines run top to bottom (y = 540 + // down to 468) and follow each other right to left (x = 676, 654.4, 632.8, 611.2). + let turned_line = |t: &str, x: f64, size: f64| Block { + text: t.into(), + rect: [x - size, 468.0, x, 540.0], + size, + bold: false, + italic: false, + color: [0.0; 3], + rotation: -90.0, + space_around: [false; 2], + }; + let rotated = Page { + width: 792.0, + height: 612.0, + blocks: vec![ + turned_line("Line one", 676.0, 20.0), + turned_line("Line two", 654.4, 18.0), + turned_line("Line three", 632.8, 18.0), + turned_line("Line four", 611.2, 18.0), + ], + images: Vec::new(), + rules: Vec::new(), + background: None, + }; + let expected_lines = ["Line one", "Line two", "Line three", "Line four"]; + for (fmt, out) in [ + ("html", html(std::slice::from_ref(&rotated), "Rotated")), + ("docx", part(&docx(std::slice::from_ref(&rotated), "Rotated"), "word/document.xml")), + ("rtf", rtf(std::slice::from_ref(&rotated))), + ("md", markdown(std::slice::from_ref(&rotated))), + ("pptx", part(&pptx(std::slice::from_ref(&rotated), "Rotated"), "ppt/slides/slide1.xml")), + ] { + let positions: Vec = expected_lines.iter().map(|s| out.find(s).unwrap_or_else(|| panic!("{s} missing in {fmt}: {out}"))).collect(); + assert!(positions.windows(2).all(|w| w[0] < w[1]), "{fmt} out of order: {positions:?}\n{out}"); + } + + // The same lines, stored bottom-to-top. Rotated lines do not join one paragraph, and + // Word's positioned boxes must follow reading order rather than the file's block order. + let mut shuffled = rotated.clone(); + shuffled.blocks.reverse(); + for (fmt, out) in [ + ("html", html(std::slice::from_ref(&shuffled), "Rotated")), + ("docx", part(&docx(std::slice::from_ref(&shuffled), "Rotated"), "word/document.xml")), + ("rtf", rtf(std::slice::from_ref(&shuffled))), + ("md", markdown(std::slice::from_ref(&shuffled))), + ("pptx", part(&pptx(std::slice::from_ref(&shuffled), "Rotated"), "ppt/slides/slide1.xml")), + ] { + let positions: Vec = expected_lines.iter().map(|s| out.find(s).unwrap_or_else(|| panic!("{s} missing in {fmt}: {out}"))).collect(); + assert!(positions.windows(2).all(|w| w[0] < w[1]), "{fmt} shuffled out of order: {positions:?}\n{out}"); + } +} + /// #5149: two columns of two rows are a table when ruled lines draw its grid, and the docx keeps /// its borders; the same text without rules (or with rules that only underline it) stays paragraphs. #[test] From 5276757b5768f1bc7ff80d1f8cb72e2f30d91889 Mon Sep 17 00:00:00 2001 From: mtlcl410 <2368091+mtlcl410@users.noreply.github.com> Date: Mon, 9 Nov 2026 18:14:39 +0800 Subject: [PATCH 03/64] M12: a page break splits an empty XFA text field that grows with its text (form.xfa-dynamic) (#5525) --- crates/xfa/src/layout.rs | 27 ++ crates/xfa/src/layout/common.rs | 55 ++- crates/xfa/src/layout/field.rs | 2 + crates/xfa/src/layout/flow.rs | 176 +++++++++- crates/xfa/src/layout/pages.rs | 16 +- crates/xfa/src/model.rs | 10 + crates/xfa/src/parse.rs | 1 + crates/xfa/src/tests.rs | 583 ++++++++++++++++++++++++++++++++ 8 files changed, 852 insertions(+), 18 deletions(-) diff --git a/crates/xfa/src/layout.rs b/crates/xfa/src/layout.rs index ed61ee6904..e98ca1f2df 100644 --- a/crates/xfa/src/layout.rs +++ b/crates/xfa/src/layout.rs @@ -125,6 +125,10 @@ struct Frame { /// the area's bottom (a table row taller than an area, an overflow leader and the row after it /// that don't fit together). The border is left out ([`Layouter::finish_frame`]). lost: bool, + /// A piece of a field being split sits in the part on this content area, whether or not it + /// drew anything there (a widget with no border or fill draws nothing below the break): the + /// part counts as holding something, as one that got items does. + held: bool, } struct Cursor { @@ -136,6 +140,15 @@ struct Cursor { placed: bool, } +/// An empty field a page break is splitting ([`Layouter::splitting`]): its widget's SOM path, its +/// box where it was drawn, and its pieces so far (page index, the page's first item of the piece, +/// the piece's box). +struct Splitting { + som: String, + rect: Rect, + pieces: Vec<(usize, usize, Rect)>, +} + struct Layouter<'t> { tpl: &'t Template, data: Option<&'t DataNode>, @@ -163,6 +176,10 @@ struct Layouter<'t> { /// The fields and draws of the block being placed: (first item, one past the last, top, /// bottom) on the last page. A page break keeps each whole (see `split_block`). leaves: Vec<(usize, usize, f64, f64)>, + /// The empty field a page break is splitting (see `flow_block` and `split_block`). + splitting: Option, + /// The items `draw_field` drew for the border of the field drawn last: (first, one past the last). + field_border: Option<(usize, usize)>, /// The flowed subforms being laid out, outermost first: a break between their children /// closes their parts on one content area and opens new ones on the next. frames: Vec, @@ -211,6 +228,8 @@ pub fn layout(tpl: &Template, data: Option<&DataNode>) -> Result measures: Cell::new(0), drawn_chars: Cell::new(0), leaves: Vec::new(), + splitting: None, + field_border: None, frames: Vec::new(), }; let ctx = Ctx { font: Font::default(), para: Para::default(), som: String::new() }; @@ -225,6 +244,8 @@ pub fn layout(tpl: &Template, data: Option<&DataNode>) -> Result for p in &mut l.pages { fill_page_count(&mut p.items, &count); } + #[cfg(test)] + LAID_OUT_ITEMS.with(|c| c.set(l.items)); Ok(Form { pages: l.pages, fields: l.fields, warnings: l.warnings }) } @@ -244,3 +265,9 @@ fn fill_page_count(items: &mut [Item], count: &str) { thread_local! { pub(crate) static EMBED_COPIED: std::cell::Cell = const { std::cell::Cell::new(0) }; } + +// The item count the last layout on this thread ended with, against `MAX_ITEMS`. +#[cfg(test)] +thread_local! { + pub(crate) static LAID_OUT_ITEMS: std::cell::Cell = const { std::cell::Cell::new(0) }; +} diff --git a/crates/xfa/src/layout/common.rs b/crates/xfa/src/layout/common.rs index a213ba1172..83fd395d40 100644 --- a/crates/xfa/src/layout/common.rs +++ b/crates/xfa/src/layout/common.rs @@ -1,4 +1,4 @@ -use super::items::{Action, Item, Rounded}; +use super::items::{Action, BorderShape, Item, Rounded, Widget, WidgetBorder}; use super::{DataNode, DataPath, HashMap, Layout, Node, Rect, Script, Template, Ui, data_path, date_letters, iso_to_pattern}; use crate::data::{data_date_letters, data_to_iso, fields_with_ids}; @@ -143,6 +143,59 @@ pub(super) fn divide(item: Item, cut: f64) -> (Option, Option) { } } +/// How much of a widget a page break must leave above it for the field to split there: a line of +/// its text. +pub(super) fn split_line(w: &Widget) -> f64 { + (w.face.size * 1.2).max(1.0) +} + +/// The rest of an empty field's widget below a page break: its box and the look of its border. +pub(super) type WidgetRest = (Rect, WidgetBorder); + +/// An empty field's widget cut at `cut`: the widget down to the cut, and the rest of its box below +/// it to go on in the following content areas ([`widget_box`] draws each piece of it). A widget the +/// cut doesn't cross stays whole on its side, as does one with no room above the cut for a line of +/// its text: the second item is the widget when it all goes below. +pub(super) fn split_widget(item: Item, cut: f64) -> (Option, Option, Option) { + let Item::Widget(mut w) = item else { return (Some(item), None, None) }; + let line = split_line(&w); + if w.rect.bottom() <= cut + 0.01 { + return (Some(Item::Widget(w)), None, None); + } + if w.rect.y + line > cut { + return (None, Some(Item::Widget(w)), None); + } + let rest = Rect::new(w.rect.x, cut, w.rect.w, w.rect.bottom() - cut); + w.rect.h = cut - w.rect.y; + let border = w.border.clone(); + (Some(Item::Widget(w)), None, Some((rest, border))) +} + +/// One piece of a split widget's box at `rect`: its fill, and its border closed on every side (a +/// border's default `break="close"`, XFA 3.3 pp. 629–630). +pub(super) fn widget_box(rect: Rect, border: &WidgetBorder) -> Vec { + let mut items = Vec::new(); + if let Some(color) = border.fill { + items.push(Item::Fill { rect, color }); + } + if let Some(color) = border.color { + let width = border.width.max(0.0); + let i = width / 2.0; + let (x0, y0, x1, y1) = (rect.x + i, rect.y + i, rect.right() - i, rect.bottom() - i); + // The border's corners in drawing order: each edge joins two in a row. + let corners: &[(f64, f64)] = match border.shape { + BorderShape::Full => &[(x0, y0), (x1, y0), (x1, y1), (x0, y1), (x0, y0)], + BorderShape::Underline => &[(x0, y1), (x1, y1)], + BorderShape::None => &[], + }; + items.extend(corners.windows(2).filter_map(|edge| match *edge { + [from, to] => Some(Item::Line { from, to, width, color, dashed: false }), + _ => None, + })); + } + items +} + /// `item` moved by (`dx`, `dy`). pub(super) fn moved(mut item: Item, dx: f64, dy: f64) -> Item { match &mut item { diff --git a/crates/xfa/src/layout/field.rs b/crates/xfa/src/layout/field.rs index fd83d88f26..8218affe2c 100644 --- a/crates/xfa/src/layout/field.rs +++ b/crates/xfa/src/layout/field.rs @@ -20,7 +20,9 @@ impl Layouter<'_> { if let Some(b) = &f.common.border && f.ui != Ui::Button { + let start = self.item_count(); self.draw_border(b, rect)?; + self.field_border = Some((start, self.item_count())); } let fctx = ctx.child(&f.common); let inner = rect.inset(&f.common.margin); diff --git a/crates/xfa/src/layout/flow.rs b/crates/xfa/src/layout/flow.rs index b65e72477a..7fadabf53b 100644 --- a/crates/xfa/src/layout/flow.rs +++ b/crates/xfa/src/layout/flow.rs @@ -1,20 +1,37 @@ -use super::common::{clamp_opt, divide, is_row, moved, solid_extent, splittable}; +use super::common::{WidgetRest, clamp_opt, divide, is_row, moved, solid_extent, split_line, split_widget, splittable, widget_box}; use super::items::Item; -use super::{Ctx, Frame, Layout, Layouter, MAX_DEPTH, MAX_MEASURES, Node, Rect, SomIndices, Subform, XfaError, data_path, instance_count}; +use super::{Ctx, Frame, Layout, Layouter, MAX_DEPTH, MAX_MEASURES, Node, Rect, SomIndices, Splitting, Subform, XfaError, data_path, instance_count}; impl Layouter<'_> { /// Place `node` as one block of size (`w`, `h`) at the cursor: in this content area when it fits, /// in the next when it fits there, and across as many as it takes when it is taller than a /// content area (a positioned or fixed-height subform holding more than a page). - fn flow_block(&mut self, node: &Node, x: f64, (w, h): (f64, f64), ctx: &Ctx, depth: usize, sib: usize) -> Result<(), XfaError> { + fn flow_block(&mut self, node: &Node, x: f64, size: (f64, f64), ctx: &Ctx, depth: usize, sib: usize) -> Result<(), XfaError> { + self.place_block(node, x, size, ctx, depth, sib, true) + } + + /// [`Self::flow_block`]; `may_split` is false when a field that might have been split turned + /// out not to be (see below), to place it as any other block. + #[allow(clippy::too_many_arguments)] + fn place_block(&mut self, node: &Node, x: f64, (w, h): (f64, f64), ctx: &Ctx, depth: usize, sib: usize, may_split: bool) -> Result<(), XfaError> { let (rect, y, _) = self.cursor(); - if y + h > rect.bottom() + 0.01 && h <= rect.h + 0.01 { + // A field a page break may split starts here, and what doesn't fit goes on in the next + // content area (XFA 3.3 pp. 291–292), rather than the whole field moving there. + let splits = may_split && matches!(node, Node::Field(f) if f.splits_at_page_break()); + if !splits && y + h > rect.bottom() + 0.01 && h <= rect.h + 0.01 { self.make_room(h, 0.0)?; } let (rect, y, _) = self.cursor(); let first = self.pages.last().map_or(0, |p| p.items.len()); + // What drawing it changes besides the page, to undo if it then isn't split: only a field that + // doesn't fit here can turn out so. The item count goes back with the items taken off the + // page; the measurement budget doesn't, as that work was done. (A text field draws no + // picture, so the picture budget is untouched.) + let undo = (splits && y + h > rect.bottom() + 0.01).then(|| (self.fields, self.names.clone(), self.items)); self.leaves.clear(); + self.field_border = None; self.draw_node(node, Rect::new(x, y, w, h), ctx, depth, sib)?; + let border_items = self.field_border.take(); let page_h = self.pages.last().map_or(f64::INFINITY, |p| p.height); // What was drawn, when it is lower than the block's own height says (content that // outgrows a fixed height) and ends off the page. @@ -32,7 +49,59 @@ impl Layouter<'_> { self.place(h); return Ok(()); } - self.split_block(first, y, drawn) + let Some(som) = (if splits { self.splittable_widget_since(first, rect.bottom() - self.frames_bottom()) } else { None }) else { + if let Some((fields, names, items)) = undo { + // Not split after all (a value, or no room for a line of it here): undone, and + // placed as before, moving to the next content area whole. + if let Some(p) = self.pages.last_mut() { + p.items.truncate(first); + } + self.fields = fields; + self.names = names; + self.items = items; + self.leaves.clear(); + return self.place_block(node, x, (w, h), ctx, depth, sib, false); + } + return self.split_block(first, y, drawn); + }; + // The field breaks into pieces, each with the field's border all round (a border's + // default `break="close"`, XFA 3.3 pp. 629–630): the border drawn round the whole field + // comes out, to be drawn round each piece once the break is known. + self.leaves.clear(); + let border = match (node, border_items) { + (Node::Field(f), Some((start, end))) if first <= start => { + if let Some(p) = self.pages.last_mut() + && let Some(drawn) = p.items.get(start..end) + && !drawn.is_empty() + { + p.items.drain(start..end); + } + f.common.border.as_ref() + } + _ => None, + }; + self.splitting = Some(Splitting { som, rect: Rect::new(x, y, w, h), pieces: Vec::new() }); + let split = self.split_block(first, y, drawn); + let pieces = self.splitting.take().map(|s| s.pieces).unwrap_or_default(); + split?; + if let Some(b) = border { + // The last piece first: a piece's place on its page stays where it was found. + for (page, at, piece) in pieces.into_iter().rev() { + self.insert_border(b, piece, page, at)?; + } + } + Ok(()) + } + + /// The SOM path of the empty widget drawn on the last page since item `first`, when the page + /// break at `bottom` leaves room above it for a line of its text: only then is the field split. + /// A value can't run on from one widget into the next piece, and a field with no line on this + /// page moves on whole. + fn splittable_widget_since(&self, first: usize, bottom: f64) -> Option { + self.pages.last()?.items.get(first..)?.iter().find_map(|item| match item { + Item::Widget(w) => (w.value.as_deref().is_none_or(|v| v.trim().is_empty()) && w.rect.y + split_line(w) <= bottom).then(|| w.som.clone()), + _ => None, + }) } /// Remember that the items the last page got since `start` are one field or draw at @@ -68,12 +137,19 @@ impl Layouter<'_> { slots.fill(Some((top, bottom))); } } + // The empty field being split: its widget is no solid item, so the break may go through it. + let (som, field) = match &self.splitting { + Some(s) => (Some(s.som.clone()), Some(s.rect)), + None => (None, None), + }; + let loose = |item: &Item| matches!((item, &som), (Item::Widget(w), Some(som)) if w.som == *som); let mut rest: Vec<(Item, Option<(f64, f64)>)> = items.into_iter().zip(extents).collect(); let origin_x = self.cursor().0.x; // An item's place on the current page is its drawn place moved by (dx, -shift). let (mut dx, mut shift) = (0.0, 0.0); let mut seg_top = top; let mut first_segment = true; + let mut widget_rest: Option = None; loop { // Each pass rescans every item still to be placed. A block split across very many // content areas, and so across very many pages, would do this once per page, costing @@ -84,7 +160,24 @@ impl Layouter<'_> { return Err(XfaError::TooLarge("a subform is split across too many content areas to lay out".into())); } let (rect, _, placed) = self.cursor(); - let limit = rect.bottom() + shift; + // A field being split leaves room for the bottom margins of the flowed subforms round it. + let margins = if field.is_some() { self.frames_bottom() } else { 0.0 }; + let mut limit = rect.bottom() - margins + shift; + if field.is_some() && !first_segment && limit <= seg_top + 0.01 { + // The subforms' margins leave nothing of this content area (a smaller one than + // the field started in): breaking on would leave the same nothing in each like it. + // The field goes on past their bottom margins, and from the top of the area when + // their top margins took all of it, and they, which can't hold it here, are drawn + // without their border, as round anything that runs out of them. + limit = rect.bottom() + shift; + if limit <= seg_top + 0.01 { + shift = seg_top - rect.y; + limit = rect.bottom() + shift; + } + for f in &mut self.frames { + f.lost = true; + } + } let mut cut = limit; if rect.h < 1.0 { // No room to split into: leave the rest here. @@ -93,8 +186,12 @@ impl Layouter<'_> { // The lowest break at or above the bottom that crosses no solid item: going // through them from the lowest top up, a break moved to an item's top can't // cross one already passed. - let mut solid: Vec<(f64, f64)> = - rest.iter().filter_map(|(item, kept)| kept.or_else(|| solid_extent(item))).filter(|e| e.0 < limit - 0.01).collect(); + let mut solid: Vec<(f64, f64)> = rest + .iter() + .filter(|(item, _)| !loose(item)) + .filter_map(|(item, kept)| kept.or_else(|| solid_extent(item))) + .filter(|e| e.0 < limit - 0.01) + .collect(); solid.sort_by(|a, b| b.0.total_cmp(&a.0)); for (t, b) in solid { if t < cut - 0.01 && b > cut + 0.01 { @@ -109,7 +206,28 @@ impl Layouter<'_> { } let mut here = Vec::new(); let mut below = Vec::new(); + // The rest of the split widget's box: a piece down to this break, closed on every + // side, and what is left of it goes on. + if let Some((r, border)) = widget_rest.take() { + let bottom = r.bottom().min(cut); + if bottom - r.y > 0.01 { + here.extend(widget_box(Rect::new(r.x, r.y, r.w, bottom - r.y), &border)); + } + if r.bottom() > cut + 0.01 { + let y = cut.max(r.y); + widget_rest = Some((Rect::new(r.x, y, r.w, r.bottom() - y), border)); + } + } for (item, kept) in rest { + if loose(&item) { + let (upper, whole, rest_box) = split_widget(item, cut); + here.extend(upper); + below.extend(whole.map(|it| (it, None))); + if rest_box.is_some() { + widget_rest = rest_box; + } + continue; + } if let Some((top, _)) = kept { if top < cut - 0.01 { here.push(item) @@ -128,10 +246,28 @@ impl Layouter<'_> { (None, None) => {} } } + // The piece of the field being split on this page, where its border goes. + if let Some(f) = field { + let (from, to) = (f.y.max(seg_top), f.bottom().min(cut)); + let page = self.pages.len().saturating_sub(1); + let at = self.pages.last().map_or(0, |p| p.items.len()); + if to - from > 0.01 + && let Some(s) = &mut self.splitting + { + s.pieces.push((page, at, Rect::new(f.x + dx, from - shift, f.w, to - from))); + // The flowed subforms round it hold this piece here, even if it draws nothing. + let here: Vec = self.frames.iter().map(|fr| self.on_area(&fr.part)).collect(); + for (fr, here) in self.frames.iter_mut().zip(here) { + fr.held |= here; + } + } + } if let Some(p) = self.pages.last_mut() { p.items.extend(here.into_iter().map(|it| moved(it, dx, -shift))); } - if below.is_empty() { + // A field being split goes on to its own bottom, even when what is left of it draws + // nothing (no fill, no border). + if below.is_empty() && widget_rest.is_none() && !field.is_some_and(|f| f.bottom() > cut + 0.01) { if let Some(c) = &mut self.cur { c.y = (end - shift).min(c.rect.bottom()).max(c.y); c.placed = true; @@ -142,9 +278,23 @@ impl Layouter<'_> { rest = below; seg_top = cut; first_segment = false; - self.next_area()?; - let (rect, _, _) = self.cursor(); - shift = cut - rect.y; + let (rect, y) = if field.is_some() { + // A field breaks the flowed subforms round it as a field moving on whole does: + // each closes its part at the break and opens a new one, after its top margin, + // in the next content area. + if let Some(c) = &mut self.cur { + c.y = (cut - shift).min(c.rect.bottom()).max(c.y); + c.placed = true; + } + self.break_frames(0.0, false)?; + let (rect, y, _) = self.cursor(); + (rect, y) + } else { + self.next_area()?; + let (rect, _, _) = self.cursor(); + (rect, rect.y) + }; + shift = cut - y; dx = rect.x - origin_x; } } @@ -187,7 +337,7 @@ impl Layouter<'_> { // Its border and fill go round what it takes from here, a part on each content area. let framed = match self.flow_mark() { Some(part) => { - self.frames.push(Frame { x, w, margin: m, border: sf.common.border.clone(), part, parts: Vec::new(), lost: false }); + self.frames.push(Frame { x, w, margin: m, border: sf.common.border.clone(), part, parts: Vec::new(), lost: false, held: false }); true } None => false, diff --git a/crates/xfa/src/layout/pages.rs b/crates/xfa/src/layout/pages.rs index 0f93eb07f5..940c76d246 100644 --- a/crates/xfa/src/layout/pages.rs +++ b/crates/xfa/src/layout/pages.rs @@ -165,7 +165,7 @@ impl Layouter<'_> { } /// Whether the flow is still in the content area `m` was taken in. - fn on_area(&self, m: &FlowMark) -> bool { + pub(super) fn on_area(&self, m: &FlowMark) -> bool { self.pages.len() == m.page && self.cur.as_ref().is_some_and(|c| c.area == m.area && c.content == m.content) } @@ -186,7 +186,7 @@ impl Layouter<'_> { let (rect, mut y, _) = self.cursor(); let page = self.pages.len().saturating_sub(1); let here: Vec = self.frames.iter().map(|f| self.on_area(&f.part)).collect(); - let filled: Vec = self.frames.iter().map(|f| self.has_items_from(page, f.part.items)).collect(); + let filled: Vec = self.frames.iter().map(|f| f.held || self.has_items_from(page, f.part.items)).collect(); for ((f, here), filled) in self.frames.iter_mut().zip(here).zip(filled).rev() { y = (y + f.margin.bottom).min(rect.bottom()); if !here { @@ -206,6 +206,7 @@ impl Layouter<'_> { let Some(mark) = self.flow_mark() else { break }; let Some(f) = self.frames.get_mut(i) else { break }; f.part = mark; + f.held = false; // `max` before `min`: a NaN margin counts as none and leaves `room` a number. let top = f.margin.top.max(0.0).min(room); room = (room - top).max(0.0); @@ -214,6 +215,13 @@ impl Layouter<'_> { Ok(()) } + /// The bottom margins of the flowed subforms being laid out, which a break leaves below their + /// content ([`Self::break_frames`]). + pub(super) fn frames_bottom(&self) -> f64 { + // `max` first: a NaN margin counts as none. + self.frames.iter().map(|f| f.margin.bottom.max(0.0)).sum() + } + /// Close flowed subform `f` once its content is placed: its last part ends where the flow /// is, and its border (edges and fill) is drawn round each part, closed on every side, under /// that part's content. One holding something that didn't fit its content area ([`Frame::lost`]) @@ -230,7 +238,7 @@ impl Layouter<'_> { let page = self.pages.len().saturating_sub(1); if !self.on_area(&f.part) { f.lost = true; - } else if self.has_items_from(page, f.part.items) && end - f.part.y >= 0.01 { + } else if (f.held || self.has_items_from(page, f.part.items)) && end - f.part.y >= 0.01 { f.parts.push((page, f.part.items, Rect::new(f.x, f.part.y, f.w, end - f.part.y))); } if f.lost { @@ -246,7 +254,7 @@ impl Layouter<'_> { /// Draw border `b` round `rect` on page `page` (an index), under what that page got from item /// `at` on. - fn insert_border(&mut self, b: &Border, rect: Rect, page: usize, at: usize) -> Result<(), XfaError> { + pub(super) fn insert_border(&mut self, b: &Border, rect: Rect, page: usize, at: usize) -> Result<(), XfaError> { let before = self.pages.last().map_or(0, |p| p.items.len()); self.draw_border(b, rect)?; let drawn: Vec = match self.pages.last_mut() { diff --git a/crates/xfa/src/model.rs b/crates/xfa/src/model.rs index 5e085113e0..51fca2f8eb 100644 --- a/crates/xfa/src/model.rs +++ b/crates/xfa/src/model.rs @@ -562,6 +562,9 @@ pub struct Field { /// The display picture clause (``), e.g. `date{YYYYMMDD}`: how the value /// shows when not being edited, and what scripts read as `formattedValue`. pub display_picture: Option, + /// `` or `"pageArea"`: a page break doesn't split the field (XFA 3.3 + /// p. 292). A field's default is `none`. + pub keep_intact: bool, } impl Field { @@ -570,6 +573,13 @@ impl Field { self.ui == Ui::TextEdit && self.multiline && self.common.h.is_none() } + /// Whether a page break may split the field (XFA 3.3 pp. 291–292): text splits between its + /// lines, and a field's `keep intact` is `none` unless it says otherwise. Only a multi-line + /// text field that grows with its text is split. + pub(crate) fn splits_at_page_break(&self) -> bool { + self.grows_with_text() && !self.keep_intact + } + /// A choice list's value as saved to the data: an item's shown text becomes its saved /// value (an editable list keeps typed text); several values stay on separate lines. /// Other fields keep `v`. diff --git a/crates/xfa/src/parse.rs b/crates/xfa/src/parse.rs index 620cc49e7c..cec433f469 100644 --- a/crates/xfa/src/parse.rs +++ b/crates/xfa/src/parse.rs @@ -564,6 +564,7 @@ fn parse_field(n: XmlNode, warnings: &mut Vec) -> Field { validate_message, numeric, display_picture: child(n, "format").and_then(|f| child(f, "picture")).map(text_of).map(|p| p.trim().to_string()).filter(|p| !p.is_empty()), + keep_intact: child(n, "keep").and_then(|k| k.attribute("intact")).is_some_and(|v| v == "contentArea" || v == "pageArea"), } } diff --git a/crates/xfa/src/tests.rs b/crates/xfa/src/tests.rs index b7725f0f13..632f6a3f0d 100644 --- a/crates/xfa/src/tests.rs +++ b/crates/xfa/src/tests.rs @@ -4894,6 +4894,589 @@ fn a_container_is_as_wide_as_a_turned_draw_stands() { } } +/// A page break splits an empty multi-line field that grows with its text (XFA 3.3 pp. 291–292: +/// text splits between lines, and a field's `keep intact` is `none`): it starts where it is, its +/// widget runs to the bottom of the content area, and the rest of its box, closed on every side, +/// goes on at the top of the next. Kept intact, holding a value or on one line, the field moves +/// to the next content area whole, as before. +#[test] +fn an_empty_growing_field_is_split_by_a_page_break() { + let form = |field_extra: &str, multi: &str| { + let body = format!( + r#"above +{field_extra} + + +"# + ); + layout_xml(&one_area_form(r#"layout="tb""#, &body)).unwrap() + }; + let notes = |page: &Page| widgets(page).into_iter().find(|w| w.name == "notes").map(|w| w.rect); + // The content area runs from 36 to 756; the field starts at 36 + 576 = 612 and is 216 tall. + let split = form("", r#"multiLine="1""#); + assert_eq!(split.pages.len(), 2); + let first = notes(&split.pages[0]).expect("the widget starts on the first page"); + assert!((first.y - 648.0).abs() < 0.5 && (first.bottom() - 756.0).abs() < 0.5, "down to the bottom of the area: {first:?}"); + assert!(notes(&split.pages[1]).is_none(), "one widget"); + let edges: Vec<((f64, f64), (f64, f64))> = + split.pages[1].items.iter().filter_map(|i| if let Item::Line { from, to, .. } = i { Some((*from, *to)) } else { None }).collect(); + assert_eq!(edges.len(), 4, "the rest of the box, closed: {edges:?}"); + let (top, bottom) = edges.iter().flat_map(|(a, b)| [a.1, b.1]).fold((f64::INFINITY, 0.0_f64), |(t, b), y| (t.min(y), b.max(y))); + assert!((top - 36.0).abs() < 1.0 && (bottom - 108.0).abs() < 1.0, "at the top of the next area, 72 tall: {top}..{bottom}"); + for (extra, multi, why) in [ + (r#""#, r#"multiLine="1""#, "kept intact"), + (r#"typed"#, r#"multiLine="1""#, "holding a value"), + ("", "", "on one line"), + ] { + let whole = form(extra, multi); + assert_eq!(whole.pages.len(), 2, "{why}"); + assert!(notes(&whole.pages[0]).is_none(), "{why}: nothing of it on the first page"); + let moved = notes(&whole.pages[1]).unwrap_or_else(|| panic!("{why}: the field is on the second page")); + assert!((moved.y - 72.0).abs() < 0.5, "{why}: whole, under its caption at the top: {moved:?}"); + } +} + +/// A field split by a page break has its own border drawn round each piece (a border's default +/// `break="close"`, XFA 3.3 pp. 629–630): an edge at the bottom of the first page and one at the +/// top of the next. One whose widget would have no room above the break for a line of its text +/// moves to the next page whole, its caption and border with it. +#[test] +fn a_split_field_closes_its_border_on_each_piece_and_a_sliver_moves_whole() { + let form = |filler: &str| { + let body = format!( + r#"above + + + + +"# + ); + layout_xml(&one_area_form(r#"layout="tb""#, &body)).unwrap() + }; + // Level lines across the field (its border's top and bottom) on `page`, top to bottom. + let levels = |page: &Page| -> Vec { + let mut ys: Vec = page + .items + .iter() + .filter_map(|i| match i { + Item::Line { from, to, .. } if (from.1 - to.1).abs() < 0.01 && (to.0 - from.0).abs() > 500.0 => Some(from.1), + _ => None, + }) + .collect(); + ys.sort_by(f64::total_cmp); + ys + }; + // The field starts at 612, with 144 left in the area (down to 756). + let split = form("8in"); + assert_eq!(split.pages.len(), 2); + let (first, next) = (levels(&split.pages[0]), levels(&split.pages[1])); + let near = |got: &[f64], want: &[f64]| got.len() == want.len() && got.iter().zip(want).all(|(g, w)| (g - w).abs() < 1.0); + assert!(near(&first, &[612.0, 756.0]), "first piece closed, once: {first:?}"); + assert!(near(&next, &[36.0, 108.0]), "next piece closed, once: {next:?}"); + // 9.9in of filler leaves 0.1in above the break, under the widget's 0.5in caption: no room. + let sliver = form("9.9in"); + assert_eq!(sliver.pages.len(), 2); + assert!(levels(&sliver.pages[0]).is_empty(), "nothing of the field left on the first page: {:?}", levels(&sliver.pages[0])); + assert!(!texts(&sliver.pages[0]).iter().any(|t| t == "Notes"), "its caption moves with it"); + let whole = levels(&sliver.pages[1]); + assert!(whole.iter().any(|y| (y - 36.0).abs() < 1.0) && whole.iter().any(|y| (y - 252.0).abs() < 1.0), "whole on the next page: {whole:?}"); +} + +/// A field split inside a bordered section breaks the section with it, as a field moving on whole +/// does: the section's border closes on the first page under its bottom margin, which the field +/// leaves free, and opens again on the next, where the rest of the field sits under its top margin. +#[test] +fn a_field_split_inside_a_bordered_section_keeps_the_section_border_on_each_page() { + let body = r#" + + + above + + + + + +"#; + let form = layout_xml(&one_area_form(r#"layout="tb""#, body)).unwrap(); + assert_eq!(form.pages.len(), 2); + // Level lines `width` long on `page`, top to bottom: the section's are 540 long, the field's 504. + let levels = |page: &Page, width: f64| -> Vec { + let mut ys: Vec = page + .items + .iter() + .filter_map(|i| match i { + Item::Line { from, to, .. } if (from.1 - to.1).abs() < 0.01 && ((to.0 - from.0).abs() - width).abs() < 1.0 => Some(from.1), + _ => None, + }) + .collect(); + ys.sort_by(f64::total_cmp); + ys + }; + let near = |got: &[f64], want: &[f64]| got.len() == want.len() && got.iter().zip(want).all(|(g, w)| (g - w).abs() < 1.0); + // The section opens at 36 and its filler ends at 36 + 18 + 576 = 630, where the field starts. + let (sec, field) = (levels(&form.pages[0], 540.0), levels(&form.pages[0], 504.0)); + assert!(near(&sec, &[36.0, 756.0]), "the section closed at the bottom of the area: {sec:?}"); + assert!(near(&field, &[630.0, 738.0]), "the field's first piece ends above the section's bottom margin: {field:?}"); + // 216 - 108 = 108 of the field are left, under the section's top margin. + let (sec, field) = (levels(&form.pages[1], 540.0), levels(&form.pages[1], 504.0)); + assert!(near(&sec, &[36.0, 180.0]), "the section opened again round the rest: {sec:?}"); + assert!(near(&field, &[54.0, 162.0]), "the rest of the field under the section's top margin: {field:?}"); +} + +/// A field with no room above the break for a line of its widget is placed exactly as one that +/// can't split: inside a bordered section with a top margin that goes on to the next page, it +/// sits where a field kept intact does, under the section's margin there. +#[test] +fn a_sliver_field_moves_on_as_a_field_kept_intact_does() { + let form = |keep: &str| { + let body = format!( + r#" + + + above + {keep} + + + + +"# + ); + let form = layout_xml(&one_area_form(r#"layout="tb""#, &body)).unwrap(); + let at = form.pages.iter().enumerate().find_map(|(i, p)| widgets(p).into_iter().find(|w| w.name == "notes").map(|w| (i, w.rect))); + (form.pages.len(), at) + }; + let (pages, at) = form(""); + assert_eq!((pages, at), form(r#""#), "the sliver field and one kept intact"); + assert_eq!(at.map(|(page, _)| page), Some(1), "on the second page"); +} + +/// #5525 review: a field laid out again to move on whole (no room above the break for a line) +/// counts its items once against `MAX_ITEMS`, as a field kept intact does: what the first try +/// drew is taken off the page, and its count with it. +#[test] +fn a_field_laid_out_again_counts_its_items_once() { + let laid_out = |keep: &str| { + let body = format!( + r#" + above + {keep} + + + + +"# + ); + let form = layout_xml(&one_area_form(r#"layout="tb""#, &body)).unwrap(); + let on_pages: usize = form.pages.iter().map(|p| p.items.len()).sum(); + (form.pages.len(), on_pages, crate::layout::LAID_OUT_ITEMS.with(std::cell::Cell::get)) + }; + let (pages, on_pages, counted) = laid_out(""); + assert_eq!(pages, 2, "the field moves on to page 2"); + assert_eq!((pages, on_pages, counted), laid_out(r#""#), "laid out again, as kept intact"); +} + +/// #5525 review: the level lines about `len` long on `page` (a border's top and bottom edges), +/// top to bottom. +fn level_lines(page: &Page, len: f64) -> Vec { + let mut ys: Vec = page + .items + .iter() + .filter_map(|i| match i { + Item::Line { from, to, .. } if (from.1 - to.1).abs() < 0.01 && ((to.0 - from.0).abs() - len).abs() < 1.0 => Some(from.1), + _ => None, + }) + .collect(); + ys.sort_by(f64::total_cmp); + ys +} + +/// #5525 review: whether `got` holds the values of `want`, each within a point. +fn near_all(got: &[f64], want: &[f64]) -> bool { + got.len() == want.len() && got.iter().zip(want).all(|(g, w)| (g - w).abs() < 1.0) +} + +/// #5525 review: the pages (by index) holding the widget at SOM path `som`, with its box on each. +/// A repeated field's PDF name is made up, so the widget is found by its SOM path. +fn widget_pieces(form: &Form, som: &str) -> Vec<(usize, Rect)> { + form.pages.iter().enumerate().flat_map(|(i, p)| widgets(p).into_iter().filter(|w| w.som == som).map(move |w| (i, w.rect))).collect() +} + +/// #5525 review: the baseline of the first text span `text` on `page`. +fn baseline_of(page: &Page, text: &str) -> Option { + page.items.iter().find_map(|i| match i { + Item::Text(s) if s.text == text => Some(s.baseline), + _ => None, + }) +} + +/// #5525 review: an empty growing field `name` with its own border all round and no widget +/// border, `min_h` tall, `extra` inside it. +fn bordered_notes(name: &str, min_h: &str, extra: &str) -> String { + format!( + r#"{extra} + + +"# + ) +} + +/// #5525 review: a field split over two pages. With 144 points left on the first page, a 216 +/// point field runs to the bottom of it and its other 72 points go on at the top of the next, +/// its border closed round each piece; what follows the field starts under that rest, not +/// under a whole field moved to the next page. +#[test] +fn a_field_split_over_two_pages_has_what_follows_start_under_its_rest() { + let body = format!( + r#"above +{} +after"#, + bordered_notes("notes", "3in", "") + ); + let form = layout_xml(&one_area_form(r#"layout="tb""#, &body)).unwrap(); + assert_eq!(form.pages.len(), 2); + let pieces = widget_pieces(&form, "form[0].notes[0]"); + assert!( + matches!(pieces.as_slice(), [(0, r)] if (r.y - 612.0).abs() < 0.5 && (r.bottom() - 756.0).abs() < 0.5), + "one widget, on the first page: {pieces:?}" + ); + let (first, next) = (level_lines(&form.pages[0], 540.0), level_lines(&form.pages[1], 540.0)); + assert!(near_all(&first, &[612.0, 756.0]), "first piece, closed: {first:?}"); + assert!(near_all(&next, &[36.0, 108.0]), "the other 72 points, closed: {next:?}"); + let after = baseline_of(&form.pages[1], "after").expect("what follows is on the second page"); + assert!(after > 108.0 && after < 144.0, "under the rest of the field: {after}"); +} + +/// #5525 review: a field split over three pages. A 936 point field with 144 points left on the +/// first page fills that, the whole of the second page's content area, and 72 points of the +/// third; its border is closed round each of the three pieces, and what follows starts under +/// the last. +#[test] +fn a_field_split_over_three_pages_closes_its_border_round_every_piece() { + let body = format!( + r#"above +{} +after"#, + bordered_notes("notes", "13in", "") + ); + let form = layout_xml(&one_area_form(r#"layout="tb""#, &body)).unwrap(); + assert_eq!(form.pages.len(), 3); + let pieces = widget_pieces(&form, "form[0].notes[0]"); + assert!( + matches!(pieces.as_slice(), [(0, r)] if (r.y - 612.0).abs() < 0.5 && (r.bottom() - 756.0).abs() < 0.5), + "one widget, on the first page: {pieces:?}" + ); + let levels: Vec> = form.pages.iter().map(|p| level_lines(p, 540.0)).collect(); + assert!(near_all(&levels[0], &[612.0, 756.0]), "first piece: {levels:?}"); + assert!(near_all(&levels[1], &[36.0, 756.0]), "a whole content area: {levels:?}"); + assert!(near_all(&levels[2], &[36.0, 108.0]), "the last 72 points: {levels:?}"); + let after = baseline_of(&form.pages[2], "after").expect("what follows is on the third page"); + assert!(after > 108.0 && after < 144.0, "under the last piece: {after}"); +} + +/// #5525 review: a field taller than a page that starts at the top of one (the page break is +/// right above it) splits at the bottom of that content area rather than running off the page: +/// 864 points become 720 here and 144 on the next page. +#[test] +fn a_field_taller_than_a_page_starting_at_its_top_splits_at_its_bottom() { + let form = layout_xml(&one_area_form(r#"layout="tb""#, &bordered_notes("notes", "12in", ""))).unwrap(); + assert_eq!(form.pages.len(), 2); + let pieces = widget_pieces(&form, "form[0].notes[0]"); + assert!( + matches!(pieces.as_slice(), [(0, r)] if (r.y - 36.0).abs() < 0.5 && (r.bottom() - 756.0).abs() < 0.5), + "the widget fills the first content area, no more: {pieces:?}" + ); + assert!(near_all(&level_lines(&form.pages[0], 540.0), &[36.0, 756.0]), "{:?}", level_lines(&form.pages[0], 540.0)); + assert!(near_all(&level_lines(&form.pages[1], 540.0), &[36.0, 180.0]), "{:?}", level_lines(&form.pages[1], 540.0)); +} + +/// #5525 review: a field that starts exactly at a page break (what is above it fills the content +/// area) has no room at all on that page: it moves to the next page whole, its border with it, +/// and nothing of it is left behind. +#[test] +fn a_field_starting_exactly_at_a_page_break_moves_on_whole() { + let body = format!( + r#"above +{}"#, + bordered_notes("notes", "3in", "") + ); + let form = layout_xml(&one_area_form(r#"layout="tb""#, &body)).unwrap(); + assert_eq!(form.pages.len(), 2); + assert!(level_lines(&form.pages[0], 540.0).is_empty(), "no border left behind: {:?}", level_lines(&form.pages[0], 540.0)); + let pieces = widget_pieces(&form, "form[0].notes[0]"); + assert!( + matches!(pieces.as_slice(), [(1, r)] if (r.y - 36.0).abs() < 0.5 && (r.bottom() - 252.0).abs() < 0.5), + "whole at the top of the next page: {pieces:?}" + ); + assert!(near_all(&level_lines(&form.pages[1], 540.0), &[36.0, 252.0]), "{:?}", level_lines(&form.pages[1], 540.0)); +} + +/// #5525 review: the room a field needs above a page break to split there is one line of its +/// text (10 point Courier, 12 points). With exactly 12 points left the field splits, its widget +/// on the last 12 points of the page; with 11 it has no room and moves to the next page whole. +#[test] +fn a_field_splits_with_one_line_of_room_and_moves_on_whole_with_less() { + let form = |filler: &str| { + let body = format!( + r#"above +{}"#, + bordered_notes("notes", "3in", "") + ); + layout_xml(&one_area_form(r#"layout="tb""#, &body)).unwrap() + }; + let line = form("708pt"); + let pieces = widget_pieces(&line, "form[0].notes[0]"); + assert!( + matches!(pieces.as_slice(), [(0, r)] if (r.y - 744.0).abs() < 0.5 && (r.bottom() - 756.0).abs() < 0.5), + "one line on the first page: {pieces:?}" + ); + assert!(near_all(&level_lines(&line.pages[1], 540.0), &[36.0, 240.0]), "the other 204 points: {:?}", level_lines(&line.pages[1], 540.0)); + let less = form("709pt"); + let pieces = widget_pieces(&less, "form[0].notes[0]"); + assert!(matches!(pieces.as_slice(), [(1, r)] if (r.y - 36.0).abs() < 0.5 && (r.h - 216.0).abs() < 0.5), "whole on the next page: {pieces:?}"); + assert!(level_lines(&less.pages[0], 540.0).is_empty(), "nothing of it left on the first page"); +} + +/// #5525 review: two such fields in a row are split by their own page breaks, and the second +/// starts from nothing of the first: the first (216 points) splits over pages one and two, the +/// second (792 points) starts under the first's rest and splits over pages two and three, and +/// each page holds only the borders of its own pieces. A second field that fits after a split +/// one is drawn whole, once. +#[test] +fn two_fields_in_a_row_are_each_split_by_their_own_page_break() { + let form = |second: &str| { + let body = format!( + r#"above +{} +{}"#, + bordered_notes("a", "3in", ""), + bordered_notes("b", second, "") + ); + layout_xml(&one_area_form(r#"layout="tb""#, &body)).unwrap() + }; + let both = form("11in"); + assert_eq!(both.pages.len(), 3); + let a = widget_pieces(&both, "form[0].a[0]"); + let b = widget_pieces(&both, "form[0].b[0]"); + assert!(matches!(a.as_slice(), [(0, r)] if (r.y - 612.0).abs() < 0.5 && (r.bottom() - 756.0).abs() < 0.5), "a: {a:?}"); + assert!(matches!(b.as_slice(), [(1, r)] if (r.y - 108.0).abs() < 0.5 && (r.bottom() - 756.0).abs() < 0.5), "b: {b:?}"); + let levels: Vec> = both.pages.iter().map(|p| level_lines(p, 540.0)).collect(); + assert!(near_all(&levels[0], &[612.0, 756.0]), "a's first piece: {levels:?}"); + assert!(near_all(&levels[1], &[36.0, 108.0, 108.0, 756.0]), "a's rest and b's first piece: {levels:?}"); + assert!(near_all(&levels[2], &[36.0, 180.0]), "b's other 144 points: {levels:?}"); + let fits = form("1in"); + assert_eq!(fits.pages.len(), 2); + let b = widget_pieces(&fits, "form[0].b[0]"); + assert!(matches!(b.as_slice(), [(1, r)] if (r.y - 108.0).abs() < 0.5 && (r.h - 72.0).abs() < 0.5), "b whole: {b:?}"); + let levels = level_lines(&fits.pages[1], 540.0); + assert!(near_all(&levels, &[36.0, 108.0, 108.0, 180.0]), "a's rest, and b once: {levels:?}"); +} + +/// #5525 review: the field of the second instance of a repeated subform splits like any other, +/// found by its SOM path (its PDF name is made up), while the first instance's field, which +/// fits, stays whole. +#[test] +fn a_field_in_a_repeated_subform_splits_in_the_instance_that_reaches_the_break() { + let body = format!( + r#"above + +{} +"#, + bordered_notes("notes", "3in", "") + ); + let form = layout_xml(&one_area_form(r#"layout="tb""#, &body)).unwrap(); + assert_eq!(form.pages.len(), 2); + let first = widget_pieces(&form, "form[0].row[0].notes[0]"); + let second = widget_pieces(&form, "form[0].row[1].notes[0]"); + assert!(matches!(first.as_slice(), [(0, r)] if (r.y - 468.0).abs() < 0.5 && (r.h - 216.0).abs() < 0.5), "first whole: {first:?}"); + assert!(matches!(second.as_slice(), [(0, r)] if (r.y - 684.0).abs() < 0.5 && (r.bottom() - 756.0).abs() < 0.5), "second split: {second:?}"); + let (page0, page1) = (level_lines(&form.pages[0], 540.0), level_lines(&form.pages[1], 540.0)); + assert!(near_all(&page0, &[468.0, 684.0, 684.0, 756.0]), "{page0:?}"); + assert!(near_all(&page1, &[36.0, 180.0]), "{page1:?}"); +} + +/// #5525 review: a field split inside two nested sections, each with a quarter-inch bottom and +/// top margin, leaves both bottom margins free under its first piece (it ends 36 points above +/// the bottom of the content area) and goes on under both top margins on the next page. +#[test] +fn a_field_split_inside_nested_sections_leaves_room_for_both_margins() { + let body = r#" + + + + above + + + + + +"#; + let form = layout_xml(&one_area_form(r#"layout="tb""#, body)).unwrap(); + assert_eq!(form.pages.len(), 2); + // The filler starts under both top margins, at 72, so the field starts at 648. + let (first, next) = (level_lines(&form.pages[0], 504.0), level_lines(&form.pages[1], 504.0)); + assert!(near_all(&first, &[648.0, 720.0]), "first piece ends above both bottom margins: {first:?}"); + assert!(near_all(&next, &[72.0, 216.0]), "the other 144 points under both top margins: {next:?}"); +} + +/// #5525 review: `` decides whether a page break may split the field (XFA 3.3 +/// p. 292): `contentArea` and `pageArea` keep it whole, moving it to the next page, while +/// `none` and a value XFA doesn't define leave a field's default, `none`, and it splits. +#[test] +fn keep_intact_values_decide_whether_the_field_splits() { + let at = |keep: &str| { + let body = format!( + r#"above +{}"#, + bordered_notes("notes", "3in", &format!(r#""#)) + ); + let form = layout_xml(&one_area_form(r#"layout="tb""#, &body)).unwrap(); + widget_pieces(&form, "form[0].notes[0]") + }; + for keep in ["contentArea", "pageArea"] { + let pieces = at(keep); + assert!(matches!(pieces.as_slice(), [(1, r)] if (r.y - 36.0).abs() < 0.5 && (r.h - 216.0).abs() < 0.5), "{keep}: whole: {pieces:?}"); + } + for keep in ["none", "sideways", ""] { + let pieces = at(keep); + assert!( + matches!(pieces.as_slice(), [(0, r)] if (r.y - 612.0).abs() < 0.5 && (r.bottom() - 756.0).abs() < 0.5), + "{keep:?}: split: {pieces:?}" + ); + } +} + +/// #5525 review: extreme and malformed sizes. A 1000 inch empty field splits over exactly 100 +/// pages, its widget on the first only and its border closed on each; a `minH` past the largest +/// length a measurement may have (100,000 points) splits over 139 pages; fields that would need +/// more pages than a form may have (500) are refused with an error rather than a panic or a +/// hang; and a negative, NaN or non-numeric `minH`, or a content area too short for a line, +/// lays out without panicking. +#[test] +fn a_split_field_survives_huge_and_malformed_sizes() { + let huge = layout_xml(&one_area_form(r#"layout="tb""#, &bordered_notes("notes", "1000in", ""))).unwrap(); + assert_eq!(huge.pages.len(), 100); + assert!(matches!(widget_pieces(&huge, "form[0].notes[0]").as_slice(), [(0, _)]), "one widget, on the first page"); + for (i, p) in huge.pages.iter().enumerate() { + assert!(near_all(&level_lines(p, 540.0), &[36.0, 756.0]), "page {i}: {:?}", level_lines(p, 540.0)); + } + let capped = layout_xml(&one_area_form(r#"layout="tb""#, &bordered_notes("notes", "100000in", ""))).unwrap(); + assert_eq!(capped.pages.len(), 139, "100,000 points over 720 point content areas"); + let four: String = (0..4).map(|i| bordered_notes(&format!("n{i}"), "100000in", "")).collect(); + let too_many = layout_xml(&one_area_form(r#"layout="tb""#, &four)); + assert!(matches!(too_many, Err(XfaError::TooLarge(_))), "{:?}", too_many.map(|f| f.pages.len())); + for min_h in ["-3in", "NaNin", "abc", "0"] { + let form = layout_xml(&one_area_form(r#"layout="tb""#, &bordered_notes("notes", min_h, ""))).unwrap(); + assert!(!form.pages.is_empty(), "{min_h}"); + } + let tiny = one_area_form(r#"layout="tb""#, &bordered_notes("notes", "3in", "")).replace(r#"h="10in""#, r#"h="0.5pt""#); + let _ = layout_xml(&tiny); +} + +/// #5525 review: the widget's own border (the text edit's) of a field split over three pages is +/// closed round every piece below the widget, not only the first: the middle page gets a box +/// from the top of its content area to the bottom, and the last page one round the last 72 +/// points, each with its top and bottom edge. +#[test] +fn a_widget_border_split_over_three_pages_is_closed_round_every_piece() { + let body = r#"above +"#; + let form = layout_xml(&one_area_form(r#"layout="tb""#, body)).unwrap(); + assert_eq!(form.pages.len(), 3); + // The edges are half a point wide, drawn a quarter of a point inside the box. + let (middle, last) = (level_lines(&form.pages[1], 539.5), level_lines(&form.pages[2], 539.5)); + assert!(near_all(&middle, &[36.25, 755.75]), "the middle piece, closed at the bottom of the page: {middle:?}"); + assert!(near_all(&last, &[36.25, 107.75]), "the last piece, closed at its top: {last:?}"); +} + +/// #5525 review: a bordered section round an empty field split over three pages keeps its +/// border on every page, even when what is left of the field draws nothing there (no widget +/// border or fill): the middle page and the last still hold a part of the section. With or +/// without the field's own border, the section is closed on page one under its bottom margin, +/// runs the whole content area on page two, and closes under the field's last 144 points on +/// page three. +#[test] +fn a_bordered_section_round_a_field_split_over_three_pages_is_closed_on_every_page() { + for field_border in ["", ""] { + let body = format!( + r#" + + + above + {field_border} + + +"# + ); + let form = layout_xml(&one_area_form(r#"layout="tb""#, &body)).unwrap(); + assert_eq!(form.pages.len(), 3, "{field_border:?}"); + let sec: Vec> = form.pages.iter().map(|p| level_lines(p, 540.0)).collect(); + // The field starts at 36 + 18 + 576 = 630 and is 936 tall: 108 on page one (down to + // 738), 684 on page two (54 to 738), and the last 144 on page three (54 to 198). + assert!(near_all(&sec[0], &[36.0, 756.0]), "{field_border:?}: page one: {sec:?}"); + assert!(near_all(&sec[1], &[36.0, 756.0]), "{field_border:?}: page two, a whole content area: {sec:?}"); + assert!(near_all(&sec[2], &[36.0, 216.0]), "{field_border:?}: page three, under the field's last piece: {sec:?}"); + if !field_border.is_empty() { + let field: Vec> = form.pages.iter().map(|p| level_lines(p, 504.0)).collect(); + assert!(near_all(&field[0], &[630.0, 738.0]), "{field:?}"); + assert!(near_all(&field[1], &[54.0, 738.0]), "{field:?}"); + assert!(near_all(&field[2], &[54.0, 198.0]), "{field:?}"); + } + } +} + +/// #5525 review: a field split into content areas of different sizes keeps moving down even +/// where the margins of the sections round it leave no room. The first page area (used once) has +/// a 720 point content area; the next has one only 36 points tall, under a section's 72 point +/// bottom margin (or with its 72 point top margin taking the whole of it). The field still goes on +/// 36 points a page, from the top of each small area, instead of breaking to new pages until the +/// form has too many; the section, which can't hold it there, is drawn without its border, and +/// the layout says so. +#[test] +fn a_field_split_into_areas_smaller_than_its_sections_margins_still_moves_on() { + let form = |margin: &str, border: &str| { + let xml = format!( + r##" + + + +"##, + bordered_notes("notes", "3in", "") + ); + layout_xml(&xml) + }; + // Under a 72 point bottom margin the field starts at 612 and its first piece ends at 684; under + // a 72 point top margin it starts at 684 and its first piece ends at 756. Either way 144 points + // are left, four small areas' worth. + for (margin, first) in [(r#"bottomInset="1in""#, [612.0, 684.0]), (r#"topInset="1in""#, [684.0, 756.0])] { + let laid_out = form(margin, ""); + let pages = match &laid_out { + Ok(f) => &f.pages, + Err(e) => panic!("{margin}: {e:?}"), + }; + assert_eq!(pages.len(), 5, "{margin}"); + let levels: Vec> = pages.iter().map(|p| level_lines(p, 540.0)).collect(); + assert!(near_all(&levels[0], &first), "{margin}: first piece: {levels:?}"); + for (i, l) in levels.iter().enumerate().skip(1) { + assert!(near_all(l, &[36.0, 72.0]), "{margin}: page {i}, the whole small area: {levels:?}"); + } + let bordered = form(margin, "").unwrap_or_else(|e| panic!("{margin}: bordered: {e:?}")); + assert_eq!(bordered.pages.len(), 5, "{margin}: bordered"); + assert!(bordered.warnings.iter().any(|w| w.contains("without its border")), "{margin}: {:?}", bordered.warnings); + } +} + /// Only a template that is a packet of the XDP is the form's: one inside imported data (a /// dataset holding XML that looks like a template) is not read, and neither are its scripts. #[test] From 7b4a904877652df6295184965dedda1038a5242b Mon Sep 17 00:00:00 2001 From: jonathanwxh-cell Date: Mon, 9 Nov 2026 18:14:56 +0800 Subject: [PATCH 04/64] fix(a11y): resolve indirect structure kids, parent tree keys, and table attributes (#5611) --- crates/a11y/src/alt.rs | 8 +- crates/a11y/src/autotag_fields.rs | 169 +++++++++++++++-- crates/a11y/src/tags.rs | 295 +++++++++++++++++++++++++++--- 3 files changed, 423 insertions(+), 49 deletions(-) diff --git a/crates/a11y/src/alt.rs b/crates/a11y/src/alt.rs index 55c0857878..29e48409ba 100644 --- a/crates/a11y/src/alt.rs +++ b/crates/a11y/src/alt.rs @@ -106,7 +106,13 @@ pub(crate) fn elements(doc: &Document) -> Vec { if !seen.insert(r) { continue; } - let Some(d) = doc.get(r).as_dict().cloned() else { continue }; + let target = doc.get(r); + if matches!(&*target, Object::Array(_) | Object::Ref(_)) { + // An indirect /K array is a container, not an element or a parent. + stack.push(((*target).clone(), pg, parent, depth)); + continue; + } + let Some(d) = target.as_dict().cloned() else { continue }; if d.contains(b"S") { let pg = d.get(b"Pg").and_then(Object::as_ref).or(pg); if let Some(kids) = d.get(b"K") { diff --git a/crates/a11y/src/autotag_fields.rs b/crates/a11y/src/autotag_fields.rs index 3203aca376..df15b3420d 100644 --- a/crates/a11y/src/autotag_fields.rs +++ b/crates/a11y/src/autotag_fields.rs @@ -10,6 +10,23 @@ fn reference(object: Option<&Object>, what: &str) -> Result { object.and_then(Object::as_ref).ok_or_else(|| format!("the {what} must be an indirect reference")) } +fn root_document_ref(doc: &Document, k: Option<&Object>) -> Result { + let Some(k) = k else { + return Err("the structure tree root K must be an indirect reference".into()); + }; + if let Some(r) = k.as_ref() + && doc.get(r).as_dict().is_some() + { + return Ok(r); + } + if let Some([item]) = doc.resolve(k).as_array().map(Vec::as_slice) + && let Some(r) = item.as_ref() + { + return Ok(r); + } + Err("the structure tree root K must be an indirect reference".into()) +} + fn nums(doc: &Document, parent_tree: ObjRef) -> Result, String> { let tree = doc.get(parent_tree); let tree = tree.as_dict().ok_or("the structure parent tree is invalid")?; @@ -31,10 +48,10 @@ pub fn tag_form_fields(doc: &mut Document) -> Result { let root = doc.root().ok_or("the PDF has no catalog")?; let catalog_object = doc.get(root); let catalog = catalog_object.as_dict().ok_or("the PDF catalog is invalid")?; - if !catalog.contains(b"AcroForm") { + if !catalog.get(b"AcroForm").is_some_and(|v| !matches!(*doc.resolve(v), Object::Null)) { return Err("the PDF has no AcroForm fields".into()); } - let existing_root = catalog.get(b"StructTreeRoot").cloned(); + let existing_root = catalog.get(b"StructTreeRoot").filter(|v| !matches!(*doc.resolve(v), Object::Null)).cloned(); let mark_info = catalog.get(b"MarkInfo").map(|v| doc.resolve(v)).as_deref().and_then(Object::as_dict).cloned().unwrap_or_default(); let pages = pdfcraft_model::pages(doc); let mut widgets = Vec::new(); @@ -46,7 +63,9 @@ pub fn tag_form_fields(doc: &mut Document) -> Result { let Some(reference) = annot.as_ref() else { continue }; let resolved = doc.get(reference); let Some(dict) = resolved.as_dict() else { continue }; - if dict.name(b"Subtype") == Some(b"Widget") && !dict.contains(b"StructParent") && seen.insert(reference) { + let is_widget = dict.get(b"Subtype").map(|s| doc.resolve(s)).as_deref().and_then(Object::as_name) == Some(b"Widget"); + let has_parent = dict.get(b"StructParent").map(|v| doc.resolve(v)).and_then(|v| v.as_int()).is_some_and(|k| k >= 0); + if is_widget && !has_parent && seen.insert(reference) { widgets.push((page.obj, reference)); } } @@ -54,16 +73,29 @@ pub fn tag_form_fields(doc: &mut Document) -> Result { if widgets.is_empty() { return Err("the AcroForm has no untagged page widgets".into()); } + let count = i64::try_from(widgets.len()).map_err(|_| "too many form widgets")?; - let (struct_root, document_ref, parent_tree, mut parent_nums, first_key, new_tree) = if let Some(existing_root) = existing_root { + let (struct_root, document_ref, mut existing_kids, parent_tree, mut parent_nums, first_key, new_tree) = if let Some(existing_root) = existing_root + { let struct_root = reference(Some(&existing_root), "structure tree root")?; let structure_object = doc.get(struct_root); let structure = structure_object.as_dict().ok_or("the structure tree root is invalid")?; - let document_ref = reference(structure.get(b"K"), "structure tree root K")?; - let document = doc.get(document_ref); - if document.as_dict().is_none_or(|d| d.name(b"S") != Some(b"Document")) { + let document_ref = root_document_ref(doc, structure.get(b"K"))?; + let document_obj = doc.get(document_ref); + let Some(document_dict) = document_obj.as_dict() else { + return Err("the structure tree root must point directly to a Document element".into()); + }; + if document_dict.get(b"S").map(|s| doc.resolve(s)).as_deref().and_then(Object::as_name) != Some(b"Document") { return Err("the structure tree root must point directly to a Document element".into()); } + let existing_kids = match document_dict.get(b"K") { + None => Vec::new(), + Some(k) => match &*doc.resolve(k) { + Object::Null => Vec::new(), + Object::Array(items) => items.clone(), + _ => vec![k.clone()], + }, + }; let parent_tree = reference(structure.get(b"ParentTree"), "structure parent tree")?; let parent_nums = nums(doc, parent_tree)?; if parent_nums.len() % 2 != 0 { @@ -71,14 +103,26 @@ pub fn tag_form_fields(doc: &mut Document) -> Result { } let mut greatest = -1_i64; for pair in parent_nums.as_chunks::<2>().0 { - let Some(key) = pair[0].as_int() else { return Err("the structure parent tree has a non-integer key".into()) }; + let Some(key) = doc.resolve(&pair[0]).as_int() else { + return Err("the structure parent tree has a non-integer key".into()); + }; if key < 0 { return Err("the structure parent tree has a negative key".into()); } greatest = greatest.max(key); } - let first_key = greatest.checked_add(1).ok_or("the structure parent tree has no available key")?; - (struct_root, document_ref, parent_tree, parent_nums, first_key, false) + let mut first_key = greatest.checked_add(1).ok_or("the structure parent tree has no available key")?; + if let Some(next_obj) = structure.get(b"ParentTreeNextKey") { + let resolved_next = doc.resolve(next_obj); + if !matches!(*resolved_next, Object::Null) { + let next_key = resolved_next.as_int().ok_or("the structure parent tree next key is invalid")?; + if next_key < 0 { + return Err("the structure parent tree next key is negative".into()); + } + first_key = first_key.max(next_key); + } + } + (struct_root, document_ref, existing_kids, parent_tree, parent_nums, first_key, false) } else { let mut root_dict = Dict::new(); root_dict.set(b"Type".to_vec(), Object::Name(b"StructTreeRoot".to_vec())); @@ -88,9 +132,10 @@ pub fn tag_form_fields(doc: &mut Document) -> Result { document.set(b"P".to_vec(), Object::Ref(struct_root)); let document_ref = doc.add(Object::Dict(document)); let parent_tree = doc.add(Object::Dict(Dict::new())); - (struct_root, document_ref, parent_tree, Vec::new(), 0, true) + (struct_root, document_ref, Vec::new(), parent_tree, Vec::new(), 0, true) }; - let mut kids = Vec::with_capacity(widgets.len()); + // ISO 32000-1 14.7.4.4: reserve the next unused key before adding any parent-tree entries. + let next_key = first_key.checked_add(count).ok_or("the structure parent tree has no available next key")?; for (offset, (page, widget)) in widgets.iter().copied().enumerate() { let key = first_key.checked_add(i64::try_from(offset).map_err(|_| "too many form widgets")?).ok_or("too many form widgets")?; let mut objr = Dict::new(); @@ -105,16 +150,10 @@ pub fn tag_form_fields(doc: &mut Document) -> Result { let form_ref = doc.add(Object::Dict(form)); doc.update_dict(widget, |d| d.set(b"StructParent".to_vec(), Object::Int(key))).map_err(|e| e.to_string())?; parent_nums.extend([Object::Int(key), Object::Ref(form_ref)]); - kids.push(Object::Ref(form_ref)); + existing_kids.push(Object::Ref(form_ref)); } doc.update_dict(document_ref, |d| { - let old = d.remove(b"K").unwrap_or(Object::Array(Vec::new())); - let mut all = match old { - Object::Array(items) => items, - item => vec![item], - }; - all.extend(kids); - d.set(b"K".to_vec(), Object::Array(all)); + d.set(b"K".to_vec(), Object::Array(existing_kids)); }) .map_err(|e| e.to_string())?; doc.update_dict(parent_tree, |d| d.set(b"Nums".to_vec(), Object::Array(parent_nums))).map_err(|e| e.to_string())?; @@ -122,6 +161,12 @@ pub fn tag_form_fields(doc: &mut Document) -> Result { doc.update_dict(struct_root, |d| { d.set(b"K".to_vec(), Object::Ref(document_ref)); d.set(b"ParentTree".to_vec(), Object::Ref(parent_tree)); + d.set(b"ParentTreeNextKey".to_vec(), Object::Int(next_key)); + }) + .map_err(|e| e.to_string())?; + } else { + doc.update_dict(struct_root, |d| { + d.set(b"ParentTreeNextKey".to_vec(), Object::Int(next_key)); }) .map_err(|e| e.to_string())?; } @@ -136,3 +181,87 @@ pub fn tag_form_fields(doc: &mut Document) -> Result { .map_err(|e| e.to_string())?; Ok(widgets.len()) } + +#[cfg(test)] +mod tests { + use std::sync::Arc; + + use super::*; + use crate::tags; + + fn pdf(bodies: &[String]) -> Document { + let mut out = b"%PDF-1.7\n".to_vec(); + let mut offsets = Vec::new(); + for (i, b) in bodies.iter().enumerate() { + offsets.push(out.len()); + out.extend_from_slice(format!("{} 0 obj\n{b}\nendobj\n", i + 1).as_bytes()); + } + let xref = out.len(); + out.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", bodies.len() + 1).as_bytes()); + for o in offsets { + out.extend_from_slice(format!("{o:010} 00000 n \n").as_bytes()); + } + out.extend_from_slice(format!("trailer << /Size {} /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n", bodies.len() + 1).as_bytes()); + Document::open(Arc::new(out)).unwrap() + } + + fn existing_tree(next: &str) -> Document { + pdf(&[ + "<< /Type /Catalog /Pages 2 0 R /AcroForm << /Fields [4 0 R] >> /StructTreeRoot 5 0 R >>".into(), + "<< /Type /Pages /Kids [3 0 R] /Count 1 >>".into(), + "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 200 200] /Annots [4 0 R] >>".into(), + "<< /Type /Annot /Subtype /Widget /FT /Tx /T (name) /Rect [20 60 120 80] >>".into(), + format!("<< /Type /StructTreeRoot /K 6 0 R /ParentTree 7 0 R {next} >>"), + "<< /S /Document /P 5 0 R /K [] >>".into(), + "<< /Nums [7 []] >>".into(), + "null".into(), + ]) + } + + #[test] + fn extending_tree_creates_missing_next_key() { + for next in ["", "/ParentTreeNextKey null", "/ParentTreeNextKey 8 0 R"] { + let mut doc = existing_tree(next); + assert_eq!(tag_form_fields(&mut doc).unwrap(), 1); + assert_eq!(doc.get(ObjRef::new(4, 0)).as_dict().unwrap().int(b"StructParent"), Some(8)); + assert_eq!(doc.get(ObjRef::new(5, 0)).as_dict().unwrap().int(b"ParentTreeNextKey"), Some(9)); + } + } + + #[test] + fn exhausted_next_key_rejects_before_mutation() { + let mut doc = existing_tree(&format!("/ParentTreeNextKey {}", i64::MAX)); + let before = doc.get(ObjRef::new(4, 0)); + let objects = doc.object_numbers().len(); + assert!(tag_form_fields(&mut doc).is_err()); + assert_eq!(doc.get(ObjRef::new(4, 0)), before); + assert_eq!(doc.object_numbers().len(), objects); + } + + #[test] + fn extends_existing_tree_with_array_root_k_indirect_document_kids_and_indirect_nums_keys() { + let mut doc = pdf(&[ + "<< /Type /Catalog /Pages 2 0 R /AcroForm << /Fields [5 0 R] >> /MarkInfo << /Marked true >> /StructTreeRoot 6 0 R >>".into(), + "<< /Type /Pages /Kids [3 0 R] /Count 1 >>".into(), + "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 200 200] /Annots [5 0 R] /StructParents 0 >>".into(), + "<< /Length 0 >>\nstream\n\nendstream".into(), + "<< /Type /Annot /Subtype /Widget /FT /Tx /T (name) /Rect [20 60 120 80] /StructParent null >>".into(), + "<< /Type /StructTreeRoot /K [7 0 R] /ParentTree 8 0 R /ParentTreeNextKey 5 >>".into(), + "<< /S /Document /P 6 0 R /K 10 0 R >>".into(), + "<< /Nums [11 0 R [9 0 R]] >>".into(), + "<< /S /P /P 7 0 R /Pg 3 0 R /K 0 >>".into(), + "[9 0 R]".into(), + "0".into(), + ]); + assert_eq!(tag_form_fields(&mut doc).unwrap(), 1); + // Existing paragraph (9 0 R) and new Form tag remain flat siblings under /Document (7 0 R). + let doc_k = doc.get(ObjRef::new(7, 0)).as_dict().unwrap().get(b"K").unwrap().as_array().unwrap().clone(); + assert_eq!(doc_k.len(), 2); + assert_eq!(doc_k[0].as_ref(), Some(ObjRef::new(9, 0))); + let listed: Vec<_> = tags(&doc).into_iter().map(|t| t.kind).collect(); + assert_eq!(listed, ["Document", "P", "Form"]); + // Widget got key 5 from /ParentTreeNextKey, and /ParentTreeNextKey advanced to 6. + assert_eq!(doc.get(ObjRef::new(5, 0)).as_dict().unwrap().int(b"StructParent"), Some(5)); + assert_eq!(doc.get(ObjRef::new(6, 0)).as_dict().unwrap().int(b"ParentTreeNextKey"), Some(6)); + } +} diff --git a/crates/a11y/src/tags.rs b/crates/a11y/src/tags.rs index ba9d639018..ab38f8aece 100644 --- a/crates/a11y/src/tags.rs +++ b/crates/a11y/src/tags.rs @@ -101,6 +101,8 @@ pub enum TagError { NotSiblings, #[error("the parent must list each tag exactly once in its /K array")] AmbiguousChildren, + #[error("the tag has too much or too deeply nested marked content")] + ContentLimit, #[error("the tag is already at the requested position")] AlreadyThere, #[error("{0} applies only to {1} tags")] @@ -118,7 +120,13 @@ pub fn move_tag(doc: &mut Document, object: ObjRef, anchor: ObjRef, placement: T return Err(TagError::NotSiblings); } let reachable = alt::elements(doc); - if !reachable.iter().any(|e| e.object == object) || !reachable.iter().any(|e| e.object == anchor) { + let Some(obj_elem) = reachable.iter().find(|e| e.object == object) else { + return Err(TagError::NotSiblings); + }; + let Some(anc_elem) = reachable.iter().find(|e| e.object == anchor) else { + return Err(TagError::NotSiblings); + }; + if obj_elem.parent != anc_elem.parent { return Err(TagError::NotSiblings); } let parent_of = |r| doc.get(r).as_dict().and_then(|d| d.get(b"P").and_then(Object::as_ref)); @@ -126,8 +134,23 @@ pub fn move_tag(doc: &mut Document, object: ObjRef, anchor: ObjRef, placement: T if parent_of(anchor) != Some(parent) { return Err(TagError::NotSiblings); } + let valid_parent = match obj_elem.parent { + Some(expected) => parent == expected, + None => doc + .root() + .and_then(|r| doc.get(r).as_dict().and_then(|c| c.get(b"StructTreeRoot").and_then(Object::as_ref))) + .is_some_and(|root_ref| parent == root_ref), + }; + if !valid_parent { + return Err(TagError::NotSiblings); + } let owner = doc.get(parent); - let Some(children) = owner.as_dict().and_then(|d| d.get(b"K").and_then(Object::as_array)) else { + let mut k_obj = owner.as_dict().and_then(|d| d.get(b"K")).cloned(); + for _ in 0..16 { + let Some(Object::Ref(r)) = k_obj else { break }; + k_obj = Some((*doc.get(r)).clone()); + } + let Some(children) = k_obj.and_then(|k| k.as_array().cloned()) else { return Err(TagError::AmbiguousChildren); }; let one_position = |child| { @@ -135,7 +158,7 @@ pub fn move_tag(doc: &mut Document, object: ObjRef, anchor: ObjRef, placement: T matches.next().and_then(|(index, _)| matches.next().is_none().then_some(index)) }; let (Some(from), Some(to)) = (one_position(object), one_position(anchor)) else { return Err(TagError::AmbiguousChildren) }; - let mut reordered = children.clone(); + let mut reordered = children; let moved = reordered.remove(from); let target = if from < to { to - 1 } else { to }; let destination = target + usize::from(placement == TagPlacement::After); @@ -151,6 +174,10 @@ fn text(doc: &Document, d: &pdfcraft_cos::Dict, key: &[u8]) -> Option { d.get(key).map(|o| doc.resolve(o)).and_then(|o| o.as_string().map(PdfString::to_text)) } +fn tag_type_name(doc: &Document, d: &Dict) -> Option> { + d.get(b"S").map(|s| doc.resolve(s)).and_then(|s| s.as_name().map(<[u8]>::to_vec)) +} + /// List reachable, indirect tags in structure-tree order. pub fn tags(doc: &Document) -> Vec { let pages = pdfcraft_model::pages(doc); @@ -162,7 +189,7 @@ pub fn tags(doc: &Document) -> Vec { object: e.object, parent: e.parent, depth: e.depth, - kind: String::from_utf8_lossy(e.dict.name(b"S")?).into_owned(), + kind: String::from_utf8_lossy(&tag_type_name(doc, &e.dict)?).into_owned(), title: text(doc, &e.dict, b"T"), actual_text: text(doc, &e.dict, b"ActualText"), alt: text(doc, &e.dict, b"Alt"), @@ -176,13 +203,16 @@ pub fn tags(doc: &Document) -> Vec { } /// List marked-content references directly owned by an indirect tag. +/// Returns `ContentLimit` rather than a partial list beyond 65,536 marks or 512 child/reference +/// levels. Child structure elements do not count as direct marks. pub fn tag_content(doc: &Document, object: ObjRef) -> Result, TagError> { let Some(element) = alt::elements(doc).into_iter().find(|e| e.object == object) else { return Err(TagError::NotATag(object)) }; let pages = pdfcraft_model::pages(doc); let page_index: std::collections::HashMap = pages.iter().enumerate().map(|(i, p)| (p.obj, i)).collect(); let mut marks = Vec::new(); + let mut seen = std::collections::HashSet::new(); if let Some(kids) = element.dict.get(b"K") { - direct_marks(doc, kids, element.page, &mut marks); + direct_marks(doc, kids, element.page, &mut seen, &mut marks)?; } // Each page's content is parsed once, however many of its MCIDs the tag owns. let mut texts: std::collections::HashMap> = std::collections::HashMap::new(); @@ -206,25 +236,63 @@ pub fn tag_content(doc: &Document, object: ObjRef) -> Result .collect()) } -fn direct_marks(doc: &Document, item: &Object, page: Option, out: &mut Vec<(Option, i64)>) { - match item { - Object::Int(mcid) => out.push((page, *mcid)), - Object::Array(items) => items.iter().for_each(|item| direct_marks(doc, item, page, out)), - Object::Dict(dict) if dict.name(b"Type") == Some(b"MCR") => { - if let Some(mcid) = dict.get(b"MCID").and_then(Object::as_int) { - out.push((dict.get(b"Pg").and_then(Object::as_ref).or(page), mcid)); - } +/// Bound both graph work and the queued array entries. Following references never uses the +/// call stack; the depth limit also rejects pathologically nested direct content explicitly. +fn direct_marks( + doc: &Document, + item: &Object, + page: Option, + seen: &mut std::collections::HashSet, + out: &mut Vec<(Option, i64)>, +) -> Result<(), TagError> { + const MAX_DEPTH: usize = 512; + const MAX_MARKS: usize = 65_536; + // Account for the root `/K` container separately so an array of 65,536 entries passes. + let mut remaining = MAX_MARKS + 1; + let mut stack = vec![(std::sync::Arc::new(item.clone()), 0usize)]; + while let Some((item, depth)) = stack.pop() { + remaining = remaining.checked_sub(1).ok_or(TagError::ContentLimit)?; + if depth > MAX_DEPTH { + return Err(TagError::ContentLimit); } - Object::Ref(reference) => { - if let Some(dict) = doc.get(*reference).as_dict() - && dict.name(b"Type") == Some(b"MCR") - && let Some(mcid) = dict.get(b"MCID").and_then(Object::as_int) - { - out.push((dict.get(b"Pg").and_then(Object::as_ref).or(page), mcid)); + match &*item { + Object::Int(mcid) => { + if out.len() >= MAX_MARKS { + return Err(TagError::ContentLimit); + } + out.push((page, *mcid)); } + Object::Array(items) => { + if items.len() > remaining.saturating_sub(stack.len()) { + return Err(TagError::ContentLimit); + } + stack.extend(items.iter().rev().map(|item| (std::sync::Arc::new(item.clone()), depth + 1))); + } + Object::Dict(dict) if dict.get(b"Type").map(|t| doc.resolve(t)).as_deref().and_then(Object::as_name) == Some(b"MCR") => { + if let Some(mcid) = dict.get(b"MCID").map(|m| doc.resolve(m)).and_then(|m| m.as_int()) { + if out.len() >= MAX_MARKS { + return Err(TagError::ContentLimit); + } + out.push((dict.get(b"Pg").and_then(Object::as_ref).or(page), mcid)); + } + } + Object::Ref(reference) if seen.insert(*reference) => { + let target = doc.get(*reference); + // Resolved non-MCR dictionaries are child structure elements, not direct content, + // so do not push them onto the stack or charge them a second time. + let is_non_mcr_dict = matches!( + &*target, + Object::Dict(dict) + if dict.get(b"Type").map(|t| doc.resolve(t)).as_deref().and_then(Object::as_name) != Some(b"MCR") + ); + if !is_non_mcr_dict { + stack.push((target, depth + 1)); + } + } + _ => {} } - _ => {} } + Ok(()) } /// The attribute objects of a tag's `/A` entry (one object, or an array of them each optionally @@ -236,8 +304,8 @@ fn attribute_entries(doc: &Document, d: &Dict) -> (Vec<(Object, Option)> let Object::Array(items) = &*resolved else { return (vec![(a.clone(), None)], false) }; let mut out: Vec<(Object, Option)> = Vec::new(); for item in items { - match (item, out.last_mut()) { - (Object::Int(_), Some((_, revision @ None))) => *revision = Some(item.clone()), + match (doc.resolve(item).as_int(), out.last_mut()) { + (Some(_), Some((_, revision @ None))) => *revision = Some(item.clone()), _ => out.push((item.clone(), None)), } } @@ -245,13 +313,18 @@ fn attribute_entries(doc: &Document, d: &Dict) -> (Vec<(Object, Option)> } /// Whether attribute object `o` (resolved) is a Table attribute object. -fn is_table_attributes(o: &Object) -> bool { - o.as_dict().is_some_and(|d| d.name(b"O") == Some(b"Table")) +fn is_table_attributes(doc: &Document, o: &Object) -> bool { + o.as_dict().and_then(|d| d.get(b"O")).map(|v| doc.resolve(v)).as_deref().and_then(Object::as_name) == Some(b"Table") } /// The value of `key` in the first Table attribute object of tag `d` that has it. fn table_attribute(doc: &Document, d: &Dict, key: &[u8]) -> Option { - attribute_entries(doc, d).0.iter().map(|(o, _)| doc.resolve(o)).filter(|o| is_table_attributes(o)).find_map(|o| o.as_dict()?.get(key).cloned()) + attribute_entries(doc, d) + .0 + .iter() + .map(|(o, _)| doc.resolve(o)) + .filter(|o| is_table_attributes(doc, o)) + .find_map(|o| o.as_dict()?.get(key).map(|v| (*doc.resolve(v)).clone()).filter(|v| !matches!(v, Object::Null))) } /// Set `key` in the Table attribute object of `object`'s `/A` entry that holds it (else the first @@ -266,11 +339,12 @@ fn set_table_attribute(doc: &mut Document, object: ObjRef, key: &[u8], value: Op let mut entries: Vec<(Object, Option, Option)> = entries .into_iter() .map(|(o, r)| { - let table = Some(doc.resolve(&o)).filter(|a| is_table_attributes(a)).and_then(|a| a.as_dict().cloned()); + let table = Some(doc.resolve(&o)).filter(|a| is_table_attributes(doc, a)).and_then(|a| a.as_dict().cloned()); (o, r, table) }) .collect(); - let holding = |e: &(Object, Option, Option)| e.2.as_ref().is_some_and(|d| d.contains(key)); + let holding = + |e: &(Object, Option, Option)| e.2.as_ref().and_then(|d| d.get(key)).is_some_and(|v| !matches!(*doc.resolve(v), Object::Null)); match value { Some(v) => match entries.iter().position(holding).or_else(|| entries.iter().position(|e| e.2.is_some())) { Some(i) => { @@ -331,7 +405,7 @@ pub fn set_tag_property(doc: &mut Document, object: ObjRef, property: TagPropert if let Some(owner) = property.table_attribute_of() { let role_map = alt::tree_root(doc).and_then(|r| r.get(b"RoleMap").map(|o| doc.resolve(o))).and_then(|o| o.as_dict().cloned()).unwrap_or_default(); - let ty = element.dict.name(b"S").map(|s| alt::standard(doc, &role_map, s)).unwrap_or_default(); + let ty = tag_type_name(doc, &element.dict).map(|s| alt::standard(doc, &role_map, &s)).unwrap_or_default(); if ty != owner.as_bytes() { let name = if property == TagProperty::Summary { "summary" } else { "scope" }; return Err(TagError::WrongTagType(name, owner)); @@ -357,3 +431,168 @@ pub fn set_tag_property(doc: &mut Document, object: ObjRef, property: TagPropert })?; Ok(()) } + +#[cfg(test)] +mod tests { + use std::sync::Arc; + + use super::*; + + fn pdf(bodies: &[String]) -> Document { + let mut out = b"%PDF-1.7\n".to_vec(); + let mut offsets = Vec::new(); + for (i, b) in bodies.iter().enumerate() { + offsets.push(out.len()); + out.extend_from_slice(format!("{} 0 obj\n{b}\nendobj\n", i + 1).as_bytes()); + } + let xref = out.len(); + out.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", bodies.len() + 1).as_bytes()); + for o in offsets { + out.extend_from_slice(format!("{o:010} 00000 n \n").as_bytes()); + } + out.extend_from_slice(format!("trailer << /Size {} /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n", bodies.len() + 1).as_bytes()); + Document::open(Arc::new(out)).unwrap() + } + + #[test] + fn tag_content_and_table_attributes_resolve_indirect_entries() { + let content = "/P << /MCID 0 >> BDC BT /F1 12 Tf (Hello) Tj ET EMC /P << /MCID 1 >> BDC BT /F1 12 Tf (World) Tj ET EMC"; + let doc = pdf(&[ + "<< /Type /Catalog /Pages 2 0 R /StructTreeRoot 5 0 R >>".into(), + "<< /Type /Pages /Kids [3 0 R] /Count 1 >>".into(), + "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 200 200] /Contents 4 0 R /Resources << /Font << /F1 << /Type /Font /Subtype /Type1 /BaseFont /Helvetica >> >> >> >>".into(), + format!("<< /Length {} >>\nstream\n{content}\nendstream", content.len()), + "<< /Type /StructTreeRoot /K 6 0 R >>".into(), + "<< /S /Document /P 5 0 R /K [7 0 R 8 0 R] >>".into(), + "<< /S /Table /P 6 0 R /Pg 3 0 R /A << /O 11 0 R /Summary 12 0 R >> /K 9 0 R >>".into(), + "<< /S /TH /P 6 0 R /Pg 3 0 R /A [ << /O 11 0 R /Scope 13 0 R >> 14 0 R ] /K 10 0 R >>".into(), + "[15 0 R << /Type /MCR /MCID 16 0 R >>]".into(), + "15 0 R".into(), + "/Table".into(), + "(Indirect summary)".into(), + "/Column".into(), + "1".into(), + "0".into(), + "1".into(), + ]); + let listed = tags(&doc); + let table = listed.iter().find(|t| t.object == ObjRef::new(7, 0)).unwrap(); + let th = listed.iter().find(|t| t.object == ObjRef::new(8, 0)).unwrap(); + assert_eq!(table.summary.as_deref(), Some("Indirect summary")); + assert_eq!(th.scope.as_deref(), Some("Column")); + + let table_marks = tag_content(&doc, ObjRef::new(7, 0)).unwrap(); + assert_eq!( + table_marks, + [TagContentItem { page: Some(0), mcid: 0, text: "Hello".into() }, TagContentItem { page: Some(0), mcid: 1, text: "World".into() },] + ); + let th_marks = tag_content(&doc, ObjRef::new(8, 0)).unwrap(); + assert_eq!(th_marks, [TagContentItem { page: Some(0), mcid: 0, text: "Hello".into() }]); + } + + #[test] + fn move_tag_resolves_indirect_array_children_and_cycles() { + let mut doc = pdf(&[ + "<< /Type /Catalog /StructTreeRoot 2 0 R >>".into(), + "<< /Type /StructTreeRoot /K 3 0 R >>".into(), + "<< /S /Document /P 2 0 R /K 7 0 R >>".into(), + "<< /S /P /P 3 0 R >>".into(), + "<< /S /P /P 3 0 R >>".into(), + "[4 0 R 6 0 R 5 0 R]".into(), + "6 0 R".into(), + ]); + let first = ObjRef::new(4, 7); + let second = ObjRef::new(5, 9); + doc.set(first, (*doc.get(ObjRef::new(4, 0))).clone()); + doc.set(second, (*doc.get(ObjRef::new(5, 0))).clone()); + doc.set(ObjRef::new(6, 0), Object::Array(vec![Object::Ref(first), Object::Ref(ObjRef::new(6, 0)), Object::Ref(second)])); + assert_eq!(tags(&doc).iter().map(|t| t.object).collect::>(), [ObjRef::new(3, 0), first, second]); + move_tag(&mut doc, second, first, TagPlacement::Before).unwrap(); + assert_eq!(tags(&doc).iter().map(|t| t.object).collect::>(), [ObjRef::new(3, 0), second, first]); + } + + #[test] + fn tag_content_rejects_excessive_indirect_depth() { + let mut doc = + pdf(&["<< /Type /Catalog /StructTreeRoot 2 0 R >>".into(), "<< /Type /StructTreeRoot /K 3 0 R >>".into(), "<< /S /P /P 2 0 R >>".into()]); + let mut kid = doc.add(Object::Int(42)); + for _ in 0..128 { + kid = doc.add(Object::Ref(kid)); + } + doc.update_dict(ObjRef::new(3, 0), |d| d.set(b"K".to_vec(), Object::Ref(kid))).unwrap(); + assert_eq!(tag_content(&doc, ObjRef::new(3, 0)).unwrap().iter().map(|m| m.mcid).collect::>(), [42]); + for _ in 0..1024 { + kid = doc.add(Object::Ref(kid)); + } + doc.update_dict(ObjRef::new(3, 0), |d| d.set(b"K".to_vec(), Object::Ref(kid))).unwrap(); + assert_eq!(tag_content(&doc, ObjRef::new(3, 0)), Err(TagError::ContentLimit)); + } + + #[test] + fn tag_content_rejects_excessive_mark_count() { + let mut doc = + pdf(&["<< /Type /Catalog /StructTreeRoot 2 0 R >>".into(), "<< /Type /StructTreeRoot /K 3 0 R >>".into(), "<< /S /P /P 2 0 R >>".into()]); + doc.update_dict(ObjRef::new(3, 0), |d| d.set(b"K".to_vec(), Object::Array(vec![Object::Int(1); 65_537]))).unwrap(); + assert_eq!(tag_content(&doc, ObjRef::new(3, 0)), Err(TagError::ContentLimit)); + } + + #[test] + fn tag_content_accepts_full_mark_limit() { + let mut doc = + pdf(&["<< /Type /Catalog /StructTreeRoot 2 0 R >>".into(), "<< /Type /StructTreeRoot /K 3 0 R >>".into(), "<< /S /P /P 2 0 R >>".into()]); + doc.update_dict(ObjRef::new(3, 0), |d| d.set(b"K".to_vec(), Object::Array(vec![Object::Int(1); 65_536]))).unwrap(); + assert_eq!(tag_content(&doc, ObjRef::new(3, 0)).unwrap().len(), 65_536); + } + + #[test] + fn tag_content_skips_many_child_elements_without_spending_mark_budget() { + let mut doc = + pdf(&["<< /Type /Catalog /StructTreeRoot 2 0 R >>".into(), "<< /Type /StructTreeRoot /K 3 0 R >>".into(), "<< /S /P /P 2 0 R >>".into()]); + let mut kids = Vec::new(); + for _ in 0..33_000 { + let mut child = Dict::new(); + child.set(b"S".to_vec(), Object::name("P")); + kids.push(Object::Ref(doc.add(Object::Dict(child)))); + } + kids.push(Object::Int(42)); + doc.update_dict(ObjRef::new(3, 0), |d| d.set(b"K".to_vec(), Object::Array(kids))).unwrap(); + assert_eq!(tag_content(&doc, ObjRef::new(3, 0)).unwrap().iter().map(|m| m.mcid).collect::>(), [42]); + } + + #[test] + fn tag_content_keeps_order_and_stops_indirect_cycles() { + let doc = pdf(&[ + "<< /Type /Catalog /StructTreeRoot 2 0 R >>".into(), + "<< /Type /StructTreeRoot /K 3 0 R >>".into(), + "<< /S /P /P 2 0 R /K 4 0 R >>".into(), + "[1 5 0 R 4 0 R 3]".into(), + "<< /Type /MCR /MCID 2 >>".into(), + ]); + assert_eq!(tag_content(&doc, ObjRef::new(3, 0)).unwrap().iter().map(|m| m.mcid).collect::>(), [1, 2, 3]); + } + + #[test] + fn move_tag_rejects_non_sibling_tags_sharing_stale_parent_ref() { + // 8 0 R is a child of 6 0 R, while 9 0 R is a child of 7 0 R, even though both point /P at 6 0 R. + let mut doc = pdf(&[ + "<< /Type /Catalog /StructTreeRoot 2 0 R >>".into(), + "<< /Type /StructTreeRoot /K 3 0 R >>".into(), + "<< /S /Document /P 2 0 R /K [6 0 R 7 0 R] >>".into(), + "<< /S /P >>".into(), + "<< /S /P >>".into(), + "<< /S /Sect /P 3 0 R /K [8 0 R 9 0 R] >>".into(), + "<< /S /Sect /P 3 0 R /K [9 0 R] >>".into(), + "<< /S /P /P 6 0 R >>".into(), + "<< /S /P /P 6 0 R >>".into(), + ]); + // Give 6 0 R only [8 0 R] in the reachable tree, and put [8 0 R 9 0 R] on an unreachable object 4 0 R. + doc.update_dict(ObjRef::new(6, 0), |d| d.set(b"K".to_vec(), Object::Array(vec![Object::Ref(ObjRef::new(8, 0))]))).unwrap(); + doc.update_dict(ObjRef::new(4, 0), |d| { + d.set(b"K".to_vec(), Object::Array(vec![Object::Ref(ObjRef::new(8, 0)), Object::Ref(ObjRef::new(9, 0))])); + }) + .unwrap(); + doc.update_dict(ObjRef::new(8, 0), |d| d.set(b"P".to_vec(), Object::Ref(ObjRef::new(4, 0)))).unwrap(); + doc.update_dict(ObjRef::new(9, 0), |d| d.set(b"P".to_vec(), Object::Ref(ObjRef::new(4, 0)))).unwrap(); + assert_eq!(move_tag(&mut doc, ObjRef::new(9, 0), ObjRef::new(8, 0), TagPlacement::Before), Err(TagError::NotSiblings)); + } +} From 778ec3fc187350ddf61ae046fc4cfb7b7b7763b0 Mon Sep 17 00:00:00 2001 From: jonathanwxh-cell Date: Mon, 9 Nov 2026 18:15:12 +0800 Subject: [PATCH 05/64] fix(organize): preserve numbered names, indirect resource dicts, and comment threads (#5612) --- crates/organize/src/comments.rs | 81 +++++++++++++++-- crates/organize/src/dedupe.rs | 128 ++++++++++++++++++++++++++- crates/organize/src/import/carry.rs | 72 +++++++++++++-- crates/organize/src/import/fields.rs | 87 ++++++++++++++++-- crates/organize/src/tests.rs | 57 ++++++++++++ 5 files changed, 403 insertions(+), 22 deletions(-) diff --git a/crates/organize/src/comments.rs b/crates/organize/src/comments.rs index dd5d63ebc4..5ab894b48c 100644 --- a/crates/organize/src/comments.rs +++ b/crates/organize/src/comments.rs @@ -54,9 +54,13 @@ pub fn import_comments(dst: &mut Document, src: &Document) -> Result = from.iter().zip(&to).map(|((s, _), (d, _))| (*s, *d)).collect(); let mut incoming: Vec> = pages.iter().map(|(s, _)| page_comments(src, *s)).collect(); - let kept = threads_kept(&incoming); + let kept = threads_kept(src, &incoming); for list in &mut incoming { - list.retain(|c| c.dict.get(b"IRT").is_none_or(|irt| irt.as_ref().is_some_and(|p| kept.contains(&p)))); + list.retain(|c| match irt_parent(src, &c.dict) { + None => true, + Some(Some(p)) => kept.contains(&p), + Some(None) => false, + }); } let mut copier = Copier::new(src, HashMap::new(), pages.iter().copied().collect()); // Every comment's object is chosen before any is copied, so a comment that another one reaches @@ -155,13 +159,34 @@ fn reserve(dst: &mut Document, src: &Document, annots: &mut PageAnnots, mut dict (target, dict) } +/// Resolve `dict`'s `/IRT` entry: `None` when `/IRT` is absent or resolves to `null`, +/// `Some(Some(r))` when it references annotation object `r` (directly or through indirect +/// reference objects), or `Some(None)` for any other non-reference value. +fn irt_parent(src: &Document, dict: &Dict) -> Option> { + let v = dict.get(b"IRT")?; + if matches!(v, Object::Null) { + return None; + } + let Some(mut r) = v.as_ref() else { + return Some(None); + }; + for _ in 0..8 { + match *src.get(r) { + Object::Null => return None, + Object::Ref(next) => r = next, + _ => break, + } + } + Some(Some(r)) +} + /// The comments of `incoming` that go: a reply goes only with the comment it answers, and that one /// only with the comment it answers, up to the first comment of the thread. A thread whose replies /// answer each other in a loop goes, as all of its comments are there. Each comment is visited once. -fn threads_kept(incoming: &[Vec]) -> HashSet { +fn threads_kept(src: &Document, incoming: &[Vec]) -> HashSet { // For each comment: no parent, a parent comment, or a parent that is no comment of the source. let parents: HashMap>> = - incoming.iter().flatten().filter_map(|c| Some((c.entry.as_ref()?, c.dict.get(b"IRT").map(Object::as_ref)))).collect(); + incoming.iter().flatten().filter_map(|c| Some((c.entry.as_ref()?, irt_parent(src, &c.dict)))).collect(); let mut known: HashMap = HashMap::new(); for &start in parents.keys() { let (mut path, mut on_path) = (Vec::new(), HashSet::new()); @@ -202,8 +227,11 @@ fn page_comments(src: &Document, page: ObjRef) -> Vec { list.unwrap_or_default() .into_iter() .filter_map(|entry| { - let dict = src.resolve(&entry).as_dict().cloned()?; + let mut dict = src.resolve(&entry).as_dict().cloned()?; let subtype = dict.get(b"Subtype").map(|s| src.resolve(s)).and_then(|s| s.as_name().map(<[u8]>::to_vec))?; + if let Some(Some(r)) = irt_parent(src, &dict) { + dict.set(b"IRT".to_vec(), Object::Ref(r)); + } COMMENTS.contains(&subtype.as_slice()).then_some(Incoming { entry, dict }) }) .collect() @@ -217,7 +245,8 @@ fn page_popups(src: &Document, page: ObjRef) -> Vec<(ObjRef, ObjRef)> { .filter_map(|entry| { let r = entry.as_ref()?; let d = src.get(r).as_dict().cloned()?; - (d.name(b"Subtype") == Some(b"Popup".as_slice())).then(|| d.reference(b"Parent").map(|p| (r, p)))? + let is_popup = d.get(b"Subtype").map(|s| src.resolve(s)).as_deref().and_then(Object::as_name) == Some(b"Popup".as_slice()); + is_popup.then(|| d.reference(b"Parent").map(|p| (r, p)))? }) .collect() } @@ -265,7 +294,7 @@ impl PageAnnots { if let Some(name) = d.get(b"NM").and_then(|n| dst.resolve(n).as_string().map(|s| s.to_text())) { names.entry(name).or_default().push(r); } - if d.name(b"Subtype") == Some(b"Popup".as_slice()) + if d.get(b"Subtype").map(|s| dst.resolve(s)).as_deref().and_then(Object::as_name) == Some(b"Popup".as_slice()) && let Some(parent) = d.reference(b"Parent") { popups.entry(parent).or_default().push(r); @@ -654,4 +683,42 @@ mod tests { assert_eq!(import_comments(&mut doc, &src).unwrap(), 0); assert_eq!(page_annots(&doc, 0).len(), before); } + + #[test] + fn comment_with_null_irt_and_popup_with_indirect_subtype_are_imported() { + let src = build(&[ + "<< /Type /Catalog /Pages 2 0 R >>", + "<< /Type /Pages /Kids [3 0 R 8 0 R] /Count 2 /MediaBox [0 0 300 400] >>", + "<< /Type /Page /Parent 2 0 R /Annots [4 0 R 5 0 R 6 0 R 9 0 R 10 0 R 11 0 R 12 0 R 13 0 R 16 0 R] >>", + "<< /Type /Annot /Subtype /Text /NM (root-null-irt) /IRT null /Contents (root comment) /Rect [10 10 30 30] /P 3 0 R >>", + "<< /Type /Annot /Subtype /Text /IRT 14 0 R /Contents (reply to root) /Rect [10 10 30 30] /P 3 0 R >>", + "<< /Type /Annot /Subtype 7 0 R /Parent 4 0 R /Rect [40 40 200 120] /P 3 0 R >>", + "/Popup", + "<< /Type /Page /Parent 2 0 R /Annots [15 0 R] >>", + // Three-reply chain on page 1 whose root comment (15 0 R) is on dropped page 2: + "<< /Type /Annot /Subtype /Text /IRT 15 0 R /Contents (dropped-1) /Rect [10 10 30 30] /P 3 0 R >>", + "<< /Type /Annot /Subtype /Text /IRT 9 0 R /Contents (dropped-2) /Rect [10 10 30 30] /P 3 0 R >>", + "<< /Type /Annot /Subtype /Text /IRT 10 0 R /Contents (dropped-3) /Rect [10 10 30 30] /P 3 0 R >>", + // Two-reply cycle on page 1: + "<< /Type /Annot /Subtype /Text /IRT 13 0 R /Contents (cycle-a) /Rect [10 10 30 30] /P 3 0 R >>", + "<< /Type /Annot /Subtype /Text /IRT 12 0 R /Contents (cycle-b) /Rect [10 10 30 30] /P 3 0 R >>", + "4 0 R", + "<< /Type /Annot /Subtype /Text /Contents (dropped root) /Rect [10 10 30 30] /P 8 0 R >>", + // Comment whose /IRT chains through an intermediate reference (17 0 R -> 18 0 R -> null): + "<< /Type /Annot /Subtype /Text /IRT 17 0 R /Contents (indirect null irt) /Rect [10 10 30 30] /P 3 0 R >>", + "18 0 R", + "null", + ]); + let mut dst = build(&[ + "<< /Type /Catalog /Pages 2 0 R >>", + "<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 300 400] >>", + "<< /Type /Page /Parent 2 0 R >>", + ]); + assert_eq!(import_comments(&mut dst, &src).unwrap(), 5); + let annots = page_annots(&dst, 0); + assert_eq!(annots.len(), 6, "two root comments, reply via indirect IRT, two cycling replies, and the popup"); + let (root_ref, _) = by_contents(&annots, "root comment"); + assert_eq!(by_contents(&annots, "reply to root").1.reference(b"IRT"), Some(*root_ref)); + assert!(by_contents(&annots, "indirect null irt").1.get(b"IRT").is_some()); + } } diff --git a/crates/organize/src/dedupe.rs b/crates/organize/src/dedupe.rs index fe5cb0749d..f242fea0e1 100644 --- a/crates/organize/src/dedupe.rs +++ b/crates/organize/src/dedupe.rs @@ -45,6 +45,39 @@ fn is_resource(o: &Object) -> bool { /// out on purpose: it names optional content groups, whose identity matters. const RESOURCE_CATEGORIES: &[&[u8]] = &[b"ExtGState", b"ColorSpace", b"Pattern", b"Shading", b"XObject", b"Font"]; +/// Whether `d` carries annotation, page, field, or structure identity rather than resource data. +/// Annotations often omit `/Type /Annot` (ISO 32000-2 Table 166 requires `/Subtype` and `/Rect`), +/// whereas resource dictionaries and category maps never carry `/Rect`, `/Popup`, `/IRT`, or `/FT`. +fn is_identity_dict(doc: &Document, d: &pdfcraft_cos::Dict) -> bool { + if let Some(t) = d.get(b"Type").map(|v| doc.resolve(v)).as_deref().and_then(Object::as_name) + && !RESOURCE_TYPES.contains(&t) + { + return true; + } + d.contains(b"Rect") || d.contains(b"Popup") || d.contains(b"IRT") || d.contains(b"FT") || d.contains(b"Parent") +} + +/// Whether a category member has the expected resource shape and is not an identity-bearing +/// dictionary such as an annotation or form field. +fn category_entry(doc: &Document, category: &[u8], value: &Object) -> bool { + let value = doc.resolve(value); + match category { + b"Font" | b"ExtGState" => matches!(&*value, Object::Dict(d) if !is_identity_dict(doc, d)), + b"XObject" => matches!(&*value, Object::Stream(s) if !is_identity_dict(doc, &s.dict)), + b"Pattern" | b"Shading" => match &*value { + Object::Dict(d) => !is_identity_dict(doc, d), + Object::Stream(s) => !is_identity_dict(doc, &s.dict), + _ => false, + }, + b"ColorSpace" => match &*value { + Object::Name(name) => matches!(name.as_slice(), b"DeviceGray" | b"DeviceRGB" | b"DeviceCMYK" | b"Pattern"), + Object::Array(items) => items.first().and_then(Object::as_name).is_some_and(|name| COLOR_SPACE_FAMILIES.contains(&name)), + _ => false, + }, + _ => false, + } +} + /// References listed in any `/Resources` dictionary reachable inside `o` (pages, forms, Type 3 /// fonts, annotation appearances), including the `/Resources` dictionary itself when indirect. fn collect_resource_refs(doc: &Document, o: &Object, out: &mut HashSet) { @@ -69,6 +102,15 @@ fn collect_resource_refs(doc: &Document, o: &Object, out: &mut HashSet) for cat in RESOURCE_CATEGORIES { let entries = res.get(cat).map(|c| doc.resolve(c)); if let Some(entries) = entries.as_ref().and_then(|e| e.as_dict()) { + // A malformed category can point directly at an annotation or list + // annotations inside a category map. Only resource-name maps whose + // members are non-identity resources are containers. + if is_identity_dict(doc, entries) || !entries.iter().all(|(_, value)| category_entry(doc, cat, value)) { + continue; + } + if let Some(Object::Ref(cat_ref)) = res.get(cat) { + out.insert(*cat_ref); + } out.extend(entries.iter().filter_map(|(_, v)| if let Object::Ref(r) = v { Some(*r) } else { None })); } } @@ -189,11 +231,15 @@ fn contains_ref(o: &Object, map: &HashMap) -> bool { /// Merge duplicate resources among `candidates` (into each other or into equal objects already /// in `doc`), rewrite references inside `candidates`, and free the merged-away objects. /// When `index_existing` is false, only candidates are compared (a brand-new document). +/// Repeated candidates are processed once, by complete object reference. /// Returns how many objects were removed. pub fn dedupe_resources(doc: &mut Document, candidates: &[ObjRef], index_existing: bool) -> usize { let cand: HashSet = candidates.iter().copied().collect(); let mut sorted: Vec = candidates.to_vec(); - sorted.sort_unstable_by_key(|r| r.num); + sorted.sort_unstable_by_key(|r| (r.num, r.generation)); + // A repeated candidate must not merge into itself: that would cycle `canon` and free + // the canonical object. Keep generations distinct when normalizing references. + sorted.dedup(); // Existing objects first, so candidates merge into them rather than the reverse. let mut all: Vec<(ObjRef, std::sync::Arc)> = Vec::new(); if index_existing { @@ -273,6 +319,37 @@ mod tests { doc.add(Object::Dict(d)) } + #[test] + fn repeated_candidate_keeps_the_canonical_object() { + for index_existing in [false, true] { + let mut doc = Document::new_empty(); + let r = doc.add(stream(b"glyphs")); + assert_eq!(dedupe_resources(&mut doc, &[r, r], index_existing), 0); + assert_eq!(*doc.get(r), stream(b"glyphs")); + } + } + + #[test] + fn repeated_candidate_with_referrer_terminates_and_keeps_generations() { + for index_existing in [false, true] { + let mut doc = Document::new_empty(); + let r = ObjRef::new(20, 7); + doc.set(r, stream(b"glyphs")); + let s = font(&mut doc, r, "A"); + assert_eq!(dedupe_resources(&mut doc, &[r, r, s], index_existing), 0); + assert_eq!(*doc.get(r), stream(b"glyphs")); + assert_eq!(doc.generation(r.num), 7); + assert_eq!(doc.get(s).as_dict().unwrap().get(b"FontFile"), Some(&Object::Ref(r))); + + let duplicate = ObjRef::new(30, 9); + doc.set(duplicate, stream(b"glyphs")); + let t = font(&mut doc, duplicate, "B"); + assert_eq!(dedupe_resources(&mut doc, &[r, r, s, duplicate, duplicate, t], index_existing), 1); + assert_eq!(*doc.get(r), stream(b"glyphs")); + assert_eq!(doc.get(t).as_dict().unwrap().get(b"FontFile"), Some(&Object::Ref(r))); + } + } + #[test] fn merges_bottom_up_and_keeps_identity_objects() { let mut doc = Document::new_empty(); @@ -367,4 +444,53 @@ mod tests { let cs = doc.get(h).as_dict().unwrap().get(b"CS").unwrap().clone(); assert_eq!(cs, Object::Array(vec![Object::name("ICCBased"), Object::Ref(old)])); } + + #[test] + fn indirect_resource_category_and_resources_dictionaries_merge() { + let mut doc = Document::new_empty(); + let mut gs = Dict::new(); + gs.set(b"CA".to_vec(), Object::Real(0.5)); + let g1 = doc.add(Object::Dict(gs.clone())); + let g2 = doc.add(Object::Dict(gs)); + + let page_with_indirect_res = |doc: &mut Document, g: ObjRef| { + let mut ext = Dict::new(); + ext.set(b"G0".to_vec(), Object::Ref(g)); + let ext_ref = doc.add(Object::Dict(ext)); + let mut res = Dict::new(); + res.set(b"ExtGState".to_vec(), Object::Ref(ext_ref)); + let res_ref = doc.add(Object::Dict(res)); + let mut p = Dict::new(); + p.set(b"Type".to_vec(), Object::name("Page")); + p.set(b"Resources".to_vec(), Object::Ref(res_ref)); + let p_ref = doc.add(Object::Dict(p)); + (ext_ref, res_ref, p_ref) + }; + let (ext1, res1, p1) = page_with_indirect_res(&mut doc, g1); + let (ext2, res2, p2) = page_with_indirect_res(&mut doc, g2); + assert_eq!(dedupe_resources(&mut doc, &[g1, g2, ext1, res1, p1, ext2, res2, p2], false), 3); + assert_eq!(doc.get(p1).as_dict().unwrap().get(b"Resources"), Some(&Object::Ref(res1))); + assert_eq!(doc.get(p2).as_dict().unwrap().get(b"Resources"), Some(&Object::Ref(res1))); + assert_eq!(doc.get(res1).as_dict().unwrap().get(b"ExtGState"), Some(&Object::Ref(ext1))); + + // Distinct font maps (`F1` and `F10` with different fonts on one page vs the same on another) + // deduplicate identical fonts while preserving distinct `F1`/`F10` entries. + let ff1 = doc.add(stream(b"font-f1")); + let ff10 = doc.add(stream(b"font-f10")); + let f1_a = font(&mut doc, ff1, "Helvetica"); + let f10_a = font(&mut doc, ff10, "Courier"); + let f1_b = font(&mut doc, ff1, "Helvetica"); + let f10_b = font(&mut doc, ff10, "Times-Roman"); + let mut fmap_a = Dict::new(); + fmap_a.set(b"F1".to_vec(), Object::Ref(f1_a)); + fmap_a.set(b"F10".to_vec(), Object::Ref(f10_a)); + let fmap_a_ref = doc.add(Object::Dict(fmap_a)); + let mut fmap_b = Dict::new(); + fmap_b.set(b"F1".to_vec(), Object::Ref(f1_b)); + fmap_b.set(b"F10".to_vec(), Object::Ref(f10_b)); + let fmap_b_ref = doc.add(Object::Dict(fmap_b)); + assert_eq!(dedupe_resources(&mut doc, &[f1_a, f10_a, f1_b, f10_b, fmap_a_ref, fmap_b_ref], false), 1); + assert_eq!(doc.get(fmap_b_ref).as_dict().unwrap().get(b"F1"), Some(&Object::Ref(f1_a))); + assert_eq!(doc.get(fmap_b_ref).as_dict().unwrap().get(b"F10"), Some(&Object::Ref(f10_b))); + } } diff --git a/crates/organize/src/import/carry.rs b/crates/organize/src/import/carry.rs index 00fb9bd0b8..fdfb9ab597 100644 --- a/crates/organize/src/import/carry.rs +++ b/crates/organize/src/import/carry.rs @@ -28,14 +28,20 @@ pub(crate) fn set_attachments(dst: &mut Document, mut entries: Vec<(Vec, Obj } // Names are compared and numbered as text: a key written as UTF-16 (a name with an accent // or Chinese letters) must not get ASCII bytes appended to it. + let reserved: std::collections::HashSet = entries.iter().map(|(k, _)| pdfcraft_cos::PdfString::literal(k.clone()).to_text()).collect(); let mut seen = std::collections::HashSet::new(); for (k, _) in entries.iter_mut() { let base = pdfcraft_cos::PdfString::literal(k.clone()).to_text(); let mut text = base.clone(); let mut n = 2; - while !seen.insert(text.clone()) { - text = format!("{base} ({n})"); - n += 1; + if !seen.insert(text.clone()) { + loop { + text = format!("{base} ({n})"); + n += 1; + if !reserved.contains(&text) && seen.insert(text.clone()) { + break; + } + } } if text != base { *k = pdfcraft_cos::PdfString::text(&text).bytes; @@ -141,10 +147,11 @@ fn outline_level(m: &Copier<'_>, first: Option, depth: u8, budget: &mut let target = item.get(b"Dest").cloned().or_else(|| { let a = m.src.resolve(item.get(b"A")?); let a = a.as_dict()?; - (a.name(b"S") == Some(b"GoTo")).then(|| a.get(b"D").cloned()).flatten() + let is_goto = a.get(b"S").map(|s| m.src.resolve(s)).as_deref().and_then(Object::as_name) == Some(b"GoTo"); + is_goto.then(|| a.get(b"D").cloned()).flatten() }); let dest = target.and_then(|t| m.map_dest(&t)); - let open = item.int(b"Count").is_some_and(|c| c > 0); + let open = item.get(b"Count").and_then(|c| m.src.resolve(c).as_int()).is_some_and(|c| c > 0); let children = outline_level(m, item.reference(b"First"), depth + 1, budget); made.push(OutlineNode { title, colour, flags, dest, open, children }); } @@ -235,3 +242,58 @@ pub(super) fn add_outline(doc: &mut Document, marks: &[Mark]) -> Result<(), Orga })?; Ok(()) } + +#[cfg(test)] +mod tests { + use std::sync::Arc; + + use super::*; + use crate::attachment_keys; + + fn build(bodies: &[&str]) -> Document { + let mut out = b"%PDF-1.7\n".to_vec(); + let mut offsets = Vec::new(); + for (i, b) in bodies.iter().enumerate() { + offsets.push(out.len()); + out.extend_from_slice(format!("{} 0 obj\n{b}\nendobj\n", i + 1).as_bytes()); + } + let xref = out.len(); + out.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", bodies.len() + 1).as_bytes()); + for o in offsets { + out.extend_from_slice(format!("{o:010} 00000 n \n").as_bytes()); + } + out.extend_from_slice(format!("trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n", bodies.len() + 1).as_bytes()); + Document::open(Arc::new(out)).expect("opens") + } + + #[test] + fn set_attachments_does_not_clobber_later_numbered_attachment_name() { + let mut doc = build(&[ + "<< /Type /Catalog /Pages 2 0 R >>", + "<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 100 100] >>", + "<< /Type /Page /Parent 2 0 R >>", + ]); + let entries = + vec![(b"report.pdf".to_vec(), Object::Null), (b"report.pdf".to_vec(), Object::Null), (b"report.pdf (2)".to_vec(), Object::Null)]; + set_attachments(&mut doc, entries).unwrap(); + assert_eq!(attachment_keys(&doc), ["report.pdf", "report.pdf (2)", "report.pdf (3)"]); + } + + #[test] + fn source_outline_resolves_indirect_goto_action_type_and_count() { + let src = build(&[ + "<< /Type /Catalog /Pages 2 0 R /Outlines 4 0 R >>", + "<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 100 100] >>", + "<< /Type /Page /Parent 2 0 R >>", + "<< /Type /Outlines /First 5 0 R /Last 5 0 R /Count 1 >>", + "<< /Title (Parent) /Parent 4 0 R /First 6 0 R /Last 6 0 R /Count 8 0 R /A << /S 7 0 R /D [3 0 R /Fit] >> >>", + "<< /Title (Child) /Parent 5 0 R /Dest [3 0 R /Fit] >>", + "/GoTo", + "1", + ]); + let nodes = source_outline(&src, HashMap::from([(ObjRef::new(3, 0), ObjRef::new(3, 0))])); + assert_eq!(nodes.len(), 1); + assert!(nodes[0].dest.is_some()); + assert!(nodes[0].open); + } +} diff --git a/crates/organize/src/import/fields.rs b/crates/organize/src/import/fields.rs index 44910d0406..daeaecd3f6 100644 --- a/crates/organize/src/import/fields.rs +++ b/crates/organize/src/import/fields.rs @@ -50,19 +50,26 @@ pub fn combine_field_conflicts(sources: &[&Document]) -> Vec) -> Vec<(ObjRef, String, String, String)> { + let mut reserved = std::collections::HashSet::new(); + let mut seen = std::collections::HashSet::new(); + for &root in roots { + collect_names(doc, root, "", &mut seen, 0, &mut reserved); + } let mut planned = Vec::new(); - let mut walk = RenameWalk::default(); + let mut walk = RenameWalk { reserved, ..RenameWalk::default() }; for &root in roots { plan_renames_under(doc, root, "", taken, &mut walk, 0, &mut planned); } planned } -/// What one [`plan_renames`] call remembers: the nodes it visited, and for each colliding name -/// the suffix to try next. +/// What one [`plan_renames`] call remembers: the nodes it visited, the incoming tree's own +/// names (so an earlier rename does not steal a later sibling's name), and for each colliding +/// name the suffix to try next. #[derive(Default)] struct RenameWalk { seen: HashSet, + reserved: HashSet, next: HashMap, } @@ -88,18 +95,19 @@ fn plan_renames_under( // still taken: resume there instead of at 1. let mut n = walk.next.get(&fqn).copied().unwrap_or(1); let mut variant; + let mut candidate; while { #[cfg(test)] crate::work(1); variant = format!("{name}-{n}"); - taken.contains(&qualified(prefix, &variant)) + candidate = qualified(prefix, &variant); + taken.contains(&candidate) || walk.reserved.contains(&candidate) } { n = n.saturating_add(1); } walk.next.insert(fqn.clone(), n.saturating_add(1)); - let new_fqn = qualified(prefix, &variant); - planned.push((node, variant.clone(), fqn, new_fqn.clone())); - taken.insert(new_fqn); + planned.push((node, variant.clone(), fqn, candidate.clone())); + taken.insert(candidate); name = variant; } else { taken.insert(fqn); @@ -143,15 +151,16 @@ fn field_roots(doc: &Document) -> Result, OrganizeError> { let annots = d.get(b"Annots").map(|a| doc.resolve(a)).and_then(|a| a.as_array().cloned()).unwrap_or_default(); for a in annots.iter() { let Some(w) = doc.resolve(a).as_dict().cloned() else { continue }; - if w.name(b"Subtype") != Some(b"Widget") { + if w.get(b"Subtype").map(|s| doc.resolve(s)).as_deref().and_then(Object::as_name) != Some(b"Widget") { continue; } // Walk up to the field the widget belongs to, however deep it sits; a widget that // is its own field is its own root. let Some(mut root) = a.as_ref() else { continue }; let mut up = 0; + let mut visited = HashSet::from([root]); while let Some(parent) = doc.get(root).as_dict().and_then(|p| p.reference(b"Parent")) { - if up >= 32 || root == parent { + if up >= 32 || !visited.insert(parent) || doc.get(parent).as_dict().is_none() { break; } root = parent; @@ -295,3 +304,63 @@ pub(super) fn register_fields(dst: &mut Document, fields: &[ObjRef]) -> Result<( } Ok(()) } + +#[cfg(test)] +mod tests { + use std::sync::Arc; + + use super::*; + + fn build(bodies: &[&str]) -> Document { + let mut out = b"%PDF-1.7\n".to_vec(); + let mut offsets = Vec::new(); + for (i, b) in bodies.iter().enumerate() { + offsets.push(out.len()); + out.extend_from_slice(format!("{} 0 obj\n{b}\nendobj\n", i + 1).as_bytes()); + } + let xref = out.len(); + out.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", bodies.len() + 1).as_bytes()); + for o in offsets { + out.extend_from_slice(format!("{o:010} 00000 n \n").as_bytes()); + } + out.extend_from_slice(format!("trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n", bodies.len() + 1).as_bytes()); + Document::open(Arc::new(out)).expect("opens") + } + + #[test] + fn renaming_colliding_field_does_not_steal_later_sibling_name() { + let dst = build(&[ + "<< /Type /Catalog /Pages 2 0 R /AcroForm << /Fields [4 0 R] >> >>", + "<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 100 100] >>", + "<< /Type /Page /Parent 2 0 R /Annots [4 0 R] >>", + "<< /Type /Annot /Subtype /Widget /FT /Tx /T (item) /Rect [0 0 10 10] >>", + ]); + let src = build(&[ + "<< /Type /Catalog /Pages 2 0 R /AcroForm << /Fields [4 0 R 5 0 R] >> >>", + "<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 100 100] >>", + "<< /Type /Page /Parent 2 0 R /Annots [4 0 R 5 0 R] >>", + "<< /Type /Annot /Subtype /Widget /FT /Tx /T (item) /Rect [0 0 10 10] >>", + "<< /Type /Annot /Subtype /Widget /FT /Tx /T (item-1) /Rect [0 20 10 30] >>", + ]); + // Only "item" collides with dst; "item-1" in src is already unique and must not be stolen. + assert_eq!(field_name_conflicts(&dst, &src), [("item".to_string(), "item-2".to_string())]); + } + + #[test] + fn field_roots_keeps_widget_with_dangling_parent_or_indirect_subtype() { + let dst = build(&[ + "<< /Type /Catalog /Pages 2 0 R >>", + "<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 100 100] >>", + "<< /Type /Page /Parent 2 0 R /Annots [4 0 R] >>", + "<< /Type /Annot /Subtype 5 0 R /FT /Tx /T (orphan) /Parent 99 0 R /Rect [0 0 10 10] >>", + "/Widget", + ]); + let src = build(&[ + "<< /Type /Catalog /Pages 2 0 R /AcroForm << /Fields [4 0 R] >> >>", + "<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 100 100] >>", + "<< /Type /Page /Parent 2 0 R /Annots [4 0 R] >>", + "<< /Type /Annot /Subtype /Widget /FT /Tx /T (orphan) /Rect [0 0 10 10] >>", + ]); + assert_eq!(field_name_conflicts(&dst, &src), [("orphan".to_string(), "orphan-1".to_string())]); + } +} diff --git a/crates/organize/src/tests.rs b/crates/organize/src/tests.rs index 4e8aa02c67..c79c3d8667 100644 --- a/crates/organize/src/tests.rs +++ b/crates/organize/src/tests.rs @@ -1747,6 +1747,63 @@ fn bookmarks_report_the_view_their_destination_asks_for() { ); } +#[test] +fn malformed_resource_categories_preserve_annotation_identity_when_combining_and_inserting() { + for typed in [true, false] { + for with_popups in [false, true] { + let mut source = doc_a(); + let page = pages(&source).unwrap()[0].obj; + let mut annot = Dict::new(); + if typed { + annot.set(b"Type".to_vec(), Object::name("Annot")); + } + annot.set(b"Subtype".to_vec(), Object::name("Text")); + annot.set(b"Rect".to_vec(), Object::Array([0, 0, 10, 10].map(Object::Int).to_vec())); + let first = source.add(Object::Dict(annot.clone())); + let second = source.add(Object::Dict(annot.clone())); + if with_popups { + annot.set(b"Subtype".to_vec(), Object::name("Popup")); + let popup1 = source.add(Object::Dict(annot.clone())); + let popup2 = source.add(Object::Dict(annot)); + source.update_dict(first, |d| d.set(b"Popup".to_vec(), Object::Ref(popup1))).unwrap(); + source.update_dict(second, |d| d.set(b"Popup".to_vec(), Object::Ref(popup2))).unwrap(); + } + source + .update_dict(page, |d| { + let mut font_map = Dict::new(); + font_map.set(b"F1".to_vec(), Object::Ref(first)); + font_map.set(b"F2".to_vec(), Object::Ref(second)); + let mut resources = Dict::new(); + resources.set(b"Font".to_vec(), Object::Dict(font_map)); + resources.set(b"ExtGState".to_vec(), Object::Ref(second)); + d.set(b"Resources".to_vec(), Object::Dict(resources)); + d.set(b"Annots".to_vec(), Object::Array(vec![Object::Ref(first), Object::Ref(second)])); + }) + .unwrap(); + let combined = crate::combine(&[("source", &source)]).unwrap(); + let mut inserted = doc_a(); + crate::import_pages(&mut inserted, &source, &[0], 0).unwrap(); + for doc in [combined, inserted] { + let doc = full_roundtrip(&doc); + let page = pages(&doc).unwrap()[0].obj; + let refs = doc.get(page).as_dict().unwrap().get(b"Annots").unwrap().as_array().unwrap().clone(); + assert_eq!(refs.len(), 2); + assert_ne!(refs[0], refs[1], "equal annotations retain their separate identities"); + if with_popups { + let popups: Vec<_> = refs.iter().map(|r| doc.resolve(r).as_dict().unwrap().reference(b"Popup").unwrap()).collect(); + assert_ne!(popups[0], popups[1], "malformed category entries must not merge distinct popups"); + for popup in popups { + assert_eq!(doc.get(popup).as_dict().unwrap().name(b"Subtype"), Some(b"Popup".as_slice())); + } + } + for r in refs { + assert_eq!(doc.resolve(&r).as_dict().unwrap().name(b"Subtype"), Some(b"Text".as_slice())); + } + } + } + } +} + #[test] fn combine_and_insert_share_identical_resources() { let a = doc_a(); From 0e199bb602fa266d419ca7e4c412e63ec1c45fbb Mon Sep 17 00:00:00 2001 From: jonathanwxh-cell Date: Mon, 9 Nov 2026 18:15:28 +0800 Subject: [PATCH 06/64] fix(forms): harden tab order, AF script parsing, and null/inherited tree entries (#5614) --- crates/forms/README.md | 6 ++ crates/forms/src/af/calc.rs | 163 ++++++++++++++++++++++++---- crates/forms/src/af/script.rs | 157 +++++++++++++++++++++++---- crates/forms/src/af/tests.rs | 42 ++++++++ crates/forms/src/author/join.rs | 30 +++++- crates/forms/src/author/tree.rs | 186 ++++++++++++++++++++++++++++++-- crates/forms/src/tab_order.rs | 122 ++++++++++++++++++--- crates/forms/src/tests.rs | 37 +++++++ parity/acrobat-features.toml | 6 +- 9 files changed, 681 insertions(+), 68 deletions(-) diff --git a/crates/forms/README.md b/crates/forms/README.md index 6126dc1cbd..67ca3b3bfb 100644 --- a/crates/forms/README.md +++ b/crates/forms/README.md @@ -75,6 +75,12 @@ let cells: Vec = grid.cells_for(names)?; // "Table1_1", "Table original entry is preserved. Positive limits still apply. - Values are validated (options, `MaxLen`, single vs multi-select, NoToggleToOff, read-only) and a failed call changes nothing. +- Native AF recognition stops at the first genuine matching function call. If its arguments + are unsupported, the complete action remains a custom script for the caller's `Scripts` + interpreter; a later literal call must not replace the original conditional validation. + Async for-of loop headers and bodies do not expose AF-looking text inside regex literals. +- Moving choice fields preserves their own options rather than copying an ancestor's `/Opt`; + inherited check-box and radio-button export mappings remain intact. - `FieldProps::appearance` applies a `LookPatch` independently to each widget. `None` keeps a property; `Some(None)` removes a border/fill colour. Unchanged `/DA`, custom font resources, widget-specific `/DA` overrides, indirect `/MK` and `/BS` contents, dash arrays and unknown diff --git a/crates/forms/src/af/calc.rs b/crates/forms/src/af/calc.rs index 5c8098dfbb..1c3724ef4a 100644 --- a/crates/forms/src/af/calc.rs +++ b/crates/forms/src/af/calc.rs @@ -19,13 +19,21 @@ pub(super) enum Js { type Token = (Range, Js); /// Words after which `/` starts a regular expression instead of dividing. -const BEFORE_REGEX: [&str; 13] = ["return", "typeof", "case", "do", "else", "in", "of", "new", "delete", "void", "throw", "instanceof", "yield"]; +const BEFORE_REGEX: [&str; 12] = ["return", "typeof", "case", "do", "else", "in", "new", "delete", "void", "throw", "instanceof", "yield"]; /// Words whose condition, in parentheses, comes before a statement, so a `/` after it starts a /// regular expression. The `while` that ends a `do` loop counts too: the loop ends at its `)`, so /// a `/` after it starts the next statement, and can't divide. const BEFORE_CONDITION: [&str; 4] = ["if", "while", "for", "with"]; +/// Context of an open delimiter. Only the left side of a for header can introduce `of`. +#[derive(Clone, Copy)] +enum Delimiter { + Group, + Condition, + For { left: bool }, +} + /// Whether a line ends at `s[j]`: JavaScript ends one at `\n`, `\r`, U+2028 and U+2029. fn line_ends_at(s: &[u8], j: usize) -> bool { matches!(s.get(j..), Some([b'\n' | b'\r', ..] | [0xE2, 0x80, 0xA8 | 0xA9, ..])) @@ -46,10 +54,11 @@ pub(super) fn js_tokens(js: &str) -> (Vec, Vec>) { // Where a regular expression that never ended stopped being read: every `/` before it // divides, so a line of them is read once, not once for each. let mut divides_until = 0; - // For each `(` still open, whether it opened the condition of `if`, `while`, `for` or - // `with`; and whether the last token is the `)` that closed one. - let mut conditions: Vec = Vec::new(); + // Nested groups hide the surrounding for header: an `of` in a destructuring default + // or nested expression is an identifier, not the for-of separator. + let mut delimiters: Vec<(char, Delimiter)> = Vec::new(); let mut after_condition = false; + let mut after_for_of = false; 'scan: while let Some(&c) = s.get(i) { let start = i; let token = match c { @@ -90,7 +99,12 @@ pub(super) fn js_tokens(js: &str) -> (Vec, Vec>) { loop { match s.get(j) { None => break 'scan, - Some(b'\\') => j += 2, + Some(b'\\') => { + let Some(step) = js.get(j + 1..).and_then(|r| r.chars().next()).map(char::len_utf8) else { + break 'scan; + }; + j += 1 + step; + } Some(b'`') => break, Some(_) => j += 1, } @@ -98,7 +112,7 @@ pub(super) fn js_tokens(js: &str) -> (Vec, Vec>) { i = j + 1; Js::Other } - b'/' if i >= divides_until && regex_may_follow(js, &out, after_condition) => { + b'/' if i >= divides_until && regex_may_follow(js, &out, after_condition, after_for_of) => { // A regular expression ends at a `/` outside a class; a line break first means it // was a division after all. let (mut j, mut class) = (i + 1, false); @@ -106,7 +120,14 @@ pub(super) fn js_tokens(js: &str) -> (Vec, Vec>) { match s.get(j) { None => break None, Some(_) if line_ends_at(s, j) => break None, - Some(b'\\') => j += 2, + Some(b'\\') => { + if s.get(j + 1).is_none() || line_ends_at(s, j + 1) { + j += 1; + break None; + } + let step = js.get(j + 1..).and_then(|r| r.chars().next()).map_or(1, char::len_utf8); + j += 1 + step; + } Some(b'/') if !class => break Some(j), Some(b) => { class = match b { @@ -133,6 +154,16 @@ pub(super) fn js_tokens(js: &str) -> (Vec, Vec>) { } } } + b'+' | b'-' + if s.get(i + 1) == Some(&c) + && !regex_may_follow(js, &out, after_condition, after_for_of) + && out.last().is_some_and(|(range, _)| !(range.end..i).any(|j| line_ends_at(s, j))) => + { + // Postfix update ends an expression: a following slash divides. A line + // terminator forbids postfix syntax, including one inside a comment. + i += 2; + Js::Other + } _ if c.is_ascii_digit() => { while word(i) || s.get(i) == Some(&b'.') { i += 1; @@ -150,38 +181,88 @@ pub(super) fn js_tokens(js: &str) -> (Vec, Vec>) { Js::Punct(char::from(c)) } }; - after_condition = match token { - Js::Punct('(') => { - conditions.push(opens_condition(js, &out)); - false - } - Js::Punct(')') => conditions.pop().unwrap_or(false), - _ => false, - }; - out.push((start..i, token)); + let token = (start..i, token); + (after_condition, after_for_of) = advance_context(js, &token, &out, &mut delimiters); + out.push(token); } (out, comments) } +/// Advance only the delimiter/for-header context after a token has been classified. +/// Returns whether it closes a statement's condition or is the for-of separator. +fn advance_context(js: &str, token: &Token, tokens: &[Token], delimiters: &mut Vec<(char, Delimiter)>) -> (bool, bool) { + let mut after_for_of = false; + if let Some((_, Delimiter::For { left })) = delimiters.last_mut() { + if matches!(token.1, Js::Ident) && js.get(token.0.clone()) == Some("of") && *left && for_target_ends(js, tokens) { + *left = false; + after_for_of = true; + } else if matches!(token.1, Js::Punct(';' | '=')) + || (matches!(token.1, Js::Ident) && js.get(token.0.clone()) == Some("in") && !matches!(tokens.last(), Some((_, Js::Punct('.'))))) + { + *left = false; + } + } + let mut after_condition = false; + match token.1 { + Js::Punct('(') => delimiters.push((')', opens_condition(js, tokens))), + Js::Punct('[') => delimiters.push((']', Delimiter::Group)), + Js::Punct('{') => delimiters.push(('}', Delimiter::Group)), + Js::Punct(close @ (')' | ']' | '}')) if delimiters.last().is_some_and(|(end, _)| *end == close) => { + after_condition = matches!(delimiters.pop(), Some((_, Delimiter::Condition | Delimiter::For { .. }))); + } + _ => {} + } + (after_condition, after_for_of) +} + /// Whether a `/` after `tokens` starts a regular expression: after an operator, an opening /// bracket, a keyword such as `return` or the condition of `if`, `while`, `for` or `with`, not /// after a value. `after_condition` says the last token is the `)` that ends such a condition. -fn regex_may_follow(js: &str, tokens: &[Token], after_condition: bool) -> bool { +fn regex_may_follow(js: &str, tokens: &[Token], after_condition: bool, after_for_of: bool) -> bool { match tokens.last() { None => true, - Some((range, Js::Ident)) => js.get(range.clone()).is_some_and(|w| BEFORE_REGEX.contains(&w)), + Some((range, Js::Ident)) => { + !matches!(tokens.get(tokens.len().wrapping_sub(2)), Some((_, Js::Punct('.')))) + && js.get(range.clone()).is_some_and(|w| BEFORE_REGEX.contains(&w) || (w == "of" && after_for_of)) + } Some((_, Js::Punct(')'))) => after_condition, Some((_, Js::Punct(c))) => !matches!(c, ']' | '}'), Some((_, Js::Str(_) | Js::Other)) => false, } } -/// Whether a `(` after `tokens` opens the condition of `if`, `while`, `for` or `with`, and not +/// Whether a `(` after `tokens` opens the condition of `if`, `while`, `for` (including +/// `for await`) or `with`, and not /// the arguments of a method that has one of those names (`o.if(x)`). -fn opens_condition(js: &str, tokens: &[Token]) -> bool { +fn opens_condition(js: &str, tokens: &[Token]) -> Delimiter { let mut back = tokens.iter().rev(); - let Some((range, Js::Ident)) = back.next() else { return false }; - js.get(range.clone()).is_some_and(|w| BEFORE_CONDITION.contains(&w)) && !matches!(back.next(), Some((_, Js::Punct('.')))) + let Some((range, Js::Ident)) = back.next() else { return Delimiter::Group }; + let mut keyword = js.get(range.clone()); + if keyword == Some("await") { + let Some((range, Js::Ident)) = back.next() else { return Delimiter::Group }; + keyword = js.get(range.clone()); + if keyword != Some("for") { + return Delimiter::Group; + } + } + if matches!(back.next(), Some((_, Js::Punct('.')))) { + return Delimiter::Group; + } + match keyword { + Some("for") => Delimiter::For { left: true }, + Some(w) if BEFORE_CONDITION.contains(&w) => Delimiter::Condition, + _ => Delimiter::Group, + } +} + +/// A binding/assignment target before the for-of separator, not a declaration keyword, +/// expression-opening operator, or the start of the header itself. +fn for_target_ends(js: &str, tokens: &[Token]) -> bool { + match tokens.last() { + Some((range, Js::Ident)) => !matches!(js.get(range.clone()), Some("var" | "let" | "const")), + Some((_, Js::Punct(']' | '}' | ')'))) => true, + _ => false, + } } /// Where a Calculate script keeps its simplified field notation: between `BVCALC` and the @@ -455,10 +536,12 @@ fn splice(src: &str, edits: impl IntoIterator, String)>) -> } /// Simplified field notation as the JavaScript Acrobat writes after its `BVCALC` comment: -/// each field name becomes `AFMakeNumber(this.getField("name").value)`, the rest stays. +/// Each field name becomes `AFMakeNumber(this.getField("name").value)`; adjacent signs +/// are separated so binary plus/minus followed by a unary sign cannot become an update. pub fn notation_js(expr: &str) -> String { let names = notation_tokens(expr).into_iter().filter_map(|(range, t)| match t { Notation::Name(name) => Some((range, format!("AFMakeNumber(this.getField({}).value)", js_str(&name)))), + Notation::Op(sign @ ('+' | '-')) if expr.get(range.end..).is_some_and(|rest| rest.starts_with(sign)) => Some((range, format!("{sign} "))), _ => None, }); splice(expr, names) @@ -507,6 +590,10 @@ pub(super) fn eval_notation(src: &str, value_of: &dyn Fn(&str) -> f64) -> Option *i += 1; Some(*n) } + T::Op('+') => { + *i += 1; + factor(t, i, d + 1) + } T::Op('-') => { *i += 1; factor(t, i, d + 1).map(|v| -v) @@ -558,3 +645,33 @@ pub fn calculate(c: &Calculate, values: &dyn Fn(&str) -> Vec) -> Option< } } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn regex_scan_does_not_cross_line_break_after_backslash_or_split_utf8() { + let js = "x = /a\\\nthis.getField(\"price\").value / 2;"; + assert_eq!(calculation_reads(js), vec!["price".to_owned()]); + + let js_utf8 = "var r = /\\é/; event.value = this.getField(\"tax\").value;"; + assert_eq!(calculation_reads(js_utf8), vec!["tax".to_owned()]); + + let trailing_backslash = "event.value = this.getField(\"subtotal\").value + /unterminated\\"; + assert_eq!(calculation_reads(trailing_backslash), vec!["subtotal".to_owned()]); + let template_trailing = "event.value = this.getField(\"shipping\").value + `unterminated\\"; + assert_eq!(calculation_reads(template_trailing), vec!["shipping".to_owned()]); + } + + #[test] + fn eval_notation_supports_unary_plus() { + let c = Calculate::Notation("+a + (+b)".to_owned()); + let out = calculate(&c, &|name| match name { + "a" => vec!["3".to_owned()], + "b" => vec!["4".to_owned()], + _ => Vec::new(), + }); + assert_eq!(out.as_deref(), Some("7")); + } +} diff --git a/crates/forms/src/af/script.rs b/crates/forms/src/af/script.rs index f15714c411..8212da5bf2 100644 --- a/crates/forms/src/af/script.rs +++ b/crates/forms/src/af/script.rs @@ -17,7 +17,7 @@ impl Arg { match self { Arg::Num(n) => Some(*n), Arg::Bool(b) => Some(f64::from(u8::from(*b))), - Arg::Str(s) => s.trim().parse().ok(), + Arg::Str(s) => s.trim().parse::().ok().filter(|n| n.is_finite()), Arg::List(_) => None, } } @@ -99,17 +99,36 @@ impl Parser<'_> { let mut encoded = [0u8; 4]; out.extend_from_slice(ch.encode_utf8(&mut encoded).as_bytes()); } + b'u' if self.s.get(self.i) == Some(&b'{') => { + self.i += 1; + let start = self.i; + let mut code = 0u32; + while let Some(&digit) = self.s.get(self.i).filter(|&&b| b != b'}') { + code = code.checked_mul(16)?.checked_add(char::from(digit).to_digit(16)?)?; + if code > 0x10FFFF { + return None; + } + self.i += 1; + } + if self.i == start || self.s.get(self.i) != Some(&b'}') { + return None; + } + self.i += 1; + // Match the existing handling of unpaired UTF-16 surrogates. + let ch = char::from_u32(code).unwrap_or('\u{FFFD}'); + let mut encoded = [0u8; 4]; + out.extend_from_slice(ch.encode_utf8(&mut encoded).as_bytes()); + } b'u' => { let code = hex4(self.s.get(self.i..self.i.checked_add(4)?)?)?; self.i += 4; - let code = if (0xD800..=0xDBFF).contains(&code) && self.s.get(self.i..self.i.checked_add(6)?)?.starts_with(b"\\u") { - let low = hex4(self.s.get(self.i.checked_add(2)?..self.i.checked_add(6)?)?)?; - if (0xDC00..=0xDFFF).contains(&low) { - self.i += 6; - 0x10000 + ((u32::from(code) - 0xD800) << 10) + (u32::from(low) - 0xDC00) - } else { - u32::from(code) - } + let code = if (0xD800..=0xDBFF).contains(&code) + && let Some(pair) = self.i.checked_add(6).and_then(|end| self.s.get(self.i..end)) + && pair.starts_with(b"\\u") + && let Some(low) = hex4(&pair[2..6]).filter(|low| (0xDC00..=0xDFFF).contains(low)) + { + self.i += 6; + 0x10000 + ((u32::from(code) - 0xD800) << 10) + (u32::from(low) - 0xDC00) } else { u32::from(code) }; @@ -158,7 +177,7 @@ impl Parser<'_> { while self.i < self.s.len() && (self.s[self.i].is_ascii_digit() || matches!(self.s[self.i], b'.' | b'-' | b'+' | b'e' | b'E')) { self.i += 1; } - std::str::from_utf8(&self.s[start..self.i]).ok()?.parse().ok().map(Arg::Num) + std::str::from_utf8(&self.s[start..self.i]).ok()?.parse::().ok().filter(|n| n.is_finite()).map(Arg::Num) } } } @@ -196,15 +215,30 @@ impl Parser<'_> { } } -/// The first call to `name(` in `js` and its arguments. +/// The first genuine call to `name(` in `js` and its arguments. +/// An unsupported first call returns `None`, preserving the custom-script fallback; later calls +/// must not substitute a different native rule. Comments, strings and unrelated identifiers are skipped. pub(super) fn call(js: &str, name: &str) -> Option> { - let at = js.find(&format!("{name}("))?; - // Not part of a longer identifier (AFDate_Format vs AFDate_FormatEx). - if js[..at].chars().last().is_some_and(|c| c.is_alphanumeric() || c == '_') { - return None; + let (tokens, _) = js_tokens(js); + for (k, (range, tok)) in tokens.iter().enumerate() { + if *tok != super::calc::Js::Ident { + continue; + } + if k > 0 && matches!(tokens.get(k - 1), Some((_, super::calc::Js::Punct('.')))) { + continue; + } + let Some(target_end) = range.start.checked_add(name.len()) else { continue }; + if js.get(range.start..target_end) != Some(name) { + continue; + } + let Some(end_k) = (k..tokens.len()).take_while(|&m| tokens[m].0.end <= target_end).find(|&m| tokens[m].0.end == target_end) else { + continue; + }; + let Some((open, super::calc::Js::Punct('('))) = tokens.get(end_k + 1) else { continue }; + let mut p = Parser { s: js.as_bytes(), i: open.end, depth: 0 }; + return p.list(b')'); } - let mut p = Parser { s: js.as_bytes(), i: at + name.len() + 1, depth: 0 }; - p.list(b')') + None } pub fn parse_format(js: &str) -> Option { @@ -246,7 +280,8 @@ pub fn parse_validate(js: &str) -> Option { let args = call(js, "AFRange_Validate")?; let flag = |i: usize| args.get(i).is_some_and(Arg::truthy); let num = |i: usize| args.get(i).and_then(Arg::num); - Some(Validate::Range { min: if flag(0) { num(1) } else { None }, max: if flag(2) { num(3) } else { None } }) + // An enabled but unparseable bound is unsupported, not an absent bound. + Some(Validate::Range { min: if flag(0) { Some(num(1)?) } else { None }, max: if flag(2) { Some(num(3)?) } else { None } }) } pub fn parse_calculate(js: &str) -> Option { @@ -361,3 +396,89 @@ pub fn calculate_js(c: &Calculate) -> Option { Calculate::Notation(expr) => Some(format!("/** BVCALC {expr} EVCALC **/ event.value = {};", notation_js(expr))), } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn unsupported_first_validation_call_does_not_select_a_later_rule() { + let js = "if (event.value < 50) AFRange_Validate(true, 10 + 5, false, 0); else AFRange_Validate(false, 0, true, 100);"; + assert_eq!(parse_validate(js), None); + assert_eq!( + parse_validate("AFRange_Validate(true, 15, false, 0); AFRange_Validate(false, 0, true, 100);"), + Some(Validate::Range { min: Some(15.0), max: None }) + ); + } + + #[test] + fn validation_call_still_skips_non_calls_before_literal_arguments() { + let js = r#"// AFRange_Validate(true, 10 + 5, false, 0) + "AFRange_Validate(true, 10 + 5, false, 0)"; + my_AFRange_Validate(true, 10 + 5, false, 0); + other.AFRange_Validate(true, 10 + 5, false, 0); + AFRange_Validate(false, 0, true, 100);"#; + assert_eq!(parse_validate(js), Some(Validate::Range { min: None, max: Some(100.0) })); + } + + #[test] + fn unpaired_high_surrogate_at_end_of_string_does_not_abort_parsing() { + let js = "AFDate_FormatEx(\"\\uD800\")"; + assert_eq!(parse_format(js), Some(Format::Date("\u{FFFD}".to_owned()))); + } + + #[test] + fn call_skips_commented_or_prefixed_identifiers_and_rejects_infinite_numbers() { + let js = "// AFPercent_Format(0, 0);\nmy_AFPercent_Format(0, 0); $AFPercent_Format(0, 0); AFPercent_Format (2, 1);"; + assert_eq!(parse_format(js), Some(Format::Percent { decimals: 2, sep: 1 })); + + assert_eq!(parse_validate("AFRange_Validate(true, 1e500, false, 0);"), None); + } + #[test] + fn review_enabled_nonfinite_bound_is_unsupported() { + for script in [r#"AFRange_Validate(true, "1e500", false, 0)"#, r#"AFRange_Validate(false, 0, true, "-1e500")"#] { + assert_eq!(parse_validate(script), None); + } + assert_eq!(parse_validate(r#"AFRange_Validate(false, "1e500", false, "NaN")"#), Some(Validate::Range { min: None, max: None })); + } + + #[test] + fn review_format_after_postfix_division() { + for op in ["++", "--"] { + let js = format!("var y = x{op} / 2; AFPercent_Format(2, 1); var z = 1 / 2;"); + assert_eq!(parse_format(&js), Some(Format::Percent { decimals: 2, sep: 1 })); + let commented = format!("var y = x/* comment */{op} / 2; AFPercent_Format(2, 1); var z = 1 / 2;"); + assert_eq!(parse_format(&commented), Some(Format::Percent { decimals: 2, sep: 1 })); + } + assert_eq!(parse_format("var of = 8; of / 2; AFPercent_Format(2, 1); var z = 1 / 2;"), Some(Format::Percent { decimals: 2, sep: 1 })); + assert_eq!( + parse_format("for (var x of /AFPercent_Format(0, 0)/g) {} AFPercent_Format(2, 1);"), + Some(Format::Percent { decimals: 2, sep: 1 }) + ); + // A newline makes ++ a prefix operator; the following regex must remain opaque. + for js in ["x\n++/AFPercent_Format(2, 1)/.lastIndex;", "x/*\n*/++/AFPercent_Format(2, 1)/.lastIndex;"] { + assert_eq!(parse_format(js), None); + } + } + + #[test] + fn review_format_after_brace_unicode() { + assert_eq!(parse_format(r#"var s = "\u{1F600}"; AFPercent_Format(2, 1);"#), Some(Format::Percent { decimals: 2, sep: 1 })); + assert_eq!(parse_format(r#"AFDate_FormatEx("\u{1F600}")"#), Some(Format::Date("😀".to_owned()))); + assert_eq!(parse_format(r#"AFDate_FormatEx("\u{00000041}\u0042\uD83D\uDE00")"#), Some(Format::Date("AB😀".to_owned()))); + for escape in ["\\u{}", "\\u{110000}", "\\u{xyz}", "\\u{41"] { + assert_eq!(parse_format(&format!("AFDate_FormatEx(\"{escape}\")")), None); + } + } + + #[test] + fn review_notation_separates_adjacent_operators() { + let field = |name| format!("AFMakeNumber(this.getField(\"{name}\").value)"); + for (expr, operators) in [("a++b", "+ +"), ("a--b", "- -"), ("a+++b", "+ + +")] { + let js = calculate_js(&Calculate::Notation(expr.to_owned())).unwrap(); + let executable = js.split("event.value = ").nth(1).unwrap(); + assert_eq!(executable, format!("{}{operators}{};", field("a"), field("b"))); + assert_eq!(super::super::calc::calculate(&Calculate::Notation(expr.to_owned()), &|_| vec!["2".to_owned()]), Some("4".to_owned())); + } + } +} diff --git a/crates/forms/src/af/tests.rs b/crates/forms/src/af/tests.rs index 1e546db75a..3c6dab1bef 100644 --- a/crates/forms/src/af/tests.rs +++ b/crates/forms/src/af/tests.rs @@ -444,3 +444,45 @@ fn validation_and_calculation() { assert_eq!(calculate(&Calculate::Notation("(a + b) * 2 - Unit\\ Price / 4".into()), &vals), Some("10".into())); assert_eq!(calculate(&Calculate::Notation("a +".into()), &vals), None); } + +/// Async for-of headers have the same regex-literal contexts as ordinary for-of headers. +#[test] +fn for_await_regex_literals_do_not_become_format_calls() { + for header in ["for await", "for /* gap */ await"] { + let js = format!("async function f() {{ {header} (const x of /AFPercent_Format(0, 0)/g) {{}} }} AFPercent_Format(2, 1);"); + assert_eq!(parse_format(&js), Some(Format::Percent { decimals: 2, sep: 1 })); + let body = format!("async function f() {{ {header} (const x of xs) /AFPercent_Format(0, 0)/.test(x); }} AFPercent_Format(2, 1);"); + assert_eq!(parse_format(&body), Some(Format::Percent { decimals: 2, sep: 1 })); + } + assert_eq!(parse_format("obj.await(x) / 2; AFPercent_Format(2, 1); var z = 1 / 2;"), Some(Format::Percent { decimals: 2, sep: 1 })); +} + +#[test] +fn identifier_of_in_conditions_keeps_division_and_real_format_calls() { + for header in [ + "if (of / 2)", + "while (of / 2)", + "with (of / 2)", + "for (of / 2; ; )", + "for (var x = of / 2; ; )", + "for (var x = 0; of / 2; )", + "for (x of of / 2)", + "for (const x of of / 2)", + "for (x in of / 2)", + "for (var of / 2; ; )", + "for (obj.of / 2; ; )", + ] { + let js = format!("{header} AFPercent_Format(2, 1); var z = 1 / 2;"); + assert_eq!(parse_format(&js), Some(Format::Percent { decimals: 2, sep: 1 }), "{header}"); + } +} + +#[test] +fn destructured_for_of_targets_keep_regex_iterables_opaque() { + for header in ["for", "for await"] { + for target in ["const [x]", "const {x}", "(x)", "const {x = of / 2}", "obj.in", "obj.of", "const of"] { + let js = format!("async function f() {{ {header} ({target} of /AFPercent_Format(0, 0)/g) {{}} }} AFPercent_Format(2, 1);"); + assert_eq!(parse_format(&js), Some(Format::Percent { decimals: 2, sep: 1 }), "{header} {target}"); + } + } +} diff --git a/crates/forms/src/author/join.rs b/crates/forms/src/author/join.rs index e9fedbd655..b8c2520dd6 100644 --- a/crates/forms/src/author/join.rs +++ b/crates/forms/src/author/join.rs @@ -5,16 +5,17 @@ use std::collections::{HashMap, HashSet}; use super::lifecycle::{drop_from_calculation_order, split_merged, split_triggers}; -use super::tree::{detach, list_of}; +use super::tree::{ancestor_sets, detach, list_of}; use super::{Dict, Document, Field, FormError, ObjRef, Object, invalid}; /// The keys a field that is also its own widget sheds when it becomes only a widget. Its `/AA` /// keeps only the widget's own triggers. const FIELD_ONLY_KEYS: [&[u8]; 15] = [b"T", b"FT", b"Kids", b"Ff", b"V", b"DV", b"Opt", b"TU", b"TM", b"MaxLen", b"Q", b"DS", b"RV", b"TI", b"I"]; -/// Whether `field` holds `/Opt`, which its buttons' export values index (ISO 32000-2 §12.7.5.2). +/// Whether `field` holds or inherits `/Opt`, which its buttons' export values index (ISO 32000-2 §12.7.5.2). fn has_opt(doc: &Document, field: ObjRef) -> bool { - doc.get(field).as_dict().is_some_and(|d| d.contains(b"Opt")) + doc.get(field).as_dict().and_then(|d| d.get(b"Opt")).is_some_and(|v| !matches!(*doc.resolve(v), Object::Null)) + || ancestor_sets(doc, field, b"Opt") } /// Refuse what [`join_radio_group`] can't do, before anything is written. @@ -127,3 +128,26 @@ fn rename_state(doc: &Document, d: &mut Dict, old: &str, new: &str) -> bool { d.set(b"AP".to_vec(), Object::Dict(ap)); true } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn has_opt_ignores_null_and_detects_inherited_opt() { + let mut doc = Document::new_empty(); + let mut parent = Dict::new(); + parent.set(b"Opt".to_vec(), Object::Array(vec![])); + let parent_ref = doc.add(Object::Dict(parent)); + + let mut f_null = Dict::new(); + f_null.set(b"Opt".to_vec(), Object::Null); + let f_null_ref = doc.add(Object::Dict(f_null)); + assert!(!has_opt(&doc, f_null_ref)); + + let mut f_inherited = Dict::new(); + f_inherited.set(b"Parent".to_vec(), Object::Ref(parent_ref)); + let f_inherited_ref = doc.add(Object::Dict(f_inherited)); + assert!(has_opt(&doc, f_inherited_ref)); + } +} diff --git a/crates/forms/src/author/tree.rs b/crates/forms/src/author/tree.rs index ce4ee687bb..55b84fa390 100644 --- a/crates/forms/src/author/tree.rs +++ b/crates/forms/src/author/tree.rs @@ -112,6 +112,11 @@ pub(crate) fn form_fonts_as_objects(doc: &mut Document, all: &[crate::Field]) -> Ok(()) } +/// Whether `d` has a non-null entry `key` (a `null` entry is absent per ISO 32000-2 §7.3.9). +fn dict_has(doc: &Document, d: &Dict, key: &[u8]) -> bool { + d.get(key).is_some_and(|v| !matches!(*doc.resolve(v), Object::Null)) +} + /// Whether the field `r`, or the nearest ancestor that has one, holds a `/V` (the entry is /// inheritable, ISO 32000-2 §12.7.4). The `/Parent` chain is followed at most 64 deep, as deep /// as the field walk goes, so a cyclic chain ends. @@ -120,7 +125,7 @@ fn holds_value(doc: &Document, r: ObjRef) -> bool { for _ in 0..=64 { let obj = doc.get(at); let Some(d) = obj.as_dict() else { return false }; - if d.contains(b"V") { + if dict_has(doc, d, b"V") { return true; } match d.reference(b"Parent") { @@ -160,8 +165,8 @@ fn add_to_fields(doc: &mut Document, af: ObjRef, field: ObjRef) -> Result<(), Fo /// The most parts a dotted field name may have ("a.b.c" has three). const MAX_NAME_PARTS: usize = 32; -/// The field attributes a field inherits from its parents (ISO 32000-2 §12.7.4.1). -const INHERITED_KEYS: [&[u8]; 7] = [b"FT", b"Ff", b"V", b"DV", b"DA", b"Q", b"MaxLen"]; +/// The field attributes a field inherits from its parents (ISO 32000-2 §12.7.4.1, §12.7.5.2). +const INHERITED_KEYS: [&[u8]; 8] = [b"FT", b"Ff", b"V", b"DV", b"DA", b"Q", b"MaxLen", b"Opt"]; /// The parts of a full field name: `a.b` is the field `b` under the parent field `a`, since a /// field's own (partial) name can't contain a period (ISO 32000-2 §12.7.4.2) (#2696). @@ -234,7 +239,7 @@ pub(super) fn check_place(doc: &Document, af: ObjRef, field: ObjRef, parts: &[&s break; }; // A value, reset value or length limit held by a parent would pass to this field. - let held = doc.get(r).as_dict().is_some_and(|d| PASSED_VALUES.iter().any(|k| d.contains(k) && !owned.contains(k))); + let held = doc.get(r).as_dict().is_some_and(|d| PASSED_VALUES.iter().any(|k| dict_has(doc, d, k) && !dict_has(doc, &owned, k))); if held { return invalid(format!("{part:?} holds a value for the fields under it, so {:?} can't be put there", parts.join("."))); } @@ -259,7 +264,7 @@ pub(super) fn attach(doc: &mut Document, af: ObjRef, field: ObjRef, parts: &[&st for part in parents { let next = match child_named(doc, af, parent, part) { Some(r) => { - inherits |= doc.get(r).as_dict().is_some_and(|d| d.contains(b"Ff") || d.contains(b"Q")); + inherits |= doc.get(r).as_dict().is_some_and(|d| dict_has(doc, d, b"Ff") || dict_has(doc, d, b"Q")); r } None => { @@ -308,12 +313,20 @@ pub(super) fn inherited(doc: &Document, field: ObjRef) -> Dict { } let Some(d) = doc.get(p).as_dict().cloned() else { break }; for k in INHERITED_KEYS { - if let Some(v) = d.get(k).filter(|_| !own.contains(k) && !add.contains(k)) { + if let Some(v) = d.get(k).filter(|_| dict_has(doc, &d, k) && !dict_has(doc, &own, k) && !add.contains(k)) { add.set(k.to_vec(), v.clone()); } } parent = d.reference(b"Parent"); } + // /Opt is inheritable for check boxes and radio buttons (Table 230), not choice + // fields (Table 234). Classify by effective /FT and /Ff before materializing it. + let effective = |key: &[u8]| if dict_has(doc, &own, key) { own.get(key) } else { add.get(key) }; + let button = effective(b"FT").is_some_and(|v| doc.resolve(v).as_name() == Some(b"Btn")); + let flags = effective(b"Ff").and_then(|v| doc.resolve(v).as_int()).and_then(|v| u32::try_from(v).ok()).unwrap_or(0); + if !button || flags & crate::flags::PUSH_BUTTON != 0 { + add.remove(b"Opt"); + } add } @@ -327,7 +340,7 @@ pub(crate) fn ancestor_sets(doc: &Document, field: ObjRef, key: &[u8]) -> bool { return false; } let Some(d) = doc.get(p).as_dict().cloned() else { return false }; - if d.contains(key) { + if dict_has(doc, &d, key) { return true; } parent = d.reference(b"Parent"); @@ -396,3 +409,162 @@ pub(super) fn top_names(doc: &Document) -> Vec { let list = af.get(b"Fields").map(|o| doc.resolve(o)).and_then(|o| o.as_array().cloned()).unwrap_or_default(); list.iter().filter_map(|o| doc.resolve(o).as_dict().and_then(|d| d.get(b"T").cloned())).filter_map(|t| crate::text_of(&doc.resolve(&t))).collect() } + +#[cfg(test)] +mod tests { + use super::*; + + /// /Opt is inheritable only for check boxes and radio buttons, using effective FT/Ff. + #[test] + fn inherited_options_are_limited_to_checkboxes_and_radios() { + for (ft, flags, expected) in + [("Btn", 0, true), ("Btn", crate::flags::RADIO, true), ("Btn", crate::flags::PUSH_BUTTON, false), ("Ch", 0, false), ("Tx", 0, false)] + { + for own_type in [false, true] { + let mut doc = Document::new_empty(); + let mut parent = Dict::new(); + parent.set(b"FT".to_vec(), Object::name(ft)); + parent.set(b"Ff".to_vec(), Object::Int(i64::from(flags))); + parent.set(b"Opt".to_vec(), Object::Array(vec![Object::String(PdfString::text("export"))])); + parent.set(b"DA".to_vec(), PdfString::text("/Helv 10 Tf 0 g")); + let p = doc.add(parent); + let mut child = Dict::new(); + child.set(b"Parent".to_vec(), Object::Ref(p)); + if own_type { + child.set(b"FT".to_vec(), Object::name(ft)); + child.set(b"Ff".to_vec(), Object::Int(i64::from(flags))); + } + let c = doc.add(child); + let inherited = inherited(&doc, c); + assert_eq!(inherited.contains(b"Opt"), expected, "{ft}, flags {flags}, own {own_type}"); + assert!(inherited.contains(b"DA")); + } + } + } + + fn one_page() -> Document { + let mut doc = Document::new_empty(); + let pages = doc.get(doc.root().unwrap()).as_dict().unwrap().reference(b"Pages").unwrap(); + let mut page = Dict::new(); + page.set(b"Type".to_vec(), Object::name("Page")); + page.set(b"Parent".to_vec(), Object::Ref(pages)); + page.set(b"MediaBox".to_vec(), Object::Array(vec![0.into(), 0.into(), 600.into(), 800.into()])); + let page = doc.add(page); + doc.update_dict(pages, |d| { + d.set(b"Kids".to_vec(), Object::Array(vec![Object::Ref(page)])); + d.set(b"Count".to_vec(), Object::Int(1)); + }) + .unwrap(); + doc + } + + fn reopen(doc: &Document) -> Document { + let bytes = pdfcraft_cos::write_incremental(doc, &pdfcraft_cos::SaveOptions::default()).unwrap(); + hayro_syntax::Pdf::new(bytes.clone()).unwrap(); + Document::open(std::sync::Arc::new(bytes)).unwrap() + } + + /// Moving a choice field must not turn its ancestor's unrelated /Opt into local choices. + #[test] + fn moving_choice_fields_does_not_copy_ancestor_options() { + for combo in [false, true] { + for own_options in [false, true] { + let mut doc = one_page(); + let options = if own_options { vec!["local".into()] } else { Vec::new() }; + let kind = if combo { crate::NewField::Combo { options, editable: false } } else { crate::NewField::List { options, multi: false } }; + crate::add_field(&mut doc, 0, [50.0, 700.0, 250.0, 720.0], &kind, Some("old.pick")).unwrap(); + let f = crate::fields(&doc).into_iter().find(|f| f.name == "old.pick").unwrap(); + let parent = doc.get(f.obj).as_dict().unwrap().reference(b"Parent").unwrap(); + if !own_options { + doc.update_dict(f.obj, |d| { + d.remove(b"Opt"); + }) + .unwrap(); + } + doc.update_dict(parent, |d| d.set(b"Opt".to_vec(), Object::Array(vec![Object::String(PdfString::text("borrowed"))]))).unwrap(); + let before = crate::fields(&doc).into_iter().find(|f| f.name == "old.pick").unwrap().options; + crate::set_props(&mut doc, "old.pick", &crate::FieldProps { name: Some("new.pick".into()), ..Default::default() }).unwrap(); + let after = crate::fields(&doc).into_iter().find(|f| f.name == "new.pick").unwrap(); + assert_eq!(after.options, before); + assert_eq!(doc.get(after.obj).as_dict().unwrap().contains(b"Opt"), own_options); + let reopened = reopen(&doc); + let after = crate::fields(&reopened).into_iter().find(|f| f.name == "new.pick").unwrap(); + assert_eq!(after.options, before); + assert_eq!(reopened.get(after.obj).as_dict().unwrap().contains(b"Opt"), own_options); + } + } + } + + #[test] + fn moving_buttons_preserves_inherited_export_mapping() { + for kind in [crate::NewField::CheckBox, crate::NewField::Radio { group: None, export: "0".into() }] { + let mut doc = one_page(); + crate::add_field(&mut doc, 0, [50.0, 700.0, 70.0, 720.0], &kind, Some("old.button")).unwrap(); + let field = crate::fields(&doc).into_iter().find(|f| f.name == "old.button").unwrap(); + let state = field.widgets.first().unwrap().on_state.clone().unwrap(); + let parent = doc.get(field.obj).as_dict().unwrap().reference(b"Parent").unwrap(); + let opt = Object::Array(vec![Object::String(PdfString::text("exported"))]); + doc.update_dict(parent, |d| d.set(b"Opt".to_vec(), opt.clone())).unwrap(); + doc.update_dict(field.obj, |d| { + d.remove(b"Opt"); + }) + .unwrap(); + crate::set_props(&mut doc, "old.button", &crate::FieldProps { name: Some("new.button".into()), ..Default::default() }).unwrap(); + for doc in [&doc, &reopen(&doc)] { + let after = crate::fields(doc).into_iter().find(|f| f.name == "new.button").unwrap(); + assert_eq!(doc.get(after.obj).as_dict().unwrap().get(b"Opt"), Some(&opt)); + assert_eq!(after.export_of(0), Some("exported")); + assert_eq!(after.state_for("exported"), Some(state.as_str())); + } + } + } + + #[test] + fn null_entries_on_parents_and_unsigned_signatures_are_treated_as_absent() { + let mut doc = Document::new_empty(); + let mut sig = Dict::new(); + sig.set(b"FT".to_vec(), Object::name("Sig")); + sig.set(b"T".to_vec(), PdfString::text("sig")); + sig.set(b"V".to_vec(), Object::Null); + let sig_ref = doc.add(Object::Dict(sig)); + assert!(!holds_value(&doc, sig_ref)); + + let mut grand = Dict::new(); + grand.set(b"FT".to_vec(), Object::name("Tx")); + grand.set(b"MaxLen".to_vec(), Object::Int(10)); + let grand_ref = doc.add(Object::Dict(grand)); + + let mut parent = Dict::new(); + parent.set(b"Parent".to_vec(), Object::Ref(grand_ref)); + parent.set(b"T".to_vec(), PdfString::text("group")); + parent.set(b"V".to_vec(), Object::Null); + parent.set(b"MaxLen".to_vec(), Object::Null); + let parent_ref = doc.add(Object::Dict(parent)); + + let mut child = Dict::new(); + child.set(b"Parent".to_vec(), Object::Ref(parent_ref)); + child.set(b"T".to_vec(), PdfString::text("item")); + let child_ref = doc.add(Object::Dict(child)); + + let inh = inherited(&doc, child_ref); + assert_eq!(inh.get(b"MaxLen"), Some(&Object::Int(10))); + assert!(!inh.contains(b"V")); + assert!(!ancestor_sets(&doc, child_ref, b"V")); + assert!(ancestor_sets(&doc, child_ref, b"MaxLen")); + + // Button /Opt is inherited, text /Opt is not; null kids and /Parent cycles are handled. + let opt_val = Object::Array(vec![Object::String(PdfString::text("Choice1"))]); + doc.update_dict(grand_ref, |d| { + d.set(b"Opt".to_vec(), opt_val.clone()); + d.set(b"Parent".to_vec(), Object::Ref(parent_ref)); + d.set(b"Kids".to_vec(), Object::Array(vec![Object::Null, Object::Ref(parent_ref)])); + }) + .unwrap(); + assert!(!inherited(&doc, child_ref).contains(b"Opt")); + doc.update_dict(grand_ref, |d| d.set(b"FT".to_vec(), Object::name("Btn"))).unwrap(); + let inh_cycle = inherited(&doc, child_ref); + assert_eq!(inh_cycle.get(b"Opt"), Some(&opt_val)); + assert!(!holds_value(&doc, child_ref)); + assert_eq!(child_named(&doc, grand_ref, Some(grand_ref), "group"), Some(parent_ref)); + } +} diff --git a/crates/forms/src/tab_order.rs b/crates/forms/src/tab_order.rs index 5b60e3927b..5c46293303 100644 --- a/crates/forms/src/tab_order.rs +++ b/crates/forms/src/tab_order.rs @@ -137,23 +137,34 @@ pub fn tab_orders(doc: &Document) -> Vec { /// annotations keep their places. pub fn move_in_tab_order(doc: &mut Document, name: &str, earlier: bool) -> Result<(), FormError> { let all = fields(doc); - let f = all.iter().find(|f| f.name == name).ok_or_else(|| FormError::NoSuchField(name.into()))?; - let page = f.widgets.iter().filter_map(|w| w.page).min().ok_or_else(|| FormError::Invalid(format!("{name} is not on a page")))?; + if !all.iter().any(|f| f.name == name) { + return Err(FormError::NoSuchField(name.into())); + } + let (target_fi, page) = all + .iter() + .enumerate() + .filter(|(_, f)| f.name == name) + .find_map(|(fi, f)| Some((fi, f.widgets.iter().filter_map(|w| w.page).min()?))) + .ok_or_else(|| FormError::Invalid(format!("{name} is not on a page")))?; // The page's fields in their current tab order (a field counts once, at its first widget). - let mut order: Vec<(usize, &str)> = - all.iter().filter_map(|g| g.widgets.iter().filter(|w| w.page == Some(page)).map(|w| w.tab).min().map(|t| (t, g.name.as_str()))).collect(); + let mut order: Vec<(usize, usize)> = all + .iter() + .enumerate() + .filter_map(|(fi, g)| g.widgets.iter().filter(|w| w.page == Some(page)).map(|w| w.tab).min().map(|t| (t, fi))) + .collect(); order.sort(); - let names: Vec<&str> = order.iter().map(|(_, n)| *n).collect(); - let Some(i) = names.iter().position(|n| *n == name) else { return invalid(format!("{name} is not on a page")) }; - let j = if earlier { i.checked_sub(1) } else { Some(i + 1).filter(|j| *j < names.len()) }; + let Some(i) = order.iter().position(|&(_, fi)| fi == target_fi) else { return invalid(format!("{name} is not on a page")) }; + let j = if earlier { i.checked_sub(1) } else { Some(i + 1).filter(|j| *j < order.len()) }; let Some(j) = j else { return Ok(()) }; - let mut names = names.into_iter().map(str::to_string).collect::>(); - names.swap(i, j); - // The widgets of this page in the new order. + order.swap(i, j); + // The widgets of this page in the new order, preserving each field's relative widget tab order. let mut widgets: Vec = Vec::new(); - for n in &names { - if let Some(g) = all.iter().find(|g| &g.name == n) { - widgets.extend(g.widgets.iter().filter(|w| w.page == Some(page)).map(|w| w.obj)); + let mut queued = std::collections::HashSet::new(); + for &(_, fi) in &order { + if let Some(g) = all.get(fi) { + let mut own: Vec<(usize, ObjRef)> = g.widgets.iter().filter(|w| w.page == Some(page)).map(|w| (w.tab, w.obj)).collect(); + own.sort_by_key(|&(tab, _)| tab); + widgets.extend(own.into_iter().map(|(_, obj)| obj).filter(|obj| queued.insert(*obj))); } } let pr = *page_refs(doc).get(page).ok_or_else(|| FormError::Invalid("no such page".into()))?; @@ -162,10 +173,11 @@ pub fn move_in_tab_order(doc: &mut Document, name: &str, earlier: bool) -> Resul let list = doc.resolve(&annots_obj).as_array().cloned().unwrap_or_default(); let mut next = widgets.into_iter(); let set: std::collections::HashSet = all.iter().flat_map(|g| g.widgets.iter().filter(|w| w.page == Some(page)).map(|w| w.obj)).collect(); + let mut seen_slots = std::collections::HashSet::new(); let new_list: Vec = list .iter() .map(|o| match o.as_ref() { - Some(r) if set.contains(&r) => next.next().map(Object::Ref).unwrap_or_else(|| o.clone()), + Some(r) if set.contains(&r) && seen_slots.insert(r) => next.next().map(Object::Ref).unwrap_or_else(|| o.clone()), _ => o.clone(), }) .collect(); @@ -178,3 +190,85 @@ pub fn move_in_tab_order(doc: &mut Document, name: &str, earlier: bool) -> Resul })?; Ok(()) } + +#[cfg(test)] +mod tests { + use super::*; + use pdfcraft_cos::{Dict, PdfString}; + + #[test] + fn move_in_tab_order_preserves_multi_widget_relative_tab_order() { + let mut doc = Document::new_empty(); + let pages_ref = doc.add(Object::Null); + let page_ref = doc.add(Object::Null); + + let mut w1 = Dict::new(); + w1.set(b"Subtype".to_vec(), Object::name("Widget")); + w1.set(b"Rect".to_vec(), Object::Array(vec![Object::Int(200), Object::Int(700), Object::Int(220), Object::Int(720)])); + let w1_ref = doc.add(Object::Dict(w1)); + + let mut w2 = Dict::new(); + w2.set(b"Subtype".to_vec(), Object::name("Widget")); + w2.set(b"Rect".to_vec(), Object::Array(vec![Object::Int(50), Object::Int(700), Object::Int(70), Object::Int(720)])); + let w2_ref = doc.add(Object::Dict(w2)); + + let mut radio = Dict::new(); + radio.set(b"FT".to_vec(), Object::name("Btn")); + radio.set(b"Ff".to_vec(), Object::Int(1 << 15)); + radio.set(b"T".to_vec(), PdfString::text("choice")); + // /Kids order is [w1 (right), w2 (left)], opposite to /R (Row) left-to-right order. + radio.set(b"Kids".to_vec(), Object::Array(vec![Object::Ref(w1_ref), Object::Ref(w2_ref)])); + let radio_ref = doc.add(Object::Dict(radio)); + doc.update_dict(w1_ref, |d| d.set(b"Parent".to_vec(), Object::Ref(radio_ref))).unwrap(); + doc.update_dict(w2_ref, |d| d.set(b"Parent".to_vec(), Object::Ref(radio_ref))).unwrap(); + + let mut text = Dict::new(); + text.set(b"FT".to_vec(), Object::name("Tx")); + text.set(b"Subtype".to_vec(), Object::name("Widget")); + text.set(b"T".to_vec(), PdfString::text("note")); + text.set(b"Rect".to_vec(), Object::Array(vec![Object::Int(50), Object::Int(600), Object::Int(150), Object::Int(620)])); + let text_ref = doc.add(Object::Dict(text)); + + let mut page = Dict::new(); + page.set(b"Type".to_vec(), Object::name("Page")); + page.set(b"Parent".to_vec(), Object::Ref(pages_ref)); + page.set(b"Tabs".to_vec(), Object::name("R")); + page.set(b"Annots".to_vec(), Object::Array(vec![Object::Ref(w1_ref), Object::Ref(w2_ref), Object::Ref(text_ref)])); + doc.set(page_ref, Object::Dict(page)); + + let mut pages = Dict::new(); + pages.set(b"Type".to_vec(), Object::name("Pages")); + pages.set(b"Kids".to_vec(), Object::Array(vec![Object::Ref(page_ref)])); + pages.set(b"Count".to_vec(), Object::Int(1)); + doc.set(pages_ref, Object::Dict(pages)); + + let mut af = Dict::new(); + af.set(b"Fields".to_vec(), Object::Array(vec![Object::Ref(radio_ref), Object::Ref(text_ref)])); + let af_ref = doc.add(Object::Dict(af)); + + let mut catalog = Dict::new(); + catalog.set(b"Type".to_vec(), Object::name("Catalog")); + catalog.set(b"Pages".to_vec(), Object::Ref(pages_ref)); + catalog.set(b"AcroForm".to_vec(), Object::Ref(af_ref)); + let root_ref = doc.root().unwrap(); + doc.set(root_ref, Object::Dict(catalog)); + + // Move "note" earlier (before "choice"): "choice"'s own buttons should stay in their + // /R tab order (w2 then w1), not revert to /Kids order (w1 then w2). + move_in_tab_order(&mut doc, "note", true).unwrap(); + let annots = doc.get(page_ref).as_dict().and_then(|d| d.get(b"Annots")).and_then(Object::as_array).cloned().unwrap(); + assert_eq!(annots, vec![Object::Ref(text_ref), Object::Ref(w2_ref), Object::Ref(w1_ref)]); + + // Missing page and unplaced field return errors cleanly. + assert!(set_tab_order(&mut doc, &[99], TabOrder::Row).is_err()); + let mut unplaced = Dict::new(); + unplaced.set(b"FT".to_vec(), Object::name("Tx")); + unplaced.set(b"T".to_vec(), PdfString::text("orphan")); + let unplaced_ref = doc.add(Object::Dict(unplaced)); + doc.update_dict(af_ref, |d| { + d.set(b"Fields".to_vec(), Object::Array(vec![Object::Ref(radio_ref), Object::Ref(text_ref), Object::Ref(unplaced_ref)])); + }) + .unwrap(); + assert!(move_in_tab_order(&mut doc, "orphan", true).is_err()); + } +} diff --git a/crates/forms/src/tests.rs b/crates/forms/src/tests.rs index a49d35b0f4..f00d1cad9a 100644 --- a/crates/forms/src/tests.rs +++ b/crates/forms/src/tests.rs @@ -1701,6 +1701,43 @@ fn a_script_that_spells_the_markers_in_a_string_runs_as_a_script() { assert_eq!(field(&fields(&doc), "total").value, ["ran"]); } +/// An unsupported first AF call keeps the entire script and reaches the caller's interpreter. +#[test] +fn unsupported_first_validation_call_preserves_custom_script_dispatch() { + const JS: &str = "if (event.value < 50) AFRange_Validate(true, 10 + 5, false, 0); else AFRange_Validate(false, 0, true, 100);"; + struct RejectingInterpreter { + calls: usize, + } + impl Scripts for RejectingInterpreter { + fn run(&mut self, event: FieldEvent, script: &str, target: &Field, value: &str, _fields: &[Field]) -> ScriptResult { + assert_eq!(event, FieldEvent::Validate); + assert_eq!(script, JS); + assert_eq!(target.name, "amount"); + self.calls += 1; + ScriptResult { rc: false, value: value.into(), changes: Vec::new(), message: Some("custom validation ran".into()) } + } + } + let mut doc = one_page(); + add_field(&mut doc, 0, [50.0, 700.0, 250.0, 720.0], &NewField::Text { multiline: false }, Some("amount")).unwrap(); + set_value(&mut doc, "amount", &FieldValue::Text("20".into())).unwrap(); + set_field_script(&mut doc, "amount", "validate", Some(JS)).unwrap(); + let reopened = reopen(&doc); + for mut doc in [doc, reopened] { + let all = fields(&doc); + let actions = &field(&all, "amount").actions; + assert_eq!(actions.validate, af::Validate::None); + assert_eq!(actions.scripts.validate.as_deref(), Some(JS)); + assert!(actions.unsupported.contains(&"validate")); + let mut scripts = RejectingInterpreter { calls: 0 }; + assert_eq!( + set_value_with(&mut doc, "amount", &FieldValue::Text("10".into()), &mut scripts), + Err(FormError::Invalid("custom validation ran".into())) + ); + assert_eq!(scripts.calls, 1); + assert_eq!(field(&fields(&doc), "amount").value, ["20"]); + } +} + /// Renaming a field renames it in the calculations that read it: a sum dropped it, notation /// read it as 0 and a script read a field that no longer existed. #[test] diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index 24b0e3e150..cc5231262b 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -4770,8 +4770,8 @@ milestone = "M6" status = "shipped" commands = ["form.field.properties"] tools = ["form_set_props", "form_fill"] -evidence = ["crates/forms/src/af/tests.rs::validation_and_calculation", "crates/forms/src/tests.rs::formats_validation_and_calculations_run_like_acrobat", "crates/automation/tests/it/automation.rs::form_formats_and_calculations_through_tools"] -notes = "AFRange_Validate with Acrobat's messages" +evidence = ["crates/forms/src/af/tests.rs::validation_and_calculation", "crates/forms/src/tests.rs::formats_validation_and_calculations_run_like_acrobat", "crates/forms/src/tests.rs::unsupported_first_validation_call_preserves_custom_script_dispatch", "crates/automation/tests/it/automation.rs::form_formats_and_calculations_through_tools"] +notes = "AFRange_Validate with Acrobat's messages; unsupported arguments in the first genuine call preserve the complete custom script for the supplied interpreter, rather than selecting a later literal call (save/reopen and validation dispatch covered)." [[feature]] id = "form.af-simple-calculate" @@ -5268,7 +5268,7 @@ status = "shipped" commands = ["form.prepare"] tools = ["form_set_props"] evidence = ["crates/forms/src/tests.rs::locked_fields_only_take_unlocking", "crates/ui-egui/tests/it/prepare.rs::preview_fills_the_form_and_locked_fields_keep_their_properties", "crates/forms/src/tests.rs::renaming_a_kid_is_checked_against_the_full_name_it_takes", "crates/forms/src/tests.rs::renaming_a_top_level_field_is_still_checked_against_top_level_names", "crates/forms/src/tests.rs::dotted_field_names_are_made_with_parent_fields"] -notes = "rename in Field Properties ▸ General, which shows the full name; a dotted name (MASTER.first) is a full name and makes or joins parent fields, as Acrobat does, moving a renamed field with what it inherited (#2696); Prepare a form's table tool still refuses a period in the cell names it makes; a plain name renames the field within its parent, so the GUI cannot yet move a nested field to the top level; Locked sets the widgets' annotation Locked flag (/F bit 8) and greys out the properties until unlocked; values can still be filled in" +notes = "rename in Field Properties ▸ General, which shows the full name; a dotted name (MASTER.first) is a full name and makes or joins parent fields, as Acrobat does, moving a renamed field with what it inherited (#2696); inherited checkbox/radio export options survive a move and save/reopen, while choice fields keep only their own options; Prepare a form's table tool still refuses a period in the cell names it makes; a plain name renames the field within its parent, so the GUI cannot yet move a nested field to the top level; Locked sets the widgets' annotation Locked flag (/F bit 8) and greys out the properties until unlocked; values can still be filled in" [[feature]] id = "form.tab-order" From 8d9f8d5f3025aba62fb266ff5604e8f7c5030100 Mon Sep 17 00:00:00 2001 From: mtlcl410 <2368091+mtlcl410@users.noreply.github.com> Date: Mon, 9 Nov 2026 18:15:44 +0800 Subject: [PATCH 07/64] M5: a reply is not drawn on the page or flattened into it (comment.reply) (#5632) --- crates/edit/src/flatten.rs | 91 +++++++-- crates/edit/src/tests.rs | 209 +++++++++++++++++++- crates/render/src/raster/tests.rs | 95 +++++++++ parity/acrobat-features.toml | 8 +- vendor/README.md | 2 +- vendor/hayro-interpret/src/interpret/mod.rs | 15 +- 6 files changed, 392 insertions(+), 28 deletions(-) diff --git a/crates/edit/src/flatten.rs b/crates/edit/src/flatten.rs index 73c5389c25..03ed272f3c 100644 --- a/crates/edit/src/flatten.rs +++ b/crates/edit/src/flatten.rs @@ -4,7 +4,8 @@ //! Each visible annotation's normal appearance (for check boxes and radio buttons, the one its //! `/AS` selects) is drawn into the page with the matrix of ISO 32000-2 §12.5.5 (Algorithm 8.1: //! the appearance's bounding box, transformed by its `/Matrix`, mapped onto `/Rect`). Hidden -//! annotations are left alone; links are kept (they have no ink and stay clickable). Flattened +//! annotations and replies are left alone (a reply is not shown on the page: [`is_reply`]); +//! links are kept (they have no ink and stay clickable). Flattened //! comments take their pop-ups and replies with them; flattened widgets are removed from the //! form's field tree. An annotation whose appearance cannot be drawn is not flattened: it stays //! as it is, so no comment or field value is lost with nothing in its place. @@ -19,6 +20,21 @@ const HIDDEN: i64 = 2; const NO_ROTATE: i64 = 16; const NO_VIEW: i64 = 32; +/// A reply of type R: a markup annotation with an `/IRT` to the annotation it answers and an +/// `/RT` other than `/Group` (R is the default, so a missing, unknown or malformed `/RT` reads as +/// R, as the comment list reads it). It is shown in its comment's thread, never on the page +/// ("Interactive PDF processors shall not display replies to an annotation individually", ISO +/// 32000-2 Table 172), so flattening neither draws it nor builds it an appearance; it goes with +/// its comment. A `/RT /Group` member is part of its group's look and is flattened as any other +/// annotation. `/IRT` is a markup annotation's entry: a widget, link or pop-up with one is no +/// reply, and is flattened (or kept) as it would be without. Keep in step with hayro-interpret's +/// PdfCraft patch (81). +fn is_reply(doc: &Document, d: &Dict) -> bool { + !matches!(d.name(b"Subtype"), Some(b"Widget" | b"Link" | b"Popup")) + && d.get(b"IRT").is_some_and(|o| doc.resolve(o).as_dict().is_some()) + && d.get(b"RT").map(|o| doc.resolve(o)).as_deref().and_then(Object::as_name) != Some(b"Group") +} + fn nums(doc: &Document, o: Option<&Object>) -> Option> { let o = doc.resolve(o?); o.as_array()?.iter().map(|x| doc.resolve(x).as_f64()).collect() @@ -319,7 +335,8 @@ fn undrawn_on(doc: &Document, all: &[pdfcraft_model::Page], pages: &[usize], whi } let Some(d) = doc.resolve(entry).as_dict().cloned() else { continue }; let flags = d.get(b"F").and_then(|f| doc.resolve(f).as_int()).unwrap_or(0); - if !selected(doc, &d, which) || flags & (HIDDEN | NO_VIEW) != 0 || !transferable(doc, &d, usable_properties) { + // A reply is never drawn into the page (`is_reply`), so its text can't bake in a `?`. + if !selected(doc, &d, which) || flags & (HIDDEN | NO_VIEW) != 0 || !transferable(doc, &d, usable_properties) || is_reply(doc, &d) { continue; } let widget = d.name(b"Subtype") == Some(b"Widget"); @@ -346,13 +363,60 @@ fn prepare_appearances(doc: &mut Document, list: &[Object], which: Which, retain continue; } let flags = d.get(b"F").and_then(|f| doc.resolve(f).as_int()).unwrap_or(0); - if flags & (HIDDEN | NO_VIEW) != 0 || appearance(doc, &d).is_some() { + // A reply is not shown on the page (`is_reply`), so it gets no appearance either. + if flags & (HIDDEN | NO_VIEW) != 0 || is_reply(doc, &d) || appearance(doc, &d).is_some() { continue; } + // A subtype this build cannot draw fails here and is kept below, so the comment is not + // deleted with nothing drawn in its place. (Text the standard fonts can't draw was + // refused before anything changed: `undrawn_on`.) let _ = pdfcraft_annot::set_appearance(doc, r); } } +/// Adds to `gone` and `gone_inline` the pop-ups and replies of what is in them, at any depth of +/// the thread and in any order of `/Annots`, passing by the entries `stays` keeps. What points at +/// each annotation (a pop-up by `/Parent`, a reply by `/IRT`) is gathered once, then followed +/// from what went. `/Annots` should hold references (ISO 32000-2 Table 31), but a pop-up or +/// reply written inline still goes with what it points at; nothing can point at it in turn. +fn take_threads( + doc: &Document, + list: &[Object], + gone: &mut HashSet, + gone_inline: &mut HashSet, + stays: impl Fn(usize, &Object) -> bool, +) { + let mut pointing: HashMap> = HashMap::new(); + let mut pointing_inline: Vec<(ObjRef, usize)> = Vec::new(); + for (k, entry) in list.iter().enumerate() { + if stays(k, entry) { + continue; + } + let r = entry.as_ref(); + let obj = doc.resolve(entry); + let Some(d) = obj.as_dict() else { continue }; + let popup_of = d.reference(b"Parent").filter(|_| d.name(b"Subtype") == Some(b"Popup")); + // Only a reply goes with what it answers: a `/RT /Group` member was drawn (and went) or + // stays, hidden or undrawable, as any annotation. + let reply_to = d.reference(b"IRT").filter(|_| is_reply(doc, d)); + for p in popup_of.into_iter().chain(reply_to) { + match r { + Some(r) => pointing.entry(p).or_default().push(r), + None => pointing_inline.push((p, k)), + } + } + } + let mut follow: Vec = gone.iter().copied().collect(); + while let Some(p) = follow.pop() { + for &r in pointing.get(&p).into_iter().flatten() { + if gone.insert(r) { + follow.push(r); + } + } + } + gone_inline.extend(pointing_inline.into_iter().filter(|(p, _)| gone.contains(p)).map(|(_, k)| k)); +} + /// Runs [`flatten_in`] on a copy of `doc` (unchanged objects are shared) and keeps it only on /// success, so a page refused part way leaves the pages before it as they were. fn flatten_which(doc: &mut Document, pages: &[usize], which: Which) -> Result { @@ -431,7 +495,8 @@ fn flatten_in(doc: &mut Document, pages: &[usize], which: Which) -> Result Result = list .iter() .enumerate() diff --git a/crates/edit/src/tests.rs b/crates/edit/src/tests.rs index 29698b5e03..0cb7366716 100644 --- a/crates/edit/src/tests.rs +++ b/crates/edit/src/tests.rs @@ -802,10 +802,12 @@ fn flattening_keeps_a_widget_whose_value_is_past_the_parent_limit() { assert_eq!(annots, Some(1), "the widget stays"); } -/// #1794: a reply this builder cannot draw stays on the page even when the comment it replies to -/// is flattened; the comment's pop-up still goes with it. +/// #1794, #5631: a reply this builder cannot draw goes with the comment it replies to, as its +/// pop-up does. A reply of type R is not shown on the page (ISO 32000-2 Table 172), so it has no +/// ink of its own to keep; before #5631 it stayed, the orphan of a comment that was gone. (A +/// `/RT /Group` member that cannot be drawn still stays: it is drawn on the page.) #[test] -fn flattening_keeps_an_undrawn_reply_to_a_flattened_comment() { +fn flattening_takes_an_undrawable_reply_with_its_comment() { let page = stream("", " "); let mut doc = build(&[ "<< /Type /Catalog /Pages 2 0 R >>", @@ -817,11 +819,208 @@ fn flattening_keeps_an_undrawn_reply_to_a_flattened_comment() { "<< /Type /Annot /Subtype /Popup /Rect [300 600 400 700] /Parent 5 0 R >>", "<< /Type /XObject /Subtype /Form /BBox [100 600 200 650] /Length 28 >>\nstream\n1 0 0 RG 100 600 100 50 re S\nendstream", ]); - assert_eq!(flatten(&mut doc, &[0], true, false).unwrap(), 1, "the comment is drawn; the cloudy reply cannot be"); + assert_eq!(flatten(&mut doc, &[0], true, false).unwrap(), 1, "the comment is drawn; the reply is not"); + let doc = reopen(&doc); + let p = &pdfcraft_model::pages(&doc)[0]; + assert!(!p.dict.contains(b"Annots"), "the comment, its reply and its pop-up are gone"); +} + +/// #5631: flattening draws no reply of type R into the page, whatever its appearance, and builds +/// none for one without (a note reply would get an icon); every reply goes with its comment, a +/// reply to a reply too, even listed before the reply it answers. A `/RT /Group` member is +/// drawn as any annotation. +#[test] +fn flattening_draws_no_reply_and_takes_the_thread_with_its_comment() { + let page = stream("", " "); + let form = |rgb: &str| stream("/Type /XObject /Subtype /Form /BBox [0 0 20 18]", &format!("{rgb} rg 0 0 20 18 re f")); + let (blue, red, green) = (form("0 0 1"), form("1 0 0"), form("0 1 0")); + let mut doc = build(&[ + "<< /Type /Catalog /Pages 2 0 R >>", + "<< /Type /Pages /Kids [3 0 R] /Count 1 >>", + // The reply to the reply (8) is listed before the reply it answers (6). + "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 600 800] /Resources << >> /Contents 4 0 R /Annots [5 0 R 8 0 R 6 0 R 7 0 R 9 0 R] >>", + &page, + // The comment (blue), its reply (red), its pop-up, a reply to the reply with no + // appearance, and a member of its group (green). + "<< /Type /Annot /Subtype /Text /F 28 /Rect [100 500 120 518] /Contents (Comment) /AP << /N 10 0 R >> /Popup 7 0 R >>", + "<< /Type /Annot /Subtype /Text /F 28 /Rect [105 495 125 513] /Contents (Reply) /IRT 5 0 R /AP << /N 11 0 R >> >>", + "<< /Type /Annot /Subtype /Popup /Rect [300 500 400 600] /Parent 5 0 R >>", + "<< /Type /Annot /Subtype /Text /F 28 /Rect [110 490 130 508] /Contents (Reply to the reply) /IRT 6 0 R /RT /R >>", + "<< /Type /Annot /Subtype /Square /F 4 /Rect [200 500 220 518] /IRT 5 0 R /RT /Group /AP << /N 12 0 R >> >>", + &blue, + &red, + &green, + ]); + let n = flatten(&mut doc, &[0], true, false).unwrap(); + assert_eq!(n, 2, "the comment and the group member are drawn; the replies are not"); + let doc = reopen(&doc); + let p = &pdfcraft_model::pages(&doc)[0]; + assert!(!p.dict.contains(b"Annots"), "the whole thread, its pop-up and the group member are gone"); + let res = doc.resolve(p.dict.get(b"Resources").unwrap()); + let xo = doc.resolve(res.as_dict().unwrap().get(b"XObject").unwrap()); + let mut drawn: Vec = xo.as_dict().unwrap().iter().filter_map(|(_, v)| v.as_ref()).map(|r| r.num).collect(); + drawn.sort_unstable(); + assert_eq!(drawn, [10, 12], "the comment's and the member's appearances; not the reply's, and none built for the other"); +} + +/// #5632 review: a reply's text never blocks flattening (a reply is not drawn into the page, so +/// it can't bake in a `?`), and a `/RT /Group` member that flattening skipped (Hidden here) +/// stays when its primary is flattened: only replies go with what they answer. +#[test] +fn flattening_skips_replies_in_the_question_mark_check_and_keeps_skipped_group_members() { + let page = stream("", " "); + let mut doc = build(&[ + "<< /Type /Catalog /Pages 2 0 R >>", + "<< /Type /Pages /Kids [3 0 R] /Count 1 >>", + "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 200 200] /Resources << >> /Contents 4 0 R /Annots [5 0 R 6 0 R 7 0 R] >>", + &page, + "<< /Type /Annot /Subtype /Square /Rect [10 10 60 40] /F 4 /AP << /N 8 0 R >> >>", + // A text-box reply whose text Helvetica can't draw, with no appearance. + "<< /Type /Annot /Subtype /FreeText /Rect [10 50 150 80] /DA (0 g /Helv 12 Tf) /Contents /F 4 /IRT 5 0 R >>", + // A hidden member of the square's group. + "<< /Type /Annot /Subtype /Circle /Rect [70 10 100 40] /F 6 /IRT 5 0 R /RT /Group /AP << /N 8 0 R >> >>", + &stream("/Type /XObject /Subtype /Form /BBox [0 0 50 30]", "0 0 1 rg 0 0 50 30 re f"), + ]); + assert_eq!(flatten(&mut doc, &[0], true, false).unwrap(), 1, "flattened, the square drawn"); let doc = reopen(&doc); let p = &pdfcraft_model::pages(&doc)[0]; let left: Vec = doc.resolve(p.dict.get(b"Annots").unwrap()).as_array().unwrap().iter().filter_map(|f| f.as_ref()).map(|r| r.num).collect(); - assert_eq!(left, [6], "the undrawn reply stays; the comment and its pop-up are gone"); + assert_eq!(left, [7], "the reply went with the square; the hidden group member stays"); +} + +/// #5632 review: a thread three replies deep, listed in `/Annots` in reverse (the deepest reply +/// first, the comment last), goes whole with its comment, none of the replies drawn; a reply to a +/// `/RT /Group` member goes with that member, which is drawn. +#[test] +fn flattening_takes_a_thread_three_deep_in_reverse_order_and_a_group_members_reply() { + let page = stream("", " "); + let form = |rgb: &str| stream("/Type /XObject /Subtype /Form /BBox [0 0 20 18]", &format!("{rgb} rg 0 0 20 18 re f")); + let (blue, red, green) = (form("0 0 1"), form("1 0 0"), form("0 1 0")); + let mut doc = build(&[ + "<< /Type /Catalog /Pages 2 0 R >>", + "<< /Type /Pages /Kids [3 0 R] /Count 1 >>", + "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 600 800] /Resources << >> /Contents 4 0 R /Annots [8 0 R 7 0 R 6 0 R 5 0 R 9 0 R 10 0 R] >>", + &page, + // The comment (blue), and three replies, each to the one before, all with appearances (red). + "<< /Type /Annot /Subtype /Text /F 28 /Rect [100 500 120 518] /Contents (Comment) /AP << /N 11 0 R >> >>", + "<< /Type /Annot /Subtype /Text /F 28 /Rect [105 495 125 513] /Contents (Reply 1) /IRT 5 0 R /AP << /N 12 0 R >> >>", + "<< /Type /Annot /Subtype /Text /F 28 /Rect [110 490 130 508] /Contents (Reply 2) /IRT 6 0 R /RT /R /AP << /N 12 0 R >> >>", + "<< /Type /Annot /Subtype /Text /F 28 /Rect [115 485 135 503] /Contents (Reply 3) /IRT 7 0 R /AP << /N 12 0 R >> >>", + // A member of the comment's group (green), and a reply to the member. + "<< /Type /Annot /Subtype /Square /F 4 /Rect [200 500 220 518] /IRT 5 0 R /RT /Group /AP << /N 13 0 R >> >>", + "<< /Type /Annot /Subtype /Text /F 28 /Rect [205 495 225 513] /Contents (Reply to the member) /IRT 9 0 R /AP << /N 12 0 R >> >>", + &blue, + &red, + &green, + ]); + assert_eq!(flatten(&mut doc, &[0], true, false).unwrap(), 2, "the comment and the group member are drawn; no reply is"); + let doc = reopen(&doc); + let p = &pdfcraft_model::pages(&doc)[0]; + assert!(!p.dict.contains(b"Annots"), "the three replies and the member's reply go with what they answer"); + let res = doc.resolve(p.dict.get(b"Resources").unwrap()); + let xo = doc.resolve(res.as_dict().unwrap().get(b"XObject").unwrap()); + let mut drawn: Vec = xo.as_dict().unwrap().iter().filter_map(|(_, v)| v.as_ref()).map(|r| r.num).collect(); + drawn.sort_unstable(); + assert_eq!(drawn, [11, 13], "the comment's and the member's appearances, not the replies'"); +} + +/// #5632 review: `/Annots` should hold references (ISO 32000-2 Table 31), but a reply or pop-up +/// written inline in it still goes with the comment it points at, as a referenced one does. One +/// pointing at a comment that stays, stays. +#[test] +fn flattening_takes_inline_replies_and_popups_with_their_comment() { + let page = stream("", " "); + let blue = stream("/Type /XObject /Subtype /Form /BBox [0 0 20 18]", "0 0 1 rg 0 0 20 18 re f"); + let mut doc = build(&[ + "<< /Type /Catalog /Pages 2 0 R >>", + "<< /Type /Pages /Kids [3 0 R] /Count 1 >>", + "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 600 800] /Resources << >> /Contents 4 0 R /Annots [5 0 R \ + << /Type /Annot /Subtype /Text /F 28 /Rect [105 495 125 513] /Contents (Inline reply) /IRT 5 0 R >> \ + << /Type /Annot /Subtype /Popup /Rect [200 400 300 500] /Parent 5 0 R >> \ + << /Type /Annot /Subtype /Text /F 28 /Rect [105 395 125 413] /Contents (Reply to a hidden note) /IRT 6 0 R >> 6 0 R] >>", + &page, + "<< /Type /Annot /Subtype /Text /F 28 /Rect [100 500 120 518] /Contents (Comment) /AP << /N 7 0 R >> >>", + // Hidden: not flattened, so it stays, and so does its inline reply. + "<< /Type /Annot /Subtype /Text /F 30 /Rect [100 400 120 418] /Contents (Hidden note) /AP << /N 7 0 R >> >>", + &blue, + ]); + assert_eq!(flatten(&mut doc, &[0], true, false).unwrap(), 1, "only the comment is drawn"); + let doc = reopen(&doc); + let p = &pdfcraft_model::pages(&doc)[0]; + let annots = doc.resolve(p.dict.get(b"Annots").unwrap()); + let left: Vec = annots + .as_array() + .unwrap() + .iter() + .map(|a| doc.resolve(a).as_dict().unwrap().get(b"Contents").and_then(|c| c.as_string().map(|s| s.to_text())).unwrap_or_default()) + .collect(); + assert_eq!(left, ["Reply to a hidden note", "Hidden note"], "the inline reply and pop-up went with the comment"); +} + +/// #5632 review: replies whose `/IRT` runs in a loop (two replying to each other, one replying to +/// itself, and a square and a note each answering the other) end the flattening. None of them is +/// drawn: each is a reply. Those that answer nothing flattened stay; the comment beside them is +/// flattened and its reply goes with it. +#[test] +fn flattening_ends_on_replies_whose_irt_loops() { + let page = stream("", " "); + let blue = stream("/Type /XObject /Subtype /Form /BBox [0 0 20 18]", "0 0 1 rg 0 0 20 18 re f"); + let mut doc = build(&[ + "<< /Type /Catalog /Pages 2 0 R >>", + "<< /Type /Pages /Kids [3 0 R] /Count 1 >>", + "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 600 800] /Resources << >> /Contents 4 0 R /Annots [5 0 R 6 0 R 7 0 R 8 0 R 9 0 R 10 0 R 11 0 R] >>", + &page, + // A comment and its reply. + "<< /Type /Annot /Subtype /Square /F 4 /Rect [100 500 120 518] /AP << /N 12 0 R >> >>", + "<< /Type /Annot /Subtype /Text /F 28 /Rect [105 495 125 513] /IRT 5 0 R /AP << /N 12 0 R >> >>", + // Two replies to each other. + "<< /Type /Annot /Subtype /Text /F 28 /Rect [200 500 220 518] /IRT 8 0 R /AP << /N 12 0 R >> >>", + "<< /Type /Annot /Subtype /Text /F 28 /Rect [205 495 225 513] /IRT 7 0 R /AP << /N 12 0 R >> >>", + // A reply to itself. + "<< /Type /Annot /Subtype /Text /F 28 /Rect [300 500 320 518] /IRT 9 0 R /AP << /N 12 0 R >> >>", + // A square and a note each answering the other: both are replies. + "<< /Type /Annot /Subtype /Square /F 4 /Rect [400 500 420 518] /IRT 11 0 R /AP << /N 12 0 R >> >>", + "<< /Type /Annot /Subtype /Text /F 28 /Rect [405 495 425 513] /IRT 10 0 R /AP << /N 12 0 R >> >>", + &blue, + ]); + assert_eq!(flatten(&mut doc, &[0], true, false).unwrap(), 1, "only the comment is drawn"); + let doc = reopen(&doc); + let p = &pdfcraft_model::pages(&doc)[0]; + let left: Vec = doc.resolve(p.dict.get(b"Annots").unwrap()).as_array().unwrap().iter().filter_map(|f| f.as_ref()).map(|r| r.num).collect(); + assert_eq!(left, [7, 8, 9, 10, 11], "the comment's reply went with it; the looping replies stay"); +} + +/// #5632 review: an `/RT` that isn't `/Group` reads as the default R, as the comment list and the +/// page read it, so replies with an unknown name or a string there are neither drawn nor kept +/// once their comment is flattened. A form field with a stray `/IRT` is no reply: it is flattened +/// as a field. +#[test] +fn flattening_reads_an_unknown_or_malformed_rt_as_a_reply_and_a_widget_with_irt_as_a_field() { + let page = stream("", " "); + let form = |rgb: &str| stream("/Type /XObject /Subtype /Form /BBox [0 0 20 18]", &format!("{rgb} rg 0 0 20 18 re f")); + let (blue, red, green) = (form("0 0 1"), form("1 0 0"), form("0 1 0")); + let mut doc = build(&[ + "<< /Type /Catalog /Pages 2 0 R >>", + "<< /Type /Pages /Kids [3 0 R] /Count 1 >>", + "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 600 800] /Resources << >> /Contents 4 0 R /Annots [5 0 R 6 0 R 7 0 R 8 0 R] >>", + &page, + "<< /Type /Annot /Subtype /Text /F 28 /Rect [100 500 120 518] /Contents (Comment) /AP << /N 9 0 R >> >>", + "<< /Type /Annot /Subtype /Text /F 28 /Rect [105 495 125 513] /Contents (Reply) /IRT 5 0 R /RT /Answer /AP << /N 10 0 R >> >>", + "<< /Type /Annot /Subtype /Text /F 28 /Rect [110 490 130 508] /Contents (Reply) /IRT 5 0 R /RT (Group) /AP << /N 10 0 R >> >>", + "<< /Type /Annot /Subtype /Widget /F 4 /Rect [200 500 220 518] /IRT 5 0 R /AP << /N 11 0 R >> >>", + &blue, + &red, + &green, + ]); + assert_eq!(flatten(&mut doc, &[0], true, true).unwrap(), 2, "the comment and the field are drawn; neither reply is"); + let doc = reopen(&doc); + let p = &pdfcraft_model::pages(&doc)[0]; + assert!(!p.dict.contains(b"Annots"), "both replies went with the comment, and the field was flattened"); + let res = doc.resolve(p.dict.get(b"Resources").unwrap()); + let xo = doc.resolve(res.as_dict().unwrap().get(b"XObject").unwrap()); + let mut drawn: Vec = xo.as_dict().unwrap().iter().filter_map(|(_, v)| v.as_ref()).map(|r| r.num).collect(); + drawn.sort_unstable(); + assert_eq!(drawn, [9, 11], "the comment's and the field's appearances, not the replies'"); } /// Flattening never makes a `?` permanent: it refuses, changing nothing, a text box without an diff --git a/crates/render/src/raster/tests.rs b/crates/render/src/raster/tests.rs index fab4e1f1ba..e42ed7c1cc 100644 --- a/crates/render/src/raster/tests.rs +++ b/crates/render/src/raster/tests.rs @@ -10529,6 +10529,101 @@ fn content_after_an_inline_image_holding_ei_is_still_read() { assert!(page.contains("AFTER"), "the content after the image was lost: {page:?}"); } +/// #5631: a reply of type R (`/IRT`, no `/RT` or `/RT /R`) is not drawn on the page, whatever +/// its appearance: ISO 32000-2 Table 172, "shall not display replies to an annotation +/// individually". The comment it answers draws, and so does a `/RT /Group` member. +#[test] +fn replies_are_not_drawn_but_their_comment_and_group_members_are() { + let form = |rgb: &str| { + let content = format!("{rgb} rg 0 0 20 18 re f"); + format!("<< /Type /XObject /Subtype /Form /BBox [0 0 20 18] /Length {} >>\nstream\n{content}\nendstream", content.len()) + }; + let objects = [ + "<< /Type /Catalog /Pages 2 0 R >>".to_string(), + "<< /Type /Pages /Kids [3 0 R] /Count 1 >>".to_string(), + "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 200 100] /Annots [4 0 R 5 0 R 6 0 R 7 0 R] >>".to_string(), + // The comment: blue. + "<< /Type /Annot /Subtype /Text /F 28 /Rect [10 40 30 58] /AP << /N 8 0 R >> >>".to_string(), + // A reply with no /RT, and one with /RT /R, to it and to that reply: red. + "<< /Type /Annot /Subtype /Text /F 28 /IRT 4 0 R /Rect [50 40 70 58] /AP << /N 9 0 R >> >>".to_string(), + "<< /Type /Annot /Subtype /Text /F 28 /IRT 5 0 R /RT /R /Rect [90 40 110 58] /AP << /N 9 0 R >> >>".to_string(), + // A member of the comment's group: green. + "<< /Type /Annot /Subtype /Square /F 4 /IRT 4 0 R /RT /Group /Rect [130 40 150 58] /AP << /N 10 0 R >> >>".to_string(), + form("0 0 1"), + form("1 0 0"), + form("0 1 0"), + ]; + let mut pdf = b"%PDF-1.7\n".to_vec(); + let mut offsets = Vec::new(); + for (i, object) in objects.iter().enumerate() { + offsets.push(pdf.len()); + pdf.extend_from_slice(format!("{} 0 obj\n{object}\nendobj\n", i + 1).as_bytes()); + } + let xref = pdf.len(); + pdf.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objects.len() + 1).as_bytes()); + for offset in offsets { + pdf.extend_from_slice(format!("{offset:010} 00000 n \n").as_bytes()); + } + pdf.extend_from_slice(format!("trailer << /Size {} /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n", objects.len() + 1).as_bytes()); + let mut r = PageRenderer::new(Arc::new(pdf), RenderConfig::default()); + let p = r.render(RenderRequest { page: 0, scale: 1.0, ..Default::default() }); + assert!(p.error.is_none(), "{:?}", p.error); + // The middle of each annotation, top-left based at scale 1. + let px = |x: u32| -> [u8; 4] { p.rgba[((51 * p.width + x) * 4) as usize..][..4].try_into().unwrap() }; + let (comment, reply, reply_r, member) = (px(20), px(60), px(100), px(140)); + assert!(comment[2] > 200 && comment[0] < 60, "the comment draws: {comment:?}"); + assert!(reply.iter().all(|v| *v > 240), "a reply with no /RT is not drawn: {reply:?}"); + assert!(reply_r.iter().all(|v| *v > 240), "a reply with /RT /R is not drawn: {reply_r:?}"); + assert!(member[1] > 200 && member[0] < 60, "a group member draws: {member:?}"); +} + +/// #5632 review: an `/RT` that isn't `/Group` reads as the default R, as the comment list reads +/// it, so a reply with an unknown name or a string there isn't drawn either. `/IRT` is a markup +/// annotation's entry: a form field carrying one still draws. +#[test] +fn an_unknown_or_malformed_rt_is_a_reply_and_a_widget_with_irt_still_draws() { + let form = |rgb: &str| { + let content = format!("{rgb} rg 0 0 20 18 re f"); + format!("<< /Type /XObject /Subtype /Form /BBox [0 0 20 18] /Length {} >>\nstream\n{content}\nendstream", content.len()) + }; + let objects = [ + "<< /Type /Catalog /Pages 2 0 R >>".to_string(), + "<< /Type /Pages /Kids [3 0 R] /Count 1 >>".to_string(), + "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 200 100] /Annots [4 0 R 5 0 R 6 0 R 7 0 R] >>".to_string(), + // The comment: blue. + "<< /Type /Annot /Subtype /Text /F 28 /Rect [10 40 30 58] /AP << /N 8 0 R >> >>".to_string(), + // Replies with an unknown /RT name and with a string there: red. + "<< /Type /Annot /Subtype /Text /F 28 /IRT 4 0 R /RT /Answer /Rect [50 40 70 58] /AP << /N 9 0 R >> >>".to_string(), + "<< /Type /Annot /Subtype /Text /F 28 /IRT 4 0 R /RT (Group) /Rect [90 40 110 58] /AP << /N 9 0 R >> >>".to_string(), + // A form field with a stray /IRT: green. + "<< /Type /Annot /Subtype /Widget /F 4 /IRT 4 0 R /Rect [130 40 150 58] /AP << /N 10 0 R >> >>".to_string(), + form("0 0 1"), + form("1 0 0"), + form("0 1 0"), + ]; + let mut pdf = b"%PDF-1.7\n".to_vec(); + let mut offsets = Vec::new(); + for (i, object) in objects.iter().enumerate() { + offsets.push(pdf.len()); + pdf.extend_from_slice(format!("{} 0 obj\n{object}\nendobj\n", i + 1).as_bytes()); + } + let xref = pdf.len(); + pdf.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objects.len() + 1).as_bytes()); + for offset in offsets { + pdf.extend_from_slice(format!("{offset:010} 00000 n \n").as_bytes()); + } + pdf.extend_from_slice(format!("trailer << /Size {} /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n", objects.len() + 1).as_bytes()); + let mut r = PageRenderer::new(Arc::new(pdf), RenderConfig::default()); + let p = r.render(RenderRequest { page: 0, scale: 1.0, ..Default::default() }); + assert!(p.error.is_none(), "{:?}", p.error); + let px = |x: u32| -> [u8; 4] { p.rgba[((51 * p.width + x) * 4) as usize..][..4].try_into().unwrap() }; + let (comment, unknown, string, widget) = (px(20), px(60), px(100), px(140)); + assert!(comment[2] > 200 && comment[0] < 60, "the comment draws: {comment:?}"); + assert!(unknown.iter().all(|v| *v > 240), "a reply with an unknown /RT is not drawn: {unknown:?}"); + assert!(string.iter().all(|v| *v > 240), "a reply with a string /RT is not drawn: {string:?}"); + assert!(widget[1] > 200 && widget[0] < 60, "a form field with /IRT draws: {widget:?}"); +} + /// `/Crypt` has nothing to undo, so a chain naming it thousands of times costs no copy of the data /// for each. A chain of filters that does work has its own cost, which this isn't about. #[test] diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index cc5231262b..39d00cacbc 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -4070,8 +4070,8 @@ milestone = "M5" status = "shipped" commands = ["comment.list"] tools = ["comment_reply"] -evidence = ["crates/ui-egui/tests/it/comments.rs::the_panel_posts_comments_and_replies", "crates/ui-egui/tests/it/comments.rs::ctrl_enter_posts_a_reply_and_shift_enter_adds_a_line", "crates/annot/src/tests.rs::replies_and_status_thread_onto_the_parent", "crates/ui-egui/tests/it/comments.rs::one_reply_is_deleted_from_its_menu_and_the_thread_stays"] -notes = "multiline reply field: Enter or Ctrl+Enter (also Command+Enter) posts, Shift+Enter adds a newline; empty replies are not posted. On the selected card each reply has its own \"…\" menu (Edit, Copy text, Delete); a Replace Text caret, half of its comment, has none. Missing: Set status on a reply from its menu, @mentions" +evidence = ["crates/render/src/raster/tests.rs::replies_are_not_drawn_but_their_comment_and_group_members_are", "crates/edit/src/tests.rs::flattening_draws_no_reply_and_takes_the_thread_with_its_comment", "crates/ui-egui/tests/it/comments.rs::the_panel_posts_comments_and_replies", "crates/ui-egui/tests/it/comments.rs::ctrl_enter_posts_a_reply_and_shift_enter_adds_a_line", "crates/annot/src/tests.rs::replies_and_status_thread_onto_the_parent", "crates/ui-egui/tests/it/comments.rs::one_reply_is_deleted_from_its_menu_and_the_thread_stays"] +notes = "multiline reply field: Enter or Ctrl+Enter (also Command+Enter) posts, Shift+Enter adds a newline; empty replies are not posted. A reply (of type R) is shown in its comment's thread, never on the page, whatever its appearance (ISO 32000-2 Table 172), and flattening neither draws nor bakes it: it goes with its comment; a `/RT /Group` member still draws. On the selected card each reply has its own \"…\" menu (Edit, Copy text, Delete); a Replace Text caret, half of its comment, has none. Missing: Set status on a reply from its menu, @mentions" [[feature]] id = "comment.set-status" @@ -4430,8 +4430,8 @@ milestone = "M5" status = "shipped" commands = ["comment.flatten"] tools = ["doc_flatten"] -evidence = ["crates/edit/src/tests.rs::flattening_draws_appearances_into_the_page_and_removes_the_comments", "crates/edit/src/tests.rs::flattening_keeps_an_undrawn_reply_to_a_flattened_comment", "crates/engine/src/tests.rs::flattening_keeps_the_look_and_drops_the_objects", "crates/automation/tests/it/automation.rs::flattening_through_tools", "crates/edit/src/tests.rs::flattening_comments_keeps_annotations_that_are_not_comments", "crates/ui-egui/tests/it/comments.rs::flatten_comments_from_the_page_menu_and_the_panel_menu"] -notes = "also in the page's right-click menu and the Comments panel's More options menu; appearances become page content; pop-ups and replies removed; a comment or reply whose appearance cannot be drawn (a cloudy border, an unsupported subtype) stays as it is instead of being deleted; annotations that are not comments (links, 3D, Screen, Movie, RichMedia, PrinterMark, TrapNet, Watermark) are left in place (#3087)" +evidence = ["crates/edit/src/tests.rs::flattening_draws_appearances_into_the_page_and_removes_the_comments", "crates/edit/src/tests.rs::flattening_takes_an_undrawable_reply_with_its_comment", "crates/edit/src/tests.rs::flattening_draws_no_reply_and_takes_the_thread_with_its_comment", "crates/engine/src/tests.rs::flattening_keeps_the_look_and_drops_the_objects", "crates/automation/tests/it/automation.rs::flattening_through_tools", "crates/edit/src/tests.rs::flattening_comments_keeps_annotations_that_are_not_comments", "crates/ui-egui/tests/it/comments.rs::flatten_comments_from_the_page_menu_and_the_panel_menu"] +notes = "also in the page's right-click menu and the Comments panel's More options menu; appearances become page content; pop-ups and replies removed with their comment, at any depth of the thread (a reply is never drawn into the page, ISO 32000-2 Table 172, nor given an appearance to draw); a comment whose appearance cannot be drawn (a cloudy border, an unsupported subtype) stays as it is instead of being deleted, and so do its replies; annotations that are not comments (links, 3D, Screen, Movie, RichMedia, PrinterMark, TrapNet, Watermark) are left in place (#3087)" [[feature]] id = "comment.migrate" diff --git a/vendor/README.md b/vendor/README.md index 0c31d0802d..3ae6b83cad 100644 --- a/vendor/README.md +++ b/vendor/README.md @@ -39,7 +39,7 @@ Other resource categories retain their existing parent-chain lookup. | 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 ş; the comma-below letters (`/scommaaccent`, `/Tcommaaccent`, `/kcommaaccent`…, found by character, so `/uni0136` and the glyph list's `/Kcedilla` too) are the letter and the font's comma, smaller and under the baseline, since the stand-ins have no `commaaccent` (Romanian ș drew nothing and ț a cedilla in forms laid out from XFA; ģ, whose comma is turned and above, is not built); (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); (35) `EncodedShadingPattern::extent`: where a mesh shading (types 4–7) can paint at all, the pixels its triangles touched when it was encoded (`None` for the types that paint everywhere, and when the shading has a `/Background`), so the renderer samples a texture there alone and skips a mesh with nothing on the canvas; (36) a mesh shading's pixel whose corner no triangle holds strictly (on an edge two triangles share, or on the mesh's side) takes the triangle that covers its centre, edges included (`Triangle::covers`): testing only the corner left a white seam along every shared edge, and the colour is still taken at the corner; (38) the `/DefaultGray`, `/DefaultRGB` and `/DefaultCMYK` colour spaces of a resource dictionary (ISO 32000-2 §8.6.5.6) replace the device space content selects: with `g`/`G`, `rg`/`RG`, `k`/`K` and `cs`/`CS` (by name, or through a `/ColorSpace` resource that is a device space), and as the `/ColorSpace` of an image XObject, an inline image or a shading painted by `sh` or as a shading pattern (`DefaultColorSpaces`, looked up once per content window in the resources and those they inherit, so a form XObject without a default of its own draws in its page's, as Poppler and MuPDF draw it); a default that is a Lab, Indexed, Pattern or device space, has another number of components or is no colour space is ignored and device colour is drawn as before; device spaces inside other spaces (an Indexed base, a Separation or DeviceN alternate, a Pattern's underlying space), shading dictionaries, masks and transparency group spaces are unchanged, and the decoded-image cache (24) keys a decode by the default it was drawn in. hayro drew device colour as it was, so pages that calibrate it this way (common in PDF/A) came out in the wrong colours; (39) knockout groups (#2971): `Device::set_knockout` right after a `/K true` group is pushed; `Device::knockout_image` for an unmasked image before its group is pushed, when `Device::in_knockout_group`: the device clears its placement rectangle only if it will draw the image (on the canvas, not a preview's tiny image, and it decodes), decoded at its draw size so the draw reuses that decode; and, when `Device::knockout_erase_wanted`, a nested group's content drawn once between `begin_knockout_erase` and `end_knockout_erase` before the group itself, which then always gets a layer of its own; (40) an optional content membership dictionary's visibility expression (`/VE`: `/And`, `/Or`, `/Not`, nested up to 32 deep) decides its visibility ahead of `/OCGs` and `/P` (ISO 32000-2 §8.11.2.2); it was ignored, so content under it stayed visible with its layers hidden; a malformed expression, including one naming an object the document doesn't list in `/OCProperties /OCGs`, falls back to `/OCGs` and `/P` (#2986); (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 (`7 Tr` since follows 64); (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`; (44) a Type 0 font whose `/ToUnicode` is the name `/Identity-H` or `/Identity-V` (not a CMap stream; WSCAD writes it) reads each code as its Unicode value, as Acrobat shows it (`Type0Font::identity_to_unicode`); the identity CMap the name resolved to maps codes to CIDs only, so such pages had no text to select or find; (46) an image XObject's group applies the fill opacity, so the fill alpha is reset to 1 before a stencil mask takes the fill colour as its paint, and a pattern paint drops the opacity it captured at `scn` (`Pattern::without_opacity`) (it was applied twice: a mask at `ca 0.5` was drawn at a quarter, #2975); (47) `path_as_rect`: a five-element path is a rectangle only when its four points are distinct, each step from one to the next runs along a single axis, and the last returns to the first. The length test admits one MoveTo and four LineTo as well as one MoveTo, three LineTo and a ClosePath, and asking only that every corner of the bounding box was visited says nothing about the order or the steps between, so a five-point polyline whose ends reached the corners was painted as that box -- a fourth side the path does not have -- and a clip of that shape was discarded; the coordinates are compared exactly, since the matrix is applied after this check and a `cm` of 10,000 turns `is_nearly_equal`'s 256th of a unit into tens of device units (#3327); (48) an `/ICCBased` colour space is cached by what its profile is read from (`IccProfileKey`): the stream object it names, looked up without reading the stream, and then the stream's dictionary and its own bytes (decrypted, not decoded), so a byte-for-byte copy of a profile in another object is read once, and a profile stream written directly in its array, which reports the object holding the array as every other stream written there does, is told apart by its bytes. hayro keyed it by the stream dictionary's bytes alone, so two different profiles with the same `/N` and `/Length` shared whichever was read first, and colours in the second were drawn through the first. `InterpreterCache::icc_profile_reads` counts the profiles read; (49) `OpenTypeFontBlob::new` also accepts a `post` table version 1.0, 2.0 or 3.0 written with its 16-bit halves swapped (`00 00 00 03`), as ScanSoft PDF Create! embeds every font; such fonts were refused and their text drawn with a substitute (Hebrew came out garbled or missing, #2946); `glyph_names` reads such a table from a copy with the version written correctly, so a 2.0 table's names serve `/Differences`. Any other unknown version is still refused (pdf.js 9462); (50) `CffFontBlob::glyph_index_by_name` looks a glyph name up in a name to glyph map built on first use (shared between clones) instead of searching the whole charset, with a string lookup per glyph, for every character code of a font; the first glyph with a name still wins; (51) at most 4096 graphics states saved at once, holding at most 64 MiB together (each `q` copies the whole state, dash array and clip list included, so short operators multiplied one large state); a `q` past either limit is counted but not copied, and its `Q` still removes the clips added since (from storytold/craft-libs#239); (52) a `/W2` run whose first CID is past `MAX_CID` stops there, as in `/W` (7), checked before the run's metrics are read: a first CID of 4294967295 inserted one entry and then overflowed, discarding the whole `/W2` table (from storytold/craft-libs#254); (55) a Separation or DeviceN tint transform that cannot be read keeps its colour space, and one that cannot be read or evaluated paints the alternate space's initial colour, as evaluation failures already did; that fallback, and a `cs`/`CS` colour space that cannot be read (drawn in DeviceGray as before), are now reported once per content stream as `InterpreterWarning::ColorFallback` (`RenderWarning::ColourFallback` in `pdfcraft-render`) instead of silently (#4260); (56) an annotation whose Invisible flag (bit 1) is set and whose `/Subtype` is none of the standard types of ISO 32000-2 Table 171 (`is_standard_annotation_type`; no `/Subtype` counts as unknown) is not drawn (Table 167), and a `/Popup` annotation's appearance is never painted on the page (§12.5.6.14: a popup has no appearance stream of its own; the viewer shows it as a window for its parent annotation). Both were drawn from their `/AP`; (57) a simple font drawn with a standard-font stand-in because the document names a font it doesn't embed (`StandardKind` with `fallback`) narrows a glyph to a narrower `/Widths` advance but no longer widens it to a wider one: the advance already places the next glyph (ISO 32000-2 §9.2.4; /Widths in Table 109), and widening drew a non-embedded `Arial,Bold` with 600-unit widths as a heavy fixed-pitch face whose `i` and `l` were solid blocks twice their width; an exact standard-14 font still matches its standard metrics both ways; (58) the fallback request for a Type 0 font that is not embedded (`FallbackFontQuery::with_descriptor`) takes its serif, italic and small-cap flags, `/FontWeight`, `/FontFamily` and `/FontStretch` from the descendant CIDFont's font descriptor (ISO 32000-2 §9.7.4.1, §9.7.6.1; §9.8): it read them from the Type 0 dictionary, which has none, so every such request asked for a regular sans-serif face and no resolver could tell a Song or Mincho font from a Hei or Gothic one, or see `/FontWeight 700`; (59) the NoRotate flag (`/F` bit 5) is honoured: the appearance, fitted to `/Rect`, is turned against the page's `/Rotate` about the rectangle's upper-left corner, which stays where it is, so it reads upright as the page is displayed (ISO 32000-2 §12.5.3). Sticky notes, which are saved NoRotate, were drawn turned with the page (#2583). Form fields and links stay in `/Rect`, where PdfCraft takes their click targets from it; (63) a luminosity soft mask whose group colour space is `/DeviceCMYK` (by name, not an ICC-based or calibrated space) reads a `/DeviceCMYK` fill or stroke colour drawn in it, and its `/BC`, as their gray `1 - min(1, 0.3 c + 0.59 m + 0.11 y + k)` (ISO 32000-2 §10.4.2.3, §11.5.3: device colour converts to gray with no colour calibration; `Context::luminosity_in_device_cmyk`, `color::device_cmyk_to_gray`): it went through the CMYK display profile, where 100% K is a dark grey, so a 100% K mask let about an eighth of the masked paint through. CMYK images, shadings and tiling patterns inside such a mask, and CMYK on the page, still use the profile; (64) text in a Type 3 font follows ISO 32000-2 §9.3.6 in the clipping modes: under `7 Tr` it is not rendered (reported to the device as clipping text, `GlyphDrawMode::Clip`, so extraction keeps it), and in modes 4 to 7 a Type 3 glyph adds nothing to the clipping path (`clip_glyph`), so text shown only in a Type 3 font leaves no clip at `ET`. Since (42) `7 Tr` painted the glyphs in the fill colour (black by default), so a fill meant to follow showed them wherever it didn't cover them; 4 to 6 still render filled; (66) a Separation colour space whose colourant is the special name `All` paints every colourant at once (ISO 32000-2 §8.6.6.4): on screen and in an RGB print raster its tint t is the gray 1 − t, complemented because the device is additive, whatever its alternate space and tint transform say (hayro drew it through the tint transform, so a `/DeviceCMYK` transform giving cyan drew registration marks light cyan); `/None` still paints nothing, any other name still goes through its alternate, and a DeviceN space naming `All` keeps its alternate, since the clause gives the name that meaning in a Separation space; (71) a lookup table in an indirect string reads: it was read as a stream first, which uses up a reference (ISO 32000-2 §8.6.6.3); (72) an uncoloured pattern's underlying space given as a name or a reference is used: it was skipped, so a CMYK cyan drew red (ISO 32000-2 §8.7.3.3); (73) `InterpreterSettings::jbig2_work`, set for each page in `interpret_page` and ended with it, and a JBIG2 image or an image with a JBIG2 mask past it left out with `ContentTruncated`; (74) colour spaces nest at most 32 levels, through every way one names another, so a chain of them ends instead of overflowing the stack; an ICC profile's `/Alternate` is read where it's met and isn't kept with the profile, or a page could build a chain past the limit on spaces already read; (75) a soft hyphen (U+00AD) that a CFF substitute for a CID font has no glyph for draws its hyphen, as Annex D.2 reads WinAnsi 0xAD in (30), instead of the CID taken as a glyph index (#5888); (76) a CID of an Arial the PDF doesn't embed (`CIDFontType2`, not of an Adobe character collection; `ArialMT`, `Arial-BoldMT`, `Arial,Bold` and the other two faces, not Arial Narrow, Black or Unicode MS) that no Unicode route draws names, through its `/CIDToGIDMap`, the Arial glyph of the standard Macintosh order (read-fonts' `post::DEFAULT_GLYPH_NAMES`, its first 258 glyphs), and a CFF substitute draws its glyph of that name (.notdef when it has none) instead of the CID taken as its own glyph index (#5889); (78) `TilingPattern::instance` numbers each setting of a tiling pattern (`scn`/`SCN`), its copies keeping the number, and `TilingPattern::is_coloured` reports `/PaintType 1`, so a renderer can draw a coloured cell once for every fill of one setting (hayro patch (30)); (82) a soft mask (or `/Mask` image) larger than its image and more than twice as large on a side as the device pixels its image is drawn at is averaged down to that size, rounded up to a power of two, in `ImageXObject::decoded_soft_mask`, and cached there (`DecodedImageKey::shrunk_to`, with the image's raster keyed by the same rounded size); every size is made from one cached intermediate mask at most 4096 on a side, so the full mask is decoded once however many sizes it is drawn at: it bypassed the decoded-image cache and was decoded, copied and staged at full size each time it was drawn. A 2 × 2 image with a 34,862 × 4,332 mask drawn 40 times on one slide (pdf.js `issue16263.pdf`) took about 3.6 s a draw and minutes a page; it now decodes the mask once and draws the page in about 2.5 s. A mask no larger than twice its drawn size keeps its resolution, which the renderer filters as before; `InterpreterCache::decoded_mask_decodes` counts the masks among the decodes; (83) an embedded TrueType program whose `head.indexToLocFormat` holds neither 0 nor 1 is read with the `loca` format its table's length gives (`with_loca_format_fixed`): the glyph reader took anything but 1 as short offsets, so a font marked 256 had its long offsets misread and drew boxes (pdf.js `issue2537r.pdf`); (84) a Type 0 font of an Adobe character collection (Japan1, GB1, CNS1, Korea1) reads a code its `/ToUnicode` has nothing for, or every code when it has none, through the collection's UCS2 CMap, code to CID to Unicode, embedded or not (ISO 32000-2 §9.10.2; `Type0Font::ucs2`, parsed the first time a code needs it): only fonts that are not embedded took that route, so Japanese and Chinese pages whose fonts are embedded without a `/ToUnicode`, or with one that maps nothing, had no text to select, find or copy; (85) a Type 0 font's `/ToUnicode` written as a CID CMap (`begincidchar` / `begincidrange`, destinations as numbers, as Word writes it) gives each number as the code's Unicode value for text, as glyph lookup (`map_via_unicode`) already read it and as pdftotext and PDFium extract it; ISO 32000-2 §9.10.3 has `beginbfchar` / `beginbfrange` there, and such text extracted nothing (pdf.js `issue9367`); (86) form fields' widgets are drawn after the page's other annotations, each group in `/Annots` order (`interpret_page`), as MuPDF and PDFium draw them; ISO 32000-2 doesn't order the two: a check box listed before a filled square comment that covers it was hidden under the comment (pdf.js `issue13003.pdf`); (87) a Type 3 font keeps each glyph procedure it decoded (`Type3::decoded_proc`), so a glyph shown again isn't inflated again: a font used as a fill pattern drew one glyph about 650,000 times on a page and decoded its procedure for each; a procedure over 64 KiB isn't kept, and the fonts of one `InterpreterCache` keep at most 32 MiB together (`MAX_CACHED_TYPE3_PROCS`), each kept procedure counting its bytes, its name and 64 bytes besides, as fonts live as long as the cache; a procedure holds its bytes within the page's content budget while the glyph is painted, kept or not (`context::hold_content`), so a page skips the same glyphs once the budget is spent, and one decoded with no more than 64 KiB of budget left, which the budget may have cut short, isn't kept; `InterpreterCache::type3_proc_decodes` counts the procedures decoded; (88) the rule of (76) also reads Times New Roman (`TimesNewRomanPSMT`, `TimesNewRomanPS-BoldMT`, `TimesNewRoman,Bold` and the other faces, not Times or Times New Roman MT Std), whose first 258 glyphs follow that order too: in a public form whose embedded Times New Roman subsets keep their glyph indexes, 941 of 942 `/ToUnicode` entries below 258 name that order's character, and its non-embedded `TimesNewRoman,Bold` drew "Salarié(e) du régime général" as "Salari²*g+f w't²i lo g'ì²p²tcn" (#6449); (89) a form XObject already being drawn is not drawn inside itself (`Context::enter_form`, reported as `ContentTruncated`, §8.10.1): a name in its content that reached it again, through its own resources or the page resources a form with its own still falls back on, drew it until the page's nested-paint budget was spent (PDFBOX-5782, 38 s); the forms being drawn are kept per page and thread, so the tiling patterns, Type 3 glyphs, soft masks and groups painted inside a form, which are interpreted in contexts of their own, see it too; (90) a Type 3 font without `/Widths` (or `/FirstChar`/`/LastChar`) advances each glyph by the width its glyph procedure sets with `d0` or `d1` (`Type3::proc_width`, ISO 32000-2 9.6.4) instead of 0, read once per code; a font with `/Widths` is unchanged, a code outside `/FirstChar`..`/LastChar` keeping width 0 (Table 110) (PDFBOX-2943: a misspelt `/Widths` key drew every glyph on one spot), and selecting such a font reports `InterpreterWarning::Type3WidthsMissing` (`Font::type3_widths_missing`, checked in `Context::resolve_font`); (91) the decoded-image cache keeps a failed decode for its key, as the warnings it reported, so a picture or stencil mask that doesn't decode (damaged data, a JPX or JBIG2 stream its decoder refuses) isn't decoded again at every draw, tile and render that shares the cache, and each draw still reports what the decode did (`DecodedImageCache::get_or_insert_with`, `ImageXObject::reporting_to`); a failure counts 128 bytes towards the cache's budget, and one the page's JBIG2 allowance may have caused (73) isn't kept (the hayro-syntax comment on `charge_page_reading` says so); (92) the text of a glyph whose name is not in the glyph list reads `uniXXXX` and `uXXXX` to `uXXXXXX` by the Adobe Glyph List Specification (`agl_unicode_from_name`: `uni` and exactly four uppercase hex digits, `u` and four to six, no surrogate, nothing past U+10FFFF), so `uni65`, `u65`, `uni20ac` and `uni+065` read as nothing instead of `e` and `€`; upstream's lenient `unicode_from_name` stays for finding a glyph in a font (TrueType `cmap` lookup, accented standard-font letters), so drawing is unchanged; (93) a CID-keyed CFF font keeps the font dicts it can read: a font dict without a Private DICT (one a subsetter emptied) leaves only the glyphs FDSelect gives it missing, where one such dict dropped the whole font and its text drew with a fallback (ISO 32000-2 §9.9 Table 124, Adobe TN #5176); (94) a font dictionary whose `/Subtype` is missing or the empty name (`/Subtype /`) is read as Type 1, so its `/BaseFont` picks the face (ISO 32000-2 Table 109 requires `/Subtype`; other readers fall back this way): a `/Courier-Bold` was drawn in the Helvetica fallback (PDFBOX-1988). Any other unknown subtype is still refused; (98) a non-embedded font named `SymbolMT`, or `Symbol`/`SymbolMT` with a `,` or `-` style suffix (`Symbol,Bold`, `Symbol-Bold`; `SYMBOL_STYLES`, so `Symbol-Helvetica` stays another font), is the standard Symbol font and draws through its built-in encoding, so a TrueType `SymbolMT` declaring `/WinAnsiEncoding` draws `mu` for code 0x6D, not `m` (ISO 32000-2 §9.6.2.2); (99) the mesh sampler walks each row of a triangle only across the columns where that row's sample points can lie in it, not its whole box (a row with none still costs a visit): thin triangles of the fixed 19 × 19 patch grid lying across the pixel grid (a narrow curved band of tensor patches) have boxes many times their area, and a mesh painted through a path the size of its own box spent the visit budget before its last patches and left them unpainted; (100) a Type 1 or Type 3 font glyph name that only numbers its glyph (`C39`, `MT72`, `G4E`: one to three letters, then decimal or hexadecimal digits) and names the very code it is shown with reads as that code's Latin-1 character (controls excepted; in a Type 3 font, which already reads codes 32-126 by the code, above 126), in a font where every such name names its own code (`numbered_names_name_their_codes`, worked out once per font); a font with any numbered name at another code (`/C1` at 233, review on #3239; `/Differences [1 /C70 …]`) or at a C0 control code (a subset counting its glyphs from code 1, `/Differences [1 /C1 /C2 …]`, where `/C65` is the 65th glyph used) keeps reading them as nothing (#3170); (101) a symbolic TrueType font with no encoding reads a code as Mac OS Roman in text extraction when its Macintosh Roman (1, 0) `cmap` subtable selects the glyph the code draws (ISO 32000-2 §9.6.5.4) and that glyph sits where the standard Macintosh glyph order (`post` version 1.0) puts the code's Mac OS Roman character, for a font whose `post` table names no glyphs, that names no base encoding (an unknown name included) and that isn't Symbol or ZapfDingbats (§9.6.6.1), after `/Differences` and before the WinAnsi last resort; Windows subsets in the same order keep their WinAnsi reading, and glyph selection is unchanged; (102) `OutlineGlyph::vertical_glyph_width`: a vertical-writing Type 0 glyph's width w0 from `/W` or `/DW`, its extent across the column (ISO 32000-2 §9.7.4.3), which `advance_width` (the horizontal advance, 0 in vertical writing) does not give | (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`; (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`; (26) `raster::tests::accented_letters_the_standard_stand_ins_lack_are_built_from_their_parts`, `raster::tests::comma_below_letters_in_standard_fonts_draw_a_comma_under_the_letter`; (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`; (35) `mesh_extent_tests::{a_mesh_paints_its_triangles_alone, a_mesh_keeps_the_graphics_state_alpha, a_mesh_off_the_canvas_paints_nothing}` (`cargo test -p hayro`); (36) `raster::tests::mesh_triangles_sharing_an_edge_leave_no_seam`; (38) `raster::tests::default_colour_spaces::device_rgb_operators_draw_in_default_rgb`, `raster::tests::default_colour_spaces::device_cmyk_operators_draw_in_default_cmyk`, `raster::tests::default_colour_spaces::device_gray_operators_draw_in_default_gray`, `raster::tests::default_colour_spaces::device_images_draw_in_the_default_colour_spaces`, `raster::tests::default_colour_spaces::device_shadings_draw_in_the_default_colour_spaces`, `raster::tests::default_colour_spaces::an_image_drawn_with_and_without_a_default_keeps_both_colours`, `raster::tests::default_colour_spaces::forms_inherit_the_default_colour_spaces_they_do_not_override`, `raster::tests::default_colour_spaces::unusable_defaults_leave_device_colour_alone`; (39) `raster::tests::knockout_groups_replace_what_their_elements_cover`; (40) `raster::tests::visibility_expressions_decide_what_a_membership_dictionary_shows`; (41) `raster::tests::a_multiply_highlight_inside_a_transparency_group_keeps_the_text`; (42) `raster::tests::rendering_modes_but_3_and_7_draw_type3_glyphs_as_filled`; (43) `raster::tests::lab_honours_the_space_white_point`, `raster::tests::indexed_palettes_over_lab_keep_their_colours`; (44) `text::tests::identity_to_unicode_names_give_text`; (46) `raster::tests::stencil_masks_are_drawn_at_the_fill_opacity_once`; (47) `raster::tests::a_five_point_polyline_is_not_painted_as_its_bounding_box`, `raster::tests::a_path_that_visits_every_corner_out_of_order_is_not_a_rectangle`, `raster::tests::a_rectangle_is_still_painted_as_a_rectangle`; (48) `raster::tests::icc_profile_cache::icc_profiles_with_identical_dictionaries_draw_their_own_colours`, `raster::tests::icc_profile_cache::icc_profiles_used_again_are_read_once`; (49) `raster::tests::a_font_whose_post_version_has_swapped_halves_is_still_used`; (50) `font::blob::tests::glyph_names_resolve_to_the_first_glyph_with_that_name`; (51) `context::saved_state_tests::repeated_saves_stay_bounded_and_balanced`; (52) `font::cid::tests::w2_run_past_the_largest_cid_keeps_the_rest_of_the_table` (hayro-interpret); (55) `raster::tests::devicen_inks::a_tint_transform_that_cannot_be_evaluated_draws_the_rest_of_the_page_and_warns`, `raster::tests::devicen_inks::a_tint_transform_that_cannot_be_read_falls_back_and_warns`; (56) `raster::tests::invisible_unknown_annotations_and_popups_paint_nothing`; (57) `substitute_widths::a_substitute_keeps_its_glyph_shapes_at_wider_advances`, `substitute_widths::a_substitute_still_narrows_glyphs_to_narrower_advances` (pdfcraft-render `tests/it`); (58) `cid_fallback_query::a_cid_font_fallback_request_carries_its_descendants_style`, `cid_fallback_query::a_cid_font_without_a_descriptor_asks_for_the_default_style` (pdfcraft-render integration tests); (59) `raster::tests::no_rotate_notes_stay_upright_at_their_corner_on_rotated_pages` (hayro-interpret); (63) `color::device_cmyk_gray_tests::{each_ink_darkens_by_its_iso_weight, ink_past_full_coverage_is_black_and_bad_components_read_as_zero}` (hayro-interpret), `it::luminosity_group_space::{black_ink_in_a_device_cmyk_group_masks_fully_and_no_ink_shows_fully, each_process_ink_in_a_device_cmyk_group_masks_by_its_weight, a_device_cmyk_backdrop_colour_masks_by_its_gray, gray_and_rgb_groups_mask_as_before, device_cmyk_on_the_page_keeps_its_display_colour}` (pdfcraft-render); (64) `type3_text_clip::{clip_only_type3_text_is_not_rendered_and_leaves_no_clip, invisible_type3_text_is_not_rendered, other_modes_render_type3_glyphs_filled_and_never_clip, only_outline_glyphs_shape_a_clip_shared_with_type3_text, self_nesting_type3_text_in_a_clip_mode_stays_bounded}` (`cargo test -p pdfcraft-render --test it`), `raster::tests::rendering_modes_but_3_and_7_draw_type3_glyphs_as_filled`, `text::tests::clipping_mode_type3_text_is_kept_by_the_ocr_filter`; (66) `separation_all::{separation_all_fills_paint_the_complemented_tint_as_gray, separation_all_strokes_paint_the_complemented_tint_as_gray, separation_all_images_paint_each_sample_as_the_complemented_gray, separation_all_shadings_paint_the_complemented_gray, separation_none_paints_nothing, a_named_separation_still_paints_through_its_alternate, a_device_n_space_naming_all_still_paints_through_its_alternate}` (`cargo test -p pdfcraft-render --test it`); (71) `raster::tests::an_indexed_lookup_table_is_read_in_every_legal_form` (pdfcraft-render); (72) `raster::tests::an_uncoloured_pattern_uses_its_underlying_space_in_every_form` (pdfcraft-render); (73) `raster::tests::the_jbig2_images_of_a_page_share_one_allowance`, `a_picture_whose_jbig2_mask_is_past_the_pages_allowance_is_not_drawn` and `a_pages_jbig2_allowance_ends_with_the_page` (pdfcraft-render); (74) `raster::tests::{a_chain_of_colour_spaces_ends, colour_spaces_are_read_to_the_limit_of_their_nesting, a_chain_built_on_spaces_already_read_ends, a_profile_read_at_the_limit_keeps_its_alternate_for_a_read_nearer_the_top}` (pdfcraft-render); (75) `raster::tests::a_soft_hyphen_a_substitute_lacks_draws_its_hyphen` (pdfcraft-render) ; (76) `raster::tests::non_embedded_arial::{an_arial_the_pdf_does_not_embed_draws_the_glyphs_its_codes_name, codes_of_other_fonts_are_not_read_in_the_macintosh_order}` (pdfcraft-render); (78) `raster::tests::{fills_of_one_pattern_setting_share_its_cell, fills_of_interleaved_pattern_settings_each_show_their_own_cell, an_uncoloured_pattern_shows_each_settings_colour}` (pdfcraft-render); (82) `raster::tests::a_soft_mask_far_larger_than_where_it_is_drawn_is_shrunk_once`, `raster::tests::an_averaged_down_soft_mask_follows_its_samples_and_its_drawn_size`, `raster::tests::decoded_images_serve_every_size_they_are_drawn_at` (pdfcraft-render); (83) `raster::tests::truetype_loca_format::a_truetype_font_with_an_out_of_range_loca_format_draws_its_glyphs` (pdfcraft-render); (84) `text::tests::an_embedded_adobe_collection_font_reads_through_its_ucs2_cmap`, `every_adobe_collection_reads_through_its_own_ucs2_cmap` (GB1, CNS1, Korea1, Identity-V, `/H` over CFF) and `a_font_outside_the_adobe_collections_gets_no_text_from_a_ucs2_cmap` (pdfcraft-render); (85) `text::tests::{a_tounicode_written_as_cid_ranges_gives_text, a_tounicode_bf_entry_wins_over_a_cid_entry_for_the_same_code}` (pdfcraft-render); (86) `widgets_above_annotations::a_widget_is_drawn_above_the_comments_on_its_page` (pdfcraft-render `tests/it`); (87) `raster::tests::type3_proc_cache::{a_type3_glyph_shown_again_decodes_its_procedure_once, a_procedure_over_64_kib_is_decoded_for_each_copy, the_procedures_one_cache_keeps_stop_at_32_mib, fonts_with_the_same_glyph_name_keep_their_own_procedures}` (pdfcraft-render); (88) `raster::tests::non_embedded_arial::{a_times_new_roman_the_pdf_does_not_embed_draws_the_glyphs_its_codes_name, codes_of_other_fonts_are_not_read_in_the_macintosh_order}` (pdfcraft-render); (89) `raster::tests::a_form_that_draws_itself_is_drawn_once`, `raster::tests::a_form_that_reaches_itself_through_a_pattern_or_a_glyph_is_drawn_once` (pdfcraft-render); (90) `raster::tests::type3_proc_widths::{a_type3_font_without_widths_advances_by_its_glyph_procedures, a_code_outside_first_char_to_last_char_still_has_width_zero, widths_the_font_gives_are_kept, a_code_without_a_usable_procedure_width_still_advances_nothing, a_type3_font_without_widths_is_reported}` (pdfcraft-render); (91) `raster::tests::failed_image_decodes::{a_picture_that_does_not_decode_is_decoded_once_however_often_it_is_drawn, a_picture_that_does_not_decode_is_decoded_once_at_each_size, a_stencil_mask_that_does_not_decode_is_decoded_once_however_often_it_is_drawn, a_picture_whose_soft_mask_does_not_decode_draws_unmasked_decoded_once, a_picture_that_decodes_is_still_decoded_once_and_drawn_every_time, a_picture_left_out_for_the_pages_allowance_draws_on_the_next_page}` (pdfcraft-render); (92) `text::tests::agl_unicode_from_name_rule_reads_text` (pdfcraft-render); (93) `cid_cff_unusable_font_dict::{glyphs_of_a_usable_font_dict_draw_beside_an_unusable_one, a_program_with_no_usable_font_dict_draws_no_square}` (pdfcraft-render integration tests); (94) `raster::tests::a_font_without_a_subtype_is_drawn_by_its_base_font` (pdfcraft-render); (98) `symbol_mt_builtin_encoding::{symbol_mt_draws_symbols_through_the_built_in_encoding, a_name_that_only_starts_with_symbol_is_not_symbol, symbol_mt_applies_its_differences, an_embedded_symbol_mt_draws_its_own_program}` (pdfcraft-render integration tests); (99) `raster::tests::thin_patches_of_a_mesh_in_a_tight_path_are_all_painted` (pdfcraft-render); (100) `text::tests::numbered_glyph_names_read_as_their_own_code`, `text::tests::numbered_type3_glyph_names_read_as_their_own_code`, `text::tests::glyph_names_outside_the_glyph_list_still_read_as_text` (pdfcraft-render); (101) `raster::tests::symbolic_truetype_mac_roman_text::{a_symbolic_truetype_font_in_the_macintosh_order_reads_its_codes_as_mac_roman, winansi_codes_of_a_font_in_the_macintosh_order_read_as_before, a_symbolic_truetype_font_without_a_1_0_subtable_reads_as_before, a_1_0_subtable_selecting_other_glyphs_than_the_drawn_ones_is_not_read, a_font_that_names_its_glyphs_is_not_read_by_their_order, a_symbol_or_zapfdingbats_font_in_the_macintosh_order_gets_no_mac_roman_reading, differences_naming_notdef_clear_the_mac_roman_reading_for_its_code, a_base_encoding_name_this_reader_does_not_know_keeps_the_mac_roman_reading_off}` (pdfcraft-render), `pdf::tests::a_symbolic_truetype_font_in_the_macintosh_order_reads_its_codes_as_mac_roman` (pdfcraft-fonts); (102) `text::tests::tracked_vertical_latin_stays_one_line` (pdfcraft-render) | +| 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 ş; the comma-below letters (`/scommaaccent`, `/Tcommaaccent`, `/kcommaaccent`…, found by character, so `/uni0136` and the glyph list's `/Kcedilla` too) are the letter and the font's comma, smaller and under the baseline, since the stand-ins have no `commaaccent` (Romanian ș drew nothing and ț a cedilla in forms laid out from XFA; ģ, whose comma is turned and above, is not built); (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); (35) `EncodedShadingPattern::extent`: where a mesh shading (types 4–7) can paint at all, the pixels its triangles touched when it was encoded (`None` for the types that paint everywhere, and when the shading has a `/Background`), so the renderer samples a texture there alone and skips a mesh with nothing on the canvas; (36) a mesh shading's pixel whose corner no triangle holds strictly (on an edge two triangles share, or on the mesh's side) takes the triangle that covers its centre, edges included (`Triangle::covers`): testing only the corner left a white seam along every shared edge, and the colour is still taken at the corner; (38) the `/DefaultGray`, `/DefaultRGB` and `/DefaultCMYK` colour spaces of a resource dictionary (ISO 32000-2 §8.6.5.6) replace the device space content selects: with `g`/`G`, `rg`/`RG`, `k`/`K` and `cs`/`CS` (by name, or through a `/ColorSpace` resource that is a device space), and as the `/ColorSpace` of an image XObject, an inline image or a shading painted by `sh` or as a shading pattern (`DefaultColorSpaces`, looked up once per content window in the resources and those they inherit, so a form XObject without a default of its own draws in its page's, as Poppler and MuPDF draw it); a default that is a Lab, Indexed, Pattern or device space, has another number of components or is no colour space is ignored and device colour is drawn as before; device spaces inside other spaces (an Indexed base, a Separation or DeviceN alternate, a Pattern's underlying space), shading dictionaries, masks and transparency group spaces are unchanged, and the decoded-image cache (24) keys a decode by the default it was drawn in. hayro drew device colour as it was, so pages that calibrate it this way (common in PDF/A) came out in the wrong colours; (39) knockout groups (#2971): `Device::set_knockout` right after a `/K true` group is pushed; `Device::knockout_image` for an unmasked image before its group is pushed, when `Device::in_knockout_group`: the device clears its placement rectangle only if it will draw the image (on the canvas, not a preview's tiny image, and it decodes), decoded at its draw size so the draw reuses that decode; and, when `Device::knockout_erase_wanted`, a nested group's content drawn once between `begin_knockout_erase` and `end_knockout_erase` before the group itself, which then always gets a layer of its own; (40) an optional content membership dictionary's visibility expression (`/VE`: `/And`, `/Or`, `/Not`, nested up to 32 deep) decides its visibility ahead of `/OCGs` and `/P` (ISO 32000-2 §8.11.2.2); it was ignored, so content under it stayed visible with its layers hidden; a malformed expression, including one naming an object the document doesn't list in `/OCProperties /OCGs`, falls back to `/OCGs` and `/P` (#2986); (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 (`7 Tr` since follows 64); (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`; (44) a Type 0 font whose `/ToUnicode` is the name `/Identity-H` or `/Identity-V` (not a CMap stream; WSCAD writes it) reads each code as its Unicode value, as Acrobat shows it (`Type0Font::identity_to_unicode`); the identity CMap the name resolved to maps codes to CIDs only, so such pages had no text to select or find; (46) an image XObject's group applies the fill opacity, so the fill alpha is reset to 1 before a stencil mask takes the fill colour as its paint, and a pattern paint drops the opacity it captured at `scn` (`Pattern::without_opacity`) (it was applied twice: a mask at `ca 0.5` was drawn at a quarter, #2975); (47) `path_as_rect`: a five-element path is a rectangle only when its four points are distinct, each step from one to the next runs along a single axis, and the last returns to the first. The length test admits one MoveTo and four LineTo as well as one MoveTo, three LineTo and a ClosePath, and asking only that every corner of the bounding box was visited says nothing about the order or the steps between, so a five-point polyline whose ends reached the corners was painted as that box -- a fourth side the path does not have -- and a clip of that shape was discarded; the coordinates are compared exactly, since the matrix is applied after this check and a `cm` of 10,000 turns `is_nearly_equal`'s 256th of a unit into tens of device units (#3327); (48) an `/ICCBased` colour space is cached by what its profile is read from (`IccProfileKey`): the stream object it names, looked up without reading the stream, and then the stream's dictionary and its own bytes (decrypted, not decoded), so a byte-for-byte copy of a profile in another object is read once, and a profile stream written directly in its array, which reports the object holding the array as every other stream written there does, is told apart by its bytes. hayro keyed it by the stream dictionary's bytes alone, so two different profiles with the same `/N` and `/Length` shared whichever was read first, and colours in the second were drawn through the first. `InterpreterCache::icc_profile_reads` counts the profiles read; (49) `OpenTypeFontBlob::new` also accepts a `post` table version 1.0, 2.0 or 3.0 written with its 16-bit halves swapped (`00 00 00 03`), as ScanSoft PDF Create! embeds every font; such fonts were refused and their text drawn with a substitute (Hebrew came out garbled or missing, #2946); `glyph_names` reads such a table from a copy with the version written correctly, so a 2.0 table's names serve `/Differences`. Any other unknown version is still refused (pdf.js 9462); (50) `CffFontBlob::glyph_index_by_name` looks a glyph name up in a name to glyph map built on first use (shared between clones) instead of searching the whole charset, with a string lookup per glyph, for every character code of a font; the first glyph with a name still wins; (51) at most 4096 graphics states saved at once, holding at most 64 MiB together (each `q` copies the whole state, dash array and clip list included, so short operators multiplied one large state); a `q` past either limit is counted but not copied, and its `Q` still removes the clips added since (from storytold/craft-libs#239); (52) a `/W2` run whose first CID is past `MAX_CID` stops there, as in `/W` (7), checked before the run's metrics are read: a first CID of 4294967295 inserted one entry and then overflowed, discarding the whole `/W2` table (from storytold/craft-libs#254); (55) a Separation or DeviceN tint transform that cannot be read keeps its colour space, and one that cannot be read or evaluated paints the alternate space's initial colour, as evaluation failures already did; that fallback, and a `cs`/`CS` colour space that cannot be read (drawn in DeviceGray as before), are now reported once per content stream as `InterpreterWarning::ColorFallback` (`RenderWarning::ColourFallback` in `pdfcraft-render`) instead of silently (#4260); (56) an annotation whose Invisible flag (bit 1) is set and whose `/Subtype` is none of the standard types of ISO 32000-2 Table 171 (`is_standard_annotation_type`; no `/Subtype` counts as unknown) is not drawn (Table 167), and a `/Popup` annotation's appearance is never painted on the page (§12.5.6.14: a popup has no appearance stream of its own; the viewer shows it as a window for its parent annotation). Both were drawn from their `/AP`; (57) a simple font drawn with a standard-font stand-in because the document names a font it doesn't embed (`StandardKind` with `fallback`) narrows a glyph to a narrower `/Widths` advance but no longer widens it to a wider one: the advance already places the next glyph (ISO 32000-2 §9.2.4; /Widths in Table 109), and widening drew a non-embedded `Arial,Bold` with 600-unit widths as a heavy fixed-pitch face whose `i` and `l` were solid blocks twice their width; an exact standard-14 font still matches its standard metrics both ways; (58) the fallback request for a Type 0 font that is not embedded (`FallbackFontQuery::with_descriptor`) takes its serif, italic and small-cap flags, `/FontWeight`, `/FontFamily` and `/FontStretch` from the descendant CIDFont's font descriptor (ISO 32000-2 §9.7.4.1, §9.7.6.1; §9.8): it read them from the Type 0 dictionary, which has none, so every such request asked for a regular sans-serif face and no resolver could tell a Song or Mincho font from a Hei or Gothic one, or see `/FontWeight 700`; (59) the NoRotate flag (`/F` bit 5) is honoured: the appearance, fitted to `/Rect`, is turned against the page's `/Rotate` about the rectangle's upper-left corner, which stays where it is, so it reads upright as the page is displayed (ISO 32000-2 §12.5.3). Sticky notes, which are saved NoRotate, were drawn turned with the page (#2583). Form fields and links stay in `/Rect`, where PdfCraft takes their click targets from it; (63) a luminosity soft mask whose group colour space is `/DeviceCMYK` (by name, not an ICC-based or calibrated space) reads a `/DeviceCMYK` fill or stroke colour drawn in it, and its `/BC`, as their gray `1 - min(1, 0.3 c + 0.59 m + 0.11 y + k)` (ISO 32000-2 §10.4.2.3, §11.5.3: device colour converts to gray with no colour calibration; `Context::luminosity_in_device_cmyk`, `color::device_cmyk_to_gray`): it went through the CMYK display profile, where 100% K is a dark grey, so a 100% K mask let about an eighth of the masked paint through. CMYK images, shadings and tiling patterns inside such a mask, and CMYK on the page, still use the profile; (64) text in a Type 3 font follows ISO 32000-2 §9.3.6 in the clipping modes: under `7 Tr` it is not rendered (reported to the device as clipping text, `GlyphDrawMode::Clip`, so extraction keeps it), and in modes 4 to 7 a Type 3 glyph adds nothing to the clipping path (`clip_glyph`), so text shown only in a Type 3 font leaves no clip at `ET`. Since (42) `7 Tr` painted the glyphs in the fill colour (black by default), so a fill meant to follow showed them wherever it didn't cover them; 4 to 6 still render filled; (66) a Separation colour space whose colourant is the special name `All` paints every colourant at once (ISO 32000-2 §8.6.6.4): on screen and in an RGB print raster its tint t is the gray 1 − t, complemented because the device is additive, whatever its alternate space and tint transform say (hayro drew it through the tint transform, so a `/DeviceCMYK` transform giving cyan drew registration marks light cyan); `/None` still paints nothing, any other name still goes through its alternate, and a DeviceN space naming `All` keeps its alternate, since the clause gives the name that meaning in a Separation space; (71) a lookup table in an indirect string reads: it was read as a stream first, which uses up a reference (ISO 32000-2 §8.6.6.3); (72) an uncoloured pattern's underlying space given as a name or a reference is used: it was skipped, so a CMYK cyan drew red (ISO 32000-2 §8.7.3.3); (73) `InterpreterSettings::jbig2_work`, set for each page in `interpret_page` and ended with it, and a JBIG2 image or an image with a JBIG2 mask past it left out with `ContentTruncated`; (74) colour spaces nest at most 32 levels, through every way one names another, so a chain of them ends instead of overflowing the stack; an ICC profile's `/Alternate` is read where it's met and isn't kept with the profile, or a page could build a chain past the limit on spaces already read; (75) a soft hyphen (U+00AD) that a CFF substitute for a CID font has no glyph for draws its hyphen, as Annex D.2 reads WinAnsi 0xAD in (30), instead of the CID taken as a glyph index (#5888); (76) a CID of an Arial the PDF doesn't embed (`CIDFontType2`, not of an Adobe character collection; `ArialMT`, `Arial-BoldMT`, `Arial,Bold` and the other two faces, not Arial Narrow, Black or Unicode MS) that no Unicode route draws names, through its `/CIDToGIDMap`, the Arial glyph of the standard Macintosh order (read-fonts' `post::DEFAULT_GLYPH_NAMES`, its first 258 glyphs), and a CFF substitute draws its glyph of that name (.notdef when it has none) instead of the CID taken as its own glyph index (#5889); (78) `TilingPattern::instance` numbers each setting of a tiling pattern (`scn`/`SCN`), its copies keeping the number, and `TilingPattern::is_coloured` reports `/PaintType 1`, so a renderer can draw a coloured cell once for every fill of one setting (hayro patch (30)); (82) a soft mask (or `/Mask` image) larger than its image and more than twice as large on a side as the device pixels its image is drawn at is averaged down to that size, rounded up to a power of two, in `ImageXObject::decoded_soft_mask`, and cached there (`DecodedImageKey::shrunk_to`, with the image's raster keyed by the same rounded size); every size is made from one cached intermediate mask at most 4096 on a side, so the full mask is decoded once however many sizes it is drawn at: it bypassed the decoded-image cache and was decoded, copied and staged at full size each time it was drawn. A 2 × 2 image with a 34,862 × 4,332 mask drawn 40 times on one slide (pdf.js `issue16263.pdf`) took about 3.6 s a draw and minutes a page; it now decodes the mask once and draws the page in about 2.5 s. A mask no larger than twice its drawn size keeps its resolution, which the renderer filters as before; `InterpreterCache::decoded_mask_decodes` counts the masks among the decodes; (83) an embedded TrueType program whose `head.indexToLocFormat` holds neither 0 nor 1 is read with the `loca` format its table's length gives (`with_loca_format_fixed`): the glyph reader took anything but 1 as short offsets, so a font marked 256 had its long offsets misread and drew boxes (pdf.js `issue2537r.pdf`); (84) a Type 0 font of an Adobe character collection (Japan1, GB1, CNS1, Korea1) reads a code its `/ToUnicode` has nothing for, or every code when it has none, through the collection's UCS2 CMap, code to CID to Unicode, embedded or not (ISO 32000-2 §9.10.2; `Type0Font::ucs2`, parsed the first time a code needs it): only fonts that are not embedded took that route, so Japanese and Chinese pages whose fonts are embedded without a `/ToUnicode`, or with one that maps nothing, had no text to select, find or copy; (85) a Type 0 font's `/ToUnicode` written as a CID CMap (`begincidchar` / `begincidrange`, destinations as numbers, as Word writes it) gives each number as the code's Unicode value for text, as glyph lookup (`map_via_unicode`) already read it and as pdftotext and PDFium extract it; ISO 32000-2 §9.10.3 has `beginbfchar` / `beginbfrange` there, and such text extracted nothing (pdf.js `issue9367`); (86) form fields' widgets are drawn after the page's other annotations, each group in `/Annots` order (`interpret_page`), as MuPDF and PDFium draw them; ISO 32000-2 doesn't order the two: a check box listed before a filled square comment that covers it was hidden under the comment (pdf.js `issue13003.pdf`); (87) a Type 3 font keeps each glyph procedure it decoded (`Type3::decoded_proc`), so a glyph shown again isn't inflated again: a font used as a fill pattern drew one glyph about 650,000 times on a page and decoded its procedure for each; a procedure over 64 KiB isn't kept, and the fonts of one `InterpreterCache` keep at most 32 MiB together (`MAX_CACHED_TYPE3_PROCS`), each kept procedure counting its bytes, its name and 64 bytes besides, as fonts live as long as the cache; a procedure holds its bytes within the page's content budget while the glyph is painted, kept or not (`context::hold_content`), so a page skips the same glyphs once the budget is spent, and one decoded with no more than 64 KiB of budget left, which the budget may have cut short, isn't kept; `InterpreterCache::type3_proc_decodes` counts the procedures decoded; (88) the rule of (76) also reads Times New Roman (`TimesNewRomanPSMT`, `TimesNewRomanPS-BoldMT`, `TimesNewRoman,Bold` and the other faces, not Times or Times New Roman MT Std), whose first 258 glyphs follow that order too: in a public form whose embedded Times New Roman subsets keep their glyph indexes, 941 of 942 `/ToUnicode` entries below 258 name that order's character, and its non-embedded `TimesNewRoman,Bold` drew "Salarié(e) du régime général" as "Salari²*g+f w't²i lo g'ì²p²tcn" (#6449); (89) a form XObject already being drawn is not drawn inside itself (`Context::enter_form`, reported as `ContentTruncated`, §8.10.1): a name in its content that reached it again, through its own resources or the page resources a form with its own still falls back on, drew it until the page's nested-paint budget was spent (PDFBOX-5782, 38 s); the forms being drawn are kept per page and thread, so the tiling patterns, Type 3 glyphs, soft masks and groups painted inside a form, which are interpreted in contexts of their own, see it too; (90) a Type 3 font without `/Widths` (or `/FirstChar`/`/LastChar`) advances each glyph by the width its glyph procedure sets with `d0` or `d1` (`Type3::proc_width`, ISO 32000-2 9.6.4) instead of 0, read once per code; a font with `/Widths` is unchanged, a code outside `/FirstChar`..`/LastChar` keeping width 0 (Table 110) (PDFBOX-2943: a misspelt `/Widths` key drew every glyph on one spot), and selecting such a font reports `InterpreterWarning::Type3WidthsMissing` (`Font::type3_widths_missing`, checked in `Context::resolve_font`); (91) the decoded-image cache keeps a failed decode for its key, as the warnings it reported, so a picture or stencil mask that doesn't decode (damaged data, a JPX or JBIG2 stream its decoder refuses) isn't decoded again at every draw, tile and render that shares the cache, and each draw still reports what the decode did (`DecodedImageCache::get_or_insert_with`, `ImageXObject::reporting_to`); a failure counts 128 bytes towards the cache's budget, and one the page's JBIG2 allowance may have caused (73) isn't kept (the hayro-syntax comment on `charge_page_reading` says so); (92) the text of a glyph whose name is not in the glyph list reads `uniXXXX` and `uXXXX` to `uXXXXXX` by the Adobe Glyph List Specification (`agl_unicode_from_name`: `uni` and exactly four uppercase hex digits, `u` and four to six, no surrogate, nothing past U+10FFFF), so `uni65`, `u65`, `uni20ac` and `uni+065` read as nothing instead of `e` and `€`; upstream's lenient `unicode_from_name` stays for finding a glyph in a font (TrueType `cmap` lookup, accented standard-font letters), so drawing is unchanged; (93) a CID-keyed CFF font keeps the font dicts it can read: a font dict without a Private DICT (one a subsetter emptied) leaves only the glyphs FDSelect gives it missing, where one such dict dropped the whole font and its text drew with a fallback (ISO 32000-2 §9.9 Table 124, Adobe TN #5176); (94) a font dictionary whose `/Subtype` is missing or the empty name (`/Subtype /`) is read as Type 1, so its `/BaseFont` picks the face (ISO 32000-2 Table 109 requires `/Subtype`; other readers fall back this way): a `/Courier-Bold` was drawn in the Helvetica fallback (PDFBOX-1988). Any other unknown subtype is still refused; (98) a non-embedded font named `SymbolMT`, or `Symbol`/`SymbolMT` with a `,` or `-` style suffix (`Symbol,Bold`, `Symbol-Bold`; `SYMBOL_STYLES`, so `Symbol-Helvetica` stays another font), is the standard Symbol font and draws through its built-in encoding, so a TrueType `SymbolMT` declaring `/WinAnsiEncoding` draws `mu` for code 0x6D, not `m` (ISO 32000-2 §9.6.2.2); (99) the mesh sampler walks each row of a triangle only across the columns where that row's sample points can lie in it, not its whole box (a row with none still costs a visit): thin triangles of the fixed 19 × 19 patch grid lying across the pixel grid (a narrow curved band of tensor patches) have boxes many times their area, and a mesh painted through a path the size of its own box spent the visit budget before its last patches and left them unpainted; (100) a Type 1 or Type 3 font glyph name that only numbers its glyph (`C39`, `MT72`, `G4E`: one to three letters, then decimal or hexadecimal digits) and names the very code it is shown with reads as that code's Latin-1 character (controls excepted; in a Type 3 font, which already reads codes 32-126 by the code, above 126), in a font where every such name names its own code (`numbered_names_name_their_codes`, worked out once per font); a font with any numbered name at another code (`/C1` at 233, review on #3239; `/Differences [1 /C70 …]`) or at a C0 control code (a subset counting its glyphs from code 1, `/Differences [1 /C1 /C2 …]`, where `/C65` is the 65th glyph used) keeps reading them as nothing (#3170); (101) a symbolic TrueType font with no encoding reads a code as Mac OS Roman in text extraction when its Macintosh Roman (1, 0) `cmap` subtable selects the glyph the code draws (ISO 32000-2 §9.6.5.4) and that glyph sits where the standard Macintosh glyph order (`post` version 1.0) puts the code's Mac OS Roman character, for a font whose `post` table names no glyphs, that names no base encoding (an unknown name included) and that isn't Symbol or ZapfDingbats (§9.6.6.1), after `/Differences` and before the WinAnsi last resort; Windows subsets in the same order keep their WinAnsi reading, and glyph selection is unchanged; (102) `OutlineGlyph::vertical_glyph_width`: a vertical-writing Type 0 glyph's width w0 from `/W` or `/DW`, its extent across the column (ISO 32000-2 §9.7.4.3), which `advance_width` (the horizontal advance, 0 in vertical writing) does not give; (81) a reply of type R (a markup annotation with an `/IRT` and an `/RT` other than `/Group`; R is the default, so a missing, unknown or malformed `/RT` reads as R; a widget, link or pop-up with `/IRT` still draws) is not drawn, whatever its appearance or flags: ISO 32000-2 Table 172 says such replies "shall not" be displayed individually but in their comment's thread (files from editors that give every reply a note-icon appearance showed a stack of icons, #5631); a `/RT /Group` member still draws | (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`; (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`; (26) `raster::tests::accented_letters_the_standard_stand_ins_lack_are_built_from_their_parts`, `raster::tests::comma_below_letters_in_standard_fonts_draw_a_comma_under_the_letter`; (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`; (35) `mesh_extent_tests::{a_mesh_paints_its_triangles_alone, a_mesh_keeps_the_graphics_state_alpha, a_mesh_off_the_canvas_paints_nothing}` (`cargo test -p hayro`); (36) `raster::tests::mesh_triangles_sharing_an_edge_leave_no_seam`; (38) `raster::tests::default_colour_spaces::device_rgb_operators_draw_in_default_rgb`, `raster::tests::default_colour_spaces::device_cmyk_operators_draw_in_default_cmyk`, `raster::tests::default_colour_spaces::device_gray_operators_draw_in_default_gray`, `raster::tests::default_colour_spaces::device_images_draw_in_the_default_colour_spaces`, `raster::tests::default_colour_spaces::device_shadings_draw_in_the_default_colour_spaces`, `raster::tests::default_colour_spaces::an_image_drawn_with_and_without_a_default_keeps_both_colours`, `raster::tests::default_colour_spaces::forms_inherit_the_default_colour_spaces_they_do_not_override`, `raster::tests::default_colour_spaces::unusable_defaults_leave_device_colour_alone`; (39) `raster::tests::knockout_groups_replace_what_their_elements_cover`; (40) `raster::tests::visibility_expressions_decide_what_a_membership_dictionary_shows`; (41) `raster::tests::a_multiply_highlight_inside_a_transparency_group_keeps_the_text`; (42) `raster::tests::rendering_modes_but_3_and_7_draw_type3_glyphs_as_filled`; (43) `raster::tests::lab_honours_the_space_white_point`, `raster::tests::indexed_palettes_over_lab_keep_their_colours`; (44) `text::tests::identity_to_unicode_names_give_text`; (46) `raster::tests::stencil_masks_are_drawn_at_the_fill_opacity_once`; (47) `raster::tests::a_five_point_polyline_is_not_painted_as_its_bounding_box`, `raster::tests::a_path_that_visits_every_corner_out_of_order_is_not_a_rectangle`, `raster::tests::a_rectangle_is_still_painted_as_a_rectangle`; (48) `raster::tests::icc_profile_cache::icc_profiles_with_identical_dictionaries_draw_their_own_colours`, `raster::tests::icc_profile_cache::icc_profiles_used_again_are_read_once`; (49) `raster::tests::a_font_whose_post_version_has_swapped_halves_is_still_used`; (50) `font::blob::tests::glyph_names_resolve_to_the_first_glyph_with_that_name`; (51) `context::saved_state_tests::repeated_saves_stay_bounded_and_balanced`; (52) `font::cid::tests::w2_run_past_the_largest_cid_keeps_the_rest_of_the_table` (hayro-interpret); (55) `raster::tests::devicen_inks::a_tint_transform_that_cannot_be_evaluated_draws_the_rest_of_the_page_and_warns`, `raster::tests::devicen_inks::a_tint_transform_that_cannot_be_read_falls_back_and_warns`; (56) `raster::tests::invisible_unknown_annotations_and_popups_paint_nothing`; (57) `substitute_widths::a_substitute_keeps_its_glyph_shapes_at_wider_advances`, `substitute_widths::a_substitute_still_narrows_glyphs_to_narrower_advances` (pdfcraft-render `tests/it`); (58) `cid_fallback_query::a_cid_font_fallback_request_carries_its_descendants_style`, `cid_fallback_query::a_cid_font_without_a_descriptor_asks_for_the_default_style` (pdfcraft-render integration tests); (59) `raster::tests::no_rotate_notes_stay_upright_at_their_corner_on_rotated_pages` (hayro-interpret); (63) `color::device_cmyk_gray_tests::{each_ink_darkens_by_its_iso_weight, ink_past_full_coverage_is_black_and_bad_components_read_as_zero}` (hayro-interpret), `it::luminosity_group_space::{black_ink_in_a_device_cmyk_group_masks_fully_and_no_ink_shows_fully, each_process_ink_in_a_device_cmyk_group_masks_by_its_weight, a_device_cmyk_backdrop_colour_masks_by_its_gray, gray_and_rgb_groups_mask_as_before, device_cmyk_on_the_page_keeps_its_display_colour}` (pdfcraft-render); (64) `type3_text_clip::{clip_only_type3_text_is_not_rendered_and_leaves_no_clip, invisible_type3_text_is_not_rendered, other_modes_render_type3_glyphs_filled_and_never_clip, only_outline_glyphs_shape_a_clip_shared_with_type3_text, self_nesting_type3_text_in_a_clip_mode_stays_bounded}` (`cargo test -p pdfcraft-render --test it`), `raster::tests::rendering_modes_but_3_and_7_draw_type3_glyphs_as_filled`, `text::tests::clipping_mode_type3_text_is_kept_by_the_ocr_filter`; (66) `separation_all::{separation_all_fills_paint_the_complemented_tint_as_gray, separation_all_strokes_paint_the_complemented_tint_as_gray, separation_all_images_paint_each_sample_as_the_complemented_gray, separation_all_shadings_paint_the_complemented_gray, separation_none_paints_nothing, a_named_separation_still_paints_through_its_alternate, a_device_n_space_naming_all_still_paints_through_its_alternate}` (`cargo test -p pdfcraft-render --test it`); (71) `raster::tests::an_indexed_lookup_table_is_read_in_every_legal_form` (pdfcraft-render); (72) `raster::tests::an_uncoloured_pattern_uses_its_underlying_space_in_every_form` (pdfcraft-render); (73) `raster::tests::the_jbig2_images_of_a_page_share_one_allowance`, `a_picture_whose_jbig2_mask_is_past_the_pages_allowance_is_not_drawn` and `a_pages_jbig2_allowance_ends_with_the_page` (pdfcraft-render); (74) `raster::tests::{a_chain_of_colour_spaces_ends, colour_spaces_are_read_to_the_limit_of_their_nesting, a_chain_built_on_spaces_already_read_ends, a_profile_read_at_the_limit_keeps_its_alternate_for_a_read_nearer_the_top}` (pdfcraft-render); (75) `raster::tests::a_soft_hyphen_a_substitute_lacks_draws_its_hyphen` (pdfcraft-render) ; (76) `raster::tests::non_embedded_arial::{an_arial_the_pdf_does_not_embed_draws_the_glyphs_its_codes_name, codes_of_other_fonts_are_not_read_in_the_macintosh_order}` (pdfcraft-render); (78) `raster::tests::{fills_of_one_pattern_setting_share_its_cell, fills_of_interleaved_pattern_settings_each_show_their_own_cell, an_uncoloured_pattern_shows_each_settings_colour}` (pdfcraft-render); (82) `raster::tests::a_soft_mask_far_larger_than_where_it_is_drawn_is_shrunk_once`, `raster::tests::an_averaged_down_soft_mask_follows_its_samples_and_its_drawn_size`, `raster::tests::decoded_images_serve_every_size_they_are_drawn_at` (pdfcraft-render); (83) `raster::tests::truetype_loca_format::a_truetype_font_with_an_out_of_range_loca_format_draws_its_glyphs` (pdfcraft-render); (84) `text::tests::an_embedded_adobe_collection_font_reads_through_its_ucs2_cmap`, `every_adobe_collection_reads_through_its_own_ucs2_cmap` (GB1, CNS1, Korea1, Identity-V, `/H` over CFF) and `a_font_outside_the_adobe_collections_gets_no_text_from_a_ucs2_cmap` (pdfcraft-render); (85) `text::tests::{a_tounicode_written_as_cid_ranges_gives_text, a_tounicode_bf_entry_wins_over_a_cid_entry_for_the_same_code}` (pdfcraft-render); (86) `widgets_above_annotations::a_widget_is_drawn_above_the_comments_on_its_page` (pdfcraft-render `tests/it`); (87) `raster::tests::type3_proc_cache::{a_type3_glyph_shown_again_decodes_its_procedure_once, a_procedure_over_64_kib_is_decoded_for_each_copy, the_procedures_one_cache_keeps_stop_at_32_mib, fonts_with_the_same_glyph_name_keep_their_own_procedures}` (pdfcraft-render); (88) `raster::tests::non_embedded_arial::{a_times_new_roman_the_pdf_does_not_embed_draws_the_glyphs_its_codes_name, codes_of_other_fonts_are_not_read_in_the_macintosh_order}` (pdfcraft-render); (89) `raster::tests::a_form_that_draws_itself_is_drawn_once`, `raster::tests::a_form_that_reaches_itself_through_a_pattern_or_a_glyph_is_drawn_once` (pdfcraft-render); (90) `raster::tests::type3_proc_widths::{a_type3_font_without_widths_advances_by_its_glyph_procedures, a_code_outside_first_char_to_last_char_still_has_width_zero, widths_the_font_gives_are_kept, a_code_without_a_usable_procedure_width_still_advances_nothing, a_type3_font_without_widths_is_reported}` (pdfcraft-render); (91) `raster::tests::failed_image_decodes::{a_picture_that_does_not_decode_is_decoded_once_however_often_it_is_drawn, a_picture_that_does_not_decode_is_decoded_once_at_each_size, a_stencil_mask_that_does_not_decode_is_decoded_once_however_often_it_is_drawn, a_picture_whose_soft_mask_does_not_decode_draws_unmasked_decoded_once, a_picture_that_decodes_is_still_decoded_once_and_drawn_every_time, a_picture_left_out_for_the_pages_allowance_draws_on_the_next_page}` (pdfcraft-render); (92) `text::tests::agl_unicode_from_name_rule_reads_text` (pdfcraft-render); (93) `cid_cff_unusable_font_dict::{glyphs_of_a_usable_font_dict_draw_beside_an_unusable_one, a_program_with_no_usable_font_dict_draws_no_square}` (pdfcraft-render integration tests); (94) `raster::tests::a_font_without_a_subtype_is_drawn_by_its_base_font` (pdfcraft-render); (98) `symbol_mt_builtin_encoding::{symbol_mt_draws_symbols_through_the_built_in_encoding, a_name_that_only_starts_with_symbol_is_not_symbol, symbol_mt_applies_its_differences, an_embedded_symbol_mt_draws_its_own_program}` (pdfcraft-render integration tests); (99) `raster::tests::thin_patches_of_a_mesh_in_a_tight_path_are_all_painted` (pdfcraft-render); (100) `text::tests::numbered_glyph_names_read_as_their_own_code`, `text::tests::numbered_type3_glyph_names_read_as_their_own_code`, `text::tests::glyph_names_outside_the_glyph_list_still_read_as_text` (pdfcraft-render); (101) `raster::tests::symbolic_truetype_mac_roman_text::{a_symbolic_truetype_font_in_the_macintosh_order_reads_its_codes_as_mac_roman, winansi_codes_of_a_font_in_the_macintosh_order_read_as_before, a_symbolic_truetype_font_without_a_1_0_subtable_reads_as_before, a_1_0_subtable_selecting_other_glyphs_than_the_drawn_ones_is_not_read, a_font_that_names_its_glyphs_is_not_read_by_their_order, a_symbol_or_zapfdingbats_font_in_the_macintosh_order_gets_no_mac_roman_reading, differences_naming_notdef_clear_the_mac_roman_reading_for_its_code, a_base_encoding_name_this_reader_does_not_know_keeps_the_mac_roman_reading_off}` (pdfcraft-render), `pdf::tests::a_symbolic_truetype_font_in_the_macintosh_order_reads_its_codes_as_mac_roman` (pdfcraft-fonts); (102) `text::tests::tracked_vertical_latin_stays_one_line` (pdfcraft-render); (81) `raster::tests::replies_are_not_drawn_but_their_comment_and_group_members_are`, `raster::tests::an_unknown_or_malformed_rt_is_a_reply_and_a_widget_with_irt_still_draws` (hayro-interpret) | | 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 (since (21), only inside Type 3 glyphs), 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 image that is interpolated (or, since (21), area-averaged) and whose axes lie along the device's (upright, mirrored or a quarter turn, as on a page under `/Rotate`) is snapped to whole device pixels: shrunk once to its rounded device rectangle (halves round up, so a tile snaps an edge where the whole page does) and drawn 1:1 from 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, and until #5530 a turned one still did, up to 200 grey levels off the same page drawn upright). An image counts as axis-aligned when the terms the snap drops move its far edges by at most a hundredth of a device pixel (`SNAP_SKEW`), not by a tolerance on the coefficients, which let a slight skew of a very wide image through; and the snapped 1:1 transform is used only when the shrink produced the snapped size, so a failed resize keeps the full transform instead of drawing the source raster at its source size; (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; under reading colours its background is painted inside it as the initial backdrop, so a Multiply highlight blends with the dark background instead of staying an opaque block, and otherwise the background stays outside, so a blend mode over an empty part of the page keeps its colour instead of blending with the white paper (#2970); (19) `set_paint` samples a mesh shading's texture over its triangles alone (`EncodedShadingPattern::extent`, padded rather than repeated) and skips a mesh with none on the canvas: a BricsCAD plan's 310 one-triangle `sh` each covered the whole tile, 2 s per 1024 px tile at any zoom (the watchdog gave the page up on a busy machine), now about 70 ms; (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); (21) an image that does not request interpolation and is drawn below its resolution, outside a Type 3 glyph, is shrunk by area averaging (`area_shrink`: each device pixel the coverage-weighted mean of the samples it covers, in 16.16 fixed point whose weights sum to exactly one, premultiplied when the image has a mask; a soft mask no larger than the image is first sampled onto the image's grid by `mask_on_image_grid` (nearest-neighbour, or bilinearly when the mask asks for `/Interpolate`, so its edge stays as smooth as an enlarged mask's), so its colour and mask are averaged together instead of the averaged colour of transparent samples showing under a mask layer at the mask's edges) to its snapped device rectangle, instead of nearest-neighbour: `/Interpolate` asks for smoothing only when an image is enlarged (ISO 32000-2 §8.9.5.3), and nearest-neighbour kept one sample in N per axis, so the text of a 300 dpi scan at 100% lost most of its strokes (sampling them again at a fractional offset dropped more). PicScale's `Box` and `Area` filters were not used: shrinking, both average a window four target pixels wide. On a 13-page 300 dpi scan every rendered sample at 96, 120 and 192 dpi is within 1 of the exact area average (it was up to 255 off, 17-23% of samples by more than 1). A snapped image whose shrink failed keeps the full transform, so it is not drawn at its source size. The shrink never enlarges: a side drawn at or above its resolution (an image shrunk across and stretched down, as a 204 × 98 dpi fax is) keeps its samples for the sampler. Each target row averages the whole source rows it covers (contiguous, cheap) and is then shrunk across with taps worked out once per image, which made the shrink about three times faster (a 300 dpi page at 96 dpi: 25 ms, nearest-neighbour 11 ms); working a row at a time (`area_shrink_rows`) keeps its work memory to a few rows. An image with a soft mask, a stencil's ink included, is premultiplied a source row at a time as it is averaged (`area_shrink_premultiplied`), never as a full-resolution RGBA copy: a 10718 × 13701 stencil shown at page size peaked at 1.16 GB with that copy and peaks at 1.03 GB without it, as with nearest-neighbour. A source with a side over `MAX_RESAMPLING_SOURCE_SIDE` (2^20) keeps (10)'s nearest-neighbour shrink, and `area_shrink` refuses it, so its accumulator row and tap table stay a few times one source row (a 2^27 × 2 image of zeros is a tiny file); (22) knockout groups: while one is innermost, fills, strokes, glyph outlines and shadings are drawn with `Compose::Copy` (weighted by coverage), so each element replaces what earlier elements of the group drew instead of compositing over it; an image or nested group, drawn into a layer of its own, first has what is under its shape cleared (`knockout_image` for an unmasked image's rectangle, only when `draw_image` will draw it, decoded at the size it is drawn; a knockout erase pass that draws a nested group's content with every paint clearing instead of painting, a shading fill's clear clipped to the shading's `/BBox` as its paint is) and then composites over it (#2971); (23) 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; (25) a tiling cell is drawn at the scale that makes one `/XStep` (`/YStep`) exactly the cell pixmap's whole number of pixels (`tiling_cell_pixels`, at least 1 and at most the cap), so the repeated pixmap puts cells at whole multiples of the step at any device scale; drawing at the unrounded scale repeated the cell up to half a pixel off at every step, and the error grew with distance from the pattern's origin (ISO 32000-2 8.7.3.1); (28) an image of page-scan size (`SMOOTHED_IMAGE_SIDE`: 512 samples or more on its shorter side) that does not request interpolation is drawn with `ImageQuality::Medium` (bilinear, as interpolated images are) when enlarged by more than 1% on either side (`SMOOTHED_IMAGE_SCALE`: a scan drawn at its resolution can come out a few ulps above 1, and blurred by half a pixel at a half-pixel offset), also when the other side is shrunk (the area shrink leaves that side at 1:1), outside a Type 3 glyph: scans rarely set `/Interpolate`, and drawn nearest-neighbour every sample of a zoomed scan became a block, so its text had staircase edges; icons, QR codes and pixel art keep their hard pixels. Bicubic (`High`) looked the same on a zoomed 300 dpi scan and drew four 1024 px tiles at 400% in 267 ms against 50 ms; (30) a stencil mask painted with a pattern is drawn under a clip of its device rectangle, padded by two pixels: the pattern in an isolated layer, cut to the stencil by a layer holding it composited with `Compose::DestIn` (drawn without a Type 3 glyph's image rules, as the sub-render it replaces was), instead of through a canvas-sized luminance mask rendered for every draw; and a coloured tiling pattern's cell pixmap, repeated or shown once under patch (20), is drawn once per setting of the pattern and cell scale (`Renderer::tiling_cell`, `TilingCells`, eight entries per renderer, keyed by hayro-interpret patch (78)'s `TilingPattern::instance`) instead of once per fill, where every cell was kept until the page was composited. pdf.js issue 8078 paints 73,047 16 × 16 stencils with one pattern whose cell is 3.5 MB at 36 dpi: it ran out of memory after 5 minutes and now renders in about 5 s. For its tests, `offscreen_draws` counts on each thread the tiling cells drawn and the pixels of every pixmap drawn offscreen (all made by `offscreen_pixmap`), so a page's cost is checked by count, not by time; (31) `RenderSettings::max_coverage` keeps the rasterizer to that many coverage values for the page (vello_cpu patch 1), and a page whose paths it left out says so with `InterpreterWarning::ContentTruncated` | (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_averages_the_columns_each_pixel_covers`, `raster::tests::non_integer_minification_averages_colour_and_mask_together`, `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::minified_image_edges_snap_to_whole_pixels`, `raster::tests::a_tile_snaps_half_pixel_image_edges_as_the_whole_page_does` (`cargo test -p hayro`), `raster::tests::a_turned_or_mirrored_minified_image_snaps_to_whole_pixels`, `rotated_image_snap::a_shrunk_image_on_a_turned_page_is_the_upright_render_turned` (`cargo test -p pdfcraft-render --test it`), `renderer::snap_tests::a_skew_that_moves_a_far_edge_is_not_snapped` (`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`; (19) `mesh_extent_tests::{a_mesh_paints_its_triangles_alone, a_mesh_keeps_the_graphics_state_alpha, a_mesh_off_the_canvas_paints_nothing}` (`cargo test -p hayro`); (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`; (21) `raster::tests::non_interpolated_minification_keeps_thin_strokes`, `raster::tests::non_integer_minification_averages_the_columns_each_pixel_covers`, `raster::tests::non_integer_minification_averages_colour_and_mask_together`, `raster::tests::non_interpolated_minification_does_not_bleed_transparent_colour`, `raster::tests::a_smaller_soft_mask_does_not_bleed_transparent_colour_when_minified`, `raster::tests::an_interpolated_smaller_soft_mask_stays_smooth_when_its_image_is_minified`, `raster::tests::a_shrunk_stencil_mask_keeps_thin_strokes`, `raster::tests::minified_image_edges_snap_to_whole_pixels`, `raster::tests::an_image_shrunk_one_way_and_stretched_the_other_is_still_averaged`, `raster::tests::wide_non_interpolated_sources_draw_after_shrinking`, `renderer::area_shrink_tests` (`cargo test -p hayro`); (22) `raster::tests::knockout_groups_replace_what_their_elements_cover`; (23) `raster::tests::axial_and_radial_shadings_draw_as_native_gradients`; (25) `tiling_cell_placement::{far_cells_of_a_fill_sit_at_whole_steps, far_cells_sit_at_whole_steps_under_a_turned_scaled_matrix, far_cells_of_a_stroke_sit_at_whole_steps, a_cell_pixmap_spans_exactly_one_step}` (`cargo test -p pdfcraft-render --test it`); (28) `raster::tests::large_non_interpolated_images_are_smoothed_when_enlarged`, `raster::tests::small_non_interpolated_images_keep_hard_pixels_when_enlarged`, `raster::tests::a_scan_shrunk_one_way_is_smoothed_the_way_it_is_stretched`, `raster::tests::a_scan_drawn_a_rounding_error_above_its_resolution_stays_sharp`; (30) `raster::tests::{a_patterned_stencil_mask_paints_only_where_it_inks, a_patterned_stencil_mask_under_a_soft_mask_is_masked_once, a_patterned_stencil_mask_in_a_type3_glyph_keeps_thin_strokes, many_patterned_stencil_masks_draw_no_page_sized_masks, fills_of_one_pattern_setting_share_its_cell, fills_of_interleaved_pattern_settings_each_show_their_own_cell, an_uncoloured_pattern_shows_each_settings_colour}`; (31) `raster::tests::coverage_budget::a_page_past_its_coverage_leaves_the_rest_out_and_says_so` | | hayro-jbig2 | 0.3.0 | Apache-2.0 OR MIT | (1) at most 2^28 pixels per bitmap and page bitmap, and 2^25 cells per halftone grid and grayscale image (each side could be 65535: 4.3 billion pixels decoded one by one, and a 4-byte value per cell); (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); (4) a segment header's long-form referred-to segment count is capped at `u16::MAX` and a larger count is refused before its retention or referred-to bytes are read: the 29-bit count went straight to `Vec::with_capacity`, a reservation of about 2 GiB whose failure aborts the process (from storytold/craft-libs#176); (5) what a page's segments hold for later ones (intermediate regions, pattern and symbol dictionaries with their headers and retained contexts, and Huffman tables) is at most 64 MiB, two square bitmaps of 16,384 a side (`MAX_RETAINED_WORDS`), and a dictionary's new and exported symbols are counted against it as they are made: each was bounded on its own, but a few dozen bytes an intermediate region held 33 MB each, and 8 MiB symbols exported again and again killed the process, so a few kilobytes could exhaust memory; (6) a segment may refer to at most 2^20 symbols, checked before any are collected: references may repeat, and 65,535 of them to one dictionary, in a 78 KB stream, asked for 17 GB; (7) a pattern dictionary keeps at most 16 MiB of shifted copies, which only speed up drawing, and none that wouldn't fit what the page has left (`MAX_SHIFTED_WORDS`); (8) a custom Huffman table has at most 2^16 lines, and a stream with its globals at most 2^20 segments and 2^22 references, checked as they are read: both grew before anything counted them, and PDFs of a kilobyte or so reached 5 GB and more behind nested Flate; (9) a page past the bounds of (1) is refused when the stream is opened: a caller sizes its output from the header before the page bitmap is made, and an Indexed image in a 743-byte PDF asked for 137 GB, which aborts; (10) a stream may ask for at most 2^30 units of decoding work, four of the largest pages, about 6 s to 17 s (`MAX_WORK`): decoding a bitmap costs a pixel each and 4 a row, copying one a word each and 1 a row, and each integer an item reads costs 38, what one takes at worst, so one with no pixels pays too, before it is decoded: each bitmap was bounded, but nothing bounded how many there were, and a 795-byte PDF took 265 s; (11) `Image::decode_within` spends from what a run of images may still do, starting with its page's pixels, and `charge_reading` charges reading a stream and its globals before anything is parsed, so an operation over a document can bound what `MAX_WORK` bounds for one image, and images that fail or decode nothing add up too: a file of thousands of images made to reach that limit took as many times as long; (12) adding to a location, a reference offset, a symbol's corner or a Huffman range line near the end of an `i32`, and negating a decoded integer of 2^31, overflowed: a panic wherever overflow is checked. The refinement context, a text region's corners, the integer decoder and the Huffman table wrap, as a release build did, and `Bitmap::combine` saturates, which comes to the same, as a location that overflows is past the end of any page. What is drawn doesn't change; (13) a bitmap filled with a default pixel of 1 (a page, or an intermediate text or halftone region) had 1s past its width in the last word of each row, and a refinement, which reads its reference a word at a time, read them as pixels where the standard has 0 (6.3.5.2): refining such a reference not a multiple of 32 wide decoded to other pixels than were coded, release builds too. `Bitmap::clear_padding` clears those bits when a bitmap is filled with 1s, as cutting symbols and patterns from a collective bitmap already did. Ghostscript reads the test's page and text streams as they decode now; (14) a region's location is unsigned (5.4.4), so one past `i32::MAX` is off the page, where the decoder took it for a signed one: a region there was drawn back onto the page from the left or the top (at 0xFFFFFFFC, four pixels left of it), and a refinement there, of the page or of a region at the same place (7.4.7.5), was refused as an overflow, and the page with it. `Bitmap::combine_region` keeps such a location past the page. A refinement of another region refines it where it is, as Table 35 has it (GRREFERENCEDX and GRREFERENCEDY 0), whatever the two locations say; a refinement of the page, see (16); (15) a refinement whose reference is offset right of it (GRREFERENCEDX above 0, which a refined text instance has from a wider bitmap, Table 12, and a refined symbol from an RDX above 0, Table 18) read the reference's first 32 columns as 0: `RefinementContextGatherer::maybe_reload_buffers` emptied its buffers left of the reference and didn't load them again at its first column. Such a refinement decoded to other pixels than were coded, or failed and lost the page. Left of the reference the buffers now keep the words of its first column each row starts with; with an offset of 0 or less nothing changes; (16) a refinement of the page refines "the current contents of the page buffer, restricted to the area of the page buffer specified by this segment's region segment information field" (7.4.7.4), at an offset of 0 (Table 35): `Bitmap::area` copies that area a word at a time, 0 off the page and past its width whatever the padding of its rows holds, and every refinement region reads its reference at offset 0. The page around the area was read too, so a refinement of the page decoded to other pixels than were coded, and one wider or taller than the page was refused. And `RefinementContextGatherer::start_row` loads the reference's words a column left of where a row starts in it, as the reload would, not from column 0: with the reference offset 32 or more left of the refinement (GRREFERENCEDX below −31, as a refined text instance has from a narrower bitmap or an RDX below −31, and a refined symbol from such an RDX), each row's first context read 0 for the reference column it starts at, and from 33 left for the one before it too, and the refinement decoded to other pixels or failed; (17) `Image::new_embedded` reads data that starts with a JBIG2 file header (the ID string `0x97 4A 42 32 0D 0A 1A 0A`, flags and an optional page count, D.4) past it, in the organization the header declares (sequential or random access), its segments counted together with the globals' as (8) has them: ISO 32000-1 7.4.7 has `JBIG2Decode` data in the embedded organization (D.3), but some producers write the file header too, and Acrobat and PDFBox draw such streams. The header's ID string was read as a segment header and the image failed (`unknown or reserved segment type`), so the page drew white | (1) `raster::tests::jbig2_regions_of_billions_of_pixels_are_refused`, `raster::tests::jbig2_halftone_grids_past_their_bound_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`; (4) `segment::tests::test_referred_to_count_is_capped` (`cargo test --manifest-path vendor/hayro-jbig2/Cargo.toml`); (5) `raster::tests::jbig2_regions_held_past_a_pages_bound_are_refused`, `jbig2_symbols_without_pixels_count_towards_a_pages_bound`, `jbig2_retained_contexts_count_towards_a_pages_bound`, `jbig2_huffman_tables_count_towards_a_pages_bound`, `jbig2_new_symbols_count_towards_a_pages_bound`, `jbig2_new_symbols_are_held_to_what_a_page_has_left`, `jbig2_pattern_dictionaries_count_towards_a_pages_bound`, and in the crate (`cargo test --manifest-path vendor/hayro-jbig2/Cargo.toml`) `decode::symbol::tests::exported_symbols_are_counted_before_they_are_copied`, `tests::the_room_left_shrinks_as_segments_are_stored`; (6) `raster::tests::jbig2_segments_referring_to_too_many_symbols_are_refused`; (7) `raster::tests::jbig2_pattern_dictionaries_too_large_to_shift_still_draw`, `jbig2_shifted_patterns_never_refuse_an_image`; (8) `raster::tests::jbig2_huffman_tables_of_too_many_lines_are_refused`, `jbig2_streams_of_too_many_segments_are_refused`, `jbig2_streams_of_too_many_references_are_refused`, and in the crate `tests::the_segments_of_a_stream_and_its_globals_count_together`; (9) `raster::tests::jbig2_pages_past_the_bounds_are_refused_before_anything_is_sized_from_them`, `tests::a_page_past_the_bounds_is_refused_when_opened`; (10) `raster::tests::jbig2_decoding_work_is_counted_item_by_item`; (11) `jbig2::decode::tests::a_run_of_images_spends_its_allowance_down_to_the_step` (pdfcraft-filters); (12) `raster::tests::jbig2_placements_at_the_end_of_an_i32_draw_nothing`; (13) `jbig2::decode::tests::a_refinement_reads_nothing_past_its_references_width` (pdfcraft-filters), and in the crate `tests::a_bitmap_of_1s_has_nothing_past_its_width`; (14) `jbig2::decode::tests::a_region_located_past_i32_max_is_off_the_page` (pdfcraft-filters), and in the crate `tests::a_region_located_past_i32_max_is_off_the_page` and `tests::a_refinement_of_a_region_decodes_the_same_wherever_they_are`; (15) `jbig2::decode::tests::a_refinement_reads_a_reference_right_of_it_from_its_first_column` (pdfcraft-filters), and in the crate `tests::a_refinement_reads_a_reference_right_of_it_from_its_first_column`; (16) `jbig2::decode::tests::a_refinement_refines_the_page_in_its_area_and_any_reference_where_it_is` (pdfcraft-filters), and in the crate `tests::a_refinement_refines_the_page_in_its_area_and_any_reference_where_it_is` and `tests::a_page_area_is_the_page_inside_it_and_0_outside` ; (17) `jbig2::decode::tests::a_stream_with_a_jbig2_file_header_decodes_past_it`, `jbig2::decode::tests::a_truncated_jbig2_file_header_is_an_error`, `jbig2::decode::tests::segments_after_a_jbig2_file_header_count_with_the_globals` (pdfcraft-filters), `raster::tests::a_jbig2_image_with_a_file_header_draws` (pdfcraft-render) | | 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; (6) `find_latest_trailer`: a rebuild takes a `/Type /XRef` stream dictionary as the trailer when the file has no `trailer` keyword (the latest in the file wins, as a later `trailer` would), so a concatenated or truncated file keeps its /Info and /ID instead of failing to load; a candidate whose /Root is the catalog beats one whose /Root is another dictionary, and an in-file /Root must match its generation (#2365) | (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`; (6) `inspect::tests::rebuilt_file_keeps_the_info_of_its_cross_reference_stream`, `inspect::tests::rebuilt_file_prefers_a_trailer_whose_root_is_the_catalog` | diff --git a/vendor/hayro-interpret/src/interpret/mod.rs b/vendor/hayro-interpret/src/interpret/mod.rs index d86264ccb8..f443988d41 100644 --- a/vendor/hayro-interpret/src/interpret/mod.rs +++ b/vendor/hayro-interpret/src/interpret/mod.rs @@ -18,7 +18,7 @@ use crate::x_object::{ use hayro_syntax::content::TypedIter; use hayro_syntax::content::ops::TypedInstruction; use hayro_syntax::object::dict::keys::{ - ACTUAL_TEXT, ANNOTS, AP, AS, C, CA, F, MCID, N, OC, QUADPOINTS, RECT, SUBTYPE, + ACTUAL_TEXT, ANNOTS, AP, AS, C, CA, F, IRT, MCID, N, OC, QUADPOINTS, RECT, RT, SUBTYPE, }; use hayro_syntax::object::{Array, Dict, Object, Rect, Stream, dict_or_stream}; use hayro_syntax::page::{ContentReader, Page, Resources, Rotation}; @@ -229,6 +229,19 @@ pub fn interpret_page<'a>( { continue; } + // PdfCraft patch (81): a reply of type R (a markup annotation with an `/IRT` to the + // annotation it answers and an `/RT` other than `/Group`; R is the default, so a + // missing, unknown or malformed `/RT` reads as R) is not drawn, whatever its + // appearance or flags: "Interactive PDF processors shall not display replies to an + // annotation individually but together in the form of threaded comments" (ISO 32000-2 + // Table 172). A `/RT /Group` member is part of its group's look and still draws. + // `/IRT` is a markup annotation's entry: a widget, link or pop-up with one still draws. + if !matches!(annot.get::>(SUBTYPE).as_deref(), Some(b"Widget" | b"Link" | b"Popup")) + && annot.get::>(IRT).is_some() + && annot.get::>(RT).as_deref() != Some(b"Group") + { + continue; + } // PdfCraft patch: "Hide all comments" keeps widgets and links. if context.settings.hide_comments && !matches!(annot.get::>(SUBTYPE).as_deref(), Some(b"Widget" | b"Link")) From 1e25260337faa9427d34d8c95047ede823e60ae6 Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Mon, 9 Nov 2026 05:17:54 -0500 Subject: [PATCH 08/64] fix(sign): read OCSP responseStatus as ENUMERATED; drop redundant RSA leading zeros (#6005) --- crates/sign/src/der.rs | 31 ++++++++++++++++++++++++++++++- crates/sign/src/revocation.rs | 2 +- crates/sign/src/rsa_pad.rs | 8 ++++++++ crates/sign/tests/it/crypto.rs | 11 ++++++++++- 4 files changed, 49 insertions(+), 3 deletions(-) diff --git a/crates/sign/src/der.rs b/crates/sign/src/der.rs index f9f2f3e6d9..a4740b4707 100644 --- a/crates/sign/src/der.rs +++ b/crates/sign/src/der.rs @@ -16,6 +16,7 @@ use crate::SignError; pub mod tag { pub const BOOLEAN: u8 = 0x01; pub const INTEGER: u8 = 0x02; + pub const ENUMERATED: u8 = 0x0A; pub const BIT_STRING: u8 = 0x03; pub const OCTET_STRING: u8 = 0x04; /// An OCTET STRING split into segments (BER only, X.690 §8.21); see [`super::Tlv::octets`]. @@ -205,12 +206,26 @@ impl<'a> Tlv<'a> { /// A non-negative INTEGER that fits in a u64. pub fn u64(&self) -> Result { - if self.tag != tag::INTEGER || self.value.is_empty() || self.value.len() > 9 || self.value[0] & 0x80 != 0 { + // (nine bytes only with a 0x00 pad: a nonzero first byte is past 64 bits) + if self.tag != tag::INTEGER + || self.value.is_empty() + || self.value.len() > 9 + || self.value[0] & 0x80 != 0 + || (self.value.len() == 9 && self.value[0] != 0) + { return Err(bad("expected a small non-negative INTEGER")); } Ok(self.value.iter().fold(0u64, |acc, b| (acc << 8) | *b as u64)) } + /// A non-negative ENUMERATED that fits in a u64 (same encoding as an INTEGER). + pub fn enumerated(&self) -> Result { + if self.tag != tag::ENUMERATED { + return Err(bad("expected an ENUMERATED")); + } + Tlv { tag: tag::INTEGER, ..*self }.u64() + } + /// The magnitude of an INTEGER (leading zero dropped). pub fn uint_bytes(&self) -> &'a [u8] { match self.value { @@ -544,6 +559,20 @@ pub fn algorithm(oid_s: &str, params: Option<&[u8]>) -> Vec { mod tests { use super::*; + #[test] + fn a_nine_byte_integer_needs_a_zero_pad_to_fit_64_bits() { + let tlv = |value: &[u8]| { + let mut b = vec![tag::INTEGER, value.len() as u8]; + b.extend_from_slice(value); + b + }; + // 2^64 would fold to 0: refused, not read as 0 + assert!(Tlv::parse_all(&tlv(&[1, 0, 0, 0, 0, 0, 0, 0, 0])).unwrap().u64().is_err()); + // u64::MAX with its pad is fine + let max = [0, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff]; + assert_eq!(Tlv::parse_all(&tlv(&max)).unwrap().u64().unwrap(), u64::MAX); + } + #[test] fn try_oid_rejects_signs_and_empty_arcs() { for bad in ["+1.2.840", "1.+2", "1.2.-3", "1..2"] { diff --git a/crates/sign/src/revocation.rs b/crates/sign/src/revocation.rs index 057e0717de..93191b2528 100644 --- a/crates/sign/src/revocation.rs +++ b/crates/sign/src/revocation.rs @@ -181,7 +181,7 @@ impl OcspResponse { return Err(bad("OCSP response is empty or exceeds the 2 MiB limit")); } let resp = Tlv::parse_all(bytes)?.expect(tag::SEQUENCE, "OCSPResponse")?.children()?; - let status = resp.first().ok_or_else(|| bad("missing responseStatus"))?.u64()?; + let status = resp.first().ok_or_else(|| bad("missing responseStatus"))?.enumerated()?; if status != 0 { return Err(bad(format!("responder reported an error (status {status})"))); } diff --git a/crates/sign/src/rsa_pad.rs b/crates/sign/src/rsa_pad.rs index 8cc6e08f1e..531f703d52 100644 --- a/crates/sign/src/rsa_pad.rs +++ b/crates/sign/src/rsa_pad.rs @@ -14,6 +14,9 @@ use crate::keys::{DigestAlg, PssParams}; /// signature value was not written with exactly the modulus' length (RFC 8017 §8.2.2 wants /// that); some signers drop or add leading zero bytes. fn encoded_message(n: &[u8], e: &[u8], sig: &[u8], notes: &mut Vec<&'static str>) -> Result>, SignError> { + // Redundant leading zeros would size the big-integer arithmetic (and the backend's own size + // limit) by the encoding rather than the number, so they are dropped before anything is built. + let (n, e) = (trim_zeros(n), trim_zeros(e)); let k = n.len(); let key = RsaPublicKey::new(BoxedUint::from_be_slice_vartime(n), BoxedUint::from_be_slice_vartime(e)) .map_err(|e| SignError::Malformed(format!("RSA key: {e}")))?; @@ -40,6 +43,10 @@ fn encoded_message(n: &[u8], e: &[u8], sig: &[u8], notes: &mut Vec<&'static str> Ok(Some(out)) } +fn trim_zeros(b: &[u8]) -> &[u8] { + b.get(b.iter().position(|x| *x != 0).unwrap_or(b.len())..).unwrap_or_default() +} + /// `DigestInfo ::= SEQUENCE { AlgorithmIdentifier, OCTET STRING }`. fn digest_info(alg: DigestAlg, digest: &[u8], null_params: bool) -> Vec { let id = der::algorithm(alg.oid(), null_params.then(der::null).as_deref()); @@ -99,6 +106,7 @@ pub fn pss_verify( notes: &mut Vec<&'static str>, ) -> Result { let Some(raw) = encoded_message(n, e, sig, notes)? else { return Ok(false) }; + let n = trim_zeros(n); let mod_bits = n.first().map_or(0, |b| (n.len() * 8) as u32 - b.leading_zeros()); let Some(em_bits) = mod_bits.checked_sub(1) else { return Ok(false) }; let em_len = em_bits.div_ceil(8) as usize; diff --git a/crates/sign/tests/it/crypto.rs b/crates/sign/tests/it/crypto.rs index f184d7161a..0df66b25e9 100644 --- a/crates/sign/tests/it/crypto.rs +++ b/crates/sign/tests/it/crypto.rs @@ -369,7 +369,7 @@ fn signed_ocsp(key: &pdfcraft_sign::PrivateKey, responder_id: &[u8], certs: Opti None => der::seq(&[&tbs, &alg, &der::bit_string(&sig)]), }; let basic_type = der::oid("1.3.6.1.5.5.7.48.1.1"); - der::seq(&[&der::int(0), &der::explicit(0, &der::seq(&[&basic_type, &der::octets(&basic)]))]) + der::seq(&[&der::tlv(tag::ENUMERATED, &[0]), &der::explicit(0, &der::seq(&[&basic_type, &der::octets(&basic)]))]) } fn mid_june() -> Time { @@ -470,6 +470,9 @@ fn ocsp_responses_verify_and_match_the_certificate() { let wrong_key = OcspResponse::parse(&ocsp(&other, RevocationStatus::Good, this, next)).unwrap(); assert_eq!(wrong_key.check(issuer, issuer, at), RevocationStatus::Unknown); // An error response is rejected at parse time. + let error = der::seq(&[&der::tlv(tag::ENUMERATED, &[6])]); + assert!(OcspResponse::parse(&error).is_err()); + // responseStatus is an ENUMERATED (RFC 6960); an INTEGER is not one. let error = der::seq(&[&der::int(6)]); assert!(OcspResponse::parse(&error).is_err()); } @@ -524,6 +527,12 @@ fn rsa_signatures_are_read_with_every_digestinfo_variant() { for (name, sig) in [("standard", RSA_STD), ("no NULL", RSA_NO_NULL)] { assert!(key.verify(Scheme::RsaPkcs1, DigestAlg::Sha256, &digest, &unhex(sig)).unwrap(), "{name}"); } + // Redundant leading zeros on the modulus change neither the result nor the arithmetic size. + if let pdfcraft_sign::keys::PublicKey::Rsa { n, e } = key { + let pad = |v: &[u8]| [vec![0u8; 1 << 16], v.to_vec()].concat(); + let padded = pdfcraft_sign::keys::PublicKey::Rsa { n: pad(n), e: pad(e) }; + assert!(padded.verify(Scheme::RsaPkcs1, DigestAlg::Sha256, &digest, &unhex(RSA_STD)).unwrap(), "padded key"); + } // The bare digest, with no DigestInfo, binds no hash algorithm: not a signature we accept. assert!(!key.verify(Scheme::RsaPkcs1, DigestAlg::Sha256, &digest, &unhex(RSA_BARE)).unwrap(), "bare digest"); // PSS with the salt length it declares, or any when none is declared. From b3793789f6b98a42bfc506f52b2266a722fb18cc Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Mon, 9 Nov 2026 05:18:11 -0500 Subject: [PATCH 09/64] fix(create): bound a PNG's frame buffer by what its bytes can hold; no truncating JP2 box lengths (#6007) --- crates/create/src/lib.rs | 16 ++++++++++++++-- crates/create/src/tests.rs | 7 +++++++ 2 files changed, 21 insertions(+), 2 deletions(-) diff --git a/crates/create/src/lib.rs b/crates/create/src/lib.rs index 149bd99d97..ef46ccb713 100644 --- a/crates/create/src/lib.rs +++ b/crates/create/src/lib.rs @@ -281,7 +281,8 @@ fn jp2_boxes(b: &[u8]) -> Vec<([u8; 4], &[u8])> { }, n => (8, n), }; - let end = i.saturating_add(len as usize).min(b.len()); + // `len as usize` would truncate on 32-bit targets and put the end before the payload. + let end = i.saturating_add(usize::try_from(len).unwrap_or(usize::MAX)).min(b.len()); if len < head as u64 || end <= i { break; } @@ -428,7 +429,13 @@ fn png_image(name: &str, bytes: &[u8]) -> Result { // The decoded size comes from the header alone, so a file of a few bytes can claim any // size: keep the ceiling, and ask for the memory fallibly (a failed allocation would abort // the whole app, and no `catch_unwind` stops that). - let size = reader.output_buffer_size().filter(|n| *n <= MAX_DECODED_BYTES).ok_or_else(|| bad("image too large".into()))?; + // Flate expands at most 1032:1 and decoding at most 32:1 more (1-bit palette to RGBA), so a + // file cannot honestly hold more than that: refuse a header the bytes cannot back before + // zero-filling a buffer of its size. The slack keeps tiny real images out of the check. + let size = reader + .output_buffer_size() + .filter(|n| *n <= MAX_DECODED_BYTES && *n <= bytes.len().saturating_mul(PNG_MAX_EXPANSION).saturating_add(PNG_SLACK)) + .ok_or_else(|| bad("image too large".into()))?; let mut buf = Vec::new(); buf.try_reserve_exact(size).map_err(|_| bad("not enough memory for this image".into()))?; buf.resize(size, 0); @@ -646,6 +653,11 @@ const MAX_GIF_PIXELS: u64 = 1 << 24; /// twice over. const MAX_DECODED_BYTES: usize = 2 << 30; +/// Most a PNG's decoded size can exceed its file size: Flate's 1032:1 times the 32:1 of +/// expanding a 1-bit palette to RGBA, plus a fixed allowance for small files. +const PNG_MAX_EXPANSION: usize = 1032 * 32; +const PNG_SLACK: usize = 1 << 20; + /// Most decoded bytes all the pages of one TIFF may take together: its pages stay in memory until /// they are embedded, and many small directories can share one compressed strip. Saturates to the /// whole address space on 32-bit targets (wasm32), where 4 GiB doesn't fit in `usize`. diff --git a/crates/create/src/tests.rs b/crates/create/src/tests.rs index 9d5a70ffcd..584b013503 100644 --- a/crates/create/src/tests.rs +++ b/crates/create/src/tests.rs @@ -316,6 +316,13 @@ fn a_tiny_png_claiming_huge_dimensions_is_refused_before_allocating() { } } +#[test] +fn a_small_png_claiming_a_large_frame_is_refused_before_zero_filling() { + // 100 MP RGBA = 400 MB is under the 2 GiB ceiling, but 68 bytes cannot hold it. + let r = from_images(&[("big.png".into(), png_claiming(10_000, 10_000))]); + assert!(matches!(&r, Err(CreateError::Image(_, m)) if m.contains("too large"))); +} + /// Likewise a PNG: the png crate's limits don't cover the frame buffer, which its header sizes /// (60000x60000 RGBA is 14.4 GB). Refused before anything is allocated, not after. #[test] From 479132770178229323e315f413b9b36bb8e84a8b Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Mon, 9 Nov 2026 05:18:28 -0500 Subject: [PATCH 10/64] fix(engine): unknown permissions on an encrypted document refuse copying and printing (#6011) --- crates/engine/src/document.rs | 10 ++++++++-- crates/engine/src/tests.rs | 15 +++++++++++++++ 2 files changed, 23 insertions(+), 2 deletions(-) diff --git a/crates/engine/src/document.rs b/crates/engine/src/document.rs index 0527d710d0..f8f3706446 100644 --- a/crates/engine/src/document.rs +++ b/crates/engine/src/document.rs @@ -442,16 +442,22 @@ impl Document { self.editor.as_ref().and_then(|e| e.cos.permissions()) } + /// The renderer opened an encrypted file that the editing parser did not, so its permission + /// bits are unknown. Unknown is not unrestricted: printing and copying are refused. + fn permissions_unknown(&self) -> bool { + self.editor.is_none() && self.info.encrypted + } + /// Printing is allowed (Table 22, bit 3). pub fn allows_printing(&self) -> bool { - self.permissions().is_none_or(|p| p.print()) + !self.permissions_unknown() && self.permissions().is_none_or(|p| p.print()) } /// Copying or otherwise extracting text and images is allowed (Table 22, bit 5): copying /// selected text, snapshots, Save Image As, exporting. Assistive technology may read the /// content regardless (§7.6.4.2), so reading aloud and accessibility tools don't ask. pub fn allows_copying(&self) -> bool { - self.permissions().is_none_or(|p| p.copy()) + !self.permissions_unknown() && self.permissions().is_none_or(|p| p.copy()) } /// [`Self::allows_copying`], as the error to report when it isn't. diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 07769fdea0..ce4938f91d 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -1652,6 +1652,21 @@ fn copy_restricted_documents_refuse_extraction_unless_opened_by_the_owner() { assert!(s.get(plain).unwrap().check_copying().is_ok() && extracting.iter().all(|c| enabled(&s, plain, c))); } +/// The renderer can open an encrypted file the editing parser rejects. Its permission bits are +/// then unknown, which is not the same as none: copying and printing stay refused. +#[test] +fn an_encrypted_document_without_an_editor_allows_neither_copying_nor_printing() { + let mut s = Session::new(); + let id = s.open("r.pdf", None, protected("", "owner", -1 ^ 0b1_0000), None).unwrap(); + s.doc_mut(id).unwrap().editor = None; + let d = s.get(id).unwrap(); + assert!(d.info.encrypted && d.permissions().is_none()); + assert!(!d.allows_copying() && !d.allows_printing()); + let (mut s, plain) = session_with(1); + s.doc_mut(plain).unwrap().editor = None; + assert!(s.get(plain).unwrap().allows_copying(), "an unencrypted document is not restricted"); +} + /// Table 22, bit 3 (#2669): a document that forbids printing disables Print up front instead /// of refusing once the dialog is confirmed. Low-resolution printing (bit 3 on, bit 12 off) is /// still printing, and the owner password lifts the restriction. From ce1cd804775ce51fcd8623d49de6c6139cb0850f Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Mon, 9 Nov 2026 05:18:44 -0500 Subject: [PATCH 11/64] fix(xfa): remember every picture hash written in a pass (#6035) --- crates/xfa/src/pdf.rs | 37 ++++++++++++++++++++++++++++++++----- 1 file changed, 32 insertions(+), 5 deletions(-) diff --git a/crates/xfa/src/pdf.rs b/crates/xfa/src/pdf.rs index ab96abbb3f..b8377cee9f 100644 --- a/crates/xfa/src/pdf.rs +++ b/crates/xfa/src/pdf.rs @@ -65,7 +65,7 @@ pub const ROUND_KEY: &[u8] = b"PCXfaRound"; const IMAGE_SHA_KEY: &[u8] = b"PCXfaSHA256"; /// Most pages searched for earlier pictures. const MAX_PAGES_SEARCHED: usize = 10_000; -/// Most pictures remembered. +/// Most pictures remembered from earlier passes. const MAX_REMEMBERED_IMAGES: usize = 1000; struct Emitter<'a> { @@ -492,9 +492,9 @@ impl Emitter<'_> { return Some(r); } let r = self.new_image_xobject(data, content_type, &hash)?; - if self.images.len() < MAX_REMEMBERED_IMAGES { - self.images.insert(hash, r); - } + // Always remembered: each entry is a hash and a reference to an object this pass wrote + // anyway, and a picture past a cap would be decoded and embedded again at every use. + self.images.insert(hash, r); Some(r) } @@ -1040,7 +1040,7 @@ pub fn pdf_rect(r: Rect, page_height: f64) -> [f64; 4] { #[cfg(test)] mod tests { - use super::parse_hex32; + use super::*; #[test] fn parse_hex32_rejects_non_hex_digits() { @@ -1053,4 +1053,31 @@ mod tests { ok.resize(64, b'0'); assert_eq!(parse_hex32(&ok).map(|h| h[0]), Some(0xff)); } + + #[test] + fn a_picture_past_the_earlier_pass_cap_is_still_embedded_once() { + let mut doc = Document::new_empty(); + let mut em = Emitter { + doc: &mut doc, + images: HashMap::new(), + fields: Vec::new(), + radio_groups: HashMap::new(), + radio_selected: HashMap::new(), + field_count: 0, + warnings: Vec::new(), + signed: Default::default(), + taken: Default::default(), + nodes: HashMap::new(), + }; + let png = crate::fixtures::tiny_png(); + let picture = |i: u32| [png.clone(), i.to_be_bytes().to_vec()].concat(); + let last = MAX_REMEMBERED_IMAGES as u32 + 10; + for i in 0..=last { + em.image_xobject(&picture(i), "image/png").expect("decodes"); + } + let before = em.doc.object_numbers().len(); + let again = em.image_xobject(&picture(last), "image/png").expect("decodes"); + assert_eq!(em.doc.object_numbers().len(), before, "no new object for a repeated picture"); + assert_eq!(em.image_xobject(&picture(last), "image/png"), Some(again)); + } } From 3a1a2272fe04678dcc42c2b5219bc17e4d8c9ae3 Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Mon, 9 Nov 2026 05:18:59 -0500 Subject: [PATCH 12/64] fix(xfdf): FDF comment export excludes source pages by identity, not by /Type (#6036) --- crates/xfdf/src/fdf.rs | 16 ++++++++++++++-- crates/xfdf/src/tests.rs | 37 +++++++++++++++++++++++++++++++++++++ 2 files changed, 51 insertions(+), 2 deletions(-) diff --git a/crates/xfdf/src/fdf.rs b/crates/xfdf/src/fdf.rs index dffd3f9098..6c2f169931 100644 --- a/crates/xfdf/src/fdf.rs +++ b/crates/xfdf/src/fdf.rs @@ -42,13 +42,23 @@ struct FdfCopy<'a> { objects: Vec, numbers: HashMap, pending: Vec<(ObjRef, usize)>, + /// The leaf pages of the source document, by identity: whatever their `/Type` says (or does not). + source_pages: HashSet, 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 } + Self { + doc, + objects: Vec::new(), + numbers: HashMap::new(), + pending: Vec::new(), + source_pages: HashSet::new(), + nodes: MAX_FDF_OBJECTS, + bytes: MAX_FDF_BYTES, + } } fn charge(&mut self, bytes: usize) -> Result<(), DataError> { @@ -181,7 +191,7 @@ impl<'a> FdfCopy<'a> { 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 { + let value = if doc.generation(source.num) != source.generation || self.source_pages.contains(&source) { std::sync::Arc::new(Object::Null) } else { match doc.try_get(source.num) { @@ -250,6 +260,8 @@ pub fn export_fdf(doc: &Document, comments: bool, fields: bool, file: &str) -> R // 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); + // Page discovery accepts a leaf with no /Type: the export keeps these out by reference too. + copy.source_pages = pages.iter().map(|p| p.obj).collect(); let mut arrays = Vec::new(); let mut picked: Vec<(Option, usize, usize)> = Vec::new(); for (pi, page) in pages.iter().enumerate() { diff --git a/crates/xfdf/src/tests.rs b/crates/xfdf/src/tests.rs index 2c1c06c988..3823c60413 100644 --- a/crates/xfdf/src/tests.rs +++ b/crates/xfdf/src/tests.rs @@ -1706,6 +1706,43 @@ fn xml_form_import_preserves_text_around_comments_and_instructions() { } } +/// Page discovery accepts a leaf without /Type, so a comment's nested reference to one must not +/// carry the page or its content into the export. +#[test] +fn fdf_export_nulls_a_nested_link_to_a_page_without_a_type() { + 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 contents = doc.add(pdfcraft_cos::Stream::from_raw(Dict::new(), b"% private-page-content\n".repeat(4_096))); + let mut page = Dict::new(); + 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)); + let page = doc.add(page); + doc.update_dict(tree, |d| { + d.set(b"Type".to_vec(), Object::name("Pages")); + d.set(b"Count".to_vec(), Object::Int(1)); + d.set(b"Kids".to_vec(), Object::Array(vec![Object::Ref(page)])); + }) + .unwrap(); + let mut target = Dict::new(); + target.set(b"Label".to_vec(), PdfString::text("kept")); + target.set(b"Target".to_vec(), Object::Ref(page)); + fdf_note_with_data(&mut doc, Object::Array(vec![Object::Dict(target)])); + write_full(&doc, &SaveOptions::default()).unwrap() + }; + let source = Document::open(Arc::new(source)).unwrap(); + assert_eq!(pdfcraft_model::pages(&source).len(), 1); + let bytes = export_fdf(&source, true, false, "source.pdf").unwrap(); + assert!(bytes.len() < 4_096, "the untyped page's content must stay in the PDF ({} bytes)", bytes.len()); + let fdf = Document::open(Arc::new(bytes)).unwrap(); + let data = exported_note_data(&fdf); + let target = data.as_array().unwrap()[0].as_dict().unwrap(); + assert_eq!(target.get(b"Label").unwrap().as_string().unwrap().to_text(), "kept"); + assert_eq!(*fdf.resolve(target.get(b"Target").unwrap()), Object::Null); +} + #[test] fn fdf_export_nulls_nested_page_links_without_copying_the_source_page_tree() { for indirect_type in [false, true] { From 5f4003e5623f14aae443eeed7e27b0100ecb5a41 Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Mon, 9 Nov 2026 05:19:15 -0500 Subject: [PATCH 13/64] fix(engine): summarizing comments requires copying permission (#6039) --- crates/engine/src/session/view.rs | 2 ++ crates/engine/src/tests.rs | 2 ++ 2 files changed, 4 insertions(+) diff --git a/crates/engine/src/session/view.rs b/crates/engine/src/session/view.rs index 67f6b47428..f6e75c2ba9 100644 --- a/crates/engine/src/session/view.rs +++ b/crates/engine/src/session/view.rs @@ -311,6 +311,8 @@ impl Session { /// with its number, author, type, date, text and replies, grouped by page. pub fn summarize_comments(&self, id: DocId, sort: SummarySort) -> Result>, EditError> { let doc = self.get(id).ok_or(EditError::NoDocument)?; + // The summary copies the comments' text out into a new document. + doc.check_copying()?; let text = comment_summary(&doc.name, &doc.info.annotations, sort); self.create_report(&format!("Summary of Comments on {}", doc.name), &text) } diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index ce4938f91d..f41d033abd 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -1647,6 +1647,8 @@ fn copy_restricted_documents_refuse_extraction_unless_opened_by_the_owner() { assert_eq!(s.compare_report(oid, id).map(|_| ()), refused()); assert!(s.compare(oid, oid).is_ok()); assert_eq!(s.mark_differences(id, oid).map(|_| ()), refused()); + // The comment summary copies comment text out like the exports do. + assert_eq!(s.summarize_comments(id, SummarySort::Page).map(|_| ()), refused()); // Unencrypted documents allow everything. let (s, plain) = session_with(1); assert!(s.get(plain).unwrap().check_copying().is_ok() && extracting.iter().all(|c| enabled(&s, plain, c))); From f64c853a3a2f448ed295571c6953d6942d8b9bd3 Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Mon, 9 Nov 2026 05:19:30 -0500 Subject: [PATCH 14/64] fix(forms): comb detection ignores repeated boxes (#6077) --- crates/forms/src/detect.rs | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/crates/forms/src/detect.rs b/crates/forms/src/detect.rs index 94ad7adb37..99f2447c02 100644 --- a/crates/forms/src/detect.rs +++ b/crates/forms/src/detect.rs @@ -391,6 +391,8 @@ fn label_for(words: &[Word], rect: [f64; 4], right_of: bool) -> String { /// enough: dates split into day and month groups of two cells. Cells are square or taller /// than wide; an empty table of wider cells is not a comb. fn combs(boxes: &[[f64; 4]]) -> Vec<([f64; 4], usize)> { + // A repeated box adds nothing to a row, but every repeat would be searched against every cell. + let mut seen = std::collections::HashSet::new(); let cells: Vec<[f64; 4]> = boxes .iter() .copied() @@ -398,6 +400,7 @@ fn combs(boxes: &[[f64; 4]]) -> Vec<([f64; 4], usize)> { let (w, h) = (r[2] - r[0], r[3] - r[1]); (5.0..=30.0).contains(&w) && (8.0..=40.0).contains(&h) && w <= h * 1.2 }) + .filter(|r| seen.insert(r.map(f64::to_bits))) .collect(); // `b` comes right after `a` in a row: same size, its left edge on a's right edge. let follows = |a: &[f64; 4], b: &[f64; 4]| { @@ -521,3 +524,17 @@ pub fn detect(words: &[Word], shapes: &Shapes, existing: &[[f64; 4]], taken: &[S } out } + +#[cfg(test)] +mod comb_tests { + use super::combs; + + #[test] + fn repeated_boxes_do_not_change_or_multiply_a_comb() { + let row = [[10.0, 10.0, 20.0, 22.0], [20.0, 10.0, 30.0, 22.0], [30.0, 10.0, 40.0, 22.0]]; + let mut many = row.to_vec(); + many.extend(std::iter::repeat_n(row[1], 5000)); + assert_eq!(combs(&row), vec![([10.0, 10.0, 40.0, 22.0], 3)]); + assert_eq!(combs(&many), combs(&row)); + } +} From 237838102eb2736707b61299a95d129259b9ece6 Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Mon, 9 Nov 2026 05:19:46 -0500 Subject: [PATCH 15/64] fix(xfa): existing-field traversal shares a field's name among its queued children (#6080) --- crates/xfa/src/pdf.rs | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/crates/xfa/src/pdf.rs b/crates/xfa/src/pdf.rs index b8377cee9f..4e87da5a7a 100644 --- a/crates/xfa/src/pdf.rs +++ b/crates/xfa/src/pdf.rs @@ -110,8 +110,9 @@ fn existing_fields(doc: &Document, catalog: &Dict) -> (HashMap, let mut names = HashSet::new(); let Some(acro) = catalog.get(b"AcroForm").map(|a| doc.resolve(a)) else { return (out, names) }; let Some(fields) = acro.as_dict().and_then(|a| a.get(b"Fields")).map(|f| doc.resolve(f)) else { return (out, names) }; - let mut stack: Vec<(Object, String, bool, usize)> = - fields.as_array().into_iter().flatten().map(|f| (f.clone(), String::new(), false, 0)).collect(); + // The accumulated name is shared by the children it is queued for, not copied into each. + let mut stack: Vec<(Object, std::rc::Rc, bool, usize)> = + fields.as_array().into_iter().flatten().map(|f| (f.clone(), "".into(), false, 0)).collect(); let mut seen = 0usize; while let Some((field, prefix, parent_is_sig, depth)) = stack.pop() { seen += 1; @@ -124,7 +125,7 @@ fn existing_fields(doc: &Document, catalog: &Dict) -> (HashMap, // Generated to name a signature field: only what it holds is looked at. if depth < MAX_FIELD_DEPTH { let kids = d.get(b"Kids").map(|k| doc.resolve(k)).and_then(|k| k.as_array().cloned()).unwrap_or_default(); - stack.extend(kids.into_iter().map(|k| (k, String::new(), false, depth + 1))); + stack.extend(kids.into_iter().map(|k| (k, "".into(), false, depth + 1))); } continue; } @@ -139,12 +140,12 @@ fn existing_fields(doc: &Document, catalog: &Dict) -> (HashMap, continue; } let name = match d.get(b"T").map(|t| doc.resolve(t)).and_then(|t| t.as_string().map(|t| t.to_text())) { - Some(t) if prefix.is_empty() => t, - Some(t) => format!("{prefix}.{t}"), + Some(t) if prefix.is_empty() => t.into(), + Some(t) => format!("{prefix}.{t}").into(), None => prefix, }; if !name.is_empty() { - names.insert(name.clone()); + names.insert(name.to_string()); } let is_sig = match d.name(b"FT") { Some(ft) => ft == b"Sig", @@ -154,7 +155,7 @@ fn existing_fields(doc: &Document, catalog: &Dict) -> (HashMap, if is_sig && let Some(value) = d.get(b"V").filter(|v| doc.resolve(v).as_dict().is_some()) { // The appearance is on the field when it is its own widget, or on its one widget. let appearance = d.get(b"AP").cloned().or_else(|| kids.iter().find_map(|k| doc.resolve(k).as_dict().and_then(|k| k.get(b"AP").cloned()))); - out.entry(name.clone()).or_insert(Signed { value: value.clone(), appearance }); + out.entry(name.to_string()).or_insert(Signed { value: value.clone(), appearance }); } if depth < MAX_FIELD_DEPTH { stack.extend(kids.into_iter().map(|k| (k, name.clone(), is_sig, depth + 1))); From 01c132a6bf65ed96632206b475d3bc8903902db4 Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Mon, 9 Nov 2026 05:20:02 -0500 Subject: [PATCH 16/64] fix(xfa): strip reads a shared indirect Kids array once (#6081) --- crates/xfa/src/pdf/strip.rs | 36 +++++++++++++++++++++++++++++++++++- 1 file changed, 35 insertions(+), 1 deletion(-) diff --git a/crates/xfa/src/pdf/strip.rs b/crates/xfa/src/pdf/strip.rs index 1d62f28b18..93f1a65307 100644 --- a/crates/xfa/src/pdf/strip.rs +++ b/crates/xfa/src/pdf/strip.rs @@ -151,6 +151,7 @@ pub fn strip(doc: &mut Document) -> Result { let fields = acro.get(b"Fields").map(|f| doc.resolve(f)).and_then(|f| f.as_array().cloned()).unwrap_or_default(); let mut seen = std::collections::HashSet::new(); let mut todo: Vec = Vec::new(); + let mut kids_seen = std::collections::HashSet::new(); for r in fields.iter().filter_map(Object::as_ref) { if seen.insert(r) && seen.len() <= MAX_STRIP_FIELDS { todo.push(r); @@ -177,7 +178,9 @@ pub fn strip(doc: &mut Document) -> Result { } })?; } - if let Some(kids) = d.get(b"Kids").map(|k| doc.resolve(k)).and_then(|k| k.as_array().cloned()) { + // An indirect /Kids array several fields share is read once: its entries are already queued. + let shared_seen = d.get(b"Kids").and_then(Object::as_ref).is_some_and(|k| !kids_seen.insert(k)); + if !shared_seen && let Some(kids) = d.get(b"Kids").map(|k| doc.resolve(k)).and_then(|k| k.as_array().cloned()) { for k in kids.iter().filter_map(Object::as_ref) { if seen.insert(k) && seen.len() <= MAX_STRIP_FIELDS { todo.push(k); @@ -283,3 +286,34 @@ pub fn existing_layout(doc: &Document) -> Option { warnings, }) } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn fields_sharing_a_kids_array_still_lose_their_private_keys_once() { + let mut doc = Document::new_empty(); + let mut kid = Dict::new(); + kid.set(CLICK_KEY.to_vec(), Object::Int(1)); + let kid = doc.add(Object::Dict(kid)); + let kids = doc.add(Object::Array(vec![Object::Ref(kid)])); + let parents: Vec = (0..3) + .map(|_| { + let mut p = Dict::new(); + p.set(b"Kids".to_vec(), Object::Ref(kids)); + Object::Ref(doc.add(Object::Dict(p))) + }) + .collect(); + let mut acro = Dict::new(); + acro.set(b"XFA".to_vec(), Object::Array(Vec::new())); + acro.set(b"Fields".to_vec(), Object::Array(parents)); + let root = doc.root().unwrap(); + let mut catalog = doc.get(root).as_dict().cloned().unwrap(); + catalog.set(b"AcroForm".to_vec(), Object::Dict(acro)); + doc.set(root, Object::Dict(catalog)); + let out = strip(&mut doc).unwrap(); + assert_eq!(out.script_buttons, 1); + assert!(!doc.get(kid).as_dict().unwrap().contains(CLICK_KEY)); + } +} From 14ae96a2aca29d93f710c95895495237e0772d9a Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Mon, 9 Nov 2026 05:20:18 -0500 Subject: [PATCH 17/64] fix(print): a printed page's form shares the page's Resources when it adds no appearance (#6089) --- crates/print/src/imposition.rs | 16 ++++++++++++---- crates/print/src/tests.rs | 12 ++++++++++++ 2 files changed, 24 insertions(+), 4 deletions(-) diff --git a/crates/print/src/imposition.rs b/crates/print/src/imposition.rs index 348ae151ba..19a75af32c 100644 --- a/crates/print/src/imposition.rs +++ b/crates/print/src/imposition.rs @@ -219,14 +219,22 @@ pub(crate) fn page_form( body.extend(format!("q {} {} {} {} {} {} cm /{name} Do Q\n", n(a0), n(b0), n(c0), n(d0), n(e0), n(f0)).bytes()); xobjects.set(name.into_bytes(), Object::Ref(ap)); } - if !xobjects.is_empty() { - res.set(b"XObject".to_vec(), Object::Dict(xobjects)); - } + // With no appearance added the form keeps the page's own Resources, shared by reference, + // rather than a deep copy per printed page. + let resources = match page.dict.get(b"Resources") { + Some(r) if k == 0 && doc.resolve(r).as_dict().is_some() => r.clone(), + _ => { + if !xobjects.is_empty() { + res.set(b"XObject".to_vec(), Object::Dict(xobjects)); + } + Object::Dict(res) + } + }; let mut d = Dict::new(); d.set(b"Type".to_vec(), Object::name("XObject")); d.set(b"Subtype".to_vec(), Object::name("Form")); d.set(b"BBox".to_vec(), Object::Array(crop.iter().map(|v| Object::Real(*v)).collect())); - d.set(b"Resources".to_vec(), Object::Dict(res)); + d.set(b"Resources".to_vec(), resources); if let Some(g) = page.dict.get(b"Group") { d.set(b"Group".to_vec(), g.clone()); } diff --git a/crates/print/src/tests.rs b/crates/print/src/tests.rs index 63d8a351e7..5b8954fee9 100644 --- a/crates/print/src/tests.rs +++ b/crates/print/src/tests.rs @@ -1285,3 +1285,15 @@ fn contents_pieces_join_with_a_space_across_a_trailing_comment() { let printed_ctrl = impose(&with_newline, &settings(vec![0], Layout::Size(SizeMode::Actual))).unwrap(); assert_eq!(form_strings(printed_ctrl), [b"HIDDEN".to_vec(), b"SHOWN".to_vec()]); } + +#[test] +fn a_page_form_without_annotations_shares_the_pages_indirect_resources() { + let mut doc = fixture(2); + let pages = pdfcraft_model::pages(&doc); + let res = doc.add(Object::Dict([(b"Font".to_vec(), Object::Dict(Dict::new()))].into_iter().collect())); + doc.update_dict(pages[1].obj, |d| d.set(b"Resources".to_vec(), Object::Ref(res))).unwrap(); + let pages = pdfcraft_model::pages(&doc); + let form = imposition::page_form(&mut doc, &pages, 1, Content::default(), &mut Default::default()).unwrap(); + let Object::Stream(s) = &*doc.get(form) else { panic!("form stream") }; + assert_eq!(s.dict.get(b"Resources"), Some(&Object::Ref(res))); +} From 3a900b8bd21de13f9de841796b452f716103eb3d Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Mon, 9 Nov 2026 05:20:34 -0500 Subject: [PATCH 18/64] fix(a11y): auto-tagging bounds the content it plans across all pages (#6090) --- crates/a11y/src/autotag.rs | 10 ++++++++++ crates/a11y/src/tests.rs | 17 +++++++++++++++++ 2 files changed, 27 insertions(+) diff --git a/crates/a11y/src/autotag.rs b/crates/a11y/src/autotag.rs index 5c0dc2bd19..1b898fdfda 100644 --- a/crates/a11y/src/autotag.rs +++ b/crates/a11y/src/autotag.rs @@ -9,6 +9,10 @@ use pdfcraft_cos::{DecodeBudget, Dict, Document, ObjRef, Object, Stream}; const MAX_PAGES: usize = 1_000; const MAX_PAGE_BYTES: usize = 16 * 1024 * 1024; const MAX_OPERATORS: usize = 100_000; +/// What all pages together may decode and keep: pages can share one stream, so the per-page limits +/// alone let a small file retain a plan for every page. Real documents stay far under these. +const MAX_TOTAL_BYTES: usize = 128 * 1024 * 1024; +const MAX_TOTAL_OPERATORS: usize = 2_000_000; const MAX_TEXT_OBJECTS: usize = 4_096; #[derive(Clone, Copy, Debug, PartialEq, Eq)] @@ -186,6 +190,7 @@ fn plan(doc: &Document) -> Result, AutoTagError> { } let mut out = Vec::with_capacity(pages.len()); let mut total = 0usize; + let (mut total_bytes, mut total_ops) = (0usize, 0usize); for (index, page) in pages.iter().enumerate() { if page.dict.contains(b"StructParents") { return Err(unsupported(index, "page already has a structure parent key")); @@ -197,10 +202,15 @@ fn plan(doc: &Document) -> Result, AutoTagError> { return Err(unsupported(index, "annotations need separate structure tags")); } let bytes = content_bytes(doc, page, index)?; + total_bytes = total_bytes.saturating_add(bytes.len()); let parsed = parse(&bytes); if parsed.skipped > 0 || parsed.ops.len() > MAX_OPERATORS { return Err(unsupported(index, "content has malformed or too many operators")); } + total_ops = total_ops.saturating_add(parsed.ops.len()); + if total_bytes > MAX_TOTAL_BYTES || total_ops > MAX_TOTAL_OPERATORS { + return Err(unsupported(index, "the document has too much content to auto-tag in one edit")); + } let spans = text_spans(&parsed.ops, index)?; total = total.saturating_add(spans.len()); out.push(Plan { page: page.obj, ops: parsed.ops, spans }); diff --git a/crates/a11y/src/tests.rs b/crates/a11y/src/tests.rs index 66ae06fce4..e5bc3e425a 100644 --- a/crates/a11y/src/tests.rs +++ b/crates/a11y/src/tests.rs @@ -1763,3 +1763,20 @@ fn marking_a_tag_artifact_refuses_content_past_the_decode_budget() { assert_eq!(mark_tag_artifact(&mut doc, ObjRef::new(7, 0)), Err(ArtifactError::TooLarge)); assert_eq!(doc.get(ObjRef::new(3, 0)).as_dict().unwrap().get(b"Contents"), Some(&contents), "the page keeps its content"); } + +#[test] +fn autotag_text_refuses_pages_that_share_content_past_the_total_operator_allowance() { + // Each page is just under the per-page limit; sharing one stream makes the total 21 times it. + let content = format!("BT (a) Tj ET {}", "q Q ".repeat(49_990)); + let pages = 21; + let kids: Vec = (0..pages).map(|i| format!("{} 0 R", 4 + i)).collect(); + let mut bodies = vec![ + "<< /Type /Catalog /Pages 2 0 R >>".to_string(), + format!("<< /Type /Pages /Kids [{}] /Count {pages} /MediaBox [0 0 200 200] >>", kids.join(" ")), + stream(&content), + ]; + bodies.extend((0..pages).map(|_| "<< /Type /Page /Parent 2 0 R /Contents 3 0 R >>".to_string())); + let mut doc = pdf(&bodies, ""); + let Err(AutoTagError::Unsupported { reason, .. }) = autotag_text(&mut doc) else { panic!("expected a refusal") }; + assert!(reason.contains("too much content"), "{reason}"); +} From ce3c1ef805c093691b8bf2d6a917e92d43c6f911 Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Mon, 9 Nov 2026 05:20:57 -0500 Subject: [PATCH 19/64] fix(sign): chain building hashes a certificate once for all candidate issuers (#6096) --- crates/sign/src/x509.rs | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/crates/sign/src/x509.rs b/crates/sign/src/x509.rs index 5dd03d086d..1cf586b3e8 100644 --- a/crates/sign/src/x509.rs +++ b/crates/sign/src/x509.rs @@ -415,10 +415,19 @@ impl Certificate { /// Whether `key` verifies this certificate's signature. pub fn signed_by(&self, key: &PublicKey) -> bool { + self.signed_by_cached(key, &mut None) + } + + /// [`Self::signed_by`] keeping the digest of the signed part in `digest`, so checking one + /// certificate against many candidate keys hashes it once. + fn signed_by_cached(&self, key: &PublicKey, digest: &mut Option>) -> bool { let Ok(alg) = Tlv::parse_all(&self.sig_alg) else { return false }; match keys::signature_algorithm(&alg) { Ok((scheme @ keys::Scheme::Ed25519, _)) => key.verify_message(scheme, &self.tbs, &self.signature).unwrap_or(false), - Ok((scheme, Some(digest))) => key.verify(scheme, digest, &digest.digest(&[&self.tbs]), &self.signature).unwrap_or(false), + Ok((scheme, Some(d))) => { + let hash = digest.get_or_insert_with(|| d.digest(&[&self.tbs])); + key.verify(scheme, d, hash, &self.signature).unwrap_or(false) + } _ => false, } } @@ -533,7 +542,8 @@ pub fn build_chain_noted<'a>(leaf: &'a Certificate, pool: &'a [Certificate], at: // CA certificates between `last` and the leaf: what a candidate's pathLenConstraint limits. let below = chain.len() - 1; let mut found = None; - for c in pool.iter().filter(|c| c.subject.raw == last.issuer.raw && !chain.contains(c) && last.signed_by(&c.public_key)) { + let mut digest = None; + for c in pool.iter().filter(|c| c.subject.raw == last.issuer.raw && !chain.contains(c) && last.signed_by_cached(&c.public_key, &mut digest)) { // Each reason is a whole sentence, not a phrase stitched into one: a language puts the // two names where it needs them. let why = if !c.may_issue() { From 910b3267740b5ff8963d761f599060524949faf5 Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Mon, 9 Nov 2026 05:21:13 -0500 Subject: [PATCH 20/64] fix(render): the named-destination index spends key bytes from one allowance (#6097) --- crates/render/src/inspect/core.rs | 3 ++- crates/render/src/inspect/navigation.rs | 29 +++++++++++++++++++++---- 2 files changed, 27 insertions(+), 5 deletions(-) diff --git a/crates/render/src/inspect/core.rs b/crates/render/src/inspect/core.rs index c5df792f23..16de483e04 100644 --- a/crates/render/src/inspect/core.rs +++ b/crates/render/src/inspect/core.rs @@ -169,7 +169,8 @@ impl<'a> Inspector<'a> { if let Some(tree) = doc.catalog().ok().and_then(|c| c.get(b"Names").ok()).and_then(|o| me.dict(o)).and_then(|n| n.get(b"Dests").ok()).and_then(|o| me.dict(o)) { - me.name_tree_raw(tree, &mut HashSet::new(), 0, &mut named); + let mut key_budget = MAX_LINK_ATTACHMENT_TEXT_BYTES; + me.name_tree_raw(tree, &mut HashSet::new(), 0, &mut named, &mut key_budget); } me.named = named; me diff --git a/crates/render/src/inspect/navigation.rs b/crates/render/src/inspect/navigation.rs index fb19526875..4c6e88400e 100644 --- a/crates/render/src/inspect/navigation.rs +++ b/crates/render/src/inspect/navigation.rs @@ -79,6 +79,7 @@ impl<'a> Inspector<'a> { seen: &mut HashSet<*const Dictionary>, depth: u32, out: &mut HashMap, &'a Object>, + key_budget: &mut usize, ) { if depth > 32 || !seen.insert(node as *const _) { return; @@ -87,18 +88,25 @@ impl<'a> Inspector<'a> { for pair in pairs.chunks(2) { if let [k, v] = pair { let key = match self.resolve(k) { - Object::String(s, _) => s.clone(), - Object::Name(n) => n.clone(), + Object::String(s, _) => s, + Object::Name(n) => n, _ => continue, }; - out.entry(key).or_insert(v); + // Every key is hashed, so its bytes are spent from one allowance for the whole + // tree (a key repeated by reference is charged each time); one past it is + // not indexed. Only a first occurrence is copied. + let Some(left) = key_budget.checked_sub(key.len()) else { continue }; + *key_budget = left; + if !out.contains_key(key.as_slice()) { + out.insert(key.clone(), v); + } } } } if let Ok(Object::Array(kids)) = node.get(b"Kids").map(|o| self.resolve(o)) { for k in kids { if let Some(d) = self.dict(k) { - self.name_tree_raw(d, seen, depth + 1, out); + self.name_tree_raw(d, seen, depth + 1, out, key_budget); } } } @@ -559,4 +567,17 @@ mod open_dest_tests { let shallow = ["<< /Kids [7 0 R] >>".to_string(), "<< /Kids [8 0 R] >>".into(), "<< /Names [(x) [4 0 R /Fit]] >>".into()]; assert_eq!(open_dest("/OpenAction (x) /Names << /Dests 6 0 R >>", &shallow), Some((1, DestView::Fit))); } + + #[test] + fn a_key_repeated_by_reference_is_charged_each_time_against_the_name_tree_allowance() { + let big = format!("<{}>", "41".repeat(1 << 20)); + let tree = |repeats: usize| { + let names = format!("{}(x) [4 0 R /Fit]", "7 0 R [4 0 R /Fit] ".repeat(repeats)); + [format!("<< /Names [{names}] >>"), big.clone()] + }; + let catalog = "/OpenAction (x) /Names << /Dests 6 0 R >>"; + assert_eq!(open_dest(catalog, &tree(2)), Some((1, DestView::Fit))); + // 17 MiB of repeats of one reference spend the 16 MiB allowance before the real key. + assert_eq!(open_dest(catalog, &tree(17)), None); + } } From e3f2f2ebf1a0ca78496b88fb84f59a86dc184a4a Mon Sep 17 00:00:00 2001 From: midasmatterhorn <248507632+midasmatterhorn@users.noreply.github.com> Date: Mon, 9 Nov 2026 13:21:47 +0300 Subject: [PATCH 21/64] export: the Office zip writer outgrows the classic records with ZIP64 (#2554) --- crates/export/src/zip.rs | 316 ++++++++++++++++++++++++++++++++++----- 1 file changed, 277 insertions(+), 39 deletions(-) diff --git a/crates/export/src/zip.rs b/crates/export/src/zip.rs index a89d08cb7b..e15966c119 100644 --- a/crates/export/src/zip.rs +++ b/crates/export/src/zip.rs @@ -1,4 +1,6 @@ -//! A minimal zip writer (deflate or store, no zip64) for Office files. +//! A minimal zip writer (deflate or store) for Office files. Archives that outgrow the classic +//! end-of-central-directory record's 16-bit entry count or 32-bit sizes are written with the +//! ZIP64 records (APPNOTE 4.4), which every reader understands; small ones are unchanged. use std::io::Write; @@ -22,12 +24,102 @@ fn crc32(data: &[u8]) -> u32 { c ^ 0xFFFF_FFFF } -/// Builds a zip archive in memory. +/// The largest entry count the classic end-of-central-directory record holds (APPNOTE 4.3.12). +const MAX_CLASSIC_COUNT: u64 = u16::MAX as u64; +/// The largest size or offset the classic records hold: past it the field is a sentinel and the +/// ZIP64 record carries the real value (APPNOTE 4.4.1). +const MAX_CLASSIC_U32: u64 = u32::MAX as u64; + +/// The ZIP64 extended-information extra field (APPNOTE 4.5.3) holding `fields` — the values that +/// no longer fit their classic fields — in specification order. Empty when everything fits. +fn zip64_extra(fields: &[u64]) -> Vec { + let mut e = Vec::with_capacity(4 + 8 * fields.len()); + if !fields.is_empty() { + e.extend(0x0001u16.to_le_bytes()); + e.extend((8 * fields.len() as u16).to_le_bytes()); + for &f in fields { + e.extend(f.to_le_bytes()); + } + } + e +} + +/// The classic 32-bit field value for `value`: the value itself, or the 0xFFFFFFFF sentinel when it +/// no longer fits — including when it equals the sentinel, which a reader cannot tell from a real +/// value — in which case the ZIP64 extra field carries the real value (APPNOTE 4.4.3). +fn classic_u32(value: u64) -> u32 { + if value >= MAX_CLASSIC_U32 { u32::MAX } else { value as u32 } +} + +/// Builds one member's local file header and central directory entry. The sizes and `offset` are +/// parameters, not read from the body, so tests can inject values past the classic fields without +/// allocating the data. +fn entry_headers(name: &str, crc: u32, method: u16, compressed: u64, uncompressed: u64, offset: u64) -> (Vec, Vec) { + // A value at or above the sentinel cannot be held by a classic field, so it is offloaded. + let size_is_zip64 = uncompressed >= MAX_CLASSIC_U32 || compressed >= MAX_CLASSIC_U32; + // A local header that carries ZIP64 sizes carries both, original then compressed, even when only + // one overflowed (APPNOTE 4.5.3). A central entry carries one value for each field it set to its + // sentinel, and only those, so a reader walking the fields by their classic ones stays in step. + let local_extra = if size_is_zip64 { zip64_extra(&[uncompressed, compressed]) } else { Vec::new() }; + let mut central_fields = Vec::new(); + if uncompressed >= MAX_CLASSIC_U32 { + central_fields.push(uncompressed); + } + if compressed >= MAX_CLASSIC_U32 { + central_fields.push(compressed); + } + if offset >= MAX_CLASSIC_U32 { + central_fields.push(offset); + } + let central_extra = zip64_extra(¢ral_fields); + // An entry that carries ZIP64 fields needs version 4.5 to extract (APPNOTE 4.4.3); the version + // in the local header and the central entry agree. + let version_needed = if size_is_zip64 || offset >= MAX_CLASSIC_U32 { 45u16 } else { 20u16 }; + let header = |sig: u32, central: bool| { + let mut h = Vec::new(); + h.extend(sig.to_le_bytes()); + if central { + h.extend(20u16.to_le_bytes()); // made by + } + h.extend(version_needed.to_le_bytes()); // version needed + h.extend(0x0800u16.to_le_bytes()); // UTF-8 names + h.extend(method.to_le_bytes()); + h.extend([0, 0, 0x21, 0]); // 00:00, 1980-01-01 (a zero date is not a valid DOS date) + h.extend(crc.to_le_bytes()); + // A local ZIP64 extra carries both sizes, so both corresponding classic fields are + // sentinels even when only one size needs offloading (APPNOTE 4.5.3). Central entries + // remain selective and use a sentinel only for fields represented in their extra. + let (compressed_field, uncompressed_field) = + if !central && size_is_zip64 { (u32::MAX, u32::MAX) } else { (classic_u32(compressed), classic_u32(uncompressed)) }; + h.extend(compressed_field.to_le_bytes()); + h.extend(uncompressed_field.to_le_bytes()); + h.extend((name.len() as u16).to_le_bytes()); + let extra = if central { ¢ral_extra } else { &local_extra }; + h.extend((extra.len() as u16).to_le_bytes()); // extra + if central { + h.extend(0u16.to_le_bytes()); // comment + h.extend(0u16.to_le_bytes()); // disk + h.extend(0u16.to_le_bytes()); // internal attributes + h.extend(0u32.to_le_bytes()); // external attributes + h.extend(classic_u32(offset).to_le_bytes()); + } + h.extend(name.as_bytes()); + h.extend(extra); + h + }; + (header(0x0403_4b50, false), header(0x0201_4b50, true)) +} + +/// Builds a zip archive in memory. An archive past the classic record's 16-bit entry count or +/// 32-bit sizes and offsets is written with a ZIP64 end-of-central-directory record: a +/// PowerPoint export writes two entries per page plus ten fixed parts, so a document with +/// 32 763 pages already passes 65 535 entries, and without the ZIP64 record the count wraps +/// and readers see a central directory that does not describe the archive. #[derive(Default)] pub struct Zip { out: Vec, central: Vec, - count: u16, + count: usize, } impl Zip { @@ -41,49 +133,44 @@ impl Zip { } else { (0u16, data.to_vec()) }; - let offset = self.out.len() as u32; - let header = |sig: u32, central: bool| { - let mut h = Vec::new(); - h.extend(sig.to_le_bytes()); - if central { - h.extend(20u16.to_le_bytes()); // made by - } - h.extend(20u16.to_le_bytes()); // version needed - h.extend(0x0800u16.to_le_bytes()); // UTF-8 names - h.extend(method.to_le_bytes()); - h.extend([0, 0, 0x21, 0]); // 00:00, 1980-01-01 (a zero date is not a valid DOS date) - h.extend(crc.to_le_bytes()); - h.extend((body.len() as u32).to_le_bytes()); - h.extend((data.len() as u32).to_le_bytes()); - h.extend((name.len() as u16).to_le_bytes()); - h.extend(0u16.to_le_bytes()); // extra - if central { - h.extend(0u16.to_le_bytes()); // comment - h.extend(0u16.to_le_bytes()); // disk - h.extend(0u16.to_le_bytes()); // internal attributes - h.extend(0u32.to_le_bytes()); // external attributes - h.extend(offset.to_le_bytes()); - } - h.extend(name.as_bytes()); - h - }; - self.out.extend(header(0x0403_4b50, false)); + let offset = self.out.len() as u64; + let (local, central) = entry_headers(name, crc, method, body.len() as u64, data.len() as u64, offset); + self.out.extend(local); self.out.extend(&body); - self.central.extend(header(0x0201_4b50, true)); + self.central.extend(central); self.count += 1; } pub fn finish(mut self) -> Vec { - let start = self.out.len() as u32; - let size = self.central.len() as u32; + let start = self.out.len() as u64; + let size = self.central.len() as u64; + let count = self.count as u64; self.out.append(&mut self.central); + let classic_count = if count > MAX_CLASSIC_COUNT { u16::MAX } else { count as u16 }; + if count > MAX_CLASSIC_COUNT || size >= MAX_CLASSIC_U32 || start >= MAX_CLASSIC_U32 { + let eocd64_at = self.out.len() as u64; + self.out.extend(0x0606_4b50u32.to_le_bytes()); // ZIP64 end of central directory record + self.out.extend(44u64.to_le_bytes()); // size of the rest of this record + self.out.extend(45u16.to_le_bytes()); // version made by (4.5, DOS) + self.out.extend(45u16.to_le_bytes()); // version needed to extract + self.out.extend(0u32.to_le_bytes()); // number of this disk + self.out.extend(0u32.to_le_bytes()); // disk with the start of the central directory + self.out.extend(count.to_le_bytes()); // entries on this disk + self.out.extend(count.to_le_bytes()); // entries in total + self.out.extend(size.to_le_bytes()); // size of the central directory + self.out.extend(start.to_le_bytes()); // offset of the central directory + self.out.extend(0x0706_4b50u32.to_le_bytes()); // ZIP64 end of central directory locator + self.out.extend(0u32.to_le_bytes()); // disk with the ZIP64 record + self.out.extend(eocd64_at.to_le_bytes()); // offset of the ZIP64 record + self.out.extend(1u32.to_le_bytes()); // total number of disks + } self.out.extend(0x0605_4b50u32.to_le_bytes()); - self.out.extend([0u8; 4]); - self.out.extend(self.count.to_le_bytes()); - self.out.extend(self.count.to_le_bytes()); - self.out.extend(size.to_le_bytes()); - self.out.extend(start.to_le_bytes()); - self.out.extend(0u16.to_le_bytes()); + self.out.extend([0u8; 4]); // number of this disk, disk with the central directory + self.out.extend(classic_count.to_le_bytes()); // entries on this disk + self.out.extend(classic_count.to_le_bytes()); // entries in total + self.out.extend(classic_u32(size).to_le_bytes()); + self.out.extend(classic_u32(start).to_le_bytes()); + self.out.extend(0u16.to_le_bytes()); // comment length self.out } } @@ -107,4 +194,155 @@ mod tests { assert_eq!(b.windows(4).filter(|w| *w == b"PK\x01\x02").count(), 2); assert_eq!(&b[b.len() - 22..b.len() - 18], b"PK\x05\x06"); } + + #[test] + fn more_entries_than_the_classic_record_holds_writes_zip64() { + // A PowerPoint export writes two entries per page plus ten fixed parts, so a + // document with 32 763 pages already passes the classic record's 16-bit entry + // count. The count must not wrap: it overflows in a debug build, and in a release + // one it silently writes a central directory readers cannot match to the archive. + let mut z = Zip::default(); + for i in 0..u16::MAX as usize + 1 { + z.add(&format!("ppt/slides/slide{i}.xml"), b"x", true); + z.add(&format!("ppt/slides/_rels/slide{i}.xml.rels"), b"y", true); + } + let b = z.finish(); + let entries = 2 * (u16::MAX as usize + 1); + // The classic end-of-central-directory record holds sentinels… + let eocd = &b[b.len() - 22..]; + assert_eq!(&eocd[..4], &0x0605_4b50u32.to_le_bytes()); + assert_eq!(&eocd[8..10], &u16::MAX.to_le_bytes(), "entries on this disk"); + assert_eq!(&eocd[10..12], &u16::MAX.to_le_bytes(), "entries in total"); + // …and the ZIP64 locator and record carry the truth. + let locator = &b[b.len() - 42..b.len() - 22]; + assert_eq!(&locator[..4], &0x0706_4b50u32.to_le_bytes()); + let at = u64::from_le_bytes(locator[8..16].try_into().unwrap_or([0; 8])) as usize; + assert_eq!(&b[at..at + 4], &0x0606_4b50u32.to_le_bytes()); + assert_eq!(u64::from_le_bytes(b[at + 24..at + 32].try_into().unwrap_or([0; 8])), entries as u64, "entries on this disk"); + assert_eq!(u64::from_le_bytes(b[at + 32..at + 40].try_into().unwrap_or([0; 8])), entries as u64, "entries in total"); + } + + /// The extra field of a local file header (APPNOTE 4.3.7), which follows the file name. + fn local_extra(h: &[u8]) -> &[u8] { + let le = |i: usize, n: usize| h[i..i + n].iter().rev().fold(0usize, |v, b| v << 8 | *b as usize); + let (name_len, extra_len) = (le(26, 2), le(28, 2)); + &h[30 + name_len..30 + name_len + extra_len] + } + + /// The extra field of a central directory entry (APPNOTE 4.3.12), which follows the file name. + fn central_extra(h: &[u8]) -> &[u8] { + let le = |i: usize, n: usize| h[i..i + n].iter().rev().fold(0usize, |v, b| v << 8 | *b as usize); + let (name_len, extra_len) = (le(28, 2), le(30, 2)); + &h[46 + name_len..46 + name_len + extra_len] + } + + /// The classic compressed and original size fields of a local file header (`at` 18) or a + /// central directory entry (`at` 20). + fn classic_sizes(h: &[u8], at: usize) -> (u32, u32) { + let field = |i: usize| u32::from_le_bytes(h[i..i + 4].try_into().unwrap_or([0; 4])); + (field(at), field(at + 4)) + } + + /// Asserts the local header carries both sizes whenever either needs ZIP64, with both classic + /// size fields set to the sentinel (APPNOTE 4.5.3: a ZIP64 value appears only for a field set to + /// its sentinel, and the local entry must carry both sizes). The central entry stays selective: + /// only the fields that themselves overflow get a value, in specification order. + fn assert_local_pair_and_central_selectivity(compressed: u64, uncompressed: u64) { + let at_limit = |v: u64| v >= u32::MAX as u64; + let (local, central) = entry_headers("a", 0, 0, compressed, uncompressed, 0); + assert_eq!(classic_sizes(&local, 18), (u32::MAX, u32::MAX), "both local size fields are sentinels for ({compressed}, {uncompressed})"); + let extra = local_extra(&local); + assert_eq!( + u16::from_le_bytes(extra[2..4].try_into().unwrap_or([0; 2])), + 16, + "both local sizes are present for ({compressed}, {uncompressed})" + ); + assert_eq!(u64::from_le_bytes(extra[4..12].try_into().unwrap_or([0; 8])), uncompressed, "original size first"); + assert_eq!(u64::from_le_bytes(extra[12..20].try_into().unwrap_or([0; 8])), compressed, "compressed size second"); + let expected = + (if at_limit(compressed) { u32::MAX } else { compressed as u32 }, if at_limit(uncompressed) { u32::MAX } else { uncompressed as u32 }); + assert_eq!( + classic_sizes(¢ral, 20), + expected, + "the central entry offloads only the fields that overflow for ({compressed}, {uncompressed})" + ); + let fields = usize::from(at_limit(uncompressed)) + usize::from(at_limit(compressed)); + let extra = central_extra(¢ral); + assert_eq!( + u16::from_le_bytes(extra[2..4].try_into().unwrap_or([0; 2])), + 8 * fields as u16, + "one central value per sentinel field for ({compressed}, {uncompressed})" + ); + if at_limit(uncompressed) { + assert_eq!(u64::from_le_bytes(extra[4..12].try_into().unwrap_or([0; 8])), uncompressed, "original size first"); + } + if at_limit(compressed) { + let at = if at_limit(uncompressed) { 12 } else { 4 }; + assert_eq!(u64::from_le_bytes(extra[at..at + 8].try_into().unwrap_or([0; 8])), compressed, "compressed size in specification order"); + } + } + + #[test] + fn both_local_sizes_are_sentinels_when_only_one_needs_zip64() { + // A local ZIP64 extra must carry both sizes, and its values correspond to classic fields set + // to their sentinel — so a small classic size next to an overflowing one is wrong even + // though the extra holds the truth. Both orders of which size overflows. + assert_local_pair_and_central_selectivity(100, u32::MAX as u64 + 1); + assert_local_pair_and_central_selectivity(u32::MAX as u64 + 1, 100); + } + + #[test] + fn both_local_sizes_are_sentinels_when_one_equals_the_sentinel() { + // A size of exactly 0xFFFFFFFF is indistinguishable from the sentinel, so it needs ZIP64 + // just like a larger one; the other local size must then become a sentinel too. Both orders. + assert_local_pair_and_central_selectivity(100, u32::MAX as u64); + assert_local_pair_and_central_selectivity(u32::MAX as u64, 100); + } + + #[test] + fn local_zip64_extra_carries_both_sizes() { + // When only one size overflows the u32 limit, the local header still carries both sizes — + // original then compressed — because a local ZIP64 extra that carries either must carry + // both (APPNOTE 4.5.3). Either size may be the one past the limit. + for (compressed, uncompressed) in [(100u64, u32::MAX as u64 + 1), (u32::MAX as u64 + 1, 100)] { + let (local, _) = entry_headers("a", 0, 0, compressed, uncompressed, 0); + let extra = local_extra(&local); + assert_eq!(u16::from_le_bytes(extra[..2].try_into().unwrap_or([0; 2])), 0x0001, "ZIP64 extra id"); + assert_eq!(u16::from_le_bytes(extra[2..4].try_into().unwrap_or([0; 2])), 16, "both sizes are present"); + assert_eq!(u64::from_le_bytes(extra[4..12].try_into().unwrap_or([0; 8])), uncompressed, "original size first"); + assert_eq!(u64::from_le_bytes(extra[12..20].try_into().unwrap_or([0; 8])), compressed, "compressed size second"); + } + } + + #[test] + fn zip64_entries_need_version_4_5() { + // An entry that carries ZIP64 fields must advertise version 4.5 to extract, in both the + // local file header and the central directory entry (APPNOTE 4.4.3). An entry that fits + // every classic field stays at 2.0, so small archives are unchanged. + let (local, central) = entry_headers("a", 0, 0, u32::MAX as u64 + 1, u32::MAX as u64 + 1, 0); + assert_eq!(u16::from_le_bytes(local[4..6].try_into().unwrap_or([0; 2])), 45, "local version needed"); + assert_eq!(u16::from_le_bytes(central[6..8].try_into().unwrap_or([0; 2])), 45, "central version needed"); + let (local, central) = entry_headers("a", 0, 0, 4, 8, 0); + assert_eq!(u16::from_le_bytes(local[4..6].try_into().unwrap_or([0; 2])), 20, "local version needed"); + assert_eq!(u16::from_le_bytes(central[6..8].try_into().unwrap_or([0; 2])), 20, "central version needed"); + } + + #[test] + fn size_equal_to_the_sentinel_is_offloaded() { + // A real size of exactly u32::MAX is indistinguishable from the 0xFFFFFFFF sentinel, so it + // too must be offloaded: every classic field set to the sentinel needs its 8-byte value in + // the central extra, or a reader such as Python's zipfile reports "Compress size not + // found". Both orders of which size is the MAX+1 one must work. + for (compressed, uncompressed) in [(u32::MAX as u64, u32::MAX as u64 + 1), (u32::MAX as u64 + 1, u32::MAX as u64)] { + let (local, central) = entry_headers("a", 0, 0, compressed, uncompressed, 0); + assert_eq!(u32::from_le_bytes(local[18..22].try_into().unwrap_or([0; 4])), u32::MAX, "local compressed sentinel"); + assert_eq!(u32::from_le_bytes(local[22..26].try_into().unwrap_or([0; 4])), u32::MAX, "local original sentinel"); + assert_eq!(u32::from_le_bytes(central[20..24].try_into().unwrap_or([0; 4])), u32::MAX, "central compressed sentinel"); + assert_eq!(u32::from_le_bytes(central[24..28].try_into().unwrap_or([0; 4])), u32::MAX, "central original sentinel"); + let extra = central_extra(¢ral); + assert_eq!(u16::from_le_bytes(extra[2..4].try_into().unwrap_or([0; 2])), 16, "a value for each sentinel field"); + assert_eq!(u64::from_le_bytes(extra[4..12].try_into().unwrap_or([0; 8])), uncompressed, "original size first"); + assert_eq!(u64::from_le_bytes(extra[12..20].try_into().unwrap_or([0; 8])), compressed, "compressed size second"); + } + } } From 938f56989fb33c3abb37e9bb4ed5272ad1dd5b0f Mon Sep 17 00:00:00 2001 From: jonathanwxh-cell Date: Mon, 9 Nov 2026 18:22:09 +0800 Subject: [PATCH 22/64] fix(organize): preserve page labels across extract, split, and combine (#2670) (#2799) --- crates/organize/src/import/combine.rs | 28 ++++- crates/organize/src/labels.rs | 123 +++++++++++++++++-- crates/organize/src/tests.rs | 170 ++++++++++++++++++++++++++ 3 files changed, 311 insertions(+), 10 deletions(-) diff --git a/crates/organize/src/import/combine.rs b/crates/organize/src/import/combine.rs index 7782e5de18..c1a1fed520 100644 --- a/crates/organize/src/import/combine.rs +++ b/crates/organize/src/import/combine.rs @@ -7,9 +7,13 @@ use super::{Dict, Document, FieldConflicts, HashMap, HashSet, ObjRef, Object, Or /// go to the copied pages. pub fn extract_pages(src: &Document, pages: &[usize]) -> Result { let mut out = Document::new_empty(); + let mut labels = Vec::new(); + let mut has_labels = false; + crate::labels::collect_page_labels(src, pages, 0, &mut labels, &mut has_labels)?; // One source: nothing to deduplicate. Its fields keep their names (a source with two // same-named field roots stays one field, as it was), so Extract and Split never rename. let imported = import_pages_mapped(&mut out, src, pages, 0, FieldConflicts::Merge)?; + crate::labels::finish_page_labels(&mut out, &labels, has_labels)?; // The bookmarks of these pages come along, mapped to the copies (#4020). let outline = kept_bookmarks(source_outline(src, imported.page_map)); if !outline.is_empty() { @@ -119,6 +123,8 @@ pub struct Combiner { out: Document, marks: Vec, attachments: Vec<(Vec, Object)>, + labels: Vec, + has_labels: bool, /// The first source's print standard, and what its pages brought along. standard: Option, /// Whether every source so far declares the first source's print standard. @@ -156,6 +162,8 @@ impl Combiner { out: Document::new_empty(), marks: Vec::new(), attachments: Vec::new(), + labels: Vec::new(), + has_labels: false, standard: None, agreed: true, field_conflicts, @@ -182,7 +190,12 @@ impl Combiner { None => (0..n).collect(), }; let at = crate::page_count(&self.out)?; + let mut labels = self.labels.clone(); + let mut has_labels = self.has_labels; + crate::labels::collect_page_labels(src, &pages, at, &mut labels, &mut has_labels)?; let imported = import_pages_mapped(&mut self.out, src, &pages, at, self.field_conflicts)?; + self.labels = labels; + self.has_labels = has_labels; self.renamed_fields.extend(imported.renamed_fields); if let Some(first) = imported.pages.first() { let outline = source_outline(src, imported.page_map); @@ -212,13 +225,14 @@ impl Combiner { /// [`Combiner::finish`] reporting what the combine renamed. pub fn finish_with_renames(self) -> Result { - let Combiner { mut out, marks, attachments, standard, agreed, renamed_fields, .. } = self; + let Combiner { mut out, marks, attachments, labels, has_labels, standard, agreed, renamed_fields, .. } = self; if let Some(first) = standard.filter(|_| agreed) { crate::pdfx::carry(&mut out, first.source.as_ref(), &first.declared, first.objects)?; } // Sources often share fonts, images and profiles (or are the same file): store them once. let all: Vec = out.object_numbers().into_iter().map(|n| ObjRef::new(n, out.generation(n))).collect(); crate::dedupe::dedupe_resources(&mut out, &all, false); + crate::labels::finish_page_labels(&mut out, &labels, has_labels)?; add_outline(&mut out, &marks)?; set_attachments(&mut out, attachments)?; Ok(CombineOutcome { doc: out, renamed_fields }) @@ -299,9 +313,18 @@ pub fn combine_grouped_with(runs: &[Run<'_>], conflicts: FieldConflicts) -> Resu // The pages in the runs' order. A file's first run comes before its others, and its pages // were copied in run order, so its bookmark (its first copied page) is its first page shown. let mut order: Vec<(ObjRef, Dict)> = Vec::new(); + let mut labels = Vec::new(); + let mut has_labels = false; for (file, start, len) in slices { - let run = copies.get(file).and_then(|c| c.get(start..start.saturating_add(len))).unwrap_or_default(); + let end = start.saturating_add(len); + let at = order.len(); + let run = copies.get(file).and_then(|c| c.get(start..end)).unwrap_or_default(); order.extend(run.iter().map(|r| (*r, Dict::new()))); + if let Some((_, f)) = files.get(file) + && let Some(pages) = f.pages.get(start..end) + { + crate::labels::collect_page_labels(f.src, pages, at, &mut labels, &mut has_labels)?; + } } rebuild(&mut out, &order)?; if let Some((declared, src, objects)) = standard.filter(|_| agreed) { @@ -309,6 +332,7 @@ pub fn combine_grouped_with(runs: &[Run<'_>], conflicts: FieldConflicts) -> Resu } let all: Vec = out.object_numbers().into_iter().map(|n| ObjRef::new(n, out.generation(n))).collect(); crate::dedupe::dedupe_resources(&mut out, &all, false); + crate::labels::finish_page_labels(&mut out, &labels, has_labels)?; add_outline(&mut out, &marks)?; set_attachments(&mut out, attachments)?; Ok(CombineOutcome { doc: out, renamed_fields }) diff --git a/crates/organize/src/labels.rs b/crates/organize/src/labels.rs index 8ee04bed53..e296148ebd 100644 --- a/crates/organize/src/labels.rs +++ b/crates/organize/src/labels.rs @@ -360,11 +360,13 @@ pub fn set_page_label_ranges(doc: &mut Document, ranges: &[LabelRange]) -> Resul return Err(invalid_label("the number tree has too many entries")); } // Validate every range and all effective labels before changing any document object. + let mut prefix_left = MAX_PREFIX_TOTAL_BYTES; for r in ranges { if r.first == 0 || i64::try_from(r.start).is_err() { return Err(invalid_label("range starts and first numbers must be valid positive PDF integers")); } r.label(r.start)?; + prefix_left = prefix_left.checked_sub(r.prefix.len()).ok_or_else(|| invalid_label("decoded prefixes exceed 4 MiB in total"))?; } let mut sorted = ranges.to_vec(); sorted.sort_by_key(|r| r.start); @@ -450,15 +452,9 @@ pub fn number_pages(doc: &mut Document, from: usize, to: usize, style: LabelStyl } // Drop ranges that only continue the previous one. let mut merged: Vec = Vec::new(); + let mut prefix_left = MAX_PREFIX_TOTAL_BYTES; for r in ranges { - let continues = merged.last().is_some_and(|p| { - p.style == r.style - && p.prefix == r.prefix - && r.start.checked_sub(p.start).and_then(|n| u32::try_from(n).ok()).and_then(|n| p.first.checked_add(n)) == Some(r.first) - }); - if !continues { - merged.push(r); - } + push_merged_range(&mut merged, &mut prefix_left, r)?; } // Plain 1, 2, 3 everywhere is what a document without labels shows: remove them. if merged.len() == 1 && merged[0] == default_range() { @@ -467,6 +463,117 @@ pub fn number_pages(doc: &mut Document, from: usize, to: usize, style: LabelStyl set_page_label_ranges(doc, &merged) } +fn push_merged_range(merged: &mut Vec, prefix_left: &mut usize, r: LabelRange) -> Result<(), OrganizeError> { + let continues = merged.last().is_some_and(|p| { + p.style == r.style + && p.prefix == r.prefix + && match r.style { + LabelStyle::None => p.first == r.first, + _ => r.start.checked_sub(p.start).and_then(|n| u32::try_from(n).ok()).and_then(|n| p.first.checked_add(n)) == Some(r.first), + } + }); + if !continues { + if merged.len() + 1 >= MAX_LABEL_TREE_WORK { + return Err(invalid_label("the number tree has too many entries")); + } + *prefix_left = prefix_left.checked_sub(r.prefix.len()).ok_or_else(|| invalid_label("decoded prefixes exceed 4 MiB in total"))?; + merged.push(r); + } + Ok(()) +} + +/// Decimal numbering that shows the source page's physical number (`src_page` is 0-based). +fn source_physical_range(dst_start: usize, src_page: usize) -> LabelRange { + LabelRange { start: dst_start, style: LabelStyle::Decimal, prefix: String::new(), first: u32::try_from(src_page.saturating_add(1)).unwrap_or(1) } +} + +/// Whether `set_page_label_ranges` can store `r` while it covers `r.start..end` (`end` exclusive). +/// Entry-count and aggregate-prefix limits stay hard errors in [`push_merged_range`]. +fn range_writable(r: &LabelRange, end: usize) -> bool { + r.label(r.start).is_ok() && check_spans(std::slice::from_ref(r), end).is_ok() +} + +/// Append the label ranges that `pages` (0-based indices in `src`) carry when placed at +/// `dst_start` in an output document. Sets `has_labels` when `src` has `/PageLabels`. +pub(crate) fn collect_page_labels( + src: &Document, + pages: &[usize], + dst_start: usize, + out: &mut Vec, + has_labels: &mut bool, +) -> Result<(), OrganizeError> { + if pages.is_empty() { + return Ok(()); + } + let mut src_ranges = checked_ranges(src).unwrap_or_default(); + if !src_ranges.is_empty() { + *has_labels = true; + } + for r in &mut src_ranges { + // An empty prefix-only range shows each page's physical number in the source. + if r.style == LabelStyle::None && r.prefix.is_empty() { + r.style = LabelStyle::Decimal; + r.first = u32::try_from(r.start.saturating_add(1)).unwrap_or(1); + } + } + if src_ranges.first().is_none_or(|r| r.start > 0) { + src_ranges.insert(0, default_range()); + } + let mut prefix_left = MAX_PREFIX_TOTAL_BYTES.saturating_sub(out.iter().map(|r| r.prefix.len()).sum::()); + let mut i = 0; + while i < pages.len() { + let src_start = pages[i]; + let mut len = 1; + while i + len < pages.len() && src_start.checked_add(len) == Some(pages[i + len]) { + len += 1; + } + let run_dst = dst_start + i; + let src_end = src_start.saturating_add(len); + let run_end = run_dst.saturating_add(len); + let idx = src_ranges.partition_point(|r| r.start <= src_start).saturating_sub(1); + let span_end = |following: Option| { + following + .filter(|start| *start > src_start && *start < src_end) + .and_then(|start| start.checked_sub(src_start).and_then(|delta| run_dst.checked_add(delta))) + .unwrap_or(run_end) + }; + if let Some(first_range) = src_ranges.get(idx) { + let mapped = match first_range.number(src_start) { + Ok(first) => LabelRange { start: run_dst, style: first_range.style, prefix: first_range.prefix.clone(), first }, + Err(_) => source_physical_range(run_dst, src_start), + }; + let mapped = if range_writable(&mapped, span_end(src_ranges.get(idx + 1).map(|r| r.start))) { + mapped + } else { + source_physical_range(run_dst, src_start) + }; + push_merged_range(out, &mut prefix_left, mapped)?; + } + let tail = src_ranges.get(idx + 1..).unwrap_or_default(); + for (n, r) in tail.iter().enumerate() { + if r.start >= src_end { + break; + } + let Some(delta) = r.start.checked_sub(src_start) else { continue }; + let Some(start) = run_dst.checked_add(delta) else { continue }; + let inner = LabelRange { start, style: r.style, prefix: r.prefix.clone(), first: r.first }; + let following = tail.get(n + 1).map(|next| next.start); + let inner = if range_writable(&inner, span_end(following)) { inner } else { source_physical_range(start, r.start) }; + push_merged_range(out, &mut prefix_left, inner)?; + } + i += len; + } + Ok(()) +} + +/// Write collected page label ranges to `dst` when at least one source carried `/PageLabels`. +pub(crate) fn finish_page_labels(dst: &mut Document, ranges: &[LabelRange], has_labels: bool) -> Result<(), OrganizeError> { + if has_labels && !ranges.is_empty() { + set_page_label_ranges(dst, ranges)?; + } + Ok(()) +} + #[cfg(test)] mod tests { use super::*; diff --git a/crates/organize/src/tests.rs b/crates/organize/src/tests.rs index c79c3d8667..0aa6d149d9 100644 --- a/crates/organize/src/tests.rs +++ b/crates/organize/src/tests.rs @@ -2223,6 +2223,176 @@ fn deleting_pages_keeps_the_label_ranges_on_their_pages() { } } +#[test] +fn extracting_splitting_and_combining_preserve_page_labels() { + use crate::{ + LabelRange, + LabelStyle::{Decimal, LowerRoman, UpperAlpha}, + SplitBy, + }; + let ten = || { + let mut d = doc_a(); + for at in 3..10 { + crate::insert_blank_page(&mut d, at, 300.0, 400.0).unwrap(); + } + let ranges = [ + LabelRange { start: 0, style: LowerRoman, prefix: String::new(), first: 1 }, + LabelRange { start: 3, style: Decimal, prefix: String::new(), first: 1 }, + LabelRange { start: 7, style: Decimal, prefix: "A-".into(), first: 1 }, + ]; + crate::set_page_label_ranges(&mut d, &ranges).unwrap(); + d + }; + let d = ten(); + + // Extracting non-contiguous pages (e.g. first front-matter page and first appendix page) + // keeps their labels and writes /PageLabels (#2670). + let pair = full_roundtrip(&extract_pages(&d, &[0, 7]).unwrap()); + assert_eq!(crate::page_labels(&pair).unwrap(), ["i", "A-1"]); + assert_eq!( + crate::page_label_ranges(&pair), + [ + LabelRange { start: 0, style: LowerRoman, prefix: String::new(), first: 1 }, + LabelRange { start: 1, style: Decimal, prefix: "A-".into(), first: 1 }, + ] + ); + + // Contiguous runs and out-of-order picks keep each page's label and merge continuations. + let picked = full_roundtrip(&extract_pages(&d, &[1, 2, 8, 9, 0]).unwrap()); + assert_eq!(crate::page_labels(&picked).unwrap(), ["ii", "iii", "A-2", "A-3", "i"]); + assert_eq!( + crate::page_label_ranges(&picked), + [ + LabelRange { start: 0, style: LowerRoman, prefix: String::new(), first: 2 }, + LabelRange { start: 2, style: Decimal, prefix: "A-".into(), first: 2 }, + LabelRange { start: 4, style: LowerRoman, prefix: String::new(), first: 1 }, + ] + ); + + // An unlabelled source stays unlabelled after extraction. + let unlabelled = full_roundtrip(&extract_pages(&doc_a(), &[2, 0]).unwrap()); + assert!(crate::page_label_ranges(&unlabelled).is_empty()); + + // Splitting preserves each part's labels, including mid-range starting numbers. + let parts = crate::split(&d, &SplitBy::PageCount(4)).unwrap(); + let part_labels: Vec> = parts.iter().map(|p| crate::page_labels(&full_roundtrip(p)).unwrap()).collect(); + assert_eq!(part_labels, vec![vec!["i", "ii", "iii", "1"], vec!["2", "3", "4", "A-1"], vec!["A-2", "A-3"],]); + + // Combining whole files, selected pages, and grouped runs preserves labels from each source. + let mut b = doc_b(); + crate::set_page_label_ranges(&mut b, &[LabelRange { start: 0, style: UpperAlpha, prefix: "B-".into(), first: 1 }]).unwrap(); + let combined = full_roundtrip(&combine_selected(&[("D", &d, Some(&[0, 1, 7])), ("Plain", &doc_a(), Some(&[0, 1])), ("B", &b, None)]).unwrap()); + assert_eq!(crate::page_labels(&combined).unwrap(), ["i", "ii", "A-1", "1", "2", "B-A", "B-B"]); + + let grouped = + full_roundtrip(&crate::combine_grouped(&[(0, "D", &d, Some(&[0, 1])), (1, "B", &b, Some(&[1])), (0, "D", &d, Some(&[2, 7]))]).unwrap()); + assert_eq!(crate::page_labels(&grouped).unwrap(), ["i", "ii", "B-B", "iii", "A-1"]); + + // Ranges starting after page 0 and prefix-only ranges (LabelStyle::None) merge and carry over. + let mut late = doc_a(); + crate::set_page_label_ranges( + &mut late, + &[ + LabelRange { start: 1, style: crate::LabelStyle::None, prefix: "Cover".into(), first: 1 }, + LabelRange { start: 2, style: LowerRoman, prefix: String::new(), first: 4 }, + ], + ) + .unwrap(); + let from_late = full_roundtrip(&extract_pages(&late, &[0, 1, 1, 2]).unwrap()); + assert_eq!(crate::page_labels(&from_late).unwrap(), ["1", "Cover", "Cover", "iv"]); + assert_eq!( + crate::page_label_ranges(&from_late), + [ + LabelRange { start: 0, style: Decimal, prefix: String::new(), first: 1 }, + LabelRange { start: 1, style: crate::LabelStyle::None, prefix: "Cover".into(), first: 1 }, + LabelRange { start: 3, style: LowerRoman, prefix: String::new(), first: 4 }, + ] + ); + + // An empty selection from a labelled source in combine_selected does not add /PageLabels. + let empty_pick = full_roundtrip(&combine_selected(&[("D", &d, Some(&[])), ("Plain", &doc_a(), Some(&[0, 1]))]).unwrap()); + assert!(crate::page_label_ranges(&empty_pick).is_empty()); + + // A damaged (cyclic) /PageLabels tree does not prevent extracting pages. + let mut cyclic = doc_a(); + let tree = cyclic.add(Object::Null); + let mut dict = Dict::new(); + dict.set(b"Kids".to_vec(), Object::Array(vec![Object::Ref(tree)])); + cyclic.set(tree, Object::Dict(dict)); + cyclic.update_dict(cyclic.root().unwrap(), |c| c.set(b"PageLabels".to_vec(), Object::Ref(tree))).unwrap(); + let extracted_cyclic = full_roundtrip(&extract_pages(&cyclic, &[0, 2]).unwrap()); + assert_eq!(crate::page_labels(&extracted_cyclic).unwrap(), ["1", "2"]); + + // An empty prefix-only range falls back to physical page numbers in the source; extracting a + // later page covered by it preserves that physical page number across save/reopen. + let mut empty_prefix = doc_a(); + crate::set_page_label_ranges( + &mut empty_prefix, + &[ + LabelRange { start: 0, style: crate::LabelStyle::None, prefix: String::new(), first: 1 }, + LabelRange { start: 2, style: LowerRoman, prefix: String::new(), first: 1 }, + ], + ) + .unwrap(); + assert_eq!(crate::PageLabels::new(&empty_prefix).unwrap().label(1).unwrap(), "2"); + let extracted_empty_prefix = full_roundtrip(&extract_pages(&empty_prefix, &[1]).unwrap()); + assert_eq!(crate::PageLabels::new(&extracted_empty_prefix).unwrap().label(0).unwrap(), "2"); + + // Repeated non-continuing ranges that exceed the 4 MiB total-prefix limit or the range-count + // limit are refused rather than writing an unreadable tree or silently dropping labels. + let mut one = flat_doc(1); + crate::number_pages(&mut one, 0, 0, Decimal, &"x".repeat(1_000), 1).unwrap(); + let err = extract_pages(&one, &vec![0; 4_195]).unwrap_err().to_string(); + assert!(err.contains("decoded prefixes exceed 4 MiB in total"), "{err}"); + + crate::number_pages(&mut one, 0, 0, LowerRoman, "", 2).unwrap(); + let err = extract_pages(&one, &vec![0; pdfcraft_cos::page_labels::MAX_LABEL_TREE_WORK]).unwrap_err().to_string(); + assert!(err.contains("the number tree has too many entries"), "{err}"); +} + +/// A range the reader accepts but the writer cannot store (a Roman numeral past 1,024 bytes, or a +/// decimal span whose labels grow past that) degrades to the source physical numbers. Extract +/// still succeeds, and a neighbouring range that does fit is kept. +#[test] +fn extracting_unrepresentable_labels_uses_physical_numbers() { + let mut roman = doc_a(); + let mut spec = Dict::new(); + spec.set(b"S".to_vec(), Object::name("R")); + spec.set(b"St".to_vec(), Object::Int(4_000_000)); + let mut tree = Dict::new(); + tree.set(b"Nums".to_vec(), Object::Array(vec![Object::Int(0), Object::Dict(spec)])); + let tree = roman.add(Object::Dict(tree)); + roman.update_dict(roman.root().unwrap(), |c| c.set(b"PageLabels".to_vec(), Object::Ref(tree))).unwrap(); + let extracted = full_roundtrip(&extract_pages(&roman, &[0, 2]).unwrap()); + assert_eq!(crate::page_labels(&extracted).unwrap(), ["1", "3"]); + + let mut wide = flat_doc(5); + let mut spec = Dict::new(); + spec.set(b"P".to_vec(), Object::String(PdfString::text(&"p".repeat(1_020)))); + spec.set(b"S".to_vec(), Object::name("D")); + spec.set(b"St".to_vec(), Object::Int(9_996)); + let mut tree = Dict::new(); + tree.set(b"Nums".to_vec(), Object::Array(vec![Object::Int(0), Object::Dict(spec)])); + let tree = wide.add(Object::Dict(tree)); + wide.update_dict(wide.root().unwrap(), |c| c.set(b"PageLabels".to_vec(), Object::Ref(tree))).unwrap(); + let extracted = full_roundtrip(&extract_pages(&wide, &[0, 1, 2, 3, 4]).unwrap()); + assert_eq!(crate::page_labels(&extracted).unwrap(), ["1", "2", "3", "4", "5"]); + + let mut mixed = doc_a(); + let mut ok = Dict::new(); + ok.set(b"P".to_vec(), Object::String(PdfString::text("ok-"))); + ok.set(b"S".to_vec(), Object::name("D")); + let mut huge = Dict::new(); + huge.set(b"S".to_vec(), Object::name("R")); + huge.set(b"St".to_vec(), Object::Int(4_000_000)); + let mut tree = Dict::new(); + tree.set(b"Nums".to_vec(), Object::Array(vec![Object::Int(0), Object::Dict(ok), Object::Int(1), Object::Dict(huge)])); + let tree = mixed.add(Object::Dict(tree)); + mixed.update_dict(mixed.root().unwrap(), |c| c.set(b"PageLabels".to_vec(), Object::Ref(tree))).unwrap(); + let extracted = full_roundtrip(&extract_pages(&mixed, &[0, 1]).unwrap()); + assert_eq!(crate::page_labels(&extracted).unwrap(), ["ok-1", "2"]); +} + #[test] fn page_label_limits_reject_unsupported_edits_before_mutating() { use crate::LabelStyle::*; From a2ad6b77f8fd5e0dcb1d53884df33a876768bbf7 Mon Sep 17 00:00:00 2001 From: mtlcl410 <2368091+mtlcl410@users.noreply.github.com> Date: Mon, 9 Nov 2026 18:22:25 +0800 Subject: [PATCH 23/64] M7: replacing a line keeps the source font for the words it can show (#2378) (#2955) --- crates/edit/src/tests.rs | 169 +++++++++++++++++++++++++++++++ crates/edit/src/text.rs | 8 +- crates/edit/src/text/fallback.rs | 43 +++++++- crates/edit/src/text/interp.rs | 6 ++ crates/edit/src/text/lines.rs | 10 +- crates/edit/src/text/rewrite.rs | 107 ++++++++++++++++++- parity/acrobat-features.toml | 4 +- 7 files changed, 334 insertions(+), 13 deletions(-) diff --git a/crates/edit/src/tests.rs b/crates/edit/src/tests.rs index 0cb7366716..2640eadd5a 100644 --- a/crates/edit/src/tests.rs +++ b/crates/edit/src/tests.rs @@ -3213,6 +3213,175 @@ fn win_ansi_high_glyphs_use_helvetica_not_the_fallback_font() { assert_eq!(lines.iter().map(|l| l.text.as_str()).collect::>().join(" "), text); } +/// #2378: a word the line's subset can't show goes to Helvetica, and only that word. The words +/// it can show keep the source font (here `F2`, a subset of `a` and `b` without a space glyph), +/// one font per word, and the line reads back whole and in place: one line, in its own font, so +/// editing it again replaces all of it (review on #2955), also when it starts with a substitute +/// word. +#[test] +fn only_the_words_a_subset_cannot_show_are_substituted() { + let mut doc = text_page("BT /F2 10 Tf 72 600 Td (ab) Tj ET"); + let r = text::replace_line(&mut doc, 0, 0, "ab ba cab ba").unwrap(); + assert_eq!(r.substituted.as_deref(), Some("Helvetica")); + let mut doc = reopen(&doc); + let content = String::from_utf8_lossy(&page_content_bytes(&doc, 0)).into_owned(); + assert!(content.contains("/F2") && content.contains("/PCEdHelvetica"), "both fonts are used: {content}"); + let lines = text::text_lines(&doc, 0).unwrap(); + let read: Vec<(&str, &str)> = lines.iter().map(|l| (l.text.trim(), l.font.as_str())).collect(); + assert_eq!(read, [("ab ba cab ba", "F2")]); + // Each word where its font puts it, the substituted `cab` included: no gap or overlap opens + // where the font changes (review on #2955). + let starts: Vec = lines[0].ink.iter().map(|b| b[0]).collect(); + assert_eq!(starts.len(), 4, "{:?}", lines[0].ink); + for (got, want) in starts.iter().zip([72.0, 85.5, 98.48, 117.9]) { + assert!((got - want).abs() < 0.01, "word starts {starts:?}, want {want} among them"); + } + assert!(lines[0].ink.windows(2).all(|w| w[0][2] < w[1][0]), "no word box overlaps the next: {:?}", lines[0].ink); + for (text, first) in [("cab ab", "PCEdHelvetica"), ("ba cab", "F2")] { + text::replace_line(&mut doc, 0, 0, text).unwrap(); + doc = reopen(&doc); + let lines = text::text_lines(&doc, 0).unwrap(); + let read: Vec<(&str, &str)> = lines.iter().map(|l| (l.text.trim(), l.font.as_str())).collect(); + assert_eq!(read, [(text, "F2")], "after replacing with {text:?} (starting with {first})"); + } +} + +/// Review on #2955: replacing a line written in two fonts again replaces its `/PCEdLine` +/// sequence too. Kept, each edit nested one more, and its `EMC`s outnumbered what the reader +/// counts once 256 deep, closing the sequence around the line (here a `/Span`). The content +/// doesn't grow from one edit to the next. +#[test] +fn replacing_a_mixed_font_line_again_replaces_its_sequence() { + let mut doc = text_page("/Span BMC BT /F2 10 Tf 72 600 Td (ab) Tj ET EMC BT /F2 10 Tf 72 500 Td (ba) Tj ET"); + let count = |content: &str, op: &str| content.split_whitespace().filter(|w| *w == op).count(); + let mut sizes = Vec::new(); + for text in ["ab ba cab ba", "cab ab", "ab cab", "ab cab", "ab ba", "ba ab"] { + text::replace_line(&mut doc, 0, 0, text).unwrap(); + doc = reopen(&doc); + let content = String::from_utf8_lossy(&page_content_bytes(&doc, 0)).into_owned(); + let mixed = text.contains('c'); + assert_eq!(content.matches("/PCEdLine").count(), usize::from(mixed), "after {text:?}: {content}"); + assert_eq!((count(&content, "BMC"), count(&content, "EMC")), (1 + usize::from(mixed), 1 + usize::from(mixed)), "after {text:?}: {content}"); + assert_eq!(count(&content, "Tf"), 2 + if mixed { 4 } else { 1 }, "after {text:?}: {content}"); + let lines = text::text_lines(&doc, 0).unwrap(); + let read: Vec<&str> = lines.iter().map(|l| l.text.trim()).collect(); + assert_eq!(read, [text, "ba"], "after {text:?}"); + assert_eq!(lines[0].baseline(), [72.0, 600.0], "after {text:?}: in place"); + sizes.push(content.split_whitespace().count()); + } + assert_eq!(sizes[2], sizes[3], "the same text again, the same content"); + // A sequence holding marked content of its own goes whole, and what follows it keeps its + // font and size unless the replacement sets the same again. + for (after, font, size) in [("/F1 12 Tf", "F1", 12.0), ("/F2 12 Tf", "F2", 12.0), ("/F1 10 Tf", "F1", 10.0)] { + let mut doc = text_page(&format!( + "BT /F2 10 Tf 72 600 Td /PCEdLine BMC /F2 10 Tf /Span <<>> BDC (ab) Tj EMC /F1 10 Tf ( cab) Tj EMC {after} 0 -20 Td (ba) Tj ET" + )); + text::replace_line(&mut doc, 0, 0, "ab ab").unwrap(); + let doc = reopen(&doc); + let content = String::from_utf8_lossy(&page_content_bytes(&doc, 0)).into_owned(); + assert_eq!((count(&content, "BMC"), count(&content, "BDC"), count(&content, "EMC")), (0, 0, 0), "{after}: {content}"); + let lines = text::text_lines(&doc, 0).unwrap(); + let read: Vec<(&str, &str, f64)> = lines.iter().map(|l| (l.text.trim(), l.font.as_str(), l.size)).collect(); + assert_eq!(read, [("ab ab", "F2", 10.0), ("ba", font, size)], "{after}: {content}"); + } +} + +/// Review on #2955: a leading space is not a word the line's font shows. " cab" (no word the +/// subset can show) was split into an empty source-font run and a substitute run, so it was +/// written as a two-font `/PCEdLine` with an empty `Tj` in the line's font; read back, the line +/// was the substitute's alone, and the next edit took Helvetica for the line's own font. It is +/// substituted as a whole instead, as a line with no word in the source font always was. +#[test] +fn a_leading_space_does_not_make_a_source_font_run() { + let mut doc = text_page("BT /F2 10 Tf 72 600 Td (ab) Tj ET"); + text::replace_line(&mut doc, 0, 0, " cab").unwrap(); + let doc = reopen(&doc); + let content = String::from_utf8_lossy(&page_content_bytes(&doc, 0)).into_owned(); + assert!(!content.contains("/PCEdLine") && !content.contains("() Tj"), "{content}"); + // With a word in the source font after it, the line is still written in both. + let mut doc = text_page("BT /F2 10 Tf 72 600 Td (ab) Tj ET"); + text::replace_line(&mut doc, 0, 0, " ab cab").unwrap(); + let doc = reopen(&doc); + let content = String::from_utf8_lossy(&page_content_bytes(&doc, 0)).into_owned(); + assert!(content.contains("/PCEdLine") && !content.contains("() Tj"), "{content}"); + let lines = text::text_lines(&doc, 0).unwrap(); + assert_eq!(lines.iter().map(|l| (l.text.trim(), l.font.as_str())).collect::>(), [("ab cab", "F2")]); + // The spaces the subset has no glyph for are moves, each one kept: the leading one before + // the first word, two between words as two. + let flat = |doc: &Document| String::from_utf8_lossy(&page_content_bytes(doc, 0)).split_whitespace().collect::>().join(" "); + assert!(flat(&doc).contains("/F2 10 Tf [-330] TJ (ab) Tj"), "{}", flat(&doc)); + let mut doc = text_page("BT /F2 10 Tf 72 600 Td (ab) Tj ET"); + text::replace_line(&mut doc, 0, 0, "ab ba cab").unwrap(); + let doc = reopen(&doc); + assert!(flat(&doc).contains("(ab) Tj [-330] TJ [-330] TJ (ba) Tj"), "{}", flat(&doc)); + // Before a substitute word, the leading spaces are the substitute's to show, all of them. + let mut doc = text_page("BT /F2 10 Tf 72 600 Td (ab) Tj ET"); + text::replace_line(&mut doc, 0, 0, " cab ab").unwrap(); + let doc = reopen(&doc); + let content = String::from_utf8_lossy(&page_content_bytes(&doc, 0)).into_owned(); + assert!(content.contains("( cab) Tj"), "{content}"); +} + +/// Review on #2955: a paragraph rewrite keeps the `/PCEdLine` delimiters of a line an earlier +/// replacement wrote in two fonts, so a rewritten paragraph's rows can end up inside one. A +/// later replacement of one of those rows must not take the sequence's other rows with it. +#[test] +fn replacing_one_row_of_a_sequence_keeps_the_other_rows() { + let content = "BT /F2 10 Tf 72 600 Td /PCEdLine BMC /F2 10 Tf (ab) Tj /F1 10 Tf ( cab) Tj 0 -20 Td /F2 10 Tf (ba) Tj EMC ET"; + let rows = text::text_lines(&text_page(content), 0).unwrap(); + assert_eq!(rows.iter().map(|l| l.text.trim()).collect::>(), ["ab cab", "ba"]); + for (row, text, other) in [(0, "ab ab", "ba"), (1, "ab", "ab cab")] { + let mut doc = text_page(content); + text::replace_line(&mut doc, 0, row, text).unwrap(); + let doc = reopen(&doc); + let after: Vec = text::text_lines(&doc, 0).unwrap().iter().map(|l| l.text.trim().to_string()).collect(); + assert!(after.iter().any(|t| t == text) && after.iter().any(|t| t == other), "replacing row {row}: {after:?}"); + } +} + +/// Review on #2955: a row inside a shared `/PCEdLine` sequence that starts with a substitute run +/// has the substitute's font in effect at its first operator. Replaced by text the row's own font +/// shows, it must set that font again, or the new text is drawn in the substitute. +#[test] +fn a_row_replaced_inside_a_shared_sequence_starts_in_its_own_font() { + // A real two-font line, starting with the substitute run, then a second row put inside its + // sequence, as a paragraph rewrite that keeps the delimiters can leave it. + let mut doc = text_page("BT /F2 10 Tf 72 600 Td (ab) Tj ET"); + text::replace_line(&mut doc, 0, 0, " cab ab").unwrap(); + let doc = reopen(&doc); + let content = String::from_utf8_lossy(&page_content_bytes(&doc, 0)).into_owned(); + assert!(content.contains("/PCEdLine") && content.contains("( cab) Tj"), "{content}"); + let mut doc = doc; + let shared = content.replacen("EMC", "0 -20 Td (ba) Tj EMC", 1); + let stream = Object::Ref(doc.add(Object::Stream(Stream::from_raw(Dict::new(), shared.into_bytes())))); + set_page_contents(&mut doc, stream, None); + let rows = text::text_lines(&doc, 0).unwrap(); + assert_eq!(rows.iter().map(|l| (l.text.trim(), l.font.as_str())).collect::>(), [("cab ab", "F2"), ("ba", "F2")]); + text::replace_line(&mut doc, 0, 0, "ab").unwrap(); + let doc = reopen(&doc); + let after = text::text_lines(&doc, 0).unwrap(); + assert_eq!(after.iter().map(|l| (l.text.trim(), l.font.as_str())).collect::>(), [("ab", "F2"), ("ba", "F2")]); +} + +/// Review on #2955: a sequence holding one row can also hold that row's positioning and text +/// state, as a paragraph rewrite writes it inside kept delimiters. Replacing the row drops the +/// delimiters, fonts and text, not the `Tm` that places it. +#[test] +fn replacing_the_only_row_of_a_sequence_keeps_its_position() { + let content = "BT /F2 10 Tf /PCEdLine BMC 1 0 0 1 72 600 Tm /F2 10 Tf (ab) Tj /F1 10 Tf ( cab) Tj EMC ET"; + let mut doc = text_page(content); + let before = text::text_lines(&doc, 0).unwrap(); + assert_eq!(before.iter().map(|l| l.text.trim()).collect::>(), ["ab cab"]); + text::replace_line(&mut doc, 0, 0, "ab").unwrap(); + let doc = reopen(&doc); + let content = String::from_utf8_lossy(&page_content_bytes(&doc, 0)).into_owned(); + assert!(!content.contains("PCEdLine") && !content.contains("cab"), "{content}"); + let after = text::text_lines(&doc, 0).unwrap(); + assert_eq!(after.len(), 1, "{content}"); + let (was, now) = (before[0].rect, after[0].rect); + assert!((was[0] - now[0]).abs() < 0.01 && (was[1] - now[1]).abs() < 0.01, "moved from {was:?} to {now:?}: {content}"); +} + #[test] fn missing_glyphs_substitute_helvetica_and_impossible_text_is_refused() { let mut doc = text_page("BT /F2 10 Tf 72 600 Td (ab) Tj ET"); diff --git a/crates/edit/src/text.rs b/crates/edit/src/text.rs index 2dbf302685..c08edb1e86 100644 --- a/crates/edit/src/text.rs +++ b/crates/edit/src/text.rs @@ -6,8 +6,9 @@ //! dropped, and every positioning operator stays, so the lines after it keep their places //! (`Td`, `T*` and friends move from the line matrix, which showing text doesn't change). //! -//! The new text uses the line's own font when every character has a code and a glyph in it; -//! otherwise it is set in Helvetica (WinAnsi), and the result says the font was substituted. +//! The new text uses the line's own font when every character has a code and a glyph in it. +//! Otherwise the words the font can show keep it and only the others are set in Helvetica +//! (WinAnsi), run by run, and the result says the font was substituted. //! Reflowing a paragraph to a new width is not done here (see `edit.text-reflow`). use std::collections::HashMap; @@ -132,6 +133,9 @@ pub struct TextLine { /// Chinese/Japanese word split), from `/PCSoft`'s `/N`; 1 when the marker carried none /// (#2529). Meaningless otherwise. soft_gap: usize, + /// The `/PCEdLine` marked-content sequence a replacement mixing fonts wrote the line in + /// (see [`fallback::continues_font`]). + edited: Option, } impl TextLine { diff --git a/crates/edit/src/text/fallback.rs b/crates/edit/src/text/fallback.rs index f7c795ca65..91e5258b31 100644 --- a/crates/edit/src/text/fallback.rs +++ b/crates/edit/src/text/fallback.rs @@ -1,4 +1,4 @@ -use super::interp::Ts; +use super::interp::{Shown, Ts}; use super::lines::page_dict; use super::{ CraftFont, Dict, Document, EditError, GlyphError, HashMap, Metrics, Object, Op, PdfString, Stream, TextLine, japanese_glyph_from, parse, @@ -148,6 +148,23 @@ fn fonts_shown(doc: &Document, page: &Dict, resources: &Dict) -> Option bool { + same_font(line, &s.font, &s.base_font, s.face.as_deref()) || (s.edited.is_some() && line.edited == s.edited) +} + +/// A line that began with a substitute run takes the source font of a later run, so the next +/// replacement starts from the line's own font again. +pub(super) fn adopt_source_font(line: &mut TextLine, s: &Shown) { + if line.edited.is_some() && line.font.starts_with("PCEd") && !s.font.starts_with(b"PCEd") { + line.font = String::from_utf8_lossy(&s.font).into_owned(); + line.base_font = s.base_font.clone(); + line.face = s.face.clone(); + } +} + /// Whether a run shown in `font` (a resource name), with `/BaseFont` `base_font` and the Japanese /// or Hebrew fallback face it subsets, if any, continues `line`: the same font resource, another /// resource for the same font (a writer may split one font into several subsets, #3661), or @@ -730,6 +747,30 @@ pub(crate) fn source_show(metrics: &Metrics, text: &str, size: f64, space: f64, Ok(out) } +/// `text` split into runs of words `metrics` can show (`true`) and words it can't (`false`), in +/// order. A word is shown in one font, so a word with any character the font lacks goes to the +/// substitute whole rather than switching fonts letter by letter. Spaces join the run they are +/// in, since [`source_show`] draws spaces the font lacks with a displacement. +pub(super) fn source_runs(metrics: &Metrics, text: &str) -> Vec<(bool, String)> { + let mut runs: Vec<(bool, String)> = Vec::new(); + // Spaces before the first word go with it: on their own they would make a run of the line's + // font with nothing to show (review on #2955). + let mut lead = String::new(); + for (i, word) in text.split(' ').enumerate() { + let piece = if i == 0 { word.to_string() } else { format!(" {word}") }; + if runs.is_empty() && word.is_empty() { + lead.push_str(&piece); + continue; + } + let own = word.is_empty() || metrics.encode(word).is_some(); + match runs.last_mut() { + Some((last, run)) if *last == own || word.is_empty() => run.push_str(&piece), + _ => runs.push((own, std::mem::take(&mut lead) + &piece)), + } + } + runs +} + #[cfg(test)] mod tests { use super::*; diff --git a/crates/edit/src/text/interp.rs b/crates/edit/src/text/interp.rs index b6c2d59cd1..019edf982a 100644 --- a/crates/edit/src/text/interp.rs +++ b/crates/edit/src/text/interp.rs @@ -359,6 +359,7 @@ pub(super) struct Shown { /// `/PCSoft` with no `/N` (a file saved before this was tracked, or by another writer), /// matching the single space a rejoin always used before #2529. pub(super) soft_gap: usize, + pub(super) edited: Option, } impl Shown { @@ -537,6 +538,8 @@ type Invocation = (usize, Vec, Ts); struct MarkedContent { regions: Vec>, layers: Vec>>, + // The `/PCEdLine` sequences around the text (a replacement mixing fonts), innermost last. + edited: Vec>, actual_text: Vec>, } @@ -550,6 +553,7 @@ impl MarkedContent { let layer = op.is("BDC") && op.name(0) == Some(b"OC".as_slice()); self.layers .push(layer.then(|| op.operands.get(1).and_then(Object::as_name).map_or_else(|| format!("@{index}").into_bytes(), <[u8]>::to_vec))); + self.edited.push(if op.name(0) == Some(b"PCEdLine") { Some(index) } else { self.edited.last().copied().flatten() }); // The property list is inline or named in the resources' /Properties (§14.6.2). let list = logical_text.and_then(|properties| match op.operands.get(1) { Some(Object::Name(name)) => properties.get(name).and_then(|object| doc.dict(object)), @@ -565,6 +569,7 @@ impl MarkedContent { fn end(&mut self, index: usize, shown: &mut Vec) { self.regions.pop(); self.layers.pop(); + self.edited.pop(); if let Some(Some((start, open, logical))) = self.actual_text.pop() { let tail = shown.split_off(start.min(shown.len())); if tail.iter().any(|s| s.glyph_count > 0) { @@ -814,6 +819,7 @@ fn show_text(op: &Op, i: usize, state: &mut TextRunState<'_>) -> Result<(), Edit soft_break: state.soft_break, hard_break: state.hard_break, soft_gap: state.soft_gap, + edited: marked.edited.last().copied().flatten(), }); Ok(()) } diff --git a/crates/edit/src/text/lines.rs b/crates/edit/src/text/lines.rs index 2755d6bb71..54c249dbf1 100644 --- a/crates/edit/src/text/lines.rs +++ b/crates/edit/src/text/lines.rs @@ -1,6 +1,6 @@ use std::sync::Arc; -use super::fallback::{same_font, same_paint}; +use super::fallback::{adopt_source_font, continues_font, same_paint}; use super::interp::{Carry, Origin, Pen, Shown, TextState, Ts, direction, interpret, to_frame, union}; use super::{DecodeBudget, Dict, Document, EditError, HashMap, Matrix, Object, Op, Pieces, Stream, TextLine, parse, serialize_ops}; @@ -340,7 +340,7 @@ fn text_lines_in(doc: &Document, p: &pdfcraft_model::Page, actual_text: bool, in piece == si && lines.last().is_some_and(|l| l.scope == s.scope && l.paint == s.paint && l.logical_order == s.logical_order) && (bt == s.bt || lines.last().is_some_and(|l| compatible_word(l, &s))) - && (s.text.trim().is_empty() || lines.last().is_some_and(|l| same_font(l, &s.font, &s.base_font, s.face.as_deref()))) + && (s.text.trim().is_empty() || lines.last().is_some_and(|l| continues_font(l, &s))) && (s.text.trim().is_empty() || lines.last().is_some_and(|l| { (s.size - l.size).abs() < 0.01 @@ -374,6 +374,7 @@ fn text_lines_in(doc: &Document, p: &pdfcraft_model::Page, actual_text: bool, in if l.unreadable_mode.is_none() { l.unreadable_mode = s.state.unreadable.as_deref().map(str::to_owned); } + adopt_source_font(l, &s); // Spaces may use a separate subset font; they do not change the visible style. } else { lines.push(TextLine { @@ -401,6 +402,7 @@ fn text_lines_in(doc: &Document, p: &pdfcraft_model::Page, actual_text: bool, in soft_break: s.soft_break, hard_break: s.hard_break, soft_gap: s.soft_gap, + edited: s.edited, ops: std::iter::once(s.op).chain(s.extra_ops.iter().copied()).collect(), origin: Origin { tm: s.tm.0, @@ -461,7 +463,7 @@ fn add_shown(lines: &mut Vec, last: &mut Option, s: Shown, stream let bt = pen.bt; lines.last().is_some_and(|l| l.scope == s.scope && l.paint == s.paint && l.logical_order == s.logical_order) && (bt == s.bt || lines.last().is_some_and(|l| compatible_word(l, &s))) - && (s.text.trim().is_empty() || lines.last().is_some_and(|l| same_font(l, &s.font, &s.base_font, s.face.as_deref()))) + && (s.text.trim().is_empty() || lines.last().is_some_and(|l| continues_font(l, &s))) && (s.text.trim().is_empty() || lines.last().is_some_and(|l| { (s.size - l.size).abs() < 0.01 @@ -492,6 +494,7 @@ fn add_shown(lines: &mut Vec, last: &mut Option, s: Shown, stream if l.unreadable_mode.is_none() { l.unreadable_mode = s.state.unreadable.as_deref().map(str::to_owned); } + adopt_source_font(l, &s); } else { lines.push(TextLine { text: s.text.clone(), @@ -518,6 +521,7 @@ fn add_shown(lines: &mut Vec, last: &mut Option, s: Shown, stream soft_break: s.soft_break, hard_break: s.hard_break, soft_gap: s.soft_gap, + edited: s.edited, ops: std::iter::once(s.op).chain(s.extra_ops.iter().copied()).collect(), origin: Origin { tm: s.tm.0, diff --git a/crates/edit/src/text/rewrite.rs b/crates/edit/src/text/rewrite.rs index c146ffcbc0..1bf27d2b9e 100644 --- a/crates/edit/src/text/rewrite.rs +++ b/crates/edit/src/text/rewrite.rs @@ -1,7 +1,7 @@ use super::blocks::{BlockStyle, TextBlock, paragraph_separator, row_break, wrap}; use super::fallback::{ Encoder, MAX_TYPE3_GLYPHS, Type3Fallback, Type3Run, drop_unused_fallback_fonts, is_variation_selector, needs_type3, no_japanese_font, - source_encodes, source_family, source_program_completes, source_show, source_space, source_width, type3_encode, type3_font, + source_encodes, source_family, source_program_completes, source_runs, source_show, source_space, source_width, type3_encode, type3_font, }; use super::interp::{Carry, Origin, TextState, interpret}; use super::lines::{content_streams, joined, page_dict, rewritten_contents, splice, text_lines}; @@ -110,10 +110,24 @@ pub(crate) fn replace_line_with_limit(doc: &mut Document, page: usize, line: usi replacement.push(Op::new("Tf", vec![Object::name(&target.font), pdfcraft_content::num(size)])); substituted = Some(format!("{} Type3", fallback.family)); } else { + // The words the line's font can show keep it; only the others are set in the + // substitute (#2378): a subset without `V` or `ü` no longer turns the whole line + // into Helvetica. Reordered text (right to left, Indic) keeps one run as before. + let mixed = font + .as_ref() + .filter(|_| !visual_indic && fallback_shown == text) + .map(|m| source_runs(m, &text)) + .filter(|runs| runs.iter().any(|(own, _)| *own)); + // What the substitute has to show: only the runs the line's font can't. + let for_substitute: String = match &mixed { + Some(runs) => runs.iter().filter(|(own, _)| !*own).map(|(_, run)| run.as_str()).collect(), + None => fallback_shown.clone(), + }; let win = pdfcraft_fonts::win_ansi(&fallback_shown); // WinAnsi turns what it can't show into '?'; refuse rather than print the wrong thing. - let back: String = win.iter().map(|b| char::from_u32(u32::from(*b)).unwrap_or('?')).collect(); - if fallback_shown.chars().zip(back.chars()).any(|(a, b)| b == '?' && a != '?') { + let checked = pdfcraft_fonts::win_ansi(&for_substitute); + let back: String = checked.iter().map(|b| char::from_u32(u32::from(*b)).unwrap_or('?')).collect(); + if for_substitute.chars().zip(back.chars()).any(|(a, b)| b == '?' && a != '?') { return Err(EditError::Invalid(format!("\"{text}\" has characters neither {} nor Helvetica can show", target.base_font))); } // The size in text space: the current Tf's size. @@ -121,8 +135,36 @@ pub(crate) fn replace_line_with_limit(doc: &mut Document, page: usize, line: usi let family = source_family(&target.base_font); let base = family.base_font(target.bold, target.italic); let substitute_name = format!("PCEd{}", base.replace('-', "")); - replacement.push(Op::new("Tf", vec![Object::name(&substitute_name), pdfcraft_content::num(size)])); - replacement.push(Op::new("Tj", vec![Object::String(PdfString::literal(win))])); + match mixed { + Some(runs) => { + let space = font.as_ref().map_or(0.33, |m| source_space(doc, &fonts_res, m)); + // Marked as one line, so it reads back as one (see `continues_font`). + replacement.push(Op::new("BMC", vec![Object::name("PCEdLine")])); + for (own, run) in runs { + if own { + replacement.push(Op::new("Tf", vec![Object::name(&target.font), pdfcraft_content::num(size)])); + if let Some(m) = font.as_ref() { + replacement.extend(source_show( + m, + &run, + target.origin.state.font.as_ref().map_or(target.size, |(_, size)| *size), + space, + target.origin.state.char_spacing, + target.origin.state.word_spacing, + )?); + } + } else { + replacement.push(Op::new("Tf", vec![Object::name(&substitute_name), pdfcraft_content::num(size)])); + replacement.push(Op::new("Tj", vec![Object::String(PdfString::literal(pdfcraft_fonts::win_ansi(&run)))])); + } + } + replacement.push(Op::new("EMC", Vec::new())); + } + None => { + replacement.push(Op::new("Tf", vec![Object::name(&substitute_name), pdfcraft_content::num(size)])); + replacement.push(Op::new("Tj", vec![Object::String(PdfString::literal(win))])); + } + } replacement.push(Op::new("Tf", vec![Object::name(&target.font), pdfcraft_content::num(size)])); substituted = Some(base.to_string()); let mut f = Dict::new(); @@ -135,6 +177,43 @@ 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. + // A line an earlier replacement wrote in two fonts is in a `/PCEdLine` sequence of its own, + // with its fonts and the moves standing in for spaces the source font lacks: those go + // with the line, or each edit would nest one more (review on #2955). The line then starts in + // its own font, set again where the sequence was. + if let Some((open, close)) = target.edited.and_then(|open| Some((open, matching_emc(&ops, open)?))) { + // Only when the sequence holds nothing but this line: a paragraph rewrite keeps the + // delimiters, so other rows can be inside it (review on #2955). Then the sequence stays + // as it is, since it is also what reads its own two-font line back as one, and only + // this line's operators go. + let others_inside = lines.iter().any(|l| l.ops.iter().any(|o| (open..=close).contains(o) && !drops.contains(o))); + // Either way the replacement starts in the line's own font: inside the sequence the font + // in effect at the line's first operator can be the substitute's, which a replacement + // the line's font shows by itself would otherwise inherit (review on #2955). + let size = font_size_before(&ops, first).unwrap_or(target.size); + replacement.insert(0, Op::new("Tf", vec![Object::name(&target.font), pdfcraft_content::num(size)])); + if !others_inside { + // What goes is what the replacement stands in for: the marked-content delimiters + // (nested ones too, which come in pairs), the fonts and the text-showing operators + // (the line's runs and the moves between them). A paragraph rewrite can also write a + // row's positioning and text state (`Tm`, `Td`, `Tw`, ...), a `q`/`Q` or a `BT`/`ET` + // inside kept delimiters; those stay, or the replacement loses its place and the + // content its balance (review on #2955). + drops.extend( + (open..=close) + .filter(|&i| ops.get(i).is_some_and(|o| [b"BMC".as_slice(), b"BDC", b"EMC", b"Tf", b"Tj", b"TJ"].contains(&o.op.as_slice()))), + ); + let own_font = |i: usize| ops.get(i).is_some_and(|o| o.is("Tf") && o.name(0) == Some(target.font.as_bytes()) && o.num(1) == Some(size)); + // The same font set just before the sequence (by the last edit) is set again here. + if let Some(i) = open.checked_sub(1).filter(|&i| own_font(i)) { + drops.insert(i); + } + // And set back after it: the replacement ends in that font too. + if let Some(i) = close.checked_add(1).filter(|&i| own_font(i)) { + drops.insert(i); + } + } + } 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; @@ -150,6 +229,24 @@ pub(crate) fn replace_line_with_limit(doc: &mut Document, page: usize, line: usi Ok(LineEdit { substituted }) } +/// The `EMC` that closes the marked-content sequence `BMC`/`BDC` operator `open` begins. +fn matching_emc(ops: &[Op], open: usize) -> Option { + let mut depth = 0usize; + for (i, op) in ops.iter().enumerate().skip(open) { + match op.op.as_slice() { + b"BMC" | b"BDC" => depth = depth.saturating_add(1), + b"EMC" => { + depth = depth.checked_sub(1)?; + if depth == 0 { + return Some(i); + } + } + _ => {} + } + } + None +} + /// The size of the last `Tf` before operator `at`. fn font_size_before(ops: &[Op], at: usize) -> Option { ops[..at].iter().rev().find(|o| o.is("Tf")).and_then(|o| o.num(1)) diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index 39d00cacbc..084b06dbae 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -1907,8 +1907,8 @@ milestone = "M7" status = "partial" commands = ["edit.edit_text"] tools = ["text_prepare", "text_lines", "text_paragraphs", "text_edit", "text_edit_layout", "text_font_files", "text_font_import", "text_fonts"] -evidence = ["crates/edit/src/tests.rs::editing_legacy_thai_keeps_the_embedded_face_weight_size_spacing_and_paint_after_reopen", "crates/edit/src/tests.rs::editing_legacy_thai_shapes_a_run_across_subsets_of_the_same_face", "crates/edit/src/tests.rs::grouped_text_nested_past_the_level_limit_is_reported", "crates/edit/src/tests.rs::grouped_image_artwork_is_not_prepared", "crates/edit/src/tests.rs::grouped_text_in_an_rgb_group_needs_a_page_that_blends_in_rgb", "crates/fonts/src/pdf/thai.rs::recovered_thai_map_checks_every_kept_glyph_against_the_font_program", "crates/automation/tests/it/automation.rs::text_prepare_refuses_pages_without_grouped_text", "crates/fonts/src/pdf/tests.rs::composite_subset_without_cmap_uses_pdf_glyph_ids_in_editor_and_shaper_without_replacing_the_program", "crates/automation/tests/it/automation.rs::grouped_existing_thai_body_edits_saves_reopens_and_undo_restores_original_glyphs", "crates/edit/src/tests.rs::grouped_text_preparation_keeps_unsafe_compositing_paths_and_malformed_forms_intact", "crates/automation/tests/it/automation.rs::explicit_bundled_thai_font_choice_layout_edit_undo_and_reopen", "crates/edit/src/tests.rs::existing_thai_subset_uses_shaped_matching_face_without_embedding_variable_font", "crates/edit/src/tests.rs::paragraphs_are_found_and_rewrapped", "crates/edit/src/tests.rs::text_lines_are_found_and_replaced_in_place", "crates/ui-egui/tests/it/editing.rs::editing_existing_text_in_place", "crates/automation/tests/it/automation.rs::editing_existing_text_through_tools", "crates/edit/src/tests.rs::a_line_whose_operator_starts_the_next_stream_can_be_edited", "crates/automation/tests/it/automation.rs::a_line_split_across_content_streams_is_listed_and_edited", "crates/ui-egui/tests/it/editing.rs::selected_text_formatting_keeps_the_rest_of_the_paragraph_and_reopens", "crates/edit/src/tests.rs::marked_paragraph_boundaries_and_cross_stream_transforms_survive_rewrite", "crates/automation/tests/it/automation.rs::explicit_font_and_disjoint_ranges_save_reopen_through_tools", "crates/edit/src/tests.rs::disjoint_style_ranges_preserve_unselected_runs_and_validate_utf8_atomically", "crates/engine/tests/it/document_fonts.rs::opentype_cff_document_font_preserves_program_codes_and_rendering_after_reopen", "crates/ui-egui/tests/it/editing.rs::cropped_images_do_not_intercept_text_and_stay_inside_each_page", "crates/automation/tests/it/automation.rs::clipped_image_bounds_and_text_edit_save_reopen_through_tools", "crates/edit/src/tests.rs::mixed_styles_and_matrix_sizes_form_visual_regions_without_merging_columns", "crates/edit/src/tests.rs::rich_region_edits_preserve_styles_positions_and_delete_all_runs_after_reopen", "crates/ui-egui/tests/it/editing.rs::one_editor_contains_mixed_styles_and_visual_rows", "crates/ui-egui/tests/it/editing.rs::inline_editor_preserves_source_font_and_previews_live_bounds", "crates/fonts/src/pdf/tests.rs::mac_subset_cmap_uses_pdf_unicode_without_changing_source_program", "crates/automation/tests/it/automation.rs::mixed_visual_region_edit_preview_save_reopen_and_undo_through_tools", "crates/ui-egui/tests/it/ui.rs::the_edit_tab_turns_on_edit_text_and_images", "crates/ui-egui/tests/it/editing.rs::the_caret_on_a_small_row_is_as_tall_as_that_row", "crates/edit/src/tests.rs::uzbek_and_kazakh_cyrillic_use_the_bundled_latin_face", "crates/automation/tests/it/automation.rs::editing_uzbek_and_kazakh_cyrillic_through_tools", "crates/edit/src/tests.rs::styled_paragraph_batches_cyrillic_without_changing_source_fonts", "crates/edit/src/tests.rs::styled_paragraph_over_240_fallback_glyphs_splits_and_refuses_atomically", "crates/edit/src/tests.rs::styled_fallback_batches_keep_face_choices_and_range_styles", "crates/edit/src/tests.rs::styled_fallback_shards_keep_text_advances_and_alignment", "crates/edit/src/tests.rs::styled_fallback_budget_is_enforced_before_objects_are_written", "crates/edit/src/tests.rs::styled_fallback_same_base_font_and_character_keep_both_weights", "crates/automation/tests/it/automation.rs::styled_cyrillic_fallback_is_batched_through_tools_and_reopen", "crates/ui-egui/tests/it/editing.rs::escape_in_an_open_paragraph_selects_its_box_for_delete", "crates/edit/src/tests.rs::ungrouping_form_artwork_makes_its_text_editable", "crates/automation/tests/it/automation.rs::ungrouping_a_table_drawn_in_a_form_makes_its_text_editable", "crates/ui-egui/tests/it/editing.rs::ungroup_in_the_artwork_menu_makes_a_forms_text_editable", "crates/edit/src/tests.rs::ungroup_carries_the_placement_into_the_patterns_it_selects", "crates/automation/tests/it/automation.rs::ungrouping_keeps_patterns_where_the_forms_placement_put_them", "crates/edit/src/tests.rs::ungroup_refuses_a_form_with_its_own_default_colour_space", "crates/edit/src/tests.rs::ungroup_renames_the_name_operand_the_renderer_reads", "crates/ui-egui/tests/it/editing.rs::one_click_on_grouped_text_opens_it_for_editing", "crates/edit/src/tests.rs::grouped_text_at_says_which_artwork_preparation_would_open", "crates/edit/src/tests.rs::legacy_thai_map_repair_needs_no_grouped_text", "crates/edit/src/tests.rs::ungroup_refuses_a_form_drawn_inside_text_or_a_path", "crates/edit/src/tests.rs::ungroup_reads_what_a_group_draws_within_one_allowance", "crates/edit/src/tests.rs::unicode_row_breaks_break_the_row_instead_of_needing_a_glyph"] -notes = "In Edit text, one click on text in a supported grouped Form prepares its page (one undoable step) and opens the paragraph clicked, so its existing source glyphs are edited directly; nothing is prepared while pages are only shown, a drag still moves the selected group, and a click on grouped artwork that preparation wouldn't open (a logo, a refused group) selects it as before. text_prepare does the same headlessly, with the same code as Ungroup. Unsafe groups remain intact and are reported by text_prepare and in the repair log when the page is prepared, as is text grouped more than eight levels deep; image-only forms are left grouped. Narrow legacy THSarabunPSK character-map recovery, checked glyph by glyph against the subset's own advances, is recorded, also for text that was never grouped (text_prepare). Legacy Thai source editing reuses original font programs, positional glyphs and same-face subsets (a Thai run split across subsets is shaped cluster by cluster; what no subset can show in its codes is shaped whole by a complete program of the face, such as one from Load font), preserving Regular/Bold, point size, spacing and paint. A different Thai family requires explicit selection; the Noto replacement is Regular only; mixed contextual style runs remain limited. The Edit tab turns Edit text & images on at once, as in Acrobat (leaving the tab puts the tool down and applies an edit in progress); Esc in an open paragraph leaves its text and selects its box, so Delete removes it (the unapplied draft is discarded); click a paragraph box and retype it in place (font reused or Helvetica substituted, colour and spacing kept, rewrapped); a page's /Contents pieces are parsed joined, so a line whose operands and operator sit in different pieces is listed and edited (lines and paragraphs still don't join across pieces; Edit image still parses each piece alone); drag a box to move it or its right-edge handle to rewrap it (edit.text-move-resize); per-character formatting changes are next Since #1116: mixed-style visual regions retain source rows, subset encodings, colour and baseline anchors in one textless-background editor. Explicit paragraph/heading scopes constrain grouping; compatible graphics planes permit cross-stream regions. Full matching document fonts complete absent subset glyphs; installed/file selection, TrueType/OpenType-CFF embedding and disjoint UTF-8 range styles have save/reopen coverage. Single-face OpenType shaping/bidi retains logical text through ToUnicode/ActualText. Replacement text the paragraph's font can't show takes the craft-fonts Japanese faces when they draw all of it (Uzbek қ, Kazakh ә are in none of them), else the bundled Inter face, so those letters write in every build (#2593). Legacy non-SFNT preview, mixed-font/style contextual runs, full structure-tree preservation and author intent in untagged PDFs remain limited; Styled-path outline fallback batches by actual face with 240 glyphs per font and preserves source styles; adjacent equal-font/style characters form runs. At most 15360 distinct fallback glyphs per styled paragraph; missing glyphs are refused before fallback objects are written. The Unicode row breaks (U+2028, U+2029, U+0085, vertical tab, form feed and the information separators) break the row instead of being looked up as glyphs (#6272). Glyph coverage, mixed contextual shaping, single-style fallback and stale-font cleanup are unchanged; see docs/editing-existing-text.md. Text drawn inside a Form XObject (HTML-to-PDF output, stamped or imposed pages) is grouped artwork until Ungroup (artwork right-click menu; image_edit action ungroup) draws that placement in place with its resources merged; knockout groups and transparency groups that blend inside, are drawn translucent or blend in a colour space other than the page's are refused, as are forms with resources of their own whose /DefaultGray, /DefaultRGB or /DefaultCMYK differs from the page's, either side lacking it included (ISO 32000-2 §8.6.5.6), and forms drawn inside a text object or an unfinished path, each resource name is read from the operand the renderer uses (a surplus operand before it is ignored; an operator whose operands can't be read is refused), and a pattern the form fills with is drawn from a copy whose /Matrix carries the placement (ISO 32000-2 §8.7.2), so ungrouping never changes the pixels." +evidence = ["crates/edit/src/tests.rs::editing_legacy_thai_keeps_the_embedded_face_weight_size_spacing_and_paint_after_reopen", "crates/edit/src/tests.rs::editing_legacy_thai_shapes_a_run_across_subsets_of_the_same_face", "crates/edit/src/tests.rs::grouped_text_nested_past_the_level_limit_is_reported", "crates/edit/src/tests.rs::grouped_image_artwork_is_not_prepared", "crates/edit/src/tests.rs::grouped_text_in_an_rgb_group_needs_a_page_that_blends_in_rgb", "crates/fonts/src/pdf/thai.rs::recovered_thai_map_checks_every_kept_glyph_against_the_font_program", "crates/automation/tests/it/automation.rs::text_prepare_refuses_pages_without_grouped_text", "crates/fonts/src/pdf/tests.rs::composite_subset_without_cmap_uses_pdf_glyph_ids_in_editor_and_shaper_without_replacing_the_program", "crates/automation/tests/it/automation.rs::grouped_existing_thai_body_edits_saves_reopens_and_undo_restores_original_glyphs", "crates/edit/src/tests.rs::grouped_text_preparation_keeps_unsafe_compositing_paths_and_malformed_forms_intact", "crates/automation/tests/it/automation.rs::explicit_bundled_thai_font_choice_layout_edit_undo_and_reopen", "crates/edit/src/tests.rs::existing_thai_subset_uses_shaped_matching_face_without_embedding_variable_font", "crates/edit/src/tests.rs::paragraphs_are_found_and_rewrapped", "crates/edit/src/tests.rs::text_lines_are_found_and_replaced_in_place", "crates/ui-egui/tests/it/editing.rs::editing_existing_text_in_place", "crates/automation/tests/it/automation.rs::editing_existing_text_through_tools", "crates/edit/src/tests.rs::a_line_whose_operator_starts_the_next_stream_can_be_edited", "crates/automation/tests/it/automation.rs::a_line_split_across_content_streams_is_listed_and_edited", "crates/ui-egui/tests/it/editing.rs::selected_text_formatting_keeps_the_rest_of_the_paragraph_and_reopens", "crates/edit/src/tests.rs::marked_paragraph_boundaries_and_cross_stream_transforms_survive_rewrite", "crates/automation/tests/it/automation.rs::explicit_font_and_disjoint_ranges_save_reopen_through_tools", "crates/edit/src/tests.rs::disjoint_style_ranges_preserve_unselected_runs_and_validate_utf8_atomically", "crates/engine/tests/it/document_fonts.rs::opentype_cff_document_font_preserves_program_codes_and_rendering_after_reopen", "crates/ui-egui/tests/it/editing.rs::cropped_images_do_not_intercept_text_and_stay_inside_each_page", "crates/automation/tests/it/automation.rs::clipped_image_bounds_and_text_edit_save_reopen_through_tools", "crates/edit/src/tests.rs::mixed_styles_and_matrix_sizes_form_visual_regions_without_merging_columns", "crates/edit/src/tests.rs::rich_region_edits_preserve_styles_positions_and_delete_all_runs_after_reopen", "crates/ui-egui/tests/it/editing.rs::one_editor_contains_mixed_styles_and_visual_rows", "crates/ui-egui/tests/it/editing.rs::inline_editor_preserves_source_font_and_previews_live_bounds", "crates/fonts/src/pdf/tests.rs::mac_subset_cmap_uses_pdf_unicode_without_changing_source_program", "crates/automation/tests/it/automation.rs::mixed_visual_region_edit_preview_save_reopen_and_undo_through_tools", "crates/ui-egui/tests/it/ui.rs::the_edit_tab_turns_on_edit_text_and_images", "crates/ui-egui/tests/it/editing.rs::the_caret_on_a_small_row_is_as_tall_as_that_row", "crates/edit/src/tests.rs::uzbek_and_kazakh_cyrillic_use_the_bundled_latin_face", "crates/automation/tests/it/automation.rs::editing_uzbek_and_kazakh_cyrillic_through_tools", "crates/edit/src/tests.rs::styled_paragraph_batches_cyrillic_without_changing_source_fonts", "crates/edit/src/tests.rs::styled_paragraph_over_240_fallback_glyphs_splits_and_refuses_atomically", "crates/edit/src/tests.rs::styled_fallback_batches_keep_face_choices_and_range_styles", "crates/edit/src/tests.rs::styled_fallback_shards_keep_text_advances_and_alignment", "crates/edit/src/tests.rs::styled_fallback_budget_is_enforced_before_objects_are_written", "crates/edit/src/tests.rs::styled_fallback_same_base_font_and_character_keep_both_weights", "crates/automation/tests/it/automation.rs::styled_cyrillic_fallback_is_batched_through_tools_and_reopen", "crates/ui-egui/tests/it/editing.rs::escape_in_an_open_paragraph_selects_its_box_for_delete", "crates/edit/src/tests.rs::ungrouping_form_artwork_makes_its_text_editable", "crates/automation/tests/it/automation.rs::ungrouping_a_table_drawn_in_a_form_makes_its_text_editable", "crates/ui-egui/tests/it/editing.rs::ungroup_in_the_artwork_menu_makes_a_forms_text_editable", "crates/edit/src/tests.rs::ungroup_carries_the_placement_into_the_patterns_it_selects", "crates/automation/tests/it/automation.rs::ungrouping_keeps_patterns_where_the_forms_placement_put_them", "crates/edit/src/tests.rs::ungroup_refuses_a_form_with_its_own_default_colour_space", "crates/edit/src/tests.rs::ungroup_renames_the_name_operand_the_renderer_reads", "crates/ui-egui/tests/it/editing.rs::one_click_on_grouped_text_opens_it_for_editing", "crates/edit/src/tests.rs::grouped_text_at_says_which_artwork_preparation_would_open", "crates/edit/src/tests.rs::legacy_thai_map_repair_needs_no_grouped_text", "crates/edit/src/tests.rs::ungroup_refuses_a_form_drawn_inside_text_or_a_path", "crates/edit/src/tests.rs::ungroup_reads_what_a_group_draws_within_one_allowance", "crates/edit/src/tests.rs::unicode_row_breaks_break_the_row_instead_of_needing_a_glyph", "crates/edit/src/tests.rs::only_the_words_a_subset_cannot_show_are_substituted"] +notes = "In Edit text, one click on text in a supported grouped Form prepares its page (one undoable step) and opens the paragraph clicked, so its existing source glyphs are edited directly; nothing is prepared while pages are only shown, a drag still moves the selected group, and a click on grouped artwork that preparation wouldn't open (a logo, a refused group) selects it as before. text_prepare does the same headlessly, with the same code as Ungroup. Unsafe groups remain intact and are reported by text_prepare and in the repair log when the page is prepared, as is text grouped more than eight levels deep; image-only forms are left grouped. Narrow legacy THSarabunPSK character-map recovery, checked glyph by glyph against the subset's own advances, is recorded, also for text that was never grouped (text_prepare). Legacy Thai source editing reuses original font programs, positional glyphs and same-face subsets (a Thai run split across subsets is shaped cluster by cluster; what no subset can show in its codes is shaped whole by a complete program of the face, such as one from Load font), preserving Regular/Bold, point size, spacing and paint. A different Thai family requires explicit selection; the Noto replacement is Regular only; mixed contextual style runs remain limited. The Edit tab turns Edit text & images on at once, as in Acrobat (leaving the tab puts the tool down and applies an edit in progress); Esc in an open paragraph leaves its text and selects its box, so Delete removes it (the unapplied draft is discarded); click a paragraph box and retype it in place (font reused or Helvetica substituted, colour and spacing kept, rewrapped; replacing one line keeps the source font for every word it can show and sets only the other words in Helvetica (#2378), while a paragraph rewrite still substitutes the whole paragraph for a Latin character its subset lacks); a page's /Contents pieces are parsed joined, so a line whose operands and operator sit in different pieces is listed and edited (lines and paragraphs still don't join across pieces; Edit image still parses each piece alone); drag a box to move it or its right-edge handle to rewrap it (edit.text-move-resize); per-character formatting changes are next Since #1116: mixed-style visual regions retain source rows, subset encodings, colour and baseline anchors in one textless-background editor. Explicit paragraph/heading scopes constrain grouping; compatible graphics planes permit cross-stream regions. Full matching document fonts complete absent subset glyphs; installed/file selection, TrueType/OpenType-CFF embedding and disjoint UTF-8 range styles have save/reopen coverage. Single-face OpenType shaping/bidi retains logical text through ToUnicode/ActualText. Replacement text the paragraph's font can't show takes the craft-fonts Japanese faces when they draw all of it (Uzbek қ, Kazakh ә are in none of them), else the bundled Inter face, so those letters write in every build (#2593). Legacy non-SFNT preview, mixed-font/style contextual runs, full structure-tree preservation and author intent in untagged PDFs remain limited; Styled-path outline fallback batches by actual face with 240 glyphs per font and preserves source styles; adjacent equal-font/style characters form runs. At most 15360 distinct fallback glyphs per styled paragraph; missing glyphs are refused before fallback objects are written. The Unicode row breaks (U+2028, U+2029, U+0085, vertical tab, form feed and the information separators) break the row instead of being looked up as glyphs (#6272). Glyph coverage, mixed contextual shaping, single-style fallback and stale-font cleanup are unchanged; see docs/editing-existing-text.md. Text drawn inside a Form XObject (HTML-to-PDF output, stamped or imposed pages) is grouped artwork until Ungroup (artwork right-click menu; image_edit action ungroup) draws that placement in place with its resources merged; knockout groups and transparency groups that blend inside, are drawn translucent or blend in a colour space other than the page's are refused, as are forms with resources of their own whose /DefaultGray, /DefaultRGB or /DefaultCMYK differs from the page's, either side lacking it included (ISO 32000-2 §8.6.5.6), and forms drawn inside a text object or an unfinished path, each resource name is read from the operand the renderer uses (a surplus operand before it is ignored; an operator whose operands can't be read is refused), and a pattern the form fills with is drawn from a copy whose /Matrix carries the placement (ISO 32000-2 §8.7.2), so ungrouping never changes the pixels." [[feature]] id = "edit.text-reflow" From cb919f0d362512ff5629d1320f62b9c1dced24d8 Mon Sep 17 00:00:00 2001 From: MarekSpodBiedry <73122968+MarekSpodBiedry@users.noreply.github.com> Date: Mon, 9 Nov 2026 11:23:10 +0100 Subject: [PATCH 24/64] Search: centre the active match in the viewport (#3530) (#3547) --- crates/ui-egui/src/canvas.rs | 8 ++++---- crates/ui-egui/src/canvas/find.rs | 26 +++++++++++++++++++++++++- crates/ui-egui/src/canvas/tests.rs | 15 ++++++++++++++- 3 files changed, 43 insertions(+), 6 deletions(-) diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index f20cfca29f..8cfd56bc05 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -628,8 +628,8 @@ pub struct DocView { viewport_w: f32, viewport_h: f32, page_count: usize, - /// Page heights in points (view space), for mapping text positions to scroll offsets. - page_heights: Vec, + /// Page sizes (width, height) in points (view space), for mapping text positions to scroll offsets. + page_sizes: Vec<(f32, f32)>, /// Screen rects of the pages drawn last frame (hit-testing, tests, automation). screen_rects: Vec<(usize, Rect)>, screen_xforms: Vec<(usize, PageXform)>, @@ -1064,7 +1064,7 @@ impl DocView { viewport_w: 800.0, viewport_h: 600.0, page_count: info.pages.len(), - page_heights: info.pages.iter().map(|p| p.height).collect(), + page_sizes: info.pages.iter().map(|p| (p.width, p.height)).collect(), screen_rects: Vec::new(), screen_xforms: Vec::new(), screen_ppp: 1.0, @@ -1154,7 +1154,7 @@ impl DocView { self.invalidate_content(); self.frame_geometry_changed = true; self.page_count = info.pages.len(); - self.page_heights = info.pages.iter().map(|p| p.height).collect(); + self.page_sizes = info.pages.iter().map(|p| (p.width, p.height)).collect(); let last = self.page_count.saturating_sub(1); self.current = self.current.min(last); self.selected.retain(|p| *p <= last); diff --git a/crates/ui-egui/src/canvas/find.rs b/crates/ui-egui/src/canvas/find.rs index 22d6e02d84..9d750cfdb2 100644 --- a/crates/ui-egui/src/canvas/find.rs +++ b/crates/ui-egui/src/canvas/find.rs @@ -180,16 +180,40 @@ impl DocView { self.flash_match(p); } + /// The centre of the current match as a fraction of its displayed page `[x, y]` (0..1 from + /// the top-left corner, after the view rotation, as `goto_point` takes it). + fn match_centre(&self) -> Option<[f32; 2]> { + let f = self.find.as_ref()?; + let (p, r) = f.matches.get(f.current?)?; + let glyphs = self.texts.get(p)?.glyphs.get(r.clone())?; + let [x0, y0, x1, y1] = glyphs.iter().map(|g| g.rect).reduce(|a, b| [a[0].min(b[0]), a[1].min(b[1]), a[2].max(b[2]), a[3].max(b[3])])?; + let &(w, h) = self.page_sizes.get(*p).filter(|(w, h)| *w > 0.0 && *h > 0.0)?; + let (u, v) = ((((x0 + x1) / 2.0) / w).clamp(0.0, 1.0), (((y0 + y1) / 2.0) / h).clamp(0.0, 1.0)); + // Turned clockwise by the view rotation, as `PageInfo::dest_fraction` does. + Some(match self.rotation % 360 { + 90 => [1.0 - v, u], + 180 => [1.0 - u, 1.0 - v], + 270 => [v, 1.0 - u], + _ => [u, v], + }) + } + fn flash_match(&mut self, page: usize) { let frac = self .find .as_ref() .and_then(|f| f.current.and_then(|c| f.matches.get(c))) .and_then(|(p, r)| self.texts.get(p).and_then(|t| t.glyphs.get(r.start)).map(|g| g.rect[1])) - .zip(self.page_heights.get(page).copied()) + .zip(self.page_sizes.get(page).map(|&(_, h)| h)) .map(|(y, h)| ((y - 60.0) / h).clamp(0.0, 1.0)) .unwrap_or(0.0); self.goto = Some((page, frac)); + // Then put the match's centre at the window's centre, as Acrobat does (#3530) and as a + // /FitR destination's rectangle is: a page wider or taller than the window no longer + // leaves it off to one side or near an edge. + if let Some([fx, fy]) = self.match_centre() { + self.goto_point = Some((page, Some(fx), Some(fy), vec2(self.viewport_w, self.viewport_h) / 2.0)); + } self.current = page; self.reset_coarse_state(); } diff --git a/crates/ui-egui/src/canvas/tests.rs b/crates/ui-egui/src/canvas/tests.rs index 85c7d3fac3..c05a03bb7b 100644 --- a/crates/ui-egui/src/canvas/tests.rs +++ b/crates/ui-egui/src/canvas/tests.rs @@ -510,7 +510,8 @@ fn explicit_search_jumps_record_history_and_keep_match_destination() { // Chrome refreshes the idle page box; navigation must not overwrite a draft // that may still be focused. DocView does not own the editor's focus state. assert_eq!(v.page_input, "draft in progress"); - assert!(v.goto_point.is_none(), "an earlier link destination cannot override the search offset"); + let middle = vec2(v.viewport_w, v.viewport_h) / 2.0; + assert_eq!(v.goto_point, Some((3, Some(40.0 / 300.0), Some(0.675), middle)), "the match centre goes to the middle of the viewport"); v.go_to_match(1); assert_eq!(v.back, vec![0], "same-page matches do not duplicate history"); assert!(v.view_history(false)); @@ -524,6 +525,18 @@ fn explicit_search_jumps_record_history_and_keep_match_destination() { } } +#[test] +fn search_centres_the_match_on_a_rotated_view() { + // The match's centre is 40 pt from the left and 270 pt down a 300 × 400 page. + for (rotation, fx, fy) in [(90, 1.0 - 0.675, 40.0 / 300.0), (180, 1.0 - 40.0 / 300.0, 1.0 - 0.675), (270, 0.675, 1.0 - 40.0 / 300.0)] { + let mut v = history_search_view(); + v.rotation = rotation; + v.find_step(true); + let middle = vec2(v.viewport_w, v.viewport_h) / 2.0; + assert_eq!(v.goto_point, Some((3, Some(fx), Some(fy), middle)), "view rotated {rotation}°"); + } +} + #[test] fn automatic_search_updates_do_not_record_history_or_discard_forward() { let mut v = history_search_view(); From 66a44f529e3a0cea5b16625cf8b322ae4b96bb6c Mon Sep 17 00:00:00 2001 From: mtlcl410 <2368091+mtlcl410@users.noreply.github.com> Date: Mon, 9 Nov 2026 18:23:26 +0800 Subject: [PATCH 25/64] M2: the content crate's parser is checked against hayro's, operator by operator (#3107, #3108, #3111, #3177) (#3563) --- Cargo.lock | 1 + crates/render/Cargo.toml | 3 + crates/render/tests/content_differential.rs | 476 ++++++++++++++++++++ 3 files changed, 480 insertions(+) create mode 100644 crates/render/tests/content_differential.rs diff --git a/Cargo.lock b/Cargo.lock index f9e243ba0b..b7ffc051f1 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4696,6 +4696,7 @@ dependencies = [ "kurbo 0.13.1", "log", "lopdf", + "pdfcraft-content", "pdfcraft-cos", "pdfcraft-fonts", "pdfcraft-geom", diff --git a/crates/render/Cargo.toml b/crates/render/Cargo.toml index 5777d987ca..b5fc0e1bf1 100644 --- a/crates/render/Cargo.toml +++ b/crates/render/Cargo.toml @@ -34,6 +34,9 @@ unicode-bidi = "0.3" getrandom = { version = "0.4", features = ["wasm_js"] } [dev-dependencies] +# The content crate's parser, compared operator by operator against hayro's in +# `tests/content_differential.rs` (L3 may depend on L2). +pdfcraft-content = { workspace = true } pdfcraft-testkit = { workspace = true } # Builds the decompression-bomb streams in the raster tests. flate2 = { workspace = true } diff --git a/crates/render/tests/content_differential.rs b/crates/render/tests/content_differential.rs new file mode 100644 index 0000000000..00c3158472 --- /dev/null +++ b/crates/render/tests/content_differential.rs @@ -0,0 +1,476 @@ +//! The two content-stream parsers PdfCraft runs, operator by operator on the same bytes. +//! +//! The renderer, text extraction and Find read a page through hayro's tokenizer +//! (`hayro_syntax::content::UntypedIter`, typed through `TypedIter`). Edit text, redaction, +//! the redaction audit, Office export, accessibility and the automation tools read it through +//! `pdfcraft_content::parse`. Where the two disagree on the same bytes, a feature acts on a +//! page the renderer never showed: #3107, #3108, #3111 and #3177 are that class. +//! +//! Each fixture here is parsed by both and the operator sequences compared. Where the parsers +//! agree, that is asserted. Where they are known to disagree, the test pins what *each* side +//! does today and names the issue, so a change on either side fails here and the expectation +//! is updated with it. Nothing in this file touches product code, and no oracle runs here: the +//! MuPDF and pdftotext readings quoted in comments were taken by hand on one-page files built +//! from the same bytes. +//! +//! Several tests pin what `vendor/hayro-syntax` does today, so a hayro upgrade or a new patch +//! to it can fail them on purpose. When that happens, update the pinned side to the new +//! behaviour and note the upstream change (the version or patch, and its PR) in that test's +//! comment, as the tests under "divergences closed" do. + +use std::fmt::Write as _; + +use hayro_syntax::content::ops::TypedInstruction; +use hayro_syntax::content::{TypedIter, UntypedIter}; +use hayro_syntax::object::{MaybeRef, Object as HObject}; +use pdfcraft_cos::Object as CObject; + +/// One operator with its operands, normalized so the two parsers can be compared as text. +#[derive(Clone, Debug, PartialEq, Eq)] +struct Tok { + op: String, + operands: Vec, +} + +fn tok(op: &str, operands: &[&str]) -> Tok { + Tok { op: op.into(), operands: operands.iter().map(|s| s.to_string()).collect() } +} + +fn num(v: f64) -> String { + let r = (v * 1e6).round() / 1e6; + if r.fract() == 0.0 && r.abs() < 1e15 { format!("{}", r as i64) } else { format!("{r}") } +} + +fn hex(bytes: &[u8]) -> String { + bytes.iter().fold(String::new(), |mut s, b| { + let _ = write!(s, "{b:02x}"); + s + }) +} + +fn norm_cos(o: &CObject) -> String { + match o { + CObject::Null => "null".into(), + CObject::Bool(b) => b.to_string(), + CObject::Int(i) => num(*i as f64), + CObject::Real(r) => num(*r), + CObject::String(s) => format!("({})", hex(&s.bytes)), + CObject::Name(n) => format!("/{}", String::from_utf8_lossy(n)), + CObject::Array(a) => format!("[{}]", a.iter().map(norm_cos).collect::>().join(" ")), + CObject::Dict(d) => { + let mut entries: Vec = d.iter().map(|(k, v)| format!("/{} {}", String::from_utf8_lossy(k), norm_cos(v))).collect(); + entries.sort(); + format!("<<{}>>", entries.join(" ")) + } + CObject::Ref(r) => format!("{} {} R", r.num, r.generation), + CObject::Stream(_) => "stream".into(), + } +} + +fn norm_hayro(o: &HObject<'_>) -> String { + match o { + HObject::Null(_) => "null".into(), + HObject::Boolean(b) => b.to_string(), + HObject::Number(n) => num(n.as_f64()), + HObject::String(s) => format!("({})", hex(s.as_bytes())), + HObject::Name(n) => format!("/{}", String::from_utf8_lossy(n.as_ref())), + HObject::Array(a) => format!("[{}]", a.raw_iter().map(|m| norm_maybe(&m)).collect::>().join(" ")), + HObject::Dict(d) => { + let mut entries: Vec = d.entries().map(|(k, v)| format!("/{} {}", String::from_utf8_lossy(k.as_ref()), norm_maybe(&v))).collect(); + entries.sort(); + format!("<<{}>>", entries.join(" ")) + } + // An inline image: its dictionary and data. ISO 32000-2 §8.9.7 (p.268) defines the data as + // the bytes between `ID` and `EI` "excluding the white-space delimiting those operators", + // which is what the content crate keeps. hayro keeps exactly the samples of an unfiltered + // image whose length its parameters give (#3438), but a filtered image, which it ends at + // a candidate `EI`, still keeps the white-space byte before that `EI` as its last byte. + // `hayro_keeps_the_delimiter_before_ei_only_in_filtered_image_data` pins both. The + // comparisons strip that byte from filtered images only, so that every other difference + // stands out: stripping it from an unfiltered image would cut a sample that happens to + // be white space. + HObject::Stream(s) => { + let raw = s.raw_data(); + let filtered = s.dict().contains_key(b"F") || s.dict().contains_key(b"Filter"); + let data = match raw.split_last() { + Some((b'\0' | b'\t' | b'\n' | b'\x0C' | b'\r' | b' ', rest)) if filtered => rest, + _ => &raw[..], + }; + format!("{} data:{}", norm_hayro(&HObject::Dict(s.dict().clone())), hex(data)) + } + } +} + +fn norm_maybe(m: &MaybeRef>) -> String { + match m { + MaybeRef::NotRef(o) => norm_hayro(o), + MaybeRef::Ref(r) => format!("{} {} R", r.obj_number, r.gen_number), + } +} + +/// The content crate's operators. +fn content(data: &[u8]) -> Vec { + pdfcraft_content::parse(data) + .ops + .iter() + .map(|op| Tok { + op: String::from_utf8_lossy(&op.op).into_owned(), + operands: match &op.inline { + Some((dict, image)) => vec![format!("{} data:{}", norm_cos(&CObject::Dict(dict.clone())), hex(image))], + None => op.operands.iter().map(norm_cos).collect(), + }, + }) + .collect() +} + +/// hayro's untyped instructions: every operator the tokenizer reads, with its operand stack. +fn hayro(data: &[u8]) -> Vec { + let mut iter = UntypedIter::new(data); + let mut out = Vec::new(); + while let Some(instr) = iter.next() { + out.push(Tok { op: String::from_utf8_lossy(instr.operator).into_owned(), operands: instr.operands().map(norm_hayro).collect() }); + } + out +} + +/// How many instructions hayro's typed iterator, the layer the renderer consumes, yields before +/// it stops. +fn hayro_typed_len(data: &[u8]) -> usize { + let mut iter = TypedIter::new(data); + let mut n = 0; + while iter.next().is_some() { + n += 1; + } + n +} + +fn ops(toks: &[Tok]) -> Vec<&str> { + toks.iter().map(|t| t.op.as_str()).collect() +} + +/// Both parsers on `data`, asserting they agree and that the operators are `expected`. +fn agree(name: &str, data: &[u8], expected: &[&str]) { + let (c, h) = (content(data), hayro(data)); + assert_eq!(c, h, "{name}: content crate (left) and hayro (right) differ on {:?}", String::from_utf8_lossy(data)); + assert_eq!(ops(&c), expected, "{name}"); +} + +/// An unfiltered 4 × 1 grey inline image whose samples are the four bytes of `samples`. +fn inline_gray(samples: &[u8; 4], after: &[u8]) -> Vec { + let mut v = b"q BI /W 4 /H 1 /BPC 8 /CS /G ID ".to_vec(); + v.extend_from_slice(samples); + v.extend_from_slice(b"\nEI Q "); + v.extend_from_slice(after); + v +} + +const GRAY_4X1: &str = "<>"; +const GRAY_1X1: &str = "<>"; + +// ── where the two parsers agree ───────────────────────────────────────────────────────────── + +#[test] +fn the_two_parsers_agree_on_well_formed_streams_and_common_damage() { + agree("plain", b"q 1 0 0 1 10 20 cm BT /F1 12 Tf (Hi) Tj ET Q", &["q", "cm", "BT", "Tf", "Tj", "ET", "Q"]); + agree("number forms", b"0.5 .5 6. -.5 +3 -0 5 w", &["w"]); + assert_eq!(content(b"0.5 .5 6. -.5 +3 -0 5 w")[0].operands, ["0.5", "0.5", "6", "-0.5", "3", "0", "5"]); + agree("strings", b"BT (a(b)c) Tj (es\\)c\\\\ape) Tj (line\\\ncont) Tj <48 65 6c> Tj ET", &["BT", "Tj", "Tj", "Tj", "Tj", "ET"]); + assert_eq!(content(b"(line\\\ncont) Tj")[0].operands, [format!("({})", hex(b"linecont"))], "a backslash joins the lines"); + agree("unterminated string ends both streams", b"BT (open Tj ET q Q", &["BT"]); + agree("names with escapes", b"/A#20B gs /#41 gs", &["gs", "gs"]); + assert_eq!(content(b"/A#20B gs /#41 gs").iter().map(|t| t.operands[0].as_str()).collect::>(), ["/A B", "/A"]); + agree("array and dictionary operands", b"/Span <> BDC [(a) -250 (b) 1.5] TJ EMC", &["BDC", "TJ", "EMC"]); + agree("ten operands", b"1 2 3 4 5 6 7 8 9 10 d0 q Q", &["d0", "q", "Q"]); + agree("unknown operators", b"q foo 1 bar Q BT ET", &["q", "foo", "bar", "Q", "BT", "ET"]); + agree("comments everywhere", b"q % c1\n1 0 % c2\n0 1 0 0 cm [1 % c3\n 2] TJ /N % c4\ngs Q % end", &["q", "cm", "TJ", "gs", "Q"]); + agree("comment without end of line", b"q Q % trailing", &["q", "Q"]); + agree("unbalanced q/Q and BT/ET", b"Q Q q BT BT ET q", &["Q", "Q", "q", "BT", "BT", "ET", "q"]); + agree("operands without an operator at the end", b"q 1 0 0 1 5", &["q"]); + agree("empty", b"", &[]); + agree("only a comment", b"% nothing", &[]); +} + +/// The content crate exposes two operand conventions: `Op::nums::` takes the last N, as +/// hayro's typed operators do (`0 20 Tc` is `Tc 20` to the renderer), while `Op::num(i)` counts +/// from the front. Both tokenizers keep every operand, so the choice is each consumer's: the +/// edit reader reads `Tc` through `num(0)` and gets 0 where the renderer gets 20, so the editor +/// and the renderer disagree on the character spacing (MuPDF renders the 20). No issue is filed +/// for the convention. +#[test] +fn surplus_operands_are_read_from_the_end_by_hayro_and_either_way_by_the_content_crate() { + let data = b"BT 0 20 Tc (x) Tj ET"; + agree("surplus operand before Tc", data, &["BT", "Tc", "Tj", "ET"]); + let parsed = pdfcraft_content::parse(data); + let tc = &parsed.ops[1]; + assert_eq!(tc.nums::<1>(), Some([20.0]), "last N, as the renderer"); + assert_eq!(tc.num(0), Some(0.0), "the i-th from the front"); + let mut iter = TypedIter::new(data); + let mut typed_tc = None; + while let Some(instr) = iter.next() { + if let TypedInstruction::CharacterSpacing(c) = instr { + typed_tc = Some(c.0.as_f64()); + } + } + assert_eq!(typed_tc, Some(20.0), "hayro's typed Tc takes the last operand"); +} + +// ── divergences closed since this file was written: both parsers now agree ───────────────── + +/// hayro's typed layer used to end the whole stream at a path operator whose operands don't +/// convert, so the renderer and text extraction showed nothing after it. +/// Closed by #3636 (30ed249a2): `TypedIter::next` (`vendor/hayro-syntax/src/content/mod.rs`) +/// now yields such an operator as `Fallback`, which adds nothing to the path, and reads on, as +/// the content crate, MuPDF and pdftotext do (`After` is shown). +#[test] +fn both_parsers_read_on_past_a_path_operator_with_too_few_operands() { + for (data, expected) in + [(&b"0 0 100 re f BT /F1 12 Tf (After) Tj ET"[..], &["re", "f", "BT", "Tf", "Tj", "ET"][..]), (b"5 m 0 0 100 100 re f", &["m", "re", "f"])] + { + agree("untyped: both keep the short operator", data, expected); + assert_eq!(hayro_typed_len(data), expected.len(), "hayro's typed stream reads on past the short operator"); + } + assert_eq!(hayro_typed_len(b"0 0 100 100 re f BT /F1 12 Tf (After) Tj ET"), 6, "control: a complete re is read through"); +} + +/// hayro's operand stack used to hold ten entries, and an eleventh ended its stream. Closed by +/// #4345 (hayro-syntax patch 28, #4260): an instruction keeps up to 256 operands and past that +/// drops the oldest, so both parsers read all eleven and go on, as MuPDF and pdftotext do +/// (`AFTER` is shown). +#[test] +fn both_parsers_keep_more_than_ten_operands() { + let data = b"1 2 3 4 5 6 7 8 9 10 11 d0 q Q"; + agree("eleven operands", data, &["d0", "q", "Q"]); + assert_eq!(content(data)[0].operands.len(), 11); +} + +/// A stray closing delimiter used to end hayro's stream while the content crate skipped it. +/// Closed by #3636 (30ed249a2): hayro steps over the malformed token too. MuPDF reports a +/// syntax error and goes on (`AFTER` is shown). +#[test] +fn both_parsers_skip_a_stray_closing_delimiter() { + agree("stray delimiters", b"q ) > ] } { Q", &["q", "Q"]); +} + +/// A name whose `#` escape is not followed by two hex digits (ISO 32000-2 §7.3.5, p.28) used to +/// end hayro's stream. Since #3636 (30ed249a2) hayro steps over the name and reads on, so both +/// parsers now read the same operators. One difference is still pinned: the content crate keeps +/// the name as written as the `gs` operand, while hayro drops it, so `gs` reaches the renderer +/// with no operand and does nothing, where the editor reads `/C#2`. No issue is filed. An empty +/// name (`/`) is read by both. MuPDF goes on in both cases, reporting the resource as missing. +#[test] +fn both_parsers_read_on_past_a_malformed_name_escape_but_only_the_content_crate_keeps_the_name() { + let data = b"/C#2 gs q Q"; + assert_eq!(content(data), [tok("gs", &["/C#2"]), tok("q", &[]), tok("Q", &[])]); + assert_eq!(hayro(data), [tok("gs", &[]), tok("q", &[]), tok("Q", &[])], "hayro: the operators, without the name"); + agree("an empty name", b"/ gs q Q", &["gs", "q", "Q"]); +} + +/// #3111 (closed by #3299) made the cos lexer, and so the content crate, read `--280` as -280. +/// hayro used to read it as the two numbers 0 and -280, so `cm` took an extra operand and the +/// renderer drew the page with the matrix `0 0 1 0 -280 10`. Closed by #3630 (766ff9d05): a +/// doubled sign is one number to hayro too, so both read the matrix MuPDF applies (text at +/// x = 120). The content crate skips no byte: without the lexer's tolerance it would skip the +/// first `-` and read the same `-280` from the rest, so the operands alone don't show it. +#[test] +fn both_parsers_read_a_doubled_minus_as_one_number() { + let data = b"1 0 0 1 --280 10 cm"; + agree("doubled minus", data, &["cm"]); + assert_eq!(content(data)[0].operands, ["1", "0", "0", "1", "-280", "10"]); + assert_eq!(pdfcraft_content::parse(data).skipped, 0, "--280 is read as one number, not recovered from -280"); +} + +// ── known divergences, pinned on both sides ───────────────────────────────────────────────── + +/// No issue filed; #2877 (open) changes number reading in cos and render. ISO 32000-2 §7.3.3 +/// (p.24) says a writer "shall not use … exponential format", so neither reading is the +/// spec's. The content crate reads `1e3` as the number 1000; hayro reads it as an unknown +/// operator and drops the operands before it. MuPDF stops the whole stream at it +/// ("unknown keyword: '1e3'"); pdftotext goes on. +#[test] +fn scientific_notation_is_a_number_to_the_content_crate_and_an_unknown_operator_to_hayro() { + let data = b"1e3 w q Q"; + assert_eq!(content(data), [tok("w", &["1000"]), tok("q", &[]), tok("Q", &[])]); + assert_eq!(hayro(data), [tok("w", &[]), tok("q", &[]), tok("Q", &[])]); + // A number glued to letters: the content crate skips the digits and reads the letters as an + // operator; hayro reads the whole token as an operator (and clears the operands before it). + let data = b"12abc w q Q"; + assert_eq!(content(data), [tok("abc", &[]), tok("w", &[]), tok("q", &[]), tok("Q", &[])]); + assert_eq!(hayro(data), [tok("w", &[]), tok("q", &[]), tok("Q", &[])]); +} + +// ── inline images ─────────────────────────────────────────────────────────────────────────── + +/// The bytes of the first inline image each parser reads in `data`: the content crate's, then +/// hayro's raw stream data (nothing stripped). +fn inline_bytes(data: &[u8]) -> (Option>, Option>) { + let parsed = pdfcraft_content::parse(data); + let c = parsed.ops.iter().find_map(|op| op.inline.as_ref().map(|(_, d)| d.clone())); + let mut iter = UntypedIter::new(data); + let mut h = None; + while let Some(instr) = iter.next() { + if let Some(HObject::Stream(s)) = instr.operands().next() { + h = Some(s.raw_data().to_vec()); + break; + } + } + (c, h) +} + +/// ISO 32000-2 §8.9.7 (p.268): the data lies between `ID` and `EI` "excluding the white-space +/// delimiting those operators". The content crate keeps exactly that. hayro used to keep the +/// delimiter before `EI` as the last byte of every inline image. Since #3438 (f9d37c089) it +/// takes an unfiltered image's length from `/W /H /BPC /CS` and keeps exactly the samples, so +/// both read `ABCD`. A filtered image, which hayro still ends at a candidate `EI`, keeps the +/// delimiter: that part is still pinned. Decoders stop at the filter's end-of-data marker and +/// ignore the extra byte, so the other tests strip it from filtered images (see `norm_hayro`). +#[test] +fn hayro_keeps_the_delimiter_before_ei_only_in_filtered_image_data() { + let unfiltered = inline_bytes(&inline_gray(b"ABCD", b"")); + assert_eq!(unfiltered, (Some(b"ABCD".to_vec()), Some(b"ABCD".to_vec())), "unfiltered: both keep exactly the samples"); + let filtered = inline_bytes(b"q BI /W 1 /H 1 /BPC 8 /CS /G /F /AHx ID 41> EI Q"); + assert_eq!(filtered, (Some(b"41>".to_vec()), Some(b"41> ".to_vec())), "filtered: hayro keeps the space before EI"); +} + +/// Samples that end in a white-space byte (here ` EI ` as four gray samples, the last one 0x20) +/// are all image data when the length is computed from the parameters (4 × 1 × 8 bits = 4 +/// bytes, ISO 32000-2 §8.9.7). Both parsers keep all four bytes; hayro's raw data, unstripped, +/// is the same four bytes, so its unfiltered-length path (#3438) does not take a white-space +/// sample for the delimiter before `EI`. +#[test] +fn both_parsers_keep_a_last_sample_that_is_white_space() { + for samples in [b" EI ", b"\x10\x20\x30\x20", b"\x10\x20\x30\x0A"] { + let (c, h) = inline_bytes(&inline_gray(samples, b"q Q")); + assert_eq!(c.as_deref(), Some(&samples[..]), "content crate: {samples:02x?}"); + assert_eq!(h.as_deref(), Some(&samples[..]), "hayro: {samples:02x?}"); + } +} + +#[test] +fn inline_images_agree_when_ei_is_delimited() { + agree("raw samples", &inline_gray(b"ABCD", b"BT (t) Tj ET"), &["q", "BI", "Q", "BT", "Tj", "ET"]); + agree("ASCIIHex with a space before EI", b"q BI /W 1 /H 1 /BPC 8 /CS /G /F /AHx ID 41> EI Q BT (t) Tj ET", &["q", "BI", "Q", "BT", "Tj", "ET"]); + agree("ASCII85 with a space before EI", b"q BI /W 1 /H 1 /BPC 8 /CS /G /F /A85 ID 5l~> EI Q BT (t) Tj ET", &["q", "BI", "Q", "BT", "Tj", "ET"]); + agree("two images in a row", b"q BI /W 1 /H 1 /BPC 8 /CS /G ID A\nEI BI /W 1 /H 1 /BPC 8 /CS /G ID B\nEI Q", &["q", "BI", "BI", "Q"]); + agree("a comment inside the parameters", b"q BI /W 1 % w\n/H 1 /BPC 8 /CS /G ID A\nEI Q", &["q", "BI", "Q"]); + // A fake ` EI ` inside the samples with no text operator after the image: hayro's look-ahead + // keeps searching and finds the real EI, so both read the four bytes. + agree("fake EI, nothing but q/Q after", &inline_gray(b" EI ", b"q Q"), &["q", "BI", "Q", "q", "Q"]); + assert_eq!(content(&inline_gray(b" EI ", b"q Q"))[1].operands, [format!("{GRAY_4X1} data:20454920")]); +} + +/// #3107. Unfiltered samples that happen to hold ` EI ` followed by a text operator: hayro's +/// look-ahead used to accept the first `EI` because text follows it, so its image held no +/// samples and the rest of them ran as content (`EI` became an operator). Closed by #3438 +/// (f9d37c089): hayro now takes the length from `/W /H /BPC /CS`, as the content crate does, and +/// both keep all four bytes (ISO 32000-2 §8.9.7). MuPDF and pdftotext show the text after. +#[test] +fn both_parsers_keep_a_fake_ei_inside_unfiltered_samples() { + let data = inline_gray(b" EI ", b"BT (t) Tj ET"); + agree("fake EI, text after", &data, &["q", "BI", "Q", "BT", "Tj", "ET"]); + assert_eq!(content(&data)[1].operands, [format!("{GRAY_4X1} data:20454920")]); +} + +/// #3108. `EI` written straight after the filter's end-of-data marker (`>EI`, `~>EI`): hayro +/// ends the image there, and the content crate used to find no delimited `EI` and swallow the +/// rest of the stream into the image. Closed by #3259 (the content crate accepts `EI` after the +/// marker) and b72078871 (the content crate takes a filtered image's end from hayro's +/// tokenizer): both end the image at the marker and read the text after, as MuPDF and pdftotext +/// do. +#[test] +fn both_parsers_end_the_image_at_ei_glued_to_the_filters_end_marker() { + for (name, data, dict, marker) in [ + ("ASCIIHex", &b"q BI /W 1 /H 1 /BPC 8 /CS /G /F /AHx ID 41>EI Q BT (t) Tj ET"[..], "<>", "34313e"), + ("ASCII85", b"q BI /W 1 /H 1 /BPC 8 /CS /G /F /A85 ID 5l~>EI Q BT (t) Tj ET", "<>", "356c7e3e"), + ] { + agree(name, data, &["q", "BI", "Q", "BT", "Tj", "ET"]); + assert_eq!(content(data)[1].operands, [format!("{dict} data:{marker}")], "{name}"); + } +} + +/// No issue filed. `EI` glued to the next operator (`EIQ`): the content crate swallows the rest +/// of the stream into the image; hayro yields a `BI` with no image at all, skips to the next +/// delimited token and reads on, so the `Q` glued to `EI` is lost too. MuPDF ends the whole +/// stream ("syntax error after inline image"). +#[test] +fn ei_glued_to_the_next_operator_loses_the_image_on_both_sides_differently() { + let data = b"q BI /W 1 /H 1 /BPC 8 /CS /G ID A EIQ BT (t) Tj ET"; + let c = content(data); + assert_eq!(ops(&c), ["q", "BI"]); + assert_eq!(c[1].operands, [format!("{GRAY_1X1} data:{}", hex(b"A EIQ BT (t) Tj ET"))]); + assert_eq!(hayro(data), [tok("q", &[]), tok("BI", &[]), tok("BT", &[]), tok("Tj", &["(74)"]), tok("ET", &[])]); +} + +/// No issue filed. An inline image with no `EI` at all: the content crate makes the rest of the +/// stream the image's data; hayro yields a `BI` with no image and then reads the samples and +/// everything after them as operators. MuPDF ends the whole stream. +#[test] +fn an_inline_image_without_ei_is_data_to_the_content_crate_and_operators_to_hayro() { + let data = b"q BI /W 1 /H 1 /BPC 8 /CS /G ID A Q BT (t) Tj ET"; + let c = content(data); + assert_eq!(ops(&c), ["q", "BI"]); + assert_eq!(c[1].operands, [format!("{GRAY_1X1} data:{}", hex(b"A Q BT (t) Tj ET"))]); + assert_eq!(ops(&hayro(data)), ["q", "BI", "A", "Q", "BT", "Tj", "ET"]); +} + +/// No issue filed. Parameters that can't be read (`/W` with no value): the content crate skips +/// to the `EI` and goes on; hayro ends its stream. MuPDF reports the dictionary and goes on. +#[test] +fn an_inline_image_with_unreadable_parameters_is_skipped_by_the_content_crate_and_ends_hayros_stream() { + let data = b"q BI /W ID A\nEI Q"; + assert_eq!(ops(&content(data)), ["q", "Q"]); + assert_eq!(ops(&hayro(data)), ["q"]); +} + +/// One page, 612 × 792, whose content stream is `content`. +fn one_page_pdf(content: &[u8]) -> Vec { + let mut stream = format!("<< /Length {} >>\nstream\n", content.len()).into_bytes(); + stream.extend_from_slice(content); + stream.extend_from_slice(b"\nendstream"); + let objs: [&[u8]; 4] = [ + b"<< /Type /Catalog /Pages 2 0 R >>", + b"<< /Type /Pages /Kids [3 0 R] /Count 1 >>", + b"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] /Contents 4 0 R /Resources << >> >>", + &stream, + ]; + let mut out = b"%PDF-1.7\n".to_vec(); + let mut offsets = Vec::new(); + for (i, body) in objs.iter().enumerate() { + offsets.push(out.len()); + out.extend_from_slice(format!("{} 0 obj\n", i + 1).as_bytes()); + out.extend_from_slice(body); + out.extend_from_slice(b"\nendobj\n"); + } + let xref = out.len(); + out.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objs.len() + 1).as_bytes()); + for off in offsets { + out.extend_from_slice(format!("{off:010} 00000 n \n").as_bytes()); + } + out.extend_from_slice(format!("trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n", objs.len() + 1).as_bytes()); + out +} + +/// End to end: the renderer draws all four samples of an unfiltered 4 × 1 gray inline image +/// whose last sample is a white-space byte (0x20 or 0x0A), so hayro's computed length (#3438) +/// reaches the pixels and no sample is taken for the delimiter before `EI`. +#[test] +fn the_renderer_draws_a_last_sample_that_is_white_space() { + use pdfcraft_render::{PageRenderer, RenderConfig, RenderRequest, RequestKind}; + for samples in [[0x20, 0x45, 0x49, 0x20], [0x10, 0x60, 0xB0, 0x20], [0x10, 0x60, 0xB0, 0x0A]] { + // Each sample is a 100 × 100 pt block from x = 100, y = 300 (PDF space). + let mut content = b"q 400 0 0 100 100 300 cm BI /W 4 /H 1 /BPC 8 /CS /G ID ".to_vec(); + content.extend_from_slice(&samples); + content.extend_from_slice(b"\nEI Q"); + let pdf = std::sync::Arc::new(one_page_pdf(&content)); + let mut renderer = PageRenderer::new(pdf, RenderConfig::default()); + let page = renderer.render(RenderRequest { page: 0, kind: RequestKind::Pixels, tile: None, scale: 1.0, tag: 0, preview: false }); + assert!(page.error.is_none(), "{samples:02x?}: {:?}", page.error); + // Block centres: x = 150 + 100 i, raster y = 792 - 350 = 442. + let drawn: Vec = (0..4u32) + .map(|i| { + let idx = ((442 * page.width + 150 + 100 * i) * 4) as usize; + page.rgba.get(idx).copied().unwrap_or(0xFF) + }) + .collect(); + assert_eq!(drawn, samples, "{samples:02x?}: the gray of each block"); + } +} From 69571065b903e911af6bfef6986eb7defae04623 Mon Sep 17 00:00:00 2001 From: NetterSilver <133535268+Silvertree2010@users.noreply.github.com> Date: Mon, 9 Nov 2026 11:23:57 +0100 Subject: [PATCH 26/64] Split edit-text paragraphs at headings and blank lines (#3642) --- crates/edit/src/tests.rs | 14 ++++++++++++++ crates/edit/src/text_regions/grouping.rs | 14 +++++++++++++- 2 files changed, 27 insertions(+), 1 deletion(-) diff --git a/crates/edit/src/tests.rs b/crates/edit/src/tests.rs index 2640eadd5a..eb80c02c39 100644 --- a/crates/edit/src/tests.rs +++ b/crates/edit/src/tests.rs @@ -5929,6 +5929,20 @@ fn replacing_a_line_keeps_a_distinct_nearby_line() { } } +#[test] +fn text_blocks_split_at_headings_and_blank_lines() { + let page = |size: u32, gap: u32, words: &[&str], top: u32| -> String { + words.iter().enumerate().map(|(i, w)| format!("BT /F1 {size} Tf 40 {} Td ({w}) Tj ET\n", top - i as u32 * gap)).collect() + }; + let count = |content: String| text::text_blocks(&text_page(&content), 0).unwrap().len(); + let heading = page(12, 14, &["First line of text", "second line here", "third line ends"], 340) + + &page(12, 14, &["HEADING"], 340 - 28 - 24) + + &page(12, 14, &["Next paragraph one", "next two", "next three"], 340 - 28 - 24 - 24); + assert_eq!(count(heading), 3); + assert_eq!(count(page(12, 14, &["Para one a", "para one b"], 340) + &page(12, 14, &["Para two a", "para two b"], 340 - 42)), 2); + assert_eq!(count(page(24, 29, &["VISIBLE", "HIDDEN"], 340)), 1); +} + #[test] fn editing_text_keeps_an_overlapping_watermark_and_covered_paragraphs() { let listed = |doc: &Document| text::text_lines(doc, 0).unwrap().into_iter().map(|l| l.text).collect::>(); diff --git a/crates/edit/src/text_regions/grouping.rs b/crates/edit/src/text_regions/grouping.rs index 1dcb9a9373..72a516292d 100644 --- a/crates/edit/src/text_regions/grouping.rs +++ b/crates/edit/src/text_regions/grouping.rs @@ -331,7 +331,19 @@ fn group_layout(lines: &[TextLine], mixed: bool) -> Vec { let scale = gap.abs().max(ngap.abs()); if same && ngap > tol * 0.8 && scale < tol * 4.0 && (ngap - gap).abs() <= scale * 0.08 + 0.5 { Some(gap) } else { None } }); - let max_gap = own_leading.filter(|lead| *lead > tol * 0.4).or(stated).or(repeated).map_or(tol * 2.05, |lead| lead * 1.5); + // A lone row with none of those: the step to the row after it is the leading of the + // paragraph it opens, and the gap above it is a heading's or a blank line's when it + // is much wider (#3577). + let following = rows.get(row_at.saturating_add(1)).and_then(|next| { + let nfirst = next.first().and_then(|i| lines.get(*i))?; + let ngap = first.frame_origin().1 - nfirst.frame_origin().1; + (same_plane(first, nfirst) && ngap > tol * 0.8 && ngap < tol * 2.05).then_some(ngap) + }); + let max_gap = own_leading + .filter(|lead| *lead > tol * 0.4) + .or(stated) + .or(repeated) + .map_or_else(|| following.map_or(tol * 2.05, |lead| (lead * 1.35).min(tol * 2.05)), |lead| lead * 1.5); // OCR lines stay apart: the scan fixes where each one is, so they never rewrap together. (!mixed || !(invisible(prev) || invisible(first))) && (mixed || same_style(prev, first)) From 5f560dfde0d1a46bee83cf2815eca99e80e816ca Mon Sep 17 00:00:00 2001 From: mtlcl410 <2368091+mtlcl410@users.noreply.github.com> Date: Mon, 9 Nov 2026 18:24:16 +0800 Subject: [PATCH 27/64] M14: the portable copy can register as a PDF handler for the current user (#3758) (#3790) --- README.md | 2 ++ packaging/windows/portable.txt | 38 ++++++++++++++++++++++++++++++++++ 2 files changed, 40 insertions(+) diff --git a/README.md b/README.md index 74288bff8f..645347d811 100644 --- a/README.md +++ b/README.md @@ -472,6 +472,8 @@ Installers and executables are code-signed. The portable zip runs from any folder, a USB stick included. Its `portable.txt` keeps the settings, logs and crash recovery in a `PdfCraftData` folder next to `pdfcraft.exe`, so nothing is written to `%APPDATA%`; delete that file to use the normal per-user folders. +Windows doesn't offer the portable copy as a default PDF app on its own, since it registers nothing; +`portable.txt` has the PowerShell lines that register it for your user only, and how to undo them. The MSI installs for your account only by default, without administrator privileges. Choose "Everyone on this computer" on its first page, or pass `ALLUSERS=1` from an elevated terminal, to install diff --git a/packaging/windows/portable.txt b/packaging/windows/portable.txt index 9586f93060..3890323976 100644 --- a/packaging/windows/portable.txt +++ b/packaging/windows/portable.txt @@ -7,3 +7,41 @@ autosaves and new digital IDs in the PdfCraftData folder beside it, and writes n Delete this file to use the normal per-user folders (%APPDATA%\PdfCraft and %LOCALAPPDATA%\PdfCraft) instead. If PdfCraftData can't be written (for example on a read-only drive), PdfCraft says so and uses the per-user folders instead. + +Opening PDF files with this copy + +The portable copy doesn't register itself with Windows, so picking it under "Open with" may not +stick. To offer it in Settings > Apps > Default apps, open PowerShell in this folder and run: + + $exe = (Resolve-Path .\pdfcraft.exe).Path + $c = "HKCU:\Software\Classes" + $cap = "HKCU:\Software\PdfCraft.Portable\Capabilities" + foreach ($k in "$c\PdfCraft.Portable\shell\open\command", "$c\.pdf\OpenWithProgids", + "$cap\FileAssociations", "HKCU:\Software\RegisteredApplications") { + if (!(Test-Path $k)) { New-Item -Path $k -Force | Out-Null } + } + Set-Item -Path "$c\PdfCraft.Portable" -Value "PDF Document" + Set-Item -Path "$c\PdfCraft.Portable\shell\open\command" -Value "`"$exe`" `"%1`"" + New-ItemProperty -Path "$c\.pdf\OpenWithProgids" -Name PdfCraft.Portable -Value "" -PropertyType String -Force | Out-Null + New-ItemProperty -Path $cap -Name ApplicationName -Value "PdfCraft (portable)" -PropertyType String -Force | Out-Null + New-ItemProperty -Path $cap -Name ApplicationDescription -Value "Open-source PDF workbench" -PropertyType String -Force | Out-Null + New-ItemProperty -Path "$cap\FileAssociations" -Name .pdf -Value PdfCraft.Portable -PropertyType String -Force | Out-Null + New-ItemProperty -Path "HKCU:\Software\RegisteredApplications" -Name "PdfCraft (portable)" -Value "Software\PdfCraft.Portable\Capabilities" -PropertyType String -Force | Out-Null + +Then open Settings > Apps > Default apps, search for .pdf (or for PdfCraft) and choose it. This +changes only your own user's settings (HKEY_CURRENT_USER), and it doesn't change the default app by +itself. If you move or rename this folder, run it again from the new place. + +To undo, first choose another app for .pdf in Settings > Apps > Default apps (otherwise Windows +keeps pointing PDF files at the removed entry), then run: + + Remove-Item -Path "HKCU:\Software\Classes\PdfCraft.Portable", "HKCU:\Software\PdfCraft.Portable" -Recurse -ErrorAction SilentlyContinue + Remove-ItemProperty -Path "HKCU:\Software\Classes\.pdf\OpenWithProgids" -Name PdfCraft.Portable -ErrorAction SilentlyContinue + Remove-ItemProperty -Path "HKCU:\Software\RegisteredApplications" -Name "PdfCraft (portable)" -ErrorAction SilentlyContinue + foreach ($k in "HKCU:\Software\Classes\.pdf\OpenWithProgids", "HKCU:\Software\Classes\.pdf") { + $i = Get-Item -Path $k -ErrorAction SilentlyContinue + if ($i -and $i.ValueCount -eq 0 -and $i.SubKeyCount -eq 0) { Remove-Item -Path $k } + } + +The last lines delete the .pdf keys only when nothing else is left in them, so another app's entry +there stays. From 59eee050c65db1f1a7ce3873e71f8d9573339d6f Mon Sep 17 00:00:00 2001 From: mtlcl410 <2368091+mtlcl410@users.noreply.github.com> Date: Mon, 9 Nov 2026 18:24:39 +0800 Subject: [PATCH 28/64] M14: the --control file is owner-only on Windows too, and the CLI checks it (#3922) (#3956) --- AGENTS.md | 2 +- Cargo.lock | 1 + README.md | 2 +- apps/pdfcraft-cli/src/ui.rs | 87 ++- apps/pdfcraft-cli/tests/it/ui_control.rs | 18 + apps/pdfcraft/Cargo.toml | 2 + apps/pdfcraft/src/main.rs | 114 +++- apps/pdfcraft/src/tests.rs | 36 ++ crates/platform/src/lib.rs | 4 +- crates/platform/src/owner_only.rs | 736 +++++++++++++++++++++++ 10 files changed, 955 insertions(+), 47 deletions(-) create mode 100644 crates/platform/src/owner_only.rs diff --git a/AGENTS.md b/AGENTS.md index 066cad3597..6126d4887f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -150,7 +150,7 @@ If you find an asset that breaks these rules, stop and remove it from the reposi ## 3. Agent control (automation and MCP) - **Everything is reachable headlessly.** Every user-facing feature must be callable without the GUI: an engine API, then a tool in `crates/automation` (`pdfcraft-cli run` and MCP use the same table). A feature is not done until its tool exists and has an end-to-end test (`crates/automation/README.md`, "Adding a tool"). -- **The UI control channel is opt-in too.** It runs only when the app is launched with `--control `. It binds to loopback, uses a fresh random token per launch, and writes it to an owner-only file. Never enable it by default, widen the bind address, or log the token. +- **The UI control channel is opt-in too.** It runs only when the app is launched with `--control `. It binds to loopback, uses a fresh random token per launch, and writes it to an owner-only file (mode 0600 on Unix; on Windows an access list of the user and SYSTEM only, which `pdfcraft_platform::owner_only` sets and checks, and which the CLI checks again before reading the token). Never enable it by default, widen the bind address, or log the token. - **The MCP server is opt-in.** Never make the app, the CLI or an installer start it, register it with an agent, or listen on a port by default. It runs only when a user (or an agent the user configured) launches `pdfcraft-cli mcp`. Any future network transport must be off by default, bound to loopback and authenticated with a token. - **Safe by default.** Tools must never discard unsaved work silently, must write atomically, must honour `--root`, and must return errors an agent can act on (say what was wrong and what is allowed). - **Track parity honestly.** Every user-facing feature has an entry in `parity/acrobat-features.toml`. Mark it `shipped` only when it works end to end and the entry cites a passing test (`evidence`); `cargo xtask parity` (part of `xtask ci`) rejects claims whose commands, tools or tests don't exist. diff --git a/Cargo.lock b/Cargo.lock index b7ffc051f1..52333f20ff 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4268,6 +4268,7 @@ dependencies = [ "fmv-macos-events", "log", "pdfcraft-engine", + "pdfcraft-platform", "pdfcraft-ui-egui", "pollster 1.0.1", "raw-window-handle", diff --git a/README.md b/README.md index 645347d811..4b534f6e17 100644 --- a/README.md +++ b/README.md @@ -331,7 +331,7 @@ Edits stay in memory, undoable, until `doc_save`. Saving to the same file append Start the desktop app with `pdfcraft --control ~/.pdfcraft-control.json` and an agent can see and operate the real interface: the widget tree with labels and positions (from the accessibility tree), clicks, typing, keys, commands, view options and screenshots. This is also off by default. It listens only on loopback, and every connection must present the random token written to that file, which only you can read. -Keep the control file in a folder only you can write, not a shared one such as `/tmp`: another user could create the file there first and receive your commands. `pdfcraft-cli ui` refuses a control file that is a symbolic link, and on macOS, Linux and FreeBSD one that another user owns or can read or write. The app doesn't start if it can't write the file. +Keep the control file in a folder only you can write, not a shared one such as `/tmp`: another user could create the file there first and receive your commands. `pdfcraft-cli ui` refuses a control file that is a symbolic link, or one that another user owns, or that another user or a group such as Administrators can read or write: on macOS, Linux and FreeBSD by its owner and mode (the app writes it with mode 600), on Windows by its owner and access list (the app gives it one with only you and SYSTEM on it). The app doesn't start if it can't write the file. On Windows, cmd.exe doesn't expand `~`: use `%USERPROFILE%\.pdfcraft-control.json` there. ```sh pdfcraft-cli ui --control ~/.pdfcraft-control.json inspect query=rotate # find widgets diff --git a/apps/pdfcraft-cli/src/ui.rs b/apps/pdfcraft-cli/src/ui.rs index dc419171b0..0ad18c154b 100644 --- a/apps/pdfcraft-cli/src/ui.rs +++ b/apps/pdfcraft-cli/src/ui.rs @@ -50,18 +50,23 @@ pub(crate) fn ui(args: &[String]) -> Result<(), CliError> { /// Most a control file may hold. The app writes under 100 bytes. const CONTROL_FILE_MAX: u64 = 64 * 1024; +#[cfg(not(windows))] const CONTROL_FILE_ADVICE: &str = "Start the app with a control file in a folder only you can write, such as `pdfcraft --control ~/.pdfcraft-control.json`"; +// cmd.exe doesn't expand `~` (#3922). +#[cfg(windows)] +const CONTROL_FILE_ADVICE: &str = + r"Start the app with a control file in a folder only you can write, such as `pdfcraft --control %USERPROFILE%\.pdfcraft-control.json`"; /// Read the file written by `pdfcraft --control FILE`. /// /// It names the port that receives the token and every command, typed text included, so it must /// be the user's own. In a shared folder such as `/tmp`, another local user can create the file /// first; the app then can't write it, and the CLI would talk to the other user's listener. -/// Refused: a symbolic link, anything but a regular file, and on Unix a file owned by someone -/// else or open to other users (the app writes it with mode 600). +/// Refused: a symbolic link, anything but a regular file, and a file owned by someone else or +/// open to other users: on Unix by its owner and mode (the app writes it with mode 600), on +/// Windows by its owner and access list (the app gives it one of its user and SYSTEM, #3922). fn read_control_file(file: &str) -> Result { - use std::io::Read as _; let at_path = std::fs::symlink_metadata(file).map_err(|e| format!("{file}: {e}"))?; if at_path.file_type().is_symlink() { return Err(format!("{file}: is a symbolic link, which could point at another user's file. {CONTROL_FILE_ADVICE}")); @@ -69,21 +74,41 @@ fn read_control_file(file: &str) -> Result { if !at_path.is_file() { return Err(format!("{file}: not a regular file. {CONTROL_FILE_ADVICE}")); } - let f = std::fs::File::open(file).map_err(|e| format!("{file}: {e}"))?; - #[cfg(unix)] - { - use std::os::unix::fs::MetadataExt; - // Checked on the open file, so nothing swapped in after `symlink_metadata` gets through. - let opened = f.metadata().map_err(|e| format!("{file}: {e}"))?; - if (opened.dev(), opened.ino()) != (at_path.dev(), at_path.ino()) { - return Err(format!("{file}: was replaced while being opened. {CONTROL_FILE_ADVICE}")); + #[cfg(not(windows))] + let text = { + use std::io::Read as _; + let f = std::fs::File::open(file).map_err(|e| format!("{file}: {e}"))?; + #[cfg(unix)] + { + use std::os::unix::fs::MetadataExt; + // Checked on the open file, so nothing swapped in after `symlink_metadata` gets through. + let opened = f.metadata().map_err(|e| format!("{file}: {e}"))?; + if (opened.dev(), opened.ino()) != (at_path.dev(), at_path.ino()) { + return Err(format!("{file}: was replaced while being opened. {CONTROL_FILE_ADVICE}")); + } + if let Some(problem) = control_file_problem(opened.uid(), opened.mode(), current_uid()) { + return Err(format!("{file}: {problem}. {CONTROL_FILE_ADVICE}")); + } } - if let Some(problem) = control_file_problem(opened.uid(), opened.mode(), current_uid()) { + let mut text = String::new(); + f.take(CONTROL_FILE_MAX.saturating_add(1)).read_to_string(&mut text).map_err(|e| format!("{file}: {e}"))?; + text + }; + // Windows: the same owner and access check, on the security descriptor of the very handle the + // contents are read through, so nothing swapped in at the path gets through (#3922). A file + // planted by another user, or one other users can read, is refused; if the check itself can't + // run, the file isn't trusted either. + #[cfg(windows)] + let text = { + use pdfcraft_platform::owner_only; + let me = owner_only::current_user_sid().map_err(|e| format!("{file}: can't check who owns it: {e}. {CONTROL_FILE_ADVICE}"))?; + let (sddl, bytes) = owner_only::read_with_sddl(std::path::Path::new(file), CONTROL_FILE_MAX) + .map_err(|e| format!("{file}: can't check who owns it: {e}. {CONTROL_FILE_ADVICE}"))?; + if let Some(problem) = owner_only::foreign_access(&sddl, &me) { return Err(format!("{file}: {problem}. {CONTROL_FILE_ADVICE}")); } - } - let mut text = String::new(); - f.take(CONTROL_FILE_MAX.saturating_add(1)).read_to_string(&mut text).map_err(|e| format!("{file}: {e}"))?; + String::from_utf8(bytes).map_err(|e| format!("{file}: {e}"))? + }; if u64::try_from(text.len()).unwrap_or(u64::MAX) > CONTROL_FILE_MAX { return Err(format!("{file}: too large for a control file (over {} KiB)", CONTROL_FILE_MAX / 1024)); } @@ -112,6 +137,38 @@ fn current_uid() -> u32 { mod tests { use super::control_file_problem; + /// #3922: on Windows the CLI checks the control file's owner and access list. A file other + /// users can read is refused; the same file restricted to its owner is accepted. + #[cfg(windows)] + #[test] + fn on_windows_a_control_file_other_users_can_read_is_refused() { + let dir = std::env::temp_dir().join(format!("pdfcraft-cli-control-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + let users = std::process::Command::new("icacls").arg(&dir).arg("/grant").arg("*S-1-5-32-545:(OI)(CI)R").output().unwrap(); + assert!(users.status.success()); + let file = dir.join("ctl.json"); + std::fs::write(&file, r#"{"port":4242,"token":"t0ken"}"#).unwrap(); + let path = file.to_string_lossy().into_owned(); + let refused = super::read_control_file(&path).unwrap_err(); + assert!(refused.contains("another account") && refused.contains("%USERPROFILE%"), "{refused}"); + pdfcraft_platform::owner_only::restrict_to_owner(&file, &pdfcraft_platform::owner_only::current_user_sid().unwrap()).unwrap(); + assert_eq!(super::read_control_file(&path).unwrap()["token"].as_str(), Some("t0ken")); + // The contents come with the check, capped: exactly the limit is read, one byte more is refused. + let padded = |len: usize| { + let json = r#"{"port":4242,"token":"t0ken"}"#; + json.to_string() + &" ".repeat(len - json.len()) + }; + std::fs::write(&file, padded(64 * 1024)).unwrap(); + assert_eq!(super::read_control_file(&path).unwrap()["port"].as_u64(), Some(4242)); + std::fs::write(&file, padded(64 * 1024 + 1)).unwrap(); + assert!(super::read_control_file(&path).unwrap_err().contains("too large")); + // Valid JSON once the byte is replaced by U+FFFD, so only a strict UTF-8 read refuses it. + std::fs::write(&file, b"{\"port\":4242,\"token\":\"\xFF\"}").unwrap(); + assert!(super::read_control_file(&path).is_err(), "not UTF-8"); + let _ = std::fs::remove_dir_all(&dir); + } + #[test] fn a_control_file_must_be_yours_and_private() { // Regular file type bits (0o100000) don't matter, only the owner and the permission bits. diff --git a/apps/pdfcraft-cli/tests/it/ui_control.rs b/apps/pdfcraft-cli/tests/it/ui_control.rs index f49771b001..082faf3426 100644 --- a/apps/pdfcraft-cli/tests/it/ui_control.rs +++ b/apps/pdfcraft-cli/tests/it/ui_control.rs @@ -86,6 +86,24 @@ fn write_private(path: &Path, text: &str) { opts.mode(0o600); } opts.open(path).unwrap().write_all(text.as_bytes()).unwrap(); + // On Windows the file inherits its folder's access list, as the app's would before it + // restricts it (#3922). + #[cfg(windows)] + { + use pdfcraft_platform::owner_only; + owner_only::restrict_to_owner(path, &owner_only::current_user_sid().unwrap()).unwrap(); + // The file the CLI is handed must already pass its check, or a refusal below would be + // the helper's fault, not the CLI's. + let sddl = owner_only::sddl_of(path).unwrap(); + let listing = |p: &Path| Command::new("icacls").arg(p).output().map(|o| String::from_utf8_lossy(&o.stdout).into_owned()); + let folder = path.parent().map(listing); + assert_eq!( + owner_only::foreign_access(&sddl, &owner_only::current_user_sid().unwrap()), + None, + "restricted file: {sddl}; icacls: {:?}; folder: {folder:?}", + listing(path) + ); + } } fn ui(control: &Path, method: &str) -> Output { diff --git a/apps/pdfcraft/Cargo.toml b/apps/pdfcraft/Cargo.toml index 966af354d1..94e1de896c 100644 --- a/apps/pdfcraft/Cargo.toml +++ b/apps/pdfcraft/Cargo.toml @@ -14,6 +14,8 @@ workspace = true pdfcraft-ui-egui = { workspace = true } # `build_info`: the version and compile-time build label for `--version` and the log. pdfcraft-engine = { workspace = true } +# `owner_only`: the --control file is restricted to its user on Windows (#3922). +pdfcraft-platform = { workspace = true } # wgpu draws; OpenGL (glow) is the fallback when wgpu can't start on an old or unusual GPU or # driver (#461, #435, #392), and can be chosen with PDFCRAFT_RENDERER=gl. eframe = { workspace = true, features = ["default_fonts", "wgpu", "glow", "accesskit", "wayland", "x11", "persistence", "links", "clipboard"] } diff --git a/apps/pdfcraft/src/main.rs b/apps/pdfcraft/src/main.rs index 58ce6ba8ec..d777ddc269 100644 --- a/apps/pdfcraft/src/main.rs +++ b/apps/pdfcraft/src/main.rs @@ -514,7 +514,9 @@ fn open_control_channel(file: &str, endpoint: std::io::Result { - let message = format!("--control {file}: {e}. Use a control file in a folder only you can write, such as ~/.pdfcraft-control.json"); + // cmd.exe doesn't expand `~` (#3922). + let example = if cfg!(windows) { r"%USERPROFILE%\.pdfcraft-control.json" } else { "~/.pdfcraft-control.json" }; + let message = format!("--control {file}: {e}. Use a control file in a folder only you can write, such as {example}"); log::error!("{message}"); Err(message) } @@ -523,15 +525,25 @@ fn open_control_channel(file: &str, endpoint: std::io::Result std::io::Result<()> { - use std::io::{ErrorKind, Write}; + use std::io::ErrorKind; let json = serde_json::json!({ "port": port, "token": token, "pid": std::process::id() }).to_string(); let path = std::path::Path::new(path); let name = path.file_name().ok_or_else(|| std::io::Error::new(ErrorKind::InvalidInput, "not a file name"))?; + // Windows: the user's SID, read once for the private folder and for the file in it, since each + // lookup starts `whoami` (review on #3956). + #[cfg(windows)] + let me = pdfcraft_platform::owner_only::current_user_sid()?; + #[cfg(windows)] + let restrict = |file: &std::path::Path| pdfcraft_platform::owner_only::restrict_to_owner(file, &me); + #[cfg(not(windows))] + let restrict = |_: &std::path::Path| Ok::<(), std::io::Error>(()); // Unpredictable suffixes, so the name can't be planted in advance. `RandomState` is keyed from // the operating system's random source. let random = std::hash::RandomState::new(); @@ -540,35 +552,79 @@ fn write_control_file(path: &str, port: u16, token: &str) -> std::io::Result<()> staged.push(name); staged.push(format!(".{:016x}.tmp", std::hash::BuildHasher::hash_one(&random, i))); let staged = path.with_file_name(staged); - let mut opts = std::fs::OpenOptions::new(); - opts.write(true).create_new(true); - #[cfg(unix)] - { - use std::os::unix::fs::OpenOptionsExt; - opts.mode(0o600); - } - let file = match opts.open(&staged) { - Ok(f) => f, - Err(e) if e.kind() == ErrorKind::AlreadyExists => continue, - // Windows refuses `create_new` on a folder with "access denied"; the name is taken all - // the same. A folder we can't write to, with nothing at the name, still fails here. - Err(e) if e.kind() == ErrorKind::PermissionDenied && staged.symlink_metadata().is_ok() => continue, - Err(e) => return Err(e), + // Windows: a file created next to `path` would inherit that folder's access list, which may + // let other users read it, and restricting it afterwards leaves a window in which one of + // them opens a handle; Windows checks access only when a handle is opened, so a later + // change of the list doesn't revoke it. The file is created in a private folder instead, + // and that folder is removed once the file has left it. + #[cfg(windows)] + let (staging, staged) = { + match pdfcraft_platform::owner_only::create_private_dir(&staged, &me) { + Ok(()) => {} + Err(e) if e.kind() == ErrorKind::AlreadyExists => continue, + // As for a file below: something at the name we may not open still takes it. + Err(e) if e.kind() == ErrorKind::PermissionDenied && staged.symlink_metadata().is_ok() => continue, + Err(e) => return Err(e), + } + let file = staged.join(name); + (staged, file) }; - // The block closes the file before it is renamed. - let written = { - let mut file = file; - file.write_all(json.as_bytes()) - }; - let done = written.and_then(|()| std::fs::rename(&staged, path)); - if done.is_err() { - let _ = std::fs::remove_file(&staged); + let done = stage_control_file(&staged, path, &json, &restrict); + #[cfg(windows)] + let _ = std::fs::remove_dir(&staging); + match done { + Some(done) => return done, + None => continue, } - return done; } Err(std::io::Error::new(ErrorKind::AlreadyExists, "no free name for the control file's temporary copy")) } +/// Create `staged` fresh and owner-only, `restrict` it, write `json` to it and rename it to `path`. +/// `None` when something already has the name, so the caller tries another. On any other failure +/// the new file is removed and nothing is left at `path`'s side. +fn stage_control_file( + staged: &std::path::Path, + path: &std::path::Path, + json: &str, + restrict: &dyn Fn(&std::path::Path) -> std::io::Result<()>, +) -> Option> { + use std::io::{ErrorKind, Write}; + let mut opts = std::fs::OpenOptions::new(); + opts.write(true).create_new(true); + #[cfg(unix)] + { + use std::os::unix::fs::OpenOptionsExt; + opts.mode(0o600); + } + let file = match opts.open(staged) { + Ok(f) => f, + Err(e) if e.kind() == ErrorKind::AlreadyExists => return None, + // Windows refuses `create_new` on a folder with "access denied"; the name is taken all + // the same. A folder we can't write to, with nothing at the name, still fails here. + Err(e) if e.kind() == ErrorKind::PermissionDenied && staged.symlink_metadata().is_ok() => return None, + Err(e) => return Some(Err(e)), + }; + // Windows: the file inherited the private folder's list. It also gets a list of its own, so it + // stays private wherever the rename puts it, and that is checked before the token is written; + // nothing is written if that fails (#3922). Unix has mode 0600 above, and `restrict` does nothing. + if let Err(e) = restrict(staged) { + drop(file); + let _ = std::fs::remove_file(staged); + return Some(Err(e)); + } + // The block closes the file before it is renamed. + let written = { + let mut file = file; + file.write_all(json.as_bytes()) + }; + let done = written.and_then(|()| std::fs::rename(staged, path)); + if done.is_err() { + let _ = std::fs::remove_file(staged); + } + Some(done) +} + /// How wgpu finds a GPU. Each choice yields to its wgpu environment variable. /// /// - Draw on the integrated GPU unless `WGPU_POWER_PREF` says otherwise. A PDF viewer has no use diff --git a/apps/pdfcraft/src/tests.rs b/apps/pdfcraft/src/tests.rs index 3512f46b36..ebd13dccc8 100644 --- a/apps/pdfcraft/src/tests.rs +++ b/apps/pdfcraft/src/tests.rs @@ -654,6 +654,42 @@ fn the_control_file_replaces_the_one_a_previous_run_left() { let _ = std::fs::remove_dir_all(&dir); } +/// #3922: a control file created in a folder that lets every user read what's in it is still +/// readable by its user and SYSTEM only, like mode 0600 on Unix. +#[cfg(windows)] +#[test] +fn the_control_file_is_owner_only_in_a_folder_other_users_can_read() { + use pdfcraft_platform::owner_only; + let dir = scratch(); + let users = std::process::Command::new("icacls").arg(&dir).arg("/grant").arg("*S-1-5-32-545:(OI)(CI)R").output().unwrap(); + assert!(users.status.success()); + // What the folder hands down, for comparison: a plain file there is readable by Users. + std::fs::write(dir.join("plain.txt"), "x").unwrap(); + let me = owner_only::current_user_sid().unwrap(); + assert!(owner_only::foreign_access(&owner_only::sddl_of(&dir.join("plain.txt")).unwrap(), &me).is_some()); + super::write_control_file(&dir.join("ctl.json").to_string_lossy(), 4242, "t0ken").unwrap(); + let sddl = owner_only::sddl_of(&dir.join("ctl.json")).unwrap(); + assert_eq!(owner_only::foreign_access(&sddl, &me), None, "{sddl}"); + let (json, names) = written(&dir); + assert_eq!(json["token"].as_str(), Some("t0ken")); + assert_eq!(names, ["ctl.json", "plain.txt"], "no private staging folder left behind"); + let _ = std::fs::remove_dir_all(&dir); +} + +/// Review on #3956: writing the control file reads the user's SID once, for both the private +/// folder and the file in it, rather than starting `whoami` for each. +#[cfg(windows)] +#[test] +fn writing_the_control_file_looks_up_the_users_sid_once() { + use pdfcraft_platform::owner_only::sid_lookups_on_this_thread; + let dir = scratch(); + let before = sid_lookups_on_this_thread(); + super::write_control_file(&dir.join("ctl.json").to_string_lossy(), 4242, "t0ken").unwrap(); + assert_eq!(sid_lookups_on_this_thread().saturating_sub(before), 1, "whoami runs once per control file"); + assert_eq!(written(&dir).0["token"].as_str(), Some("t0ken")); + let _ = std::fs::remove_dir_all(&dir); +} + /// X11 startup notification and docks match a window to its launcher by `StartupWMClass`, /// so it has to be the class the window carries: the app id, on Wayland and X11 alike. #[test] diff --git a/crates/platform/src/lib.rs b/crates/platform/src/lib.rs index ba8f8ab50a..7763cb8102 100644 --- a/crates/platform/src/lib.rs +++ b/crates/platform/src/lib.rs @@ -1,8 +1,10 @@ //! pdfcraft-platform — L7 operating-system services shared by the desktop app (`ui-egui`) and the -//! automation tools (`automation`), which sit on the same layer and can't depend on each other. +//! automation tools (`automation`), which sit on the same layer and can't depend on each other, +//! and by the two executables (`owner_only`, for the `--control` file). #![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] pub mod desktop_theme; +pub mod owner_only; pub mod staging; pub mod zone; diff --git a/crates/platform/src/owner_only.rs b/crates/platform/src/owner_only.rs new file mode 100644 index 0000000000..7c181dab48 --- /dev/null +++ b/crates/platform/src/owner_only.rs @@ -0,0 +1,736 @@ +//! Owner-only files on Windows (#3922): the `--control` token file must be readable by its user +//! alone, as mode 0600 makes it on Unix. +//! +//! A file created in a folder inherits the folder's access list, and folders such as a new folder +//! at a drive root, `C:\Users\Public` or `C:\ProgramData` let every signed-in user read what is +//! created in them. [`restrict_to_owner`] replaces the whole list with the current user and +//! SYSTEM only, makes that user the owner, and checks the result. [`create_private_dir`] does the +//! same for a new folder whose list the files created in it inherit, so a file can be private from +//! the moment it exists. [`read_with_sddl`] reads a file and +//! its security descriptor through one handle. [`foreign_access`] reads the descriptor in SDDL +//! form, which names accounts by SID and so reads the same in every Windows language, and says why +//! another user could have written or could read the file. They run the tools Windows ships rather +//! than calling the security API directly, which would need `unsafe`. + +/// Why the file whose security descriptor is `sddl` (`O:` owner and `D:` access list, in SDDL or +/// as [`read_with_sddl`] writes it) can't be trusted as user `me`'s own: it is owned by someone else, it has no +/// access list (everyone may use it), or it lets an account other than `me` or SYSTEM use it. +/// Accounts are compared by SID; `None` means it is `me`'s alone. +/// +/// Stricter than OpenSSH's rule for private keys, which also lets Administrators in: an entry for +/// the Administrators group would let another administrator read the token without taking the +/// file over. [`restrict_to_owner`] never grants it, so the app's own file always passes; a file +/// that inherited it from a user's folder is refused. +pub fn foreign_access(sddl: &str, me: &str) -> Option { + let me = me.trim(); + let owner = section(sddl, "O:"); + match owner { + Some(o) if same_account(o, me) => {} + Some(o) => return Some(format!("it is owned by {}", another_account(o, ""))), + None => return Some("its owner can't be read".into()), + } + let Some(dacl) = section(sddl, "D:") else { + return Some("it has no access list, so every user may read it".into()); + }; + if dacl.starts_with("NO_ACCESS_CONTROL") { + return Some("it has no access control, so every user may read it".into()); + } + for ace in dacl.split('(').skip(1) { + let ace = ace.split(')').next().unwrap_or_default(); + let parts: Vec<&str> = ace.split(';').collect(); + // `type;flags;rights;object guid;inherited object guid;account SID[;(condition)]`. + let (Some(kind), Some(flags)) = (parts.first(), parts.get(1)) else { continue }; + // Deny entries take access away, and inherit-only entries apply to children, not the file. + let allows = matches!(*kind, "A" | "OA" | "XA" | "ZA"); + if !allows || flags.contains("IO") { + continue; + } + // An entry that grants access but names no account can't be shown to be the user's. + let Some(sid) = parts.get(5).filter(|s| !s.is_empty()) else { + return Some(format!("its access list has an entry that can't be read ({ace})")); + }; + if !(same_account(sid, me) || matches!(*sid, "SY" | "S-1-5-18" | "OW" | "S-1-3-4")) { + return Some(another_account(sid, "has access to it")); + } + } + None +} + +/// `another account () `, with the account's name before its SID when it is one of the +/// well-known accounts a control file can carry, such as `another account, Administrators +/// (S-1-5-32-544), has access to it` (review on #3956). The SID stays, so the message reads the +/// same in every Windows language and can still be looked up. +fn another_account(sid: &str, rest: &str) -> String { + match (well_known_name(sid), rest.is_empty()) { + (Some(name), true) => format!("another account, {name} ({sid})"), + (Some(name), false) => format!("another account, {name} ({sid}), {rest}"), + (None, true) => format!("another account ({sid})"), + (None, false) => format!("another account ({sid}) {rest}"), + } +} + +/// The English name Windows gives a well-known SID that can be on a file's access list or own it: +/// those a folder hands down at a drive root, in `C:\Users\Public` and in a user's profile, and +/// Everyone. The SIDs and names are Microsoft's public list of well-known SIDs +/// (); the names are +/// the ones `icacls` shows, without the `BUILTIN\` or `NT AUTHORITY\` prefix. Any other SID, +/// a user's or a domain group's, has no fixed name and gets `None`. +fn well_known_name(sid: &str) -> Option<&'static str> { + const NAMES: [(&str, &str); 8] = [ + ("S-1-1-0", "Everyone"), + ("S-1-5-3", "BATCH"), + ("S-1-5-4", "INTERACTIVE"), + ("S-1-5-6", "SERVICE"), + ("S-1-5-11", "Authenticated Users"), + ("S-1-5-18", "SYSTEM"), + ("S-1-5-32-544", "Administrators"), + ("S-1-5-32-545", "Users"), + ]; + NAMES.iter().find(|(known, _)| known.eq_ignore_ascii_case(sid)).map(|&(_, name)| name) +} + +/// `section(sddl, "O:")` is the owner, `"D:"` the access list: the text after the tag up to the +/// next top-level tag. +fn section<'a>(sddl: &'a str, tag: &str) -> Option<&'a str> { + let start = sddl.find(tag)?.checked_add(tag.len())?; + let rest = sddl.get(start..)?; + // The owner and group are bare SIDs; the access lists run until the next `X:` tag outside + // parentheses. + let mut depth = 0usize; + let mut end = rest.len(); + for (i, c) in rest.char_indices() { + match c { + '(' => depth = depth.saturating_add(1), + ')' => depth = depth.saturating_sub(1), + ':' if depth == 0 && i > 0 => { + // The tag is the one letter before this colon. + end = rest.get(..i).and_then(|s| s.char_indices().last()).map_or(i, |(j, _)| j); + break; + } + _ => {} + } + } + rest.get(..end).map(str::trim) +} + +/// Whether account `a` names user SID `me`. `OW` (owner rights) and `CO` are not users. An alias +/// is never taken for the user: [`read_with_sddl`] gives every account as a full SID, because +/// Windows' own SDDL writes some users by alias (the built-in Administrator as `LA`, whose SID +/// depends on the machine). +fn same_account(a: &str, me: &str) -> bool { + a.eq_ignore_ascii_case(me) +} + +/// The tools Windows ships that these checks run, by their path under `%SystemRoot%\System32`. +#[cfg(windows)] +const WHOAMI: &str = "whoami.exe"; +#[cfg(windows)] +const POWERSHELL: &str = r"WindowsPowerShell\v1.0\powershell.exe"; +#[cfg(windows)] +const ICACLS: &str = "icacls.exe"; + +/// A tool Windows ships, started by its full path (review on #3956). Started by bare name, a +/// `whoami.exe`, `powershell.exe` or `icacls.exe` found first (next to the app, or earlier on the +/// search path) would answer instead, and `whoami`'s answer is what every check here trusts. +#[cfg(windows)] +fn system_tool(relative: &str) -> std::process::Command { + let root = std::env::var_os("SystemRoot").map_or_else(|| std::path::PathBuf::from(r"C:\Windows"), std::path::PathBuf::from); + std::process::Command::new(root.join("System32").join(relative)) +} + +#[cfg(windows)] +thread_local! { + /// How many times [`current_user_sid`] ran on this thread, for [`sid_lookups_on_this_thread`]. + static SID_LOOKUPS: std::cell::Cell = const { std::cell::Cell::new(0) }; +} + +/// How many times [`current_user_sid`] has started `whoami` on the calling thread. Each lookup +/// starts a process, so a test can check that a caller reads the SID once and passes it on +/// (review on #3956). Per thread, so tests running in parallel don't count each other's. +#[cfg(windows)] +#[doc(hidden)] +pub fn sid_lookups_on_this_thread() -> usize { + SID_LOOKUPS.with(std::cell::Cell::get) +} + +/// The current user's SID (`whoami /user`). It starts a process, so a caller that needs it for +/// several steps reads it once and passes it to each. +#[cfg(windows)] +pub fn current_user_sid() -> std::io::Result { + SID_LOOKUPS.with(|n| n.set(n.get().saturating_add(1))); + let out = system_tool(WHOAMI).args(["/user", "/fo", "csv", "/nh"]).output()?; + if !out.status.success() { + return Err(std::io::Error::other("whoami /user failed")); + } + sid_from_whoami(&String::from_utf8_lossy(&out.stdout)).ok_or_else(|| std::io::Error::other("whoami /user printed no SID")) +} + +#[cfg(any(test, windows))] +/// The SID in `whoami /user /fo csv /nh` output, `"DOMAIN\user","S-1-5-21-…"`: the last quoted +/// field, when it is one. Anything else (an error message, nothing) gives `None`. +fn sid_from_whoami(text: &str) -> Option { + let last = text.trim().rsplit(',').next()?.trim().trim_matches('"'); + let digits = last.strip_prefix("S-1-")?; + // Every part after `S-1-` is a number; an empty remainder is one empty part, so it fails too. + digits.split('-').all(|part| !part.is_empty() && part.bytes().all(|b| b.is_ascii_digit())).then(|| last.to_string()) +} + +/// Replace `path`'s access list with one that only user `me` (the current user's SID, from +/// [`current_user_sid`], which the caller reads once for every step) and SYSTEM are on, then make +/// that user its owner (`icacls /setowner`), then read the file's descriptor back and check it with +/// [`foreign_access`]. The whole list is replaced, not just what was inherited: on the +/// windows-latest runner a new file in the temp folder carries an Administrators entry that `icacls +/// /inheritance:r` leaves in place. The owner is set because a file an elevated administrator creates +/// is owned by the Administrators group, not by the user. The list comes first: its owner may always +/// replace it, while setting the owner needs the Full control it grants (a folder may give Modify). +#[cfg(windows)] +pub fn restrict_to_owner(path: &std::path::Path, me: &str) -> std::io::Result<()> { + set_access_list(path, me, Kind::File)?; + icacls(path, &["/setowner".into(), format!("*{me}")])?; + still_foreign(&sddl_of(path)?, me) +} + +/// Create the folder `dir`, which must not exist yet, and give it an access list of user `me` (as +/// for [`restrict_to_owner`]) and SYSTEM only, which every file created in it inherits; then make +/// that user its owner and check the result as [`restrict_to_owner`] does. A file created in it is +/// private from the moment it exists. Restricting a file after creating it leaves a window in which another user +/// whom the parent folder lets read it can open a handle, and Windows checks access only when a +/// handle is opened, so a later change of the list doesn't revoke it (review on #3956). A handle +/// on the folder itself, opened during the folder's own window, gives no access to the files +/// created in it later. The folder is removed again when it can't be restricted. +#[cfg(windows)] +pub fn create_private_dir(dir: &std::path::Path, me: &str) -> std::io::Result<()> { + std::fs::create_dir(dir)?; + fn restrict(dir: &std::path::Path, me: &str) -> std::io::Result<()> { + set_access_list(dir, me, Kind::Folder)?; + icacls(dir, &["/setowner".into(), format!("*{me}")])?; + still_foreign(&sddl_of(dir)?, me) + } + let restricted = restrict(dir, me); + if restricted.is_err() { + let _ = std::fs::remove_dir(dir); + } + restricted +} + +/// `Err` naming the problem when the descriptor read back after restricting still isn't `me`'s +/// alone, so a file that only looks restricted is never used as one. +#[cfg(any(test, windows))] +fn still_foreign(sddl: &str, me: &str) -> std::io::Result<()> { + match foreign_access(sddl, me) { + None => Ok(()), + Some(problem) => Err(std::io::Error::other(format!("the file could not be restricted to its owner: {problem}"))), + } +} + +/// What [`set_access_list`] restricts: a file, or a folder whose entries the files and folders +/// created in it inherit. +#[cfg(windows)] +#[derive(Clone, Copy)] +enum Kind { + File, + Folder, +} + +/// Set `path`'s access list to Full control for `me` and SYSTEM and nothing else, protected from +/// inheritance; on a folder, what is created in it inherits the two entries. Only the list is +/// written, not the owner. `me` and the path reach PowerShell through environment variables, never +/// as script text. +#[cfg(windows)] +fn set_access_list(path: &std::path::Path, me: &str, kind: Kind) -> std::io::Result<()> { + // No double quotes: the command line would strip them. + const FILE: &str = "$s = New-Object Security.AccessControl.FileSecurity; \ + $s.SetSecurityDescriptorSddlForm('D:P(A;;FA;;;' + $env:PDFCRAFT_ACL_USER + ')(A;;FA;;;SY)', 'Access'); \ + [IO.File]::SetAccessControl($env:PDFCRAFT_ACL_PATH, $s)"; + const FOLDER: &str = "$s = New-Object Security.AccessControl.DirectorySecurity; \ + $s.SetSecurityDescriptorSddlForm('D:P(A;OICI;FA;;;' + $env:PDFCRAFT_ACL_USER + ')(A;OICI;FA;;;SY)', 'Access'); \ + [IO.Directory]::SetAccessControl($env:PDFCRAFT_ACL_PATH, $s)"; + let script = match kind { + Kind::File => FILE, + Kind::Folder => FOLDER, + }; + let out = system_tool(POWERSHELL) + .args(["-NoProfile", "-NonInteractive", "-Command", script]) + .env("PDFCRAFT_ACL_PATH", path) + .env("PDFCRAFT_ACL_USER", me) + .output()?; + if out.status.success() { + Ok(()) + } else { + Err(std::io::Error::other(format!("PowerShell could not restrict the file to its owner: {}", String::from_utf8_lossy(&out.stderr).trim()))) + } +} + +#[cfg(windows)] +fn icacls(path: &std::path::Path, args: &[String]) -> std::io::Result<()> { + let out = system_tool(ICACLS).arg(path).args(args).output()?; + if out.status.success() { + Ok(()) + } else { + Err(std::io::Error::other(format!("icacls could not restrict the file to its owner: {}", String::from_utf8_lossy(&out.stderr).trim()))) + } +} + +/// PowerShell that turns `$raw`, a `RawSecurityDescriptor`, into `$sddl` as [`read_with_sddl`] +/// describes it. No double quotes: the command line would strip them. An entry with no account (a +/// custom entry) is written with none, and `foreign_access` refuses it. +#[cfg(windows)] +macro_rules! describe_raw { + () => { + "$sddl = 'O:' + $raw.Owner.Value; if ($raw.DiscretionaryAcl -ne $null) { $sddl += 'D:'; foreach ($a in $raw.DiscretionaryAcl) { $t = 'A'; if ([string]$a.AceType -like 'AccessDenied*') { $t = 'D' }; $f = ''; if ($a.AceFlags.HasFlag([Security.AccessControl.AceFlags]::InheritOnly)) { $f = 'IO' }; $sid = ''; if ($a.SecurityIdentifier) { $sid = $a.SecurityIdentifier.Value }; $sddl += '(' + $t + ';' + $f + ';' + $a.AccessMask + ';;;' + $sid + ')' } }; " + }; +} + +/// Open `path` shared for reading only, so that while it is open it can't be replaced, renamed or +/// written, and read through that one handle both its security descriptor (owner and access list) +/// and its first `max + 1` bytes. The descriptor checked is the one of the handle the bytes come +/// from, so there is no moment at which the path could name a different file (a time-of-check to +/// time-of-use race). The path reaches PowerShell through an environment variable, never as script +/// text. +/// +/// The descriptor is written in SDDL's shape but with every account as a full SID, read from the +/// binary descriptor (`RawSecurityDescriptor`): `O:D:(;;;;;)…`, +/// where the type is `D` for a deny entry and `A` for any other, and the flags are `IO` for an +/// inherit-only entry. Windows' own SDDL writes some accounts by alias, and the built-in +/// Administrator's alias `LA` stands for a SID that depends on the machine. +#[cfg(windows)] +pub fn read_with_sddl(path: &std::path::Path, max: u64) -> std::io::Result<(String, Vec)> { + const SCRIPT: &str = concat!( + "$fs = [IO.File]::Open($env:PDFCRAFT_ACL_PATH, 'Open', 'Read', 'Read'); try { $raw = New-Object Security.AccessControl.RawSecurityDescriptor -ArgumentList @($fs.GetAccessControl().GetSecurityDescriptorBinaryForm(), 0); ", + describe_raw!(), + "$max = [int]$env:PDFCRAFT_ACL_MAX + 1; $buf = New-Object byte[] $max; $total = 0; while ($total -lt $max) { $n = $fs.Read($buf, $total, $max - $total); if ($n -le 0) { break }; $total += $n }; [Console]::Out.Write($sddl + [char]10 + [BitConverter]::ToString($buf, 0, $total)) } finally { $fs.Dispose() }" + ); + // Bounded so the script's `[int]` and buffer stay small; a control file is far below this. + let max = max.min(1 << 20); + let out = system_tool(POWERSHELL) + .args(["-NoProfile", "-NonInteractive", "-Command", SCRIPT]) + .env("PDFCRAFT_ACL_PATH", path) + .env("PDFCRAFT_ACL_MAX", max.to_string()) + .output()?; + let read = if out.status.success() { sddl_and_bytes(&String::from_utf8_lossy(&out.stdout)) } else { None }; + read.ok_or_else(|| std::io::Error::other("PowerShell could not read the file with its owner and access list")) +} + +/// The security descriptor of `path` as [`read_with_sddl`] writes it, read by name without opening +/// the file's contents, so it works on a file that is open for writing (the app restricts its +/// control file before writing the token through the handle it created). +#[cfg(windows)] +pub fn sddl_of(path: &std::path::Path) -> std::io::Result { + const SCRIPT: &str = concat!( + // Stop: a failed read (a missing file) must end the script with an error, not print `O:`. + "$ErrorActionPreference = 'Stop'; $raw = New-Object Security.AccessControl.RawSecurityDescriptor -ArgumentList @([IO.File]::GetAccessControl($env:PDFCRAFT_ACL_PATH).GetSecurityDescriptorBinaryForm(), 0); ", + describe_raw!(), + "[Console]::Out.Write($sddl + [char]10)" + ); + let out = system_tool(POWERSHELL).args(["-NoProfile", "-NonInteractive", "-Command", SCRIPT]).env("PDFCRAFT_ACL_PATH", path).output()?; + let read = if out.status.success() { sddl_and_bytes(&String::from_utf8_lossy(&out.stdout)) } else { None }; + read.map(|(sddl, _)| sddl).ok_or_else(|| std::io::Error::other("PowerShell could not read the file's owner and access list")) +} + +/// [`read_with_sddl`]'s output: the SDDL, a newline, then the bytes as `[BitConverter]::ToString` +/// prints them (`7B-22-…`, empty for none). Anything else gives `None`. +#[cfg(any(test, windows))] +fn sddl_and_bytes(text: &str) -> Option<(String, Vec)> { + let (sddl, hex) = text.split_once('\n')?; + let sddl = sddl.trim(); + if !sddl.contains("O:") { + return None; + } + let hex = hex.trim(); + if hex.is_empty() { + return Some((sddl.to_string(), Vec::new())); + } + // Two hex digits each; `from_str_radix` alone would also take a sign, as in `+7`. + let byte = |pair: &str| (pair.len() == 2 && pair.bytes().all(|b| b.is_ascii_hexdigit())).then(|| u8::from_str_radix(pair, 16).ok()).flatten(); + let bytes = hex.split('-').map(byte).collect::>>()?; + Some((sddl.to_string(), bytes)) +} + +#[cfg(test)] +mod tests { + use super::*; + + const ME: &str = "S-1-5-21-1-2-3-1001"; + + /// The SID of the user running the tests, as `write_control_file` reads it once and passes on. + #[cfg(windows)] + fn user() -> String { + current_user_sid().unwrap() + } + + #[test] + fn the_users_own_file_with_system_is_trusted_and_administrators_are_not() { + assert_eq!(foreign_access(&format!("O:{ME}G:{ME}D:P(A;;FA;;;{ME})(A;;FA;;;SY)"), ME), None); + assert_eq!(foreign_access(&format!("O:{ME}D:(A;;2032127;;;S-1-5-18)(A;;2032127;;;{ME})"), ME), None); + // Another administrator could read it through the Administrators group, by alias or by SID. + for admins in ["BA", "S-1-5-32-544"] { + let sddl = format!("O:{ME}D:PAI(A;;FA;;;SY)(A;;FR;;;{admins})(A;;FA;;;{ME})"); + assert!(foreign_access(&sddl, ME).is_some_and(|p| p.contains(admins)), "{sddl}"); + } + // Inherit-only entries and deny entries don't grant access to the file itself. + assert_eq!(foreign_access(&format!("O:{ME}D:(A;OICIIO;GA;;;CO)(D;;FA;;;BU)(A;;FA;;;{ME})"), ME), None); + } + + /// Review on #3956: each tool is started by its full path in System32, never looked up by + /// name. + #[cfg(windows)] + #[test] + fn the_tools_are_started_from_system32() { + for tool in [WHOAMI, POWERSHELL, ICACLS] { + let command = system_tool(tool); + let path = std::path::Path::new(command.get_program()); + assert!(path.is_absolute() && path.is_file(), "{path:?}"); + assert!(path.to_string_lossy().to_ascii_lowercase().contains(r"\system32\"), "{path:?}"); + } + } + + #[test] + fn other_owners_readers_and_missing_lists_are_refused() { + let other = "S-1-5-21-1-2-3-1002"; + assert!(foreign_access(&format!("O:{other}D:(A;;FA;;;{ME})"), ME).unwrap().contains("owned by another account")); + // Users (BU) and Authenticated Users (AU), as a folder at a drive root passes on. + assert!(foreign_access(&format!("O:{ME}D:AI(A;ID;FA;;;{ME})(A;ID;0x1301bf;;;AU)"), ME).unwrap().contains("AU")); + assert!(foreign_access(&format!("O:{ME}D:(A;;FR;;;BU)(A;;FA;;;{ME})"), ME).unwrap().contains("BU")); + assert!(foreign_access(&format!("O:{ME}D:(A;;FA;;;{other})"), ME).is_some()); + assert!(foreign_access(&format!("O:{ME}G:SY"), ME).unwrap().contains("no access list")); + assert!(foreign_access(&format!("O:{ME}D:NO_ACCESS_CONTROL"), ME).is_some()); + assert!(foreign_access("D:(A;;FA;;;SY)", ME).unwrap().contains("owner")); + } + + /// A conditional entry can hold a `:` in a string; the access list doesn't end there, so the + /// entry after it is still checked. + #[test] + fn a_colon_inside_an_entry_does_not_end_the_access_list() { + let sddl = format!(r#"O:{ME}D:(XA;;FA;;;{ME};(@User.dept == "a:b"))(A;;FR;;;BU)"#); + assert!(foreign_access(&sddl, ME).is_some_and(|p| p.contains("BU")), "{sddl}"); + } + + /// A conditional entry for the user is the user's; one that grants access but whose account + /// can't be read is refused rather than skipped. + #[test] + fn conditional_and_malformed_entries() { + assert_eq!(foreign_access(&format!(r#"O:{ME}D:(XA;;FA;;;{ME};(@User.dept == "x"))"#), ME), None); + assert!(foreign_access(&format!("O:{ME}D:(A;;FA;;;{ME})(A;;FR)"), ME).is_some_and(|p| p.contains("can't be read"))); + assert!(foreign_access(&format!("O:{ME}D:(A;;FA;;;)"), ME).is_some_and(|p| p.contains("can't be read"))); + // A conditional entry grants access like a plain one: another account on it is refused. + let other = format!(r#"O:{ME}D:(A;;FA;;;{ME})(XA;;FR;;;BU;(@User.dept == "x"))"#); + assert!(foreign_access(&other, ME).is_some_and(|p| p.contains("BU")), "{other}"); + } + + #[test] + fn the_sid_is_read_from_whoamis_output_and_nothing_else() { + assert_eq!(sid_from_whoami("\"desk\\ada\",\"S-1-5-21-1-2-3-1001\"\r\n").as_deref(), Some("S-1-5-21-1-2-3-1001")); + for bad in [ + "", + "ERROR: Access is denied.", + "\"desk\\ada\",\"\"", + "\"desk\\ada\",\"S-1-\"", + "\"S-1-5-x\"", + "\"desk\\ada\"", + "\"desk\\ada\",\"1-5-21-7\"", + ] { + assert_eq!(sid_from_whoami(bad), None, "{bad:?}"); + } + } + + /// The real tools: a file created in a folder that lets Users read what's in it inherits that + /// access; after `restrict_to_owner` only the current user and SYSTEM are left. + #[cfg(windows)] + #[test] + fn a_file_in_a_shared_folder_is_restricted_to_its_owner() { + let dir = std::env::temp_dir().join(format!("pdfcraft-owner-only-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let ok = std::process::Command::new("icacls").arg(&dir).arg("/grant").arg("*S-1-5-32-545:(OI)(CI)R").output().unwrap(); + assert!(ok.status.success(), "{}", String::from_utf8_lossy(&ok.stderr)); + let file = dir.join("ctl.json"); + std::fs::write(&file, b"{}").unwrap(); + let me = current_user_sid().unwrap(); + // Users inherit read access, so the file is refused. (Run elevated, the file is also owned + // by Administrators, which is the reason given first.) + let before = sddl_of(&file).unwrap(); + assert!(before.contains(";BU)") || before.contains(";S-1-5-32-545)"), "Users inherit read access: {before}"); + assert!(foreign_access(&before, &me).is_some(), "{before}"); + restrict_to_owner(&file, &user()).unwrap(); + let sddl = sddl_of(&file).unwrap(); + assert_eq!(section(&sddl, "O:"), Some(me.as_str()), "{sddl}"); + assert_eq!(foreign_access(&sddl, &me), None, "{sddl}"); + let _ = std::fs::remove_dir_all(&dir); + } + + /// Review on #3956: a file created in a private folder inside a folder that lets Users read + /// what's in it is the user's alone from the moment it exists, before anything restricts it. + #[cfg(windows)] + #[test] + fn a_file_created_in_a_private_folder_is_private_from_the_start() { + let dir = std::env::temp_dir().join(format!("pdfcraft-owner-only-private-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + let ok = std::process::Command::new("icacls").arg(&dir).arg("/grant").arg("*S-1-5-32-545:(OI)(CI)R").output().unwrap(); + assert!(ok.status.success(), "{}", String::from_utf8_lossy(&ok.stderr)); + let private = dir.join("staging"); + create_private_dir(&private, &user()).unwrap(); + let me = current_user_sid().unwrap(); + let folder = sddl_of(&private).unwrap(); + assert_eq!((section(&folder, "O:"), foreign_access(&folder, &me)), (Some(me.as_str()), None), "{folder}"); + let file = private.join("ctl.json"); + std::fs::OpenOptions::new().write(true).create_new(true).open(&file).unwrap(); + let sddl = sddl_of(&file).unwrap(); + assert!(!sddl.contains(";BU)") && !sddl.contains(";S-1-5-32-545)"), "Users must not inherit read access: {sddl}"); + assert_eq!(foreign_access(&sddl, &me), None, "{sddl}"); + // The name is taken: the folder isn't reused. + assert!(create_private_dir(&private, &user()).is_err()); + let _ = std::fs::remove_dir_all(&dir); + } + + /// A folder may give its user Modify but not Full control, so a file created there doesn't let + /// its owner change the owner (no `WRITE_OWNER`). An owner can always replace the access list, + /// though, and the user's Full control on it then allows setting the owner. + #[cfg(windows)] + #[test] + fn a_file_in_a_folder_granting_only_modify_is_still_restricted() { + let dir = std::env::temp_dir().join(format!("pdfcraft-owner-only-modify-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + let me = current_user_sid().unwrap(); + let modify = std::process::Command::new("icacls") + .arg(&dir) + .args(["/inheritance:r", "/grant:r"]) + .arg(format!("*{me}:(OI)(CI)M")) + .arg("*S-1-5-18:(OI)(CI)F") + .output() + .unwrap(); + assert!(modify.status.success(), "{}", String::from_utf8_lossy(&modify.stderr)); + let file = dir.join("ctl.json"); + std::fs::write(&file, b"{}").unwrap(); + restrict_to_owner(&file, &user()).unwrap(); + let sddl = sddl_of(&file).unwrap(); + assert_eq!((section(&sddl, "O:"), foreign_access(&sddl, &me)), (Some(me.as_str()), None), "{sddl}"); + let _ = std::fs::remove_dir_all(&dir); + } + + /// A null access list (`NO_ACCESS_CONTROL`) lets every account use the file, so it is refused. + #[cfg(windows)] + #[test] + fn a_file_with_no_access_list_is_refused() { + let dir = std::env::temp_dir().join(format!("pdfcraft-owner-only-null-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + let file = dir.join("ctl.json"); + std::fs::write(&file, b"{}").unwrap(); + restrict_to_owner(&file, &user()).unwrap(); + // A null access list lets everyone in; the user, as owner, may set one. + let null = std::process::Command::new("powershell") + .args([ + "-NoProfile", + "-NonInteractive", + "-Command", + "$s = New-Object Security.AccessControl.FileSecurity; $s.SetSecurityDescriptorSddlForm('D:NO_ACCESS_CONTROL', 'Access'); [IO.File]::SetAccessControl($env:PDFCRAFT_NULL_PATH, $s)", + ]) + .env("PDFCRAFT_NULL_PATH", &file) + .output() + .unwrap(); + assert!(null.status.success(), "{}", String::from_utf8_lossy(&null.stderr)); + let (sddl, _) = read_with_sddl(&file, 64).unwrap(); + let me = current_user_sid().unwrap(); + assert!(foreign_access(&sddl, &me).is_some_and(|p| p.contains("no access list")), "{sddl}"); + let _ = std::fs::remove_dir_all(&dir); + } + + /// An `OWNER RIGHTS` entry replaces what an owner may do implicitly. Granting only *write + /// owner* that way, the access list can't be replaced although the owner can still be set: the + /// file isn't taken as restricted. + #[cfg(windows)] + #[test] + fn a_file_whose_access_list_cant_be_replaced_is_not_restricted() { + let dir = std::env::temp_dir().join(format!("pdfcraft-owner-only-ow-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + let me = current_user_sid().unwrap(); + let file = dir.join("ctl.json"); + std::fs::write(&file, b"{}").unwrap(); + // The whole list is written, as `restrict_to_owner` does: `icacls /inheritance:r` leaves + // the Administrators entry the windows-latest runner gives a new file, and with it an + // elevated administrator could replace the list after all. + let rights = std::process::Command::new("powershell") + .args([ + "-NoProfile", + "-NonInteractive", + "-Command", + "$s = New-Object Security.AccessControl.FileSecurity; $s.SetSecurityDescriptorSddlForm('D:P(A;;FR;;;' + $env:PDFCRAFT_OW_USER + ')(A;;WO;;;OW)', 'Access'); [IO.File]::SetAccessControl($env:PDFCRAFT_OW_PATH, $s)", + ]) + .env("PDFCRAFT_OW_PATH", &file) + .env("PDFCRAFT_OW_USER", &me) + .output() + .unwrap(); + assert!(rights.status.success(), "{}", String::from_utf8_lossy(&rights.stderr)); + // Nothing but the two entries: no Administrators entry for the elevated runner to use. + let sddl = sddl_of(&file).unwrap(); + assert_eq!(section(&sddl, "D:"), Some(format!("(A;;524288;;;S-1-3-4)(A;;1179785;;;{me})").as_str()), "{sddl}"); + assert!(restrict_to_owner(&file, &user()).is_err(), "the access list could not be replaced"); + let _ = std::fs::remove_dir_all(&dir); + } + + /// When the tools fail, the functions say so: nothing is taken as restricted or as checked. + #[cfg(windows)] + #[test] + fn tool_failures_are_errors() { + let missing = std::env::temp_dir().join(format!("pdfcraft-owner-only-missing-{}", std::process::id())).join("none.json"); + assert!(restrict_to_owner(&missing, &user()).is_err(), "icacls on a missing file"); + assert!(sddl_of(&missing).is_err(), "Get-Acl on a missing file"); + let me = current_user_sid().unwrap(); + assert!(me.starts_with("S-1-5-") && me.split('-').count() >= 4, "{me}"); + assert!(!me.contains('"') && !me.contains('\\'), "the SID alone, not the account name: {me}"); + } + + #[test] + fn the_descriptor_and_bytes_are_read_from_the_scripts_output_and_nothing_else() { + let sddl = format!("O:{ME}D:P(A;;FA;;;{ME})"); + assert_eq!(sddl_and_bytes(&format!("{sddl}\n7B-0A-ff\r\n")), Some((sddl.clone(), vec![0x7B, 0x0A, 0xFF]))); + assert_eq!(sddl_and_bytes(&format!("{sddl}\n")), Some((sddl.clone(), Vec::new()))); + for bad in [ + String::new(), + sddl.clone(), + "D:P(A;;FA;;;SY)\n7B".into(), + format!("{sddl}\n7B-2"), + format!("{sddl}\n7B--22"), + format!("{sddl}\n7G"), + format!("{sddl}\n+7-22"), + ] { + assert_eq!(sddl_and_bytes(&bad), None, "{bad:?}"); + } + } + + /// The bytes come with the descriptor of the same handle, capped at `max + 1` so an oversized + /// file shows, and the restricted file is owned by the user. + #[cfg(windows)] + #[test] + fn the_file_is_read_with_its_descriptor_through_one_handle() { + let dir = std::env::temp_dir().join(format!("pdfcraft-owner-only-read-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let file = dir.join("ctl.json"); + std::fs::write(&file, br#"{"port":1}"#).unwrap(); + restrict_to_owner(&file, &user()).unwrap(); + let me = current_user_sid().unwrap(); + let (sddl, bytes) = read_with_sddl(&file, 64).unwrap(); + assert_eq!(section(&sddl, "O:"), Some(me.as_str()), "{sddl}"); + assert_eq!((foreign_access(&sddl, &me), bytes.as_slice()), (None, &br#"{"port":1}"#[..])); + assert_eq!(read_with_sddl(&file, 3).unwrap().1, br#"{"po"#); + // A limit past what the script's `[int]` holds is capped, not handed to it. + assert_eq!(read_with_sddl(&file, u64::MAX).unwrap().1, br#"{"port":1}"#); + assert!(read_with_sddl(&dir.join("none.json"), 64).is_err()); + // A deny entry is read as one: denying Guests takes nothing from the user. + let guests = std::process::Command::new("icacls").arg(&file).arg("/deny").arg("*S-1-5-32-546:(R)").output().unwrap(); + assert!(guests.status.success(), "{}", String::from_utf8_lossy(&guests.stderr)); + let (sddl, _) = read_with_sddl(&file, 64).unwrap(); + assert!(sddl.contains(";;;S-1-5-32-546)") && sddl.contains("(D;"), "{sddl}"); + assert_eq!(foreign_access(&sddl, &me), None, "{sddl}"); + // The handle shares the file for reading only: a file someone has open for writing (or + // could rename or delete) isn't read, and so can't change between the check and the read. + let writer = std::fs::OpenOptions::new().write(true).open(&file).unwrap(); + assert!(read_with_sddl(&file, 64).is_err(), "a file open for writing is not read"); + // Restricting reads the descriptor alone, so it works on the file the app holds open to + // write the token through. + restrict_to_owner(&file, &user()).unwrap(); + assert_eq!(foreign_access(&sddl_of(&file).unwrap(), &me), None); + drop(writer); + assert!(read_with_sddl(&file, 64).is_ok()); + let _ = std::fs::remove_dir_all(&dir); + } + + /// Entries of the file's own, not inherited, are replaced too: an Administrators or Users + /// entry (as the windows-latest runner's temp folder leaves on a new file, #3922) and a deny + /// that would keep the user from setting the owner. + #[cfg(windows)] + #[test] + fn entries_of_the_files_own_are_replaced() { + let dir = std::env::temp_dir().join(format!("pdfcraft-owner-only-own-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + let me = current_user_sid().unwrap(); + let mine = format!("*{me}:(R)"); + for (name, entries) in [ + ("others.json", ["/grant", "*S-1-5-32-544:(F)", "/grant", "*S-1-5-32-545:(R)"]), + ("deny.json", ["/deny", "*S-1-1-0:(WO)", "/grant", "*S-1-5-32-544:(F)"]), + ] { + let file = dir.join(name); + std::fs::write(&file, b"{}").unwrap(); + // The user keeps read access, so the descriptor can be read before restricting. + let own = std::process::Command::new("icacls").arg(&file).arg("/inheritance:r").args(entries).args(["/grant", &mine]).output().unwrap(); + assert!(own.status.success(), "{}", String::from_utf8_lossy(&own.stderr)); + assert!(foreign_access(&sddl_of(&file).unwrap(), &me).is_some(), "{name}"); + restrict_to_owner(&file, &user()).unwrap(); + let sddl = sddl_of(&file).unwrap(); + assert_eq!((section(&sddl, "O:"), foreign_access(&sddl, &me)), (Some(me.as_str()), None), "{name}: {sddl}"); + assert!(!sddl.contains("(D;"), "{name}: the deny is gone: {sddl}"); + } + let _ = std::fs::remove_dir_all(&dir); + } + + #[test] + fn a_descriptor_still_open_to_others_after_restricting_is_an_error() { + assert!(still_foreign(&format!("O:{ME}D:P(A;;FA;;;SY)(A;;FA;;;{ME})"), ME).is_ok()); + for bad in [format!("O:{ME}D:P(A;;FA;;;S-1-5-32-544)(A;;FA;;;{ME})"), format!("O:S-1-5-32-544D:P(A;;FA;;;{ME})"), format!("O:{ME}")] { + assert!(still_foreign(&bad, ME).is_err_and(|e| e.to_string().contains("could not be restricted")), "{bad}"); + } + } + + /// [`read_with_sddl`] gives accounts as full SIDs (rights as a number, no list flags), the way a + /// CI runner's built-in Administrator reads its own restricted file. An alias such as `LA` + /// is never taken for the user, whatever the user's RID: on a domain-joined machine a domain + /// account can share the local Administrator's RID 500. + #[test] + fn descriptors_with_full_sids_and_aliases_that_are_never_the_user() { + let admin = "S-1-5-21-1-2-3-500"; + let full = format!("O:{admin}D:(A;;2032127;;;S-1-5-18)(A;;2032127;;;{admin})"); + assert_eq!(foreign_access(&full, admin), None); + // Owner rights by SID, a deny by SID, an inherit-only entry for Creator Owner. + let more = format!("O:{ME}D:(D;;1179785;;;S-1-5-32-546)(A;IO;268435456;;;S-1-3-0)(A;;2032127;;;S-1-3-4)(A;;2032127;;;{ME})"); + assert_eq!(foreign_access(&more, ME), None); + assert!(foreign_access(&format!("O:{ME}D:(A;;1179785;;;S-1-5-32-545)(A;;2032127;;;{ME})"), ME).is_some_and(|p| p.contains("S-1-5-32-545"))); + for me in [admin, "S-1-5-21-1-2-3-501"] { + assert!(foreign_access("O:LAD:PAI(A;;FA;;;SY)(A;;FA;;;LA)", me).is_some_and(|p| p.contains("owned by another account (LA)")), "{me}"); + assert!(foreign_access("O:LGD:P(A;;FA;;;LG)", me).is_some(), "{me}"); + } + assert!(foreign_access(&format!("O:{admin}D:P(A;;FA;;;{admin})(A;;FR;;;LA)"), admin).is_some_and(|p| p.contains("LA"))); + } + + /// Review on #3956: a refusal names a well-known account as well as giving its SID, so + /// `S-1-5-32-544` reads as Administrators without a lookup; any other SID is given alone. + #[test] + fn refusals_name_well_known_accounts_and_keep_their_sid() { + for (sid, name) in [ + ("S-1-5-32-544", "Administrators"), + ("S-1-5-32-545", "Users"), + ("S-1-5-11", "Authenticated Users"), + ("S-1-5-4", "INTERACTIVE"), + ("S-1-1-0", "Everyone"), + ("S-1-5-6", "SERVICE"), + ("S-1-5-3", "BATCH"), + ] { + let sddl = format!("O:{ME}D:PAI(A;;FA;;;{ME})(A;ID;1179817;;;{sid})"); + assert_eq!(foreign_access(&sddl, ME), Some(format!("another account, {name} ({sid}), has access to it")), "{sddl}"); + } + // SYSTEM may have access, but a file it owns isn't the user's. + assert_eq!(foreign_access(&format!("O:S-1-5-18D:P(A;;FA;;;{ME})"), ME).as_deref(), Some("it is owned by another account, SYSTEM (S-1-5-18)")); + assert_eq!( + foreign_access(&format!("O:S-1-5-32-544D:P(A;;FA;;;{ME})"), ME).as_deref(), + Some("it is owned by another account, Administrators (S-1-5-32-544)") + ); + // Another user, a lookalike and an alias have no fixed name: the account is given as it was. + for sid in ["S-1-5-21-1-2-3-1002", "S-1-5-32-5440", "S-1-5-1", "BA"] { + let sddl = format!("O:{ME}D:(A;;FA;;;{ME})(A;;FR;;;{sid})"); + assert_eq!(foreign_access(&sddl, ME), Some(format!("another account ({sid}) has access to it")), "{sddl}"); + } + assert_eq!( + foreign_access("O:S-1-5-21-1-2-3-1002D:(A;;FA;;;SY)", ME).as_deref(), + Some("it is owned by another account (S-1-5-21-1-2-3-1002)") + ); + } + + #[test] + fn sections_stop_at_the_next_tag() { + assert_eq!(section("O:S-1-5-18G:S-1-5-32-544D:(A;;FA;;;SY)", "O:"), Some("S-1-5-18")); + assert_eq!(section("O:BAG:SYD:P(A;;FA;;;SY)S:(AU;SA;FA;;;WD)", "D:"), Some("P(A;;FA;;;SY)")); + assert_eq!(section("O:BA", "D:"), None); + } +} From 0bb77ce5e6a14278b275e9c9db6fb2062d58257d Mon Sep 17 00:00:00 2001 From: mtlcl410 <2368091+mtlcl410@users.noreply.github.com> Date: Mon, 9 Nov 2026 18:24:59 +0800 Subject: [PATCH 29/64] M5: review statuses are hidden, chain per reviewer and show per reviewer, by /IRT reference (#4122) (#4597) --- crates/annot/src/info.rs | 8 +- crates/annot/src/modify.rs | 67 ++- crates/annot/src/tests.rs | 116 +++++ crates/automation/src/comments.rs | 30 +- crates/automation/tests/it/automation.rs | 92 ++++ crates/render/src/inspect.rs | 2 +- crates/render/src/inspect/annotations.rs | 6 +- crates/render/src/inspect/model.rs | 451 ++++++++++++++++++-- crates/render/src/lib.rs | 3 +- crates/ui-egui/src/dialogs/comment_props.rs | 8 +- crates/ui-egui/src/panels/comments_panel.rs | 91 ++-- crates/ui-egui/src/tools/comments/menu.rs | 7 +- crates/ui-egui/tests/it/comments.rs | 90 +++- parity/acrobat-features.toml | 4 +- 14 files changed, 883 insertions(+), 92 deletions(-) diff --git a/crates/annot/src/info.rs b/crates/annot/src/info.rs index c86b9f8f78..71a4fbfa32 100644 --- a/crates/annot/src/info.rs +++ b/crates/annot/src/info.rs @@ -154,7 +154,13 @@ pub fn summaries(doc: &Document) -> Vec { grouped: d.name(b"RT") == Some(b"Group"), rect, color, - state: text_value(doc, d, b"State"), + // A reply that gives only `/StateModel` has the model's default state (ISO 32000-2 + // Table 175), as `last_state_reply` and the inspector read it. + state: text_value(doc, d, b"State").or_else(|| match text_value(doc, d, b"StateModel").as_deref() { + Some("Marked") => Some("Unmarked".into()), + Some("Review") => Some("None".into()), + _ => None, + }), quads, locked: annotation_flags(doc, d) & FLAG_LOCKED != 0, intent: d.get(b"IT").and_then(|o| doc.resolve(o).as_name().map(|n| String::from_utf8_lossy(n).into_owned())), diff --git a/crates/annot/src/modify.rs b/crates/annot/src/modify.rs index 78200f5c16..2156ad045a 100644 --- a/crates/annot/src/modify.rs +++ b/crates/annot/src/modify.rs @@ -260,8 +260,9 @@ pub fn add_reply(doc: &mut Document, page: usize, index: usize, text: &str, auth reply(doc, page, index, text, author, meta, None) } -/// Set a comment's review status (Acrobat: "Set status ▸ Accepted"…). Like Acrobat this adds a -/// state reply (`/State`, `/StateModel /Review`) by `author`; the latest one wins. +/// Set a comment's review status ("Set status ▸ Accepted"…). This adds a hidden state reply +/// (`/State`, `/StateModel /Review`) by `author`, in reply to `author`'s previous one when there is +/// one; each reviewer's latest is their status. pub fn set_review_state(doc: &mut Document, page: usize, index: usize, state: ReviewState, author: &str, meta: &Meta) -> Result { let text = format!("{} set by {}", state.name(), if author.is_empty() { "unknown" } else { author }); reply(doc, page, index, &text, author, meta, Some(state)) @@ -294,6 +295,11 @@ fn reply( d.set(b"State".to_vec(), PdfString::text(s.name())); d.set(b"StateModel".to_vec(), PdfString::text("Review")); d.set(b"Subj".to_vec(), PdfString::text("Status")); + // Hidden (Table 167, bit 2): the state "is not specified in the annotation itself but in a + // separate text annotation" (ISO 32000-2 §12.5.6.3), so this reply is a state record, not + // a note, and is neither drawn nor listed as one. + d.set(b"F".to_vec(), Object::Int(FLAG_HIDDEN | FLAG_PRINT | FLAG_NO_ZOOM | FLAG_NO_ROTATE)); + d.set(b"IRT".to_vec(), Object::Ref(last_state_reply(doc, p, parent, author, "Review").unwrap_or(parent))); } // An empty form: nothing is drawn for the reply itself. let mut fd = Dict::new(); @@ -330,8 +336,9 @@ pub fn set_locked(doc: &mut Document, page: usize, index: usize, locked: bool) - Ok(()) } -/// Mark or unmark a comment with a checkmark. Like Acrobat this adds a hidden state reply with -/// `/StateModel /Marked` by `author`; the latest one wins. It's private bookkeeping, not a status. +/// Mark or unmark a comment with a checkmark. This adds a hidden state reply with `/StateModel +/// /Marked` by `author`, in reply to `author`'s previous one when there is one; their latest one +/// wins. It's each reviewer's private bookkeeping, not a status. pub fn set_marked(doc: &mut Document, page: usize, index: usize, marked: bool, author: &str, meta: &Meta) -> Result { let (p, parent) = annot_ref(doc, page, index)?; let pd = annot_dict(doc, parent); @@ -342,7 +349,7 @@ pub fn set_marked(doc: &mut Document, page: usize, index: usize, marked: bool, a let text = format!("{state} set by {}", if author.is_empty() { "unknown" } else { author }); let mut d = base_dict("Text", [0.0; 4], p, &text, author, meta); d.set(b"Rect".to_vec(), pd.get(b"Rect").cloned().unwrap_or_else(|| num_array(&[0.0; 4]))); - d.set(b"IRT".to_vec(), Object::Ref(parent)); + d.set(b"IRT".to_vec(), Object::Ref(last_state_reply(doc, p, parent, author, "Marked").unwrap_or(parent))); d.set(b"F".to_vec(), Object::Int(FLAG_HIDDEN | FLAG_PRINT | FLAG_NO_ZOOM | FLAG_NO_ROTATE)); d.set(b"State".to_vec(), PdfString::text(state)); d.set(b"StateModel".to_vec(), PdfString::text("Marked")); @@ -354,6 +361,56 @@ pub fn set_marked(doc: &mut Document, page: usize, index: usize, marked: bool, a Ok(i) } +/// `author`'s latest `model` state reply about `target` on page `p`, for the next change to reply +/// to: ISO 32000-2 §12.5.6.3, "Additional state changes shall be made by adding text annotations +/// in reply to the previous reply for a given user". A state reply is about the +/// annotation its `/IRT` chain reaches past other state replies; a loop reaches nothing. Each +/// reply's answer is kept, so a long history is walked once. +fn last_state_reply(doc: &Document, p: ObjRef, target: ObjRef, author: &str, model: &str) -> Option { + use std::collections::{HashMap, HashSet}; + // As the readers see it: a readable `/State` (not null, empty or another type), or a known + // `/StateModel` whose default state applies. + let is_state = |r: ObjRef| { + doc.get(r).as_dict().is_some_and(|d| { + text_value(doc, d, b"State").is_some() || matches!(text_value(doc, d, b"StateModel").as_deref(), Some("Marked" | "Review")) + }) + }; + let irt = |r: ObjRef| doc.get(r).as_dict().and_then(|d| d.get(b"IRT").and_then(Object::as_ref)); + let mut reaches: HashMap> = HashMap::new(); + let mut mine = Vec::new(); + for r in annots(doc, p).iter().filter_map(Object::as_ref).filter(|&r| r != target) { + let d = annot_dict(doc, r); + if text_value(doc, &d, b"StateModel").as_deref() != Some(model) || text_value(doc, &d, b"T").unwrap_or_default() != author.trim() { + continue; + } + let (mut path, mut seen, mut at) = (vec![r], HashSet::from([r]), irt(r)); + let about = loop { + let Some(up) = at else { break None }; + if let Some(&known) = reaches.get(&up) { + break known; + } + if !is_state(up) { + break Some(up); + } + if !seen.insert(up) { + break None; + } + path.push(up); + at = irt(up); + }; + for x in path { + reaches.insert(x, about); + } + if about == Some(target) { + mine.push(r); + } + } + // The end of the chain, whatever order the page lists it in: a reply none of the others + // replies to; among several (changes that don't chain), the last listed. + let answered: HashSet = mine.iter().filter_map(|&r| irt(r)).collect(); + mine.iter().rev().copied().find(|r| !answered.contains(r)) +} + /// Move a comment (and its pop-up) by `(dx, dy)` points. The appearance moves with `/Rect`. pub fn move_annotation(doc: &mut Document, page: usize, index: usize, dx: f64, dy: f64, meta: &Meta) -> Result<(), AnnotError> { if !finite(&[dx, dy]) { diff --git a/crates/annot/src/tests.rs b/crates/annot/src/tests.rs index 526cd02770..9d9a71dd34 100644 --- a/crates/annot/src/tests.rs +++ b/crates/annot/src/tests.rs @@ -213,6 +213,122 @@ fn replies_and_status_thread_onto_the_parent() { assert_eq!(text(&all[2], b"Contents"), "Accepted set by Bob"); } +/// A state reply records a state, not a note (ISO 32000-2 §12.5.6.3: the state is kept "in a +/// separate text annotation"), so it is written Hidden; a reviewer's next change replies to their +/// previous state reply ("Additional state changes shall be made by adding text annotations in +/// reply to the previous reply for a given user"). Another reviewer's state, and another state +/// model, start their own chain on the comment. +#[test] +fn status_and_checkmark_replies_are_hidden_and_chain_per_reviewer() { + let mut doc = fixture(); + let first = set_review_state(&mut doc, 0, 0, ReviewState::Accepted, "Bob", &meta("s1")).unwrap(); + let carol = set_review_state(&mut doc, 0, 0, ReviewState::Completed, "Carol", &meta("s2")).unwrap(); + let second = set_review_state(&mut doc, 0, 0, ReviewState::Rejected, "Bob", &meta("s3")).unwrap(); + let mark = set_marked(&mut doc, 0, 0, true, "Bob", &meta("m1")).unwrap(); + let unmark = set_marked(&mut doc, 0, 0, false, "Bob", &meta("m2")).unwrap(); + let doc = reopen(&doc); + let p = page_refs(&doc).unwrap()[0]; + let entries = annots(&doc, p); + let at = |i: usize| entries[i].as_ref().unwrap(); + let all = list(&doc, 0); + for i in [first, carol, second, mark, unmark] { + assert_eq!(all[i].int(b"F"), Some(30), "hidden, printable, no zoom, no rotate: {i}"); + } + assert_eq!(all[first].reference(b"IRT"), Some(at(0))); + assert_eq!(all[carol].reference(b"IRT"), Some(at(0)), "another reviewer starts on the comment"); + assert_eq!(all[second].reference(b"IRT"), Some(at(first)), "Bob's change replies to Bob's last status"); + assert_eq!(all[mark].reference(b"IRT"), Some(at(0)), "a checkmark is its own chain"); + assert_eq!(all[unmark].reference(b"IRT"), Some(at(mark))); + // A reply that isn't a state stays visible. + let mut doc = doc; + let r = add_reply(&mut doc, 0, 0, "Agreed", "Bob", &meta("r")).unwrap(); + assert_eq!(list(&doc, 0)[r].int(b"F"), Some(28)); +} + +/// Review on #4126: a history has no length limit. The 70th change still replies to the 69th, +/// and a reviewer whose name was typed with spaces continues their own chain. +#[test] +fn a_long_status_history_keeps_chaining_to_the_latest() { + let mut doc = fixture(); + let mut prev: Option = None; + for i in 0..70 { + let state = if i % 2 == 0 { ReviewState::Accepted } else { ReviewState::Rejected }; + let at = set_review_state(&mut doc, 0, 0, state, "Bob", &meta(&format!("s{i}"))).unwrap(); + if let Some(p) = prev { + let entries = annots(&doc, page_refs(&doc).unwrap()[0]); + assert_eq!(list(&doc, 0)[at].reference(b"IRT"), entries[p].as_ref(), "change {i}"); + } + prev = Some(at); + } + let padded = set_review_state(&mut doc, 0, 0, ReviewState::Completed, " Bob ", &meta("pad")).unwrap(); + let entries = annots(&doc, page_refs(&doc).unwrap()[0]); + assert_eq!(list(&doc, 0)[padded].reference(b"IRT"), prev.and_then(|p| entries[p].as_ref())); + // Listed newest first, the first reply read walks the whole chain at once. + let latest = entries[padded].as_ref(); + let p = page_refs(&doc).unwrap()[0]; + let mut reversed = entries.clone(); + reversed.reverse(); + set_annots(&mut doc, p, reversed).unwrap(); + let next = set_review_state(&mut doc, 0, 71, ReviewState::Accepted, "Bob", &meta("next")).unwrap(); + assert_eq!(list(&doc, 0)[next].reference(b"IRT"), latest); +} + +/// Review on #4126: a reply with `/StateModel` but no `/State` has the model's default state +/// (ISO 32000-2 Table 175), for the reader as for the writer: it is Bob's latest status, None, +/// and his next change replies to it. +#[test] +fn a_state_model_without_a_state_is_the_default_state() { + let mut doc = fixture(); + let bare = add_reply(&mut doc, 0, 0, "", "Bob", &meta("bare")).unwrap(); + let p = page_refs(&doc).unwrap()[0]; + let r = annots(&doc, p)[bare].as_ref().unwrap(); + doc.update_dict(r, |d| { + d.set(b"StateModel".to_vec(), PdfString::text("Review")); + d.set(b"F".to_vec(), Object::Int(30)); + }) + .unwrap(); + let summary = summaries(&doc).into_iter().find(|s| s.index == bare).unwrap(); + assert_eq!(summary.state.as_deref(), Some("None")); + let next = set_review_state(&mut doc, 0, 0, ReviewState::Accepted, "Bob", &meta("next")).unwrap(); + assert_eq!(list(&doc, 0)[next].reference(b"IRT"), Some(r)); + + // A model PdfCraft doesn't know isn't a state: a status replying to such a reply is about + // that reply, not about the comment, so Bob's next change on the comment starts afresh. + let mut doc = fixture(); + let other = add_reply(&mut doc, 0, 0, "", "Ann", &meta("other")).unwrap(); + let p = page_refs(&doc).unwrap()[0]; + let o = annots(&doc, p)[other].as_ref().unwrap(); + // Nor is a `/State` the readers can't read, such as a number (review on #4126). + doc.update_dict(o, |d| { + d.set(b"StateModel".to_vec(), PdfString::text("Other")); + d.set(b"State".to_vec(), Object::Int(5)); + }) + .unwrap(); + assert_eq!(summaries(&doc).into_iter().find(|s| s.index == other).unwrap().state, None); + set_review_state(&mut doc, 0, other, ReviewState::Rejected, "Bob", &meta("on-other")).unwrap(); + let fresh = set_review_state(&mut doc, 0, 0, ReviewState::Completed, "Bob", &meta("fresh")).unwrap(); + assert_eq!(list(&doc, 0)[fresh].reference(b"IRT"), annots(&doc, p)[0].as_ref()); +} + +/// A third change replies to the second, not the first; and Bob's status on another comment +/// is never the one his next change on this comment replies to. +#[test] +fn a_reviewers_chain_continues_from_their_latest_status_on_that_comment() { + let mut doc = fixture(); + let note = add_annotation(&mut doc, &new(0, Shape::Note { at: [100.0, 100.0], icon: NoteIcon::Comment }), &meta("n")).unwrap(); + let first = set_review_state(&mut doc, 0, 0, ReviewState::Accepted, "Bob", &meta("s1")).unwrap(); + let elsewhere = set_review_state(&mut doc, 0, note, ReviewState::Cancelled, "Bob", &meta("s2")).unwrap(); + let second = set_review_state(&mut doc, 0, 0, ReviewState::Rejected, "Bob", &meta("s3")).unwrap(); + let third = set_review_state(&mut doc, 0, 0, ReviewState::Completed, "Bob", &meta("s4")).unwrap(); + let doc = reopen(&doc); + let entries = annots(&doc, page_refs(&doc).unwrap()[0]); + let at = |i: usize| entries[i].as_ref().unwrap(); + let all = list(&doc, 0); + assert_eq!(all[elsewhere].reference(b"IRT"), Some(at(note))); + assert_eq!(all[second].reference(b"IRT"), Some(at(first)), "not Bob's status on the note"); + assert_eq!(all[third].reference(b"IRT"), Some(at(second)), "the latest, not the first"); +} + #[test] fn delete_takes_popup_and_reply_chain() { let mut doc = fixture(); diff --git a/crates/automation/src/comments.rs b/crates/automation/src/comments.rs index 390e3b4d1a..3174e68dbd 100644 --- a/crates/automation/src/comments.rs +++ b/crates/automation/src/comments.rs @@ -224,24 +224,27 @@ impl Automation { }; // Every reply under the comment its thread starts from, at any depth, in document order: // a reply to a reply keeps its `/IRT` on the direct parent, so following one level would - // drop it. Parents are found by reference, as `/NM` need not be unique. A status or - // checkmark belongs to the annotation its `/IRT` names (ISO 32000-2 §12.5.6.2), as in the - // Comments panel: one set on a reply is not the comment's. + // drop it. Parents are found by reference, as `/NM` need not be unique. Status and + // checkmark replies are read through `review` instead. let tops = pdfcraft_render::thread_tops(&all); let mut threads: HashMap<(usize, usize), Vec<&Annotation>> = HashMap::new(); - for r in &all { + for r in all.iter().filter(|r| r.state.is_none()) { let Some(&top) = tops.get(&(r.page, r.index)) else { continue }; - let key = match r.parent { - Some(parent) if r.state.is_some() => parent, - _ => top, - }; - threads.entry(key).or_default().push(r); + threads.entry(top).or_default().push(r); } - // An annotation's review status and checkmark: the latest of each set on it. + // An annotation's review states. A state reply's `/IRT` refers to the annotation whose + // state it sets, and a reviewer's later change replies to their previous one (ISO 32000-2 + // §12.5.6.3), so a status on a reply is that reply's, as in the Comments panel. `status` + // is the latest set by anyone; `statuses` has each reviewer's latest (a state is "for the + // specified user"). Checkmarks are each reviewer's own too: `marked_by` names who has one + // on, and `marked` whether anyone has. + let review = pdfcraft_render::ReviewStates::new(&all); let states = |c: &Annotation, v: &mut Value| { - let set = threads.get(&(c.page, c.index)).map(Vec::as_slice).unwrap_or(&[]); - v["status"] = json!(set.iter().rev().filter(|r| !r.is_mark()).find_map(|r| r.state.clone())); - v["marked"] = json!(set.iter().rev().find(|r| r.is_mark()).is_some_and(|r| r.state.as_deref() == Some("Marked"))); + v["status"] = json!(review.latest_status(c)); + v["statuses"] = json!(review.statuses(c).iter().map(|(who, state)| json!({ "author": who, "status": state })).collect::>()); + let marked_by = review.marked(c); + v["marked"] = json!(!marked_by.is_empty()); + v["marked_by"] = json!(marked_by); }; let roots: Vec = all .iter() @@ -253,7 +256,6 @@ impl Automation { let thread = threads.get(&(c.page, c.index)).map(Vec::as_slice).unwrap_or(&[]); v["replies"] = thread .iter() - .filter(|r| r.state.is_none()) .map(|r| { let mut reply = view(r); states(r, &mut reply); diff --git a/crates/automation/tests/it/automation.rs b/crates/automation/tests/it/automation.rs index 19a172214e..046179ffaf 100644 --- a/crates/automation/tests/it/automation.rs +++ b/crates/automation/tests/it/automation.rs @@ -2311,6 +2311,98 @@ fn numbering_pages_through_tools() { assert!(matches!(a.call("page_number", &json!({ "doc": doc, "from": 1, "to": 1, "style": "klingon" })), Err(ToolError::InvalidArgs(_)))); } +/// ISO 32000-2 §12.5.6.3: a state is "for the specified user" (`/T`), so there is one status per +/// reviewer (their latest), and a checkmark ("marked by the user", Table 174) belongs only to the +/// reviewer who set it. `comment_list` reports each reviewer's status and who has a checkmark on; +/// the replies written are hidden (a state reply records a state, not a note) and a reviewer's +/// later change replies to their earlier one. +#[test] +fn review_statuses_are_per_reviewer_through_tools() { + let dir = workdir("statuses"); + let mut a = auto(&dir); + let doc = ok(&mut a, "doc_open", json!({ "path": "a.pdf" }))["doc"].as_u64().unwrap(); + let id = + ok(&mut a, "comment_add", json!({ "doc": doc, "page": 1, "type": "note", "at": [20, 20], "contents": "Check", "author": "Ada" }))["comment"] + ["id"] + .as_str() + .unwrap() + .to_string(); + for (status, author) in [("accepted", "Bob"), ("completed", "Carol"), ("rejected", "Bob")] { + ok(&mut a, "comment_set_status", json!({ "doc": doc, "id": id, "status": status, "author": author })); + } + ok(&mut a, "comment_mark", json!({ "doc": doc, "id": id, "author": "Bob" })); + ok(&mut a, "comment_mark", json!({ "doc": doc, "id": id, "author": "Carol" })); + ok(&mut a, "comment_mark", json!({ "doc": doc, "id": id, "author": "Carol", "marked": false })); + ok(&mut a, "doc_save", json!({ "doc": doc, "path": "statuses.pdf" })); + let doc = ok(&mut a, "doc_open", json!({ "path": "statuses.pdf" }))["doc"].as_u64().unwrap(); + let list = ok(&mut a, "comment_list", json!({ "doc": doc })); + assert_eq!(list["count"], 1, "status replies aren't comments: {list}"); + let c = &list["comments"][0]; + assert_eq!(c["statuses"], json!([{ "author": "Bob", "status": "Rejected" }, { "author": "Carol", "status": "Completed" }])); + assert_eq!(c["status"], "Rejected", "the latest set by anyone"); + assert_eq!((c["marked"].clone(), c["marked_by"].clone()), (json!(true), json!(["Bob"]))); + assert_eq!(c["replies"], json!([])); + + let bytes = std::fs::read(dir.join("statuses.pdf")).unwrap(); + let saved = pdfcraft_cos::Document::open(std::sync::Arc::new(bytes)).unwrap(); + let replies: Vec = saved + .object_numbers() + .into_iter() + .filter_map(|n| saved.get(pdfcraft_cos::ObjRef { num: n, generation: 0 }).as_dict().cloned()) + .filter(|d| d.contains(b"StateModel")) + .collect(); + assert_eq!(replies.len(), 6); + assert!(replies.iter().all(|d| d.int(b"F") == Some(30)), "every state reply is hidden: {replies:?}"); +} + +/// ISO 32000-2 §12.5.6.3: a state reply's `/IRT` "shall refer to the original annotation", which +/// for a status set on a reply is that reply. The reviewer's next change replies to their previous +/// state reply, and the chain, found by reference, stays the reply's: it is reported on the reply +/// and never on its comment. +#[test] +fn statuses_set_on_a_reply_chain_on_that_reply_through_tools() { + let dir = workdir("reply-statuses"); + let mut a = auto(&dir); + let doc = ok(&mut a, "doc_open", json!({ "path": "a.pdf" }))["doc"].as_u64().unwrap(); + let id = + ok(&mut a, "comment_add", json!({ "doc": doc, "page": 1, "type": "note", "at": [20, 20], "contents": "Check", "author": "Ada" }))["comment"] + ["id"] + .as_str() + .unwrap() + .to_string(); + ok(&mut a, "comment_reply", json!({ "doc": doc, "id": id, "text": "ANSWER", "author": "Bob" })); + let listed = ok(&mut a, "comment_list", json!({ "doc": doc })); + let reply_id = listed["comments"][0]["replies"][0]["id"].as_str().unwrap().to_string(); + for (status, author) in [("accepted", "Carol"), ("completed", "Dan"), ("rejected", "Carol")] { + ok(&mut a, "comment_set_status", json!({ "doc": doc, "id": reply_id, "status": status, "author": author })); + } + ok(&mut a, "doc_save", json!({ "doc": doc, "path": "reply-statuses.pdf" })); + let doc = ok(&mut a, "doc_open", json!({ "path": "reply-statuses.pdf" }))["doc"].as_u64().unwrap(); + let list = ok(&mut a, "comment_list", json!({ "doc": doc })); + let c = &list["comments"][0]; + assert_eq!((&c["status"], &c["statuses"]), (&Value::Null, &json!([])), "not the comment's: {c}"); + assert_eq!(c["replies"].as_array().map(Vec::len), Some(1), "{c}"); + let reply = &c["replies"][0]; + assert_eq!(reply["statuses"], json!([{ "author": "Carol", "status": "Rejected" }, { "author": "Dan", "status": "Completed" }]), "{reply}"); + + // Carol's second status replies to her first, which replies to the reply. + let bytes = std::fs::read(dir.join("reply-statuses.pdf")).unwrap(); + let saved = pdfcraft_cos::Document::open(std::sync::Arc::new(bytes)).unwrap(); + let dicts: Vec<(pdfcraft_cos::ObjRef, pdfcraft_cos::Dict)> = saved + .object_numbers() + .into_iter() + .map(|n| pdfcraft_cos::ObjRef { num: n, generation: 0 }) + .filter_map(|r| Some((r, saved.get(r).as_dict()?.clone()))) + .collect(); + let find = |text: &str| -> pdfcraft_cos::ObjRef { + dicts.iter().find(|(_, d)| d.get(b"Contents").and_then(|o| o.as_string()).is_some_and(|s| s.to_text().contains(text))).unwrap().0 + }; + let irt = |r: pdfcraft_cos::ObjRef| dicts.iter().find(|(x, _)| *x == r).and_then(|(_, d)| d.get(b"IRT").and_then(|o| o.as_ref())); + let (answer, first, second) = (find("ANSWER"), find("Accepted set by Carol"), find("Rejected set by Carol")); + assert_eq!(irt(first), Some(answer)); + assert_eq!(irt(second), Some(first)); +} + /// Typed text and text boxes take an oval or a circle for their border through the tools; /// comment_list reports it (a rectangle is not reported). Only typed text, dates and text boxes /// have one, and only the three shapes. diff --git a/crates/render/src/inspect.rs b/crates/render/src/inspect.rs index 0cf287b3f1..0079ba6f37 100644 --- a/crates/render/src/inspect.rs +++ b/crates/render/src/inspect.rs @@ -37,7 +37,7 @@ pub use core::{check_pages, inspect, pretty_date}; use core::{inspect_structure, spend}; pub use model::{ Annotation, Attachment, AttachmentSource, DestView, Field, FieldKind, FontInfo, Layer, LayerOp, Link, LinkTarget, OutlineItem, PageInfo, - PageTransition, RemoteDestination, TransitionStyle, Xfa, link_replies, thread_tops, + PageTransition, RemoteDestination, ReviewStates, TransitionStyle, Xfa, link_replies, thread_tops, }; #[cfg(test)] use navigation::{OUTLINE_DEPTH, OUTLINE_WINDOW}; diff --git a/crates/render/src/inspect/annotations.rs b/crates/render/src/inspect/annotations.rs index 1cc7a6eae1..bf5e8ebb93 100644 --- a/crates/render/src/inspect/annotations.rs +++ b/crates/render/src/inspect/annotations.rs @@ -1,5 +1,7 @@ use super::core::{limited_pdf_string, pretty_date, spend}; -use super::model::{Annotation, AttachmentSource, DestView, Field, FieldKind, LayerOp, Link, LinkTarget, RemoteDestination, link_replies}; +use super::model::{ + Annotation, AttachmentSource, DestView, Field, FieldKind, LayerOp, Link, LinkTarget, RemoteDestination, link_replies, state_or_default, +}; use super::navigation::text_string; use super::{Dictionary, DocInfo, Entry, HashMap, HashSet, Inspector, Object, ObjectId}; @@ -215,7 +217,7 @@ impl<'a> Inspector<'a> { rect, color, index, - state: self.text(d, b"State"), + state: state_or_default(self.text(d, b"State"), self.text(d, b"StateModel").as_deref()), quads, locked: d.get(b"F").ok().and_then(|f| self.resolve(f).as_i64().ok()).unwrap_or(0) & 128 != 0, intent: self.name(d, b"IT"), diff --git a/crates/render/src/inspect/model.rs b/crates/render/src/inspect/model.rs index 981de248ae..dc54296c83 100644 --- a/crates/render/src/inspect/model.rs +++ b/crates/render/src/inspect/model.rs @@ -1,4 +1,4 @@ -use super::HashMap; +use super::{HashMap, HashSet}; /// What kind of XFA form a document has (`/AcroForm /XFA`). #[derive(Clone, Copy, Debug, PartialEq, Eq)] @@ -425,10 +425,12 @@ fn rect_from_upright(shown: [f64; 4], rotation: u16) -> [f64; 4] { } /// Settle the reply links of a document's annotations: a reply keeps its `parent` only when -/// following the links reaches a comment of its own within 64 steps. An `/IRT` to an unlisted -/// object, a pop-up, the annotation itself or a loop leaves it a comment of its own, so it stays -/// in view instead of hanging off nothing. Both comment readers (inspect, and the engine's list -/// after an edit) run this. +/// following the links reaches a comment of its own. An `/IRT` to an unlisted object, a pop-up, +/// the annotation itself or a loop leaves it a comment of its own, so it stays in view instead of +/// hanging off nothing. A chain of any length settles: each reviewer's state changes reply to +/// their previous one (ISO 32000-2 §12.5.6.3), so a long review history is a long chain. Each +/// annotation is walked once, with a seen-set against loops. Both comment readers (inspect, and +/// the engine's list after an edit) run this. /// /// Only comments can be parents: both readers leave links, form widgets, pop-ups and other /// non-markup annotations out of `annotations` (`pdfcraft_annot::is_comment_subtype`), so an @@ -439,38 +441,181 @@ pub fn link_replies(annotations: &mut [Annotation]) { annotations.iter().enumerate().filter(|(_, a)| a.subtype != "Popup").map(|(i, a)| ((a.page, a.index), i)).collect(); let links: Vec> = annotations.iter().enumerate().map(|(i, a)| a.parent.and_then(|p| at.get(&p).copied()).filter(|&j| j != i)).collect(); - for (i, a) in annotations.iter_mut().enumerate() { - let mut up = links.get(i).copied().flatten(); - let mut settled = false; - for _ in 0..64 { - let Some(j) = up else { break }; - up = links.get(j).copied().flatten(); - if up.is_none() { - settled = true; - break; + // Whether each annotation's chain reaches a comment of its own, filled in along each walk. + let mut settled: Vec> = vec![None; annotations.len()]; + for start in 0..annotations.len() { + let (mut path, mut seen, mut at) = (Vec::new(), HashSet::new(), start); + let ends_well = loop { + match settled.get(at).copied().flatten() { + Some(known) => break known, + None if !seen.insert(at) => break false, + None => {} + } + path.push(at); + match links.get(at).copied().flatten() { + Some(up) => at = up, + None => break true, + } + }; + for i in path { + if let Some(s) = settled.get_mut(i) { + *s = Some(ends_well); } } - if !settled { + } + // A reply keeps its parent when it has a listed one and its chain reaches a comment; one whose + // `/IRT` leads nowhere listed (itself, an unlisted object) is a comment of its own. + for ((a, ok), link) in annotations.iter_mut().zip(settled).zip(links) { + if link.is_none() || ok != Some(true) { a.parent = None; } } } /// The comment each reply's thread starts from, by the reply's page and `/Annots` position: a -/// reply to a reply files under that comment too. Expects links [`link_replies`] settled. +/// reply to a reply files under that comment too. Expects links [`link_replies`] settled; a loop +/// in an unsettled list ends where it comes back round. pub fn thread_tops(annotations: &[Annotation]) -> HashMap<(usize, usize), (usize, usize)> { let parents: HashMap<(usize, usize), (usize, usize)> = annotations.iter().filter_map(|a| Some(((a.page, a.index), a.parent?))).collect(); - parents - .iter() - .map(|(&reply, &first)| { - let mut top = first; - for _ in 0..64 { - let Some(&up) = parents.get(&top) else { break }; - top = up; + let mut tops: HashMap<(usize, usize), (usize, usize)> = HashMap::new(); + for &reply in parents.keys() { + let (mut path, mut seen, mut at) = (Vec::new(), HashSet::new(), reply); + let top = loop { + if let Some(&known) = tops.get(&at) { + break known; } - (reply, top) - }) - .collect() + let Some(&up) = parents.get(&at) else { break at }; + if !seen.insert(at) { + break up; + } + path.push(at); + at = up; + }; + for r in path { + tops.insert(r, top); + } + } + tops +} + +/// The status and checkmark replies of a document's annotations, by the annotation whose state +/// each one sets, found by object reference (the settled `parent` links of [`link_replies`]). +/// +/// ISO 32000-2 §12.5.6.3: a state change is a text annotation whose `/IRT` "shall refer to the +/// original annotation" and whose `/State` and `/StateModel` "update the state of the original +/// annotation for the specified user" (`/T`); "Additional state changes shall be made by adding +/// text annotations in reply to the previous reply for a given user". So a state reply sets the +/// state of the first annotation up its `/IRT` chain that is not itself a state reply: the +/// comment, or the reply the state was set on (a reply is an annotation with a state of its own, +/// and is never folded into its thread's comment). There is one status per reviewer, and a +/// checkmark only for the reviewer who set it. +pub struct ReviewStates<'a> { + by_target: HashMap<(usize, usize), Vec<&'a Annotation>>, +} + +impl<'a> ReviewStates<'a> { + pub fn new(all: &'a [Annotation]) -> Self { + let at: HashMap<(usize, usize), &Annotation> = all.iter().filter(|a| a.subtype != "Popup").map(|a| ((a.page, a.index), a)).collect(); + // The annotation each state reply's chain reaches past other state replies, `None` for a + // loop or a reference to nothing listed; filled in along each walk, so a long history + // costs one walk, not one per reply. + let mut reaches: HashMap<(usize, usize), Option<(usize, usize)>> = HashMap::new(); + let mut by_target: HashMap<(usize, usize), Vec<&Annotation>> = HashMap::new(); + for r in all.iter().filter(|a| a.state.is_some()) { + let (mut path, mut seen, mut up) = (vec![(r.page, r.index)], HashSet::from([(r.page, r.index)]), r.parent); + let target = loop { + let Some(key) = up else { break None }; + if let Some(&known) = reaches.get(&key) { + break known; + } + match at.get(&key) { + None => break None, + Some(p) if p.state.is_none() => break Some(key), + Some(_) if !seen.insert(key) => break None, + Some(p) => { + path.push(key); + up = p.parent; + } + } + }; + for key in path { + reaches.insert(key, target); + } + if let Some(t) = target { + by_target.entry(t).or_default().push(r); + } + } + Self { by_target } + } + + /// Every status and checkmark reply about `target`, in document order. + pub fn history(&self, target: &Annotation) -> &[&'a Annotation] { + self.by_target.get(&(target.page, target.index)).map(Vec::as_slice).unwrap_or(&[]) + } + + /// Each reviewer's review status on `target`: their latest, in the order they first set one. + /// Empty when nobody set one; a status set back to "None" stays listed, since None is a state + /// (ISO 32000-2 Table 174: "The user has indicated nothing about the change"). + pub fn statuses(&self, target: &Annotation) -> Vec<(&'a str, &'a str)> { + self.by_reviewer(target, false).into_iter().filter_map(|(who, mine)| Some((who, latest(&mine)?.state.as_deref()?))).collect() + } + + /// The latest review status set on `target` by anyone, for callers that show one. + pub fn latest_status(&self, target: &Annotation) -> Option<&'a str> { + self.history(target).iter().rev().filter(|r| !r.is_mark()).find_map(|r| r.state.as_deref()) + } + + /// Whether `author`'s latest checkmark on `target` is "Marked". Checkmarks are each + /// reviewer's own (ISO 32000-2 Table 174: "The annotation has been marked by the user"). + pub fn marked_by(&self, target: &Annotation, author: &str) -> bool { + let mine: Vec<&Annotation> = self.history(target).iter().copied().filter(|r| r.is_mark() && reviewer(r) == author.trim()).collect(); + latest(&mine).is_some_and(|r| r.state.as_deref() == Some("Marked")) + } + + /// The reviewers whose latest checkmark on `target` is "Marked". + pub fn marked(&self, target: &Annotation) -> Vec<&'a str> { + self.by_reviewer(target, true) + .into_iter() + .filter(|(_, mine)| latest(mine).is_some_and(|r| r.state.as_deref() == Some("Marked"))) + .map(|(who, _)| who) + .collect() + } + + /// The checkmark (`marks`) or status replies about `target`, by reviewer, in the order each + /// reviewer first set one. + fn by_reviewer(&self, target: &Annotation, marks: bool) -> Vec<(&'a str, Vec<&'a Annotation>)> { + let mut out: Vec<(&str, Vec<&Annotation>)> = Vec::new(); + for &r in self.history(target).iter().filter(|r| r.is_mark() == marks) { + match out.iter_mut().find(|(who, _)| *who == reviewer(r)) { + Some((_, mine)) => mine.push(r), + None => out.push((reviewer(r), vec![r])), + } + } + out + } +} + +/// Of one reviewer's replies in one state model, the latest: the end of their chain, a reply none +/// of the others replies to (by reference), whatever order the file lists them in. Among several +/// such (changes that don't chain), the last listed. +fn latest<'b>(mine: &[&'b Annotation]) -> Option<&'b Annotation> { + let answered: HashSet<(usize, usize)> = mine.iter().filter_map(|r| r.parent).collect(); + mine.iter().rev().find(|r| !answered.contains(&(r.page, r.index))).copied() +} + +/// A reply's state: `/State`, or for a reply that gives only `/StateModel` the model's default +/// (ISO 32000-2 Table 175: "Unmarked if StateModel is Marked; None if StateModel is Review"). +pub(super) fn state_or_default(state: Option, model: Option<&str>) -> Option { + state.or_else(|| match model? { + "Marked" => Some("Unmarked".into()), + "Review" => Some("None".into()), + _ => None, + }) +} + +/// Who set a state: `/T`, trimmed (an author setting typed with spaces is the same reviewer). +fn reviewer(r: &Annotation) -> &str { + r.author.as_deref().unwrap_or("").trim() } #[derive(Clone, Copy, Debug, PartialEq, Eq)] @@ -542,3 +687,257 @@ pub struct FontInfo { /// the document's own glyphs are being shown. pub substitution: Option, } + +/// The review states and reply links of synthetic annotation lists. +#[cfg(test)] +mod review_state_tests { + use super::{Annotation, ReviewStates, link_replies, thread_tops}; + + /// One annotation of a synthetic page: `key` is its `/NM`, and `irt` the key of the + /// annotation its `/IRT` references, resolved by position as the readers resolve references. + type Spec<'s> = (&'s str, Option<&'s str>, &'s str, Option<&'s str>); + + /// The annotations of `specs` on page 0, in order, with their reply links settled as the + /// readers settle them. An `irt` that names no key references nothing listed. + fn listed(specs: &[Spec<'_>]) -> Vec { + let mut all: Vec = specs + .iter() + .enumerate() + .map(|(index, &(nm, irt, author, state))| Annotation { + page: 0, + subtype: "Text".into(), + author: Some(author.into()), + contents: None, + modified: None, + name: Some(nm.into()), + in_reply_to: irt.map(Into::into), + parent: irt.map(|k| (0, specs.iter().position(|s| s.0 == k).unwrap_or(usize::MAX))), + grouped: false, + rect: [0.0; 4], + color: None, + index, + state: state.map(Into::into), + quads: Vec::new(), + locked: false, + intent: None, + fill_sign: false, + no_rotate: false, + points: Vec::new(), + }) + .collect(); + link_replies(&mut all); + all + } + + fn by_name<'a>(all: &'a [Annotation], nm: &str) -> &'a Annotation { + all.iter().find(|a| a.name.as_deref() == Some(nm)).expect("listed") + } + + /// The states these replies set (ISO 32000-2 §12.5.6.3, with synthetic files): one status per + /// reviewer, their latest, whether or not their changes chain; "None" stays listed; a + /// checkmark only for the reviewer who set it. + #[test] + fn review_states_are_each_reviewers_latest_through_chains() { + let all = listed(&[ + ("c", None, "Alice", None), + ("b1", Some("c"), "Bob", Some("Accepted")), + ("k1", Some("c"), "Carol", Some("Completed")), + ("b2", Some("b1"), "Bob", Some("Rejected")), // chained, per §12.5.6.3 + ("r", Some("c"), "Dan", None), // a reply, not a state + ("rs", Some("r"), "Dan", Some("Cancelled")), // a state on the reply, not on c + ("m1", Some("c"), "Bob", Some("Marked")), + ("m2", Some("m1"), "Bob", Some("Unmarked")), + ("m3", Some("c"), "Carol", Some("Marked")), + ("n", None, "Alice", None), + ("n1", Some("n"), "Bob", Some("Completed")), + ("n2", Some("n"), "Bob", Some("None")), // unchained, as PdfCraft wrote it before + // A loop of state replies belongs to nothing. + ("x", Some("y"), "Eve", Some("Accepted")), + ("y", Some("x"), "Eve", Some("Accepted")), + ]); + let states = ReviewStates::new(&all); + let (c, r, n, x) = (by_name(&all, "c"), by_name(&all, "r"), by_name(&all, "n"), by_name(&all, "x")); + assert_eq!(states.statuses(c), [("Bob", "Rejected"), ("Carol", "Completed")]); + assert_eq!(states.latest_status(c), Some("Rejected")); + assert_eq!(states.history(c).len(), 6, "every change, the checkmarks included"); + assert_eq!(states.statuses(r), [("Dan", "Cancelled")]); + assert!(!states.marked_by(c, "Bob"), "Bob took his off"); + assert!(states.marked_by(c, "Carol")); + assert!(!states.marked_by(c, "Alice"), "nobody else's checkmark shows"); + assert_eq!(states.marked(c), ["Carol"]); + assert_eq!(states.statuses(n), [("Bob", "None")], "a status set back to None stays listed"); + assert!(states.statuses(x).is_empty() && states.history(x).is_empty()); + } + + /// §12.5.6.3: a state reply's `/IRT` "shall refer to the original annotation", and a reply is + /// an annotation of its own. A status set on a reply, and the changes that chain from it, are + /// that reply's and never its thread's comment's, at any depth of the thread. + #[test] + fn a_status_on_a_reply_and_its_chain_stay_the_replys() { + let all = listed(&[ + ("c", None, "Alice", None), + ("r", Some("c"), "Bob", None), + ("rr", Some("r"), "Carol", None), + ("s1", Some("rr"), "Dan", Some("Accepted")), + ("s2", Some("s1"), "Dan", Some("Rejected")), + ("e1", Some("s2"), "Eve", Some("Completed")), // Eve answering Dan's state reply + ("m1", Some("r"), "Dan", Some("Marked")), + ]); + let states = ReviewStates::new(&all); + let (c, r, rr) = (by_name(&all, "c"), by_name(&all, "r"), by_name(&all, "rr")); + assert!(states.history(c).is_empty() && states.statuses(c).is_empty(), "not folded to the comment"); + assert!(states.statuses(r).is_empty() && states.marked_by(r, "Dan")); + assert_eq!(states.statuses(rr), [("Dan", "Rejected"), ("Eve", "Completed")]); + assert!(states.history(by_name(&all, "s1")).is_empty(), "a state reply has no states of its own"); + } + + /// States follow object references, not `/NM`: two comments with the same `/NM` (some writers + /// name the first comment on every page alike) each keep their own statuses. + #[test] + fn states_follow_references_not_repeated_names() { + let mut all = listed(&[ + ("a", None, "Alice", None), + ("b", None, "Alice", None), + ("sa", Some("a"), "Bob", Some("Accepted")), + ("sb", Some("b"), "Bob", Some("Rejected")), + ("sb2", Some("sb"), "Bob", Some("Completed")), + ]); + for a in all.iter_mut().take(2) { + a.name = Some("dup".into()); + } + for s in all.iter_mut().skip(2) { + if s.in_reply_to.as_deref().is_some_and(|k| k == "a" || k == "b") { + s.in_reply_to = Some("dup".into()); + } + } + let states = ReviewStates::new(&all); + assert_eq!(states.statuses(&all[0]), [("Bob", "Accepted")]); + assert_eq!(states.statuses(&all[1]), [("Bob", "Completed")]); + } + + /// A state reply whose `/IRT` references nothing listed, or nothing at all, sets no + /// annotation's state, and a loop that never reaches an annotation without a state belongs to + /// nothing even in a list whose links were not settled. + #[test] + fn a_state_reply_without_a_listed_target_belongs_to_nothing() { + let all = listed(&[ + ("c", None, "Alice", None), + ("gone", Some("deleted"), "Bob", Some("Accepted")), + ("bare", None, "Bob", Some("Rejected")), + ("next", Some("gone"), "Bob", Some("Completed")), + ]); + let states = ReviewStates::new(&all); + assert!(all.iter().all(|a| states.history(a).is_empty())); + // Unsettled: the loop's links are still in place. + let mut raw = listed(&[("c", None, "Alice", None)]); + let mut x = raw[0].clone(); + (x.index, x.name, x.state, x.parent) = (1, Some("x".into()), Some("Accepted".into()), Some((0, 2))); + let mut y = x.clone(); + (y.index, y.name, y.parent) = (2, Some("y".into()), Some((0, 1))); + raw.extend([x, y]); + let states = ReviewStates::new(&raw); + assert!(raw.iter().all(|a| states.history(a).is_empty())); + } + + /// A long reply chain, as a reviewer's long status history makes (§12.5.6.3: each change + /// replies to the previous), settles, and its replies file under the comment; a loop, an `/IRT` + /// to itself and one to nothing listed leave an annotation a comment of its own. + #[test] + fn reply_links_settle_at_any_depth_but_not_in_a_loop() { + let keys: Vec = (0..200).map(|i| format!("r{i}")).collect(); + let mut specs: Vec> = vec![("c", None, "Alice", None)]; + for (i, k) in keys.iter().enumerate() { + specs.push((k, Some(if i == 0 { "c" } else { keys[i - 1].as_str() }), "Bob", None)); + } + specs.extend([ + ("x", Some("y"), "Eve", None), + ("y", Some("x"), "Eve", None), + ("z", Some("x"), "Eve", None), + ("me", Some("me"), "Eve", None), + ("lost", Some("unlisted"), "Eve", None), + ]); + // In file order and newest first: the first reply read then walks the whole chain at once. + for newest_first in [false, true] { + if newest_first { + specs.reverse(); + } + let all = listed(&specs); + let replies: Vec<&Annotation> = all.iter().filter(|a| a.name.as_deref().is_some_and(|n| n.starts_with('r'))).collect(); + assert!(replies.len() == 200 && replies.iter().all(|a| a.is_reply()), "{newest_first}"); + let nowhere = all.iter().filter(|a| matches!(a.name.as_deref(), Some("x" | "y" | "z" | "me" | "lost"))); + assert!(nowhere.clone().count() == 5 && nowhere.clone().all(|a| !a.is_reply()), "a loop, a chain into one, itself or nothing listed"); + let c = by_name(&all, "c"); + let tops = thread_tops(&all); + assert_eq!(tops.len(), 200, "{newest_first}"); + assert!(tops.values().all(|&t| t == (c.page, c.index)), "{newest_first}"); + } + } + + /// Review on #4126: a history has no length limit, and a reviewer's name is compared without + /// surrounding spaces. + #[test] + fn a_long_chain_still_reaches_its_comment_and_names_are_trimmed() { + let keys: Vec = (0..200).map(|i| format!("s{i}")).collect(); + let mut specs: Vec> = vec![("c", None, "Alice", None)]; + for (i, k) in keys.iter().enumerate() { + let parent = if i == 0 { "c" } else { keys[i - 1].as_str() }; + specs.push((k, Some(parent), "Bob", Some(if i == 199 { "Completed" } else { "Accepted" }))); + } + specs.push(("m", Some("c"), " Carol ", Some("Marked"))); + let all = listed(&specs); + let states = ReviewStates::new(&all); + assert_eq!(states.history(&all[0]).len(), 201); + assert_eq!(states.statuses(&all[0]), [("Bob", "Completed")]); + assert!(states.marked_by(&all[0], "Carol") && states.marked_by(&all[0], "Carol ")); + assert_eq!(states.marked(&all[0]), ["Carol"]); + // Listed newest first, the first reply read walks the whole chain at once. + specs.reverse(); + let all = listed(&specs); + let states = ReviewStates::new(&all); + let c = by_name(&all, "c"); + assert_eq!(states.statuses(c), [("Bob", "Completed")]); + assert_eq!(states.history(c).len(), 201); + } + + /// The inspector reads a `/StateModel`-only reply as the model's default state, so it counts + /// as Bob's status (None) on the note. + #[test] + fn the_inspector_reads_a_state_model_alone_as_its_default() { + let objs: Vec<&[u8]> = vec![ + b"<< /Type /Catalog /Pages 2 0 R >>", + b"<< /Type /Pages /Kids [3 0 R] /Count 1 >>", + b"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] /Annots [4 0 R 5 0 R] >>", + b"<< /Type /Annot /Subtype /Text /Rect [10 10 30 30] /NM (note) /T (Ann) >>", + b"<< /Type /Annot /Subtype /Text /Rect [10 10 30 30] /NM (bare) /T (Bob) /IRT 4 0 R /F 30 /StateModel (Review) >>", + ]; + let mut out = b"%PDF-1.7\n".to_vec(); + let mut offsets = Vec::new(); + for (i, o) in objs.iter().enumerate() { + offsets.push(out.len()); + out.extend_from_slice(format!("{} 0 obj\n", i + 1).as_bytes()); + out.extend_from_slice(o); + out.extend_from_slice(b"\nendobj\n"); + } + let xref = out.len(); + out.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objs.len() + 1).as_bytes()); + for o in offsets { + out.extend_from_slice(format!("{o:010} 00000 n \n").as_bytes()); + } + out.extend_from_slice(format!("trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n", objs.len() + 1).as_bytes()); + let info = crate::inspect(std::sync::Arc::new(out), None).expect("inspects"); + let note = by_name(&info.annotations, "note"); + assert_eq!(ReviewStates::new(&info.annotations).statuses(note), [("Bob", "None")]); + } + + /// ISO 32000-2 Table 175: a missing `/State` is "Unmarked if StateModel is Marked; None if + /// StateModel is Review". + #[test] + fn a_state_model_alone_gives_its_default_state() { + use super::state_or_default; + assert_eq!(state_or_default(None, Some("Review")).as_deref(), Some("None")); + assert_eq!(state_or_default(None, Some("Marked")).as_deref(), Some("Unmarked")); + assert_eq!(state_or_default(None, Some("Other")), None); + assert_eq!(state_or_default(None, None), None); + assert_eq!(state_or_default(Some("Accepted".into()), Some("Review")).as_deref(), Some("Accepted")); + } +} diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs index 6f9a3ac319..245f3a53f3 100644 --- a/crates/render/src/lib.rs +++ b/crates/render/src/lib.rs @@ -19,7 +19,8 @@ pub mod transition; pub use inspect::{ Annotation, Attachment, AttachmentSource, DestView, DocInfo, Field, FieldKind, FontInfo, Layer, LayerOp, Link, LinkTarget, OutlineItem, PageInfo, - PageTransition, RemoteDestination, TransitionStyle, Xfa, attachment_data, check_pages, inspect, link_replies, pretty_date, thread_tops, + PageTransition, RemoteDestination, ReviewStates, TransitionStyle, Xfa, attachment_data, check_pages, inspect, link_replies, pretty_date, + thread_tops, }; pub use pixels::Pixels; pub use raster::{ diff --git a/crates/ui-egui/src/dialogs/comment_props.rs b/crates/ui-egui/src/dialogs/comment_props.rs index 7b404c2f76..2309e7e600 100644 --- a/crates/ui-egui/src/dialogs/comment_props.rs +++ b/crates/ui-egui/src/dialogs/comment_props.rs @@ -312,10 +312,12 @@ pub(crate) fn body(ui: &mut egui::Ui, app: &mut PdfCraftApp, t: &Tokens) -> (boo .zip(app.comment_props.as_ref()) .map(|(doc, d)| { let me = doc.info.annotations.iter().find(|a| a.page == d.page && a.index == d.index && !a.is_reply()); - doc.info - .annotations + // Every state change set on it, including a reviewer's later ones, which reply to + // their earlier (ISO 32000-2 §12.5.6.3). + let states = pdfcraft_render::ReviewStates::new(&doc.info.annotations); + me.map(|a| states.history(a)) + .unwrap_or_default() .iter() - .filter(|r| r.state.is_some() && me.is_some_and(|a| r.replies_to(a))) .map(|r| (r.state.clone().unwrap_or_default(), r.author.clone().unwrap_or_default(), r.modified.clone().unwrap_or_default())) .collect() }) diff --git a/crates/ui-egui/src/panels/comments_panel.rs b/crates/ui-egui/src/panels/comments_panel.rs index f6b0043637..cc328db5ab 100644 --- a/crates/ui-egui/src/panels/comments_panel.rs +++ b/crates/ui-egui/src/panels/comments_panel.rs @@ -1,16 +1,16 @@ //! The Comments panel (Acrobat's right-hand comment list; 11-visual-spec §3, audit `a15-*`). //! -//! Header "Comments N" (comments, as Acrobat counts them) with "N replies" under it and search; -//! an "Add a comment" box that posts a sticky note on the current page; cards grouped by page. -//! Selecting a card selects the comment on the page and opens its reply box and "…" menu (Edit, -//! Set status, Delete), and gives each reply a "…" of its own (Edit, Copy text, Delete). Status -//! replies show as a badge. +//! Header "Comments N" (comments) with "N replies" under it and search; an "Add a comment" box +//! that posts a sticky note on the current page; cards grouped by page. Selecting a card selects +//! the comment on the page and opens its reply box and "…" menu (Edit, Set status, Delete), and +//! gives each reply a "…" of its own (Edit, Copy text, Delete). Status replies show as one badge +//! per reviewer. use std::collections::HashMap; use egui::{Align, Color32, CornerRadius, Layout, Sense, Stroke, vec2}; use pdfcraft_engine::{Edit, NoteIcon, ReviewState, Shape}; -use pdfcraft_render::{Annotation, DocInfo}; +use pdfcraft_render::{Annotation, DocInfo, ReviewStates}; use crate::canvas::DocView; use crate::comments::{CommentPrefs, CommentTool, SortBy, color32, status_badge}; @@ -84,13 +84,7 @@ fn check_label(marked: bool) -> &'static str { if marked { "Checked" } else { "Unchecked" } } -/// The latest checkmark reply in a thread says "Marked". -pub fn is_marked(thread: &[&Annotation]) -> bool { - thread.iter().rev().find(|r| r.is_mark()).is_some_and(|r| r.state.as_deref() == Some("Marked")) -} - -/// The number shown in the header: comments, not their replies or status changes (as Acrobat -/// counts them). +/// The number shown in the header: comments, not their replies or status changes. pub fn count(info: &DocInfo) -> usize { info.annotations.iter().filter(|a| a.is_comment()).count() } @@ -178,15 +172,18 @@ pub(crate) fn show( let mut page_counts: HashMap = HashMap::new(); // Replies find their parent by reference (`/NM` need not be unique) and keep source order. // A reply at any depth joins the thread of the comment it starts from, as comment_list lists - // it. A status or checkmark stays with the annotation it was set on (its `/IRT` names it, - // ISO 32000-2 §12.5.6.2): one set on a reply, at any depth, is that reply's and not the - // comment's, so the card's badge and checkmark leave it out (comment_list reports it on the - // reply). Page badges count roots before filtering. + // it. Status and checkmark replies are read through `states`: each sets the state of the + // annotation its `/IRT` chain refers to past earlier state replies (ISO 32000-2 §12.5.6.3), + // so one set on a reply, at any depth, is that reply's and not the comment's, and the card's + // badges and checkmark leave it out (comment_list reports it on the reply). Page badges count + // roots before filtering. let tops = pdfcraft_render::thread_tops(&info.annotations); + let states = ReviewStates::new(&info.annotations); for a in &info.annotations { if let Some(parent) = a.parent { - let key = if a.state.is_none() { tops.get(&(a.page, a.index)).copied().unwrap_or(parent) } else { parent }; - replies.entry(key).or_default().push(a); + if a.state.is_none() { + replies.entry(tops.get(&(a.page, a.index)).copied().unwrap_or(parent)).or_default().push(a); + } } else if a.is_comment() { let count = page_counts.entry(a.page).or_default(); *count = count.saturating_add(1); @@ -194,18 +191,31 @@ pub(crate) fn show( } let replies_of = |a: &Annotation| -> &[&Annotation] { replies.get(&(a.page, a.index)).map(Vec::as_slice).unwrap_or(&[]) }; let cv = &view.comments; - let status_of = - |a: &Annotation| replies_of(a).iter().rev().filter(|r| !r.is_mark()).find_map(|r| r.state.clone()).unwrap_or_else(|| "None".into()); + // Hidden by the status filter when every reviewer's status is hidden ("None" when nobody set one). + let status_hidden = |a: &Annotation| { + let all = states.statuses(a); + if all.is_empty() { + cv.hidden_statuses.iter().any(|s| s == "None") + } else { + all.iter().all(|(_, s)| cv.hidden_statuses.iter().any(|h| h == s)) + } + }; let mut roots: Vec<&Annotation> = info .annotations .iter() .filter(|a| a.is_comment()) - .filter(|a| query.as_deref().is_none_or(|q| matches(a, q) || replies_of(a).iter().any(|r| matches(r, q)))) + // Search finds a comment by its text, its replies' and the status replies set on any of + // them: one set on a reply is that reply's (`states`), but it belongs to this thread. + .filter(|a| { + query.as_deref().is_none_or(|q| { + std::iter::once(*a).chain(replies_of(a).iter().copied()).any(|x| matches(x, q) || states.history(x).iter().any(|r| matches(r, q))) + }) + }) .filter(|a| !cv.hidden_types.iter().any(|t| t == kind_label(a))) .filter(|a| !cv.hidden_authors.contains(&a.author.clone().unwrap_or_default())) - .filter(|a| !cv.hidden_statuses.contains(&status_of(a))) + .filter(|a| !status_hidden(a)) .filter(|a| !cv.hidden_colors.contains(&color_key(a))) - .filter(|a| !cv.hidden_checks.contains(&check_label(is_marked(replies_of(a))).to_string())) + .filter(|a| !cv.hidden_checks.contains(&check_label(states.marked_by(a, &prefs.author)).to_string())) .collect(); let sort = cv.sort; match sort { @@ -282,7 +292,7 @@ pub(crate) fn show( // A card with an editor open on it or on a reply is always drawn too (CodeRabbit on // #5583): a kept height would show it at its size before the editor opened. let editing = view.comments.editing.as_ref().is_some_and(|(p, i, _)| (*p, *i) == key || thread.iter().any(|r| (r.page, r.index) == (*p, *i))); - let id = ui.scope_id().with(("comment-card", view.id, key, card_signature(a, thread), selected, editing)); + let id = ui.scope_id().with(("comment-card", view.id, key, card_signature(a, thread, &states), selected, editing)); // The selected card is always drawn, so it can scroll itself into view and keep its menu // and editor. if virtual_list && !selected && !editing && skip_if_hidden(ui, id) { @@ -290,7 +300,9 @@ pub(crate) fn show( } let rotation = info.pages.get(a.page).map_or(0, |p| p.rotation); let top = ui.cursor().min.y; - if let Some(e) = card(ui, view, CardContext { theme: t, annotation: a, rotation, thread, order: &order, doc, prefs, allowed, nav }) { + if let Some(e) = + card(ui, view, CardContext { theme: t, annotation: a, rotation, thread, order: &order, states: &states, doc, prefs, allowed, nav }) + { edit = Some(e); } if virtual_list { @@ -332,14 +344,18 @@ fn page_header(ui: &mut egui::Ui, t: &Tokens, info: &DocInfo, page: usize, n: us } /// What a card's height depends on in the document: the comment and its replies as shown (their -/// kind, author, text, date, status, colour and lock). Two cards with the same signature lay out +/// kind, author, text, date, status, colour and lock), and the status and checkmark replies set on +/// each, which the thread leaves out and `states` reads. Two cards with the same signature lay out /// alike at one width. -fn card_signature(a: &Annotation, thread: &[&Annotation]) -> u64 { +fn card_signature(a: &Annotation, thread: &[&Annotation], states: &ReviewStates) -> u64 { use std::hash::{Hash, Hasher}; let mut h = std::collections::hash_map::DefaultHasher::new(); for x in std::iter::once(a).chain(thread.iter().copied()) { (&x.subtype, &x.author, &x.contents, &x.modified, &x.state, x.locked).hash(&mut h); x.color.map(|c| c.map(f32::to_bits)).hash(&mut h); + for r in states.history(x) { + (&r.subtype, &r.author, &r.modified, &r.state).hash(&mut h); + } } h.finish() } @@ -381,6 +397,7 @@ struct CardContext<'a> { thread: &'a [&'a Annotation], /// The cards in the order shown, for a ⇧-click range. order: &'a [(usize, usize)], + states: &'a ReviewStates<'a>, doc: &'a pdfcraft_engine::Document, prefs: &'a CommentPrefs, allowed: bool, @@ -388,7 +405,7 @@ struct CardContext<'a> { } fn card(ui: &mut egui::Ui, view: &mut DocView, context: CardContext<'_>) -> Option { - let CardContext { theme: t, annotation: a, rotation, thread, order, doc, prefs, allowed, nav } = context; + let CardContext { theme: t, annotation: a, rotation, thread, order, states, doc, prefs, allowed, nav } = context; let info = &doc.info; let mut edit = None; let key = (a.page, a.index); @@ -397,9 +414,11 @@ fn card(ui: &mut egui::Ui, view: &mut DocView, context: CardContext<'_>) -> Opti // the handles. let primary = view.comments.selected == Some(key); let color = a.color.map(|c| color32(c.map(f64::from))).unwrap_or(t.accent); - let status = thread.iter().rev().filter(|r| !r.is_mark()).find_map(|r| r.state.as_deref()); - let marked = is_marked(thread); - let replies: Vec<&&Annotation> = thread.iter().filter(|r| r.state.is_none()).collect(); + // Each reviewer's status, and the checkmark of the reviewer using this copy (prefs.author): + // checkmarks are each reviewer's own (ISO 32000-2 Table 174, "marked by the user"). + let statuses = states.statuses(a); + let marked = states.marked_by(a, &prefs.author); + let replies: Vec<&&Annotation> = thread.iter().collect(); let editing = view.comments.editing.as_ref().is_some_and(|(p, i, _)| (*p, *i) == key); let fill = if selected { t.hover.gamma_multiply(0.7) } else { Color32::TRANSPARENT }; let mut open_menu = false; @@ -460,11 +479,15 @@ fn card(ui: &mut egui::Ui, view: &mut DocView, context: CardContext<'_>) -> Opti widgets::wrapped_text(ui, body, theme::regular(13.0), t.text, !selected); } } - if let Some((icon, label, c)) = status.and_then(status_badge) { + // One badge per reviewer, with their name: a state is "for the specified user" + // (ISO 32000-2 §12.5.6.3). + for (who, state) in &statuses { + let Some((icon, label, c)) = status_badge(state) else { continue }; ui.add_space(4.0); ui.horizontal(|ui| { ui.add(icons::image(icon, 13.0, c)); - ui.label(egui::RichText::new(tl!(label)).font(theme::medium(11.5)).color(c)); + let text = if who.is_empty() { tl!(label).to_string() } else { format!("{who}: {}", tl!(label)) }; + ui.label(egui::RichText::new(text).font(theme::medium(11.5)).color(c)); }); } }); diff --git a/crates/ui-egui/src/tools/comments/menu.rs b/crates/ui-egui/src/tools/comments/menu.rs index a2c38b2628..9a599b7796 100644 --- a/crates/ui-egui/src/tools/comments/menu.rs +++ b/crates/ui-egui/src/tools/comments/menu.rs @@ -118,8 +118,9 @@ pub(crate) fn context_menu( } }); }); - let thread: Vec<&pdfcraft_render::Annotation> = info.annotations.iter().filter(|r| r.replies_to(a)).collect(); - let marked = crate::comments_panel::is_marked(&thread); + // The checkmark of the reviewer using this copy: checkmarks are each reviewer's own + // (ISO 32000-2 Table 174: "The annotation has been marked by the user"). + let marked = pdfcraft_render::ReviewStates::new(&info.annotations).marked_by(a, &prefs.author); if ui.add_enabled(allowed, egui::Button::new(tl!(if marked { "Remove checkmark" } else { "Mark with checkmark" }))).clicked() { action = Some(CanvasAction::Edit(Box::new(Edit::MarkAnnotation { page, index, marked: !marked, author: prefs.author.clone() }))); ui.close(); @@ -460,6 +461,8 @@ pub fn status_badge(state: &str) -> Option<(&'static str, &'static str, Color32) "Rejected" => Some(("thumbs-down", "Rejected", Color32::from_rgb(0xD3, 0x2F, 0x2F))), "Cancelled" => Some(("circle-x", "Cancelled", Color32::from_rgb(0x8E, 0x8E, 0x8E))), "Completed" => Some(("check", "Completed", Color32::from_rgb(0x2D, 0x9D, 0x5B))), + // A status set back to None stays shown: None is a state (ISO 32000-2 Table 174). + "None" => Some(("minus", "None", Color32::from_rgb(0x8E, 0x8E, 0x8E))), _ => None, } } diff --git a/crates/ui-egui/tests/it/comments.rs b/crates/ui-egui/tests/it/comments.rs index c9ab522ecd..599f1863e1 100644 --- a/crates/ui-egui/tests/it/comments.rs +++ b/crates/ui-egui/tests/it/comments.rs @@ -1627,6 +1627,32 @@ fn the_panel_preserves_threads_source_order_and_unfiltered_page_counts() { assert_eq!(h.query_all_by_label("Needle first reply").count(), 0, "nothing replies to the unnamed root"); } +// Contributor-original PDF. A status is set on a reply (its /IRT names the reply, ISO 32000-2 +// §12.5.6.3), not on the comment; the status reply's text is found only there. +const REPLY_STATUS_FIXTURE: &[u8] = b"%PDF-1.7 +1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj +2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 >> endobj +3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 300 200] /Annots [5 0 R 6 0 R 7 0 R 8 0 R] >> endobj +5 0 obj << /Type /Annot /Subtype /Text /Rect [20 140 40 160] /T (Alpha) /Contents (Plain root) >> endobj +6 0 obj << /Type /Annot /Subtype /Text /Rect [60 140 80 160] /T (Beta) /Contents (Other root) >> endobj +7 0 obj << /Type /Annot /Subtype /Text /Rect [20 100 40 120] /IRT 5 0 R /T (Reader) /Contents (Ordinary reply) >> endobj +8 0 obj << /Type /Annot /Subtype /Text /Rect [20 100 40 120] /IRT 7 0 R /T (Carol) /StateModel /Review /State /Accepted /Contents (Reply-status token) >> endobj +trailer << /Root 1 0 R >> +%%EOF"; + +#[test] +fn search_finds_a_comment_by_the_status_set_on_one_of_its_replies() { + let mut h = harness_with_bytes(REPLY_STATUS_FIXTURE.to_vec(), |app| { + app.set_option("panel", "comments").unwrap(); + app.views[0].comments.search = Some("reply-status token".into()); + }); + h.run_steps(2); + h.get_by_label("Plain root"); + h.get_by_label("Ordinary reply"); + assert_eq!(h.query_all_by_label("Other root").count(), 0, "the other comment has no such status"); + assert_eq!(h.query_all_by_label("No comments match.").count(), 0); +} + // Contributor-original PDF, shaped like PyMuPDF output: every page's first comment is named // `fitz-A0`, so only the `/IRT` reference tells which comment a reply belongs to. const PER_PAGE_NAMES: &[u8] = b"%PDF-1.7 @@ -1706,7 +1732,7 @@ fn one_reply_is_deleted_from_its_menu_and_the_thread_stays() { assert_eq!(h.query_all_by_label("Wrong reply").count(), 0); assert_eq!(h.query_all_by_label("Answer to the wrong reply").count(), 0); h.get_by_label("Right reply"); - h.get_by_label("Accepted"); + h.get_by_label("Ben: Accepted"); // One badge per reviewer. assert_eq!(h.state().session.get(id).unwrap().can_undo(), Some("Delete comment"), "one undo step"); // Copy text copies the reply's text. @@ -2333,6 +2359,68 @@ fn the_panel_filters_by_colour_and_checkmark() { assert_eq!(cards(&h), before); } +/// ISO 32000-2 §12.5.6.3 and Table 174: a comment shows each reviewer's latest status, with +/// their name, and a status set back to None stays shown; a checkmark shows only for the +/// reviewer who set it. +#[test] +fn the_panel_shows_each_reviewers_status_and_only_my_checkmark() { + let mut h = harness(|app| { + app.set_option("panel", "comments").unwrap(); + app.set_option("quick", "square").unwrap(); + }); + drag_pt(&mut h, (40.0, 100.0), (140.0, 40.0)); + h.state_mut().set_option("quick", "select").unwrap(); + let me = h.state().comment_prefs.author.clone(); + let other = format!("{me} (other)"); + for (state, author) in [ + (pdfcraft_engine::ReviewState::Accepted, "Bob"), + (pdfcraft_engine::ReviewState::Completed, "Carol"), + (pdfcraft_engine::ReviewState::Rejected, "Bob"), + (pdfcraft_engine::ReviewState::Completed, "Dan"), + (pdfcraft_engine::ReviewState::None, "Dan"), + ] { + h.state_mut().apply_edit(pdfcraft_engine::Edit::SetAnnotationStatus { page: 0, index: 0, state, author: author.into() }); + } + h.state_mut().apply_edit(pdfcraft_engine::Edit::MarkAnnotation { page: 0, index: 0, marked: true, author: other.clone() }); + h.state_mut().views[0].comments.selected = Some((0, 0)); + h.run_steps(2); + h.get_by_label("Bob: Rejected"); + h.get_by_label("Carol: Completed"); + h.get_by_label("Dan: None"); + assert_eq!(h.query_all_by_label("Bob: Accepted").count(), 0, "only Bob's latest"); + assert!(h.query_all_by_label("Remove checkmark").next().is_none(), "another reviewer's checkmark isn't mine"); + h.get_by_label("Mark with checkmark"); + + // Filters: a comment stays while any reviewer's status is shown; one nobody set a status on + // counts as None; the checkmark filter goes by my checkmark only. + h.state_mut().set_option("quick", "circle").unwrap(); + drag_pt(&mut h, (160.0, 100.0), (260.0, 40.0)); + h.state_mut().set_option("quick", "select").unwrap(); + h.state_mut().views[0].comments.selected = None; + h.run_steps(2); + let cards = |h: &Harness<'static, PdfCraftApp>| (h.query_all_by_label("Rectangle").count(), h.query_all_by_label("Oval").count()); + let shown = cards(&h); + h.state_mut().views[0].comments.hidden_statuses = vec!["Rejected".into()]; + h.run_steps(2); + assert_eq!(cards(&h), shown, "Carol's and Dan's statuses still show the rectangle"); + h.state_mut().views[0].comments.hidden_statuses = vec!["None".into()]; + h.run_steps(2); + assert_eq!(cards(&h), (shown.0, shown.1 - 1), "the oval has no status, so it counts as None"); + h.state_mut().views[0].comments.hidden_statuses = vec!["Rejected".into(), "Completed".into(), "None".into()]; + h.run_steps(2); + assert_eq!(cards(&h), (shown.0 - 1, shown.1 - 1), "every status hidden"); + h.state_mut().views[0].comments.hidden_statuses.clear(); + h.state_mut().views[0].comments.hidden_checks = vec!["Checked".into()]; + h.run_steps(2); + assert_eq!(cards(&h), shown, "another reviewer's checkmark isn't mine"); + + h.state_mut().views[0].comments.hidden_checks.clear(); + h.state_mut().views[0].comments.selected = Some((0, 0)); + h.state_mut().comment_prefs.author = other; + h.run_steps(2); + h.get_by_label("Remove checkmark"); +} + #[test] fn make_current_properties_default() { let mut h = harness(|app| { diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index 084b06dbae..714ba4814f 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -4082,8 +4082,8 @@ milestone = "M5" status = "shipped" commands = ["comment.list"] tools = ["comment_set_status"] -evidence = ["crates/annot/src/tests.rs::replies_and_status_thread_onto_the_parent", "crates/engine/src/tests.rs::comments_are_added_drawn_threaded_and_undone"] -notes = "status replies (/State, /StateModel Review), shown as a badge" +evidence = ["crates/annot/src/tests.rs::replies_and_status_thread_onto_the_parent", "crates/engine/src/tests.rs::comments_are_added_drawn_threaded_and_undone", "crates/annot/src/tests.rs::status_and_checkmark_replies_are_hidden_and_chain_per_reviewer", "crates/render/src/inspect/model.rs::review_states_are_each_reviewers_latest_through_chains", "crates/render/src/inspect/model.rs::a_status_on_a_reply_and_its_chain_stay_the_replys", "crates/render/src/inspect/model.rs::states_follow_references_not_repeated_names", "crates/automation/tests/it/automation.rs::review_statuses_are_per_reviewer_through_tools", "crates/automation/tests/it/automation.rs::statuses_set_on_a_reply_chain_on_that_reply_through_tools", "crates/ui-egui/tests/it/comments.rs::the_panel_shows_each_reviewers_status_and_only_my_checkmark"] +notes = "Status replies (/State, /StateModel Review) are written Hidden because a state reply records a state, not a note (ISO 32000-2 12.5.6.3: the state is kept 'in a separate text annotation'), and a reviewer's next change replies to their previous one (12.5.6.3: 'in reply to the previous reply for a given user'). Each reviewer's latest status shows as its own badge with their name (a state is 'for the specified user'), a status set back to None stays shown (None is a state, Table 174), and checkmarks (/StateModel Marked, 'marked by the user', Table 174) show only for the current author; comment_list reports statuses and marked_by. Tested with synthetic files. States are found by /IRT object reference, not /NM: a state reply sets the state of the first annotation up its /IRT chain that is not itself a state reply (12.5.6.3: /IRT 'shall refer to the original annotation'), so a status set on a reply stays that reply's and comment_list reports it there. Still missing: the shared-review Resolve and Mark as Unread (Document Cloud)." [[feature]] id = "comment.checkmark" From 3006212ba12ad4f96e9ac039f0c8964868ff984b Mon Sep 17 00:00:00 2001 From: Hitesh Date: Mon, 9 Nov 2026 15:55:24 +0530 Subject: [PATCH 30/64] fix(edit): catch paragraph edges and frames without aiming for the hairline (#4624) --- crates/ui-egui/src/tools/edit_text_ui.rs | 59 +++++++++++++++++++---- crates/ui-egui/tests/it/editing.rs | 61 ++++++++++++++++++++++++ 2 files changed, 110 insertions(+), 10 deletions(-) diff --git a/crates/ui-egui/src/tools/edit_text_ui.rs b/crates/ui-egui/src/tools/edit_text_ui.rs index 7abee76dd9..942b39674c 100644 --- a/crates/ui-egui/src/tools/edit_text_ui.rs +++ b/crates/ui-egui/src/tools/edit_text_ui.rs @@ -536,11 +536,12 @@ pub struct BlockDrag { grip: Grip, } -/// How far an edge grip reaches either side of a box's edge (screen pixels). -const EDGE_REACH: f32 = 6.0; +/// How far an edge grip reaches either side of a box's edge (screen pixels). Wide enough to +/// catch without aiming for the hairline (#2203); a narrow box still keeps its middle for moving. +const EDGE_REACH: f32 = 8.0; /// How far outside an open paragraph's frame the pointer still means the frame (screen pixels): /// the frame's Move cursor shows, and a press there takes hold of the box (`object_ui`). -pub(crate) const FRAME_REACH: f32 = 6.0; +pub(crate) const FRAME_REACH: f32 = 8.0; /// The rewrap handles drawn on a paragraph box's left and right edges (screen). fn width_handles(b: Rect) -> [Rect; 2] { @@ -564,12 +565,25 @@ fn edge_at(b: Rect, p: Pos2) -> Option { } } -/// The topmost box under `p` and what it grabs there; edges only on an upright page. +/// The box under `p` and what it grabs there; edges only on an upright page. An edge reaches a +/// little above and below its box, so where that reach overlaps another box the one `p` is level +/// with wins: a press beside one paragraph's edge doesn't rewrap the paragraph just under it. +/// Among boxes equally near, the topmost. fn grab_at(boxes: &[Rect], p: Pos2, upright: bool) -> Option<(usize, Grip)> { - boxes.iter().enumerate().rev().find_map(|(i, b)| match upright.then(|| edge_at(*b, p)).flatten() { - Some(g) => Some((i, g)), - None => b.contains(p).then_some((i, Grip::Move)), - }) + let mut best: Option<(f32, usize, Grip)> = None; + for (i, b) in boxes.iter().enumerate().rev() { + let grip = match upright.then(|| edge_at(*b, p)).flatten() { + Some(g) => g, + None if b.contains(p) => Grip::Move, + None => continue, + }; + // How far above or below the box `p` is: 0 within its height. + let off = (b.top() - p.y).max(p.y - b.bottom()).max(0.0); + if best.is_none_or(|(o, ..)| off < o) { + best = Some((off, i, grip)); + } + } + best.map(|(_, i, g)| (i, g)) } /// A paragraph's box on screen: its text rect (user space), with a little room around it. @@ -942,8 +956,12 @@ pub(crate) fn overlay(ctx: &egui::Context, view: &mut DocView, info: &DocInfo) - let live = draft_rect.map_or(visible, |bounds| bounds.union(visible)); ui.painter().rect_stroke(live.expand(2.0), CornerRadius::ZERO, Stroke::new(1.5, ACCENT), egui::StrokeKind::Outside); // On the frame, a press takes hold of the box (a click selects it, a drag moves it): - // the pointer says so there, and reads as text inside. - if let Some(p) = ui.input(|i| i.pointer.hover_pos()) + // the pointer says so there, and reads as text inside. Once the text is changed, a + // press there applies the typing like any click off the text instead of taking the + // box (`object_ui` would drop the typing), so the pointer doesn't offer it then + // (review on #4624). + if !ed.has_changes() + && let Some(p) = ui.input(|i| i.pointer.hover_pos()) && ui.clip_rect().contains(p) && live.expand(2.0 + FRAME_REACH).contains(p) && !live.expand(2.0).contains(p) @@ -1082,6 +1100,27 @@ pub(crate) fn lost_focus_once(r: &egui::Response, had: &mut bool) -> bool { mod tests { use super::*; + /// Where an edge's reach above or below its box overlaps the next paragraph's edge, the + /// paragraph the pointer is level with takes the press, whichever is drawn on top (the play + /// session "resize grown neighbor" at 50% zoom). + #[test] + fn an_edge_goes_to_the_box_the_pointer_is_level_with() { + let upper = Rect::from_min_max(Pos2::new(100.0, 100.0), Pos2::new(200.0, 140.0)); + let lower = Rect::from_min_max(Pos2::new(100.0, 146.0), Pos2::new(201.0, 160.0)); + // Inside the upper box's height, 6 px above the lower one: within both edges' reach. + let p = Pos2::new(199.0, 140.0); + assert_eq!(edge_at(lower, p), Some(Grip::Right), "the lower edge reaches up to p"); + assert_eq!(grab_at(&[upper, lower], p, true), Some((0, Grip::Right))); + assert_eq!(grab_at(&[lower, upper], p, true), Some((1, Grip::Right))); + // Level with neither, 4 px below one and 2 px above the other: the nearer box. + assert_eq!(grab_at(&[lower, upper], Pos2::new(199.0, 144.0), true), Some((0, Grip::Right))); + // Overlapping boxes both level with p: the topmost, as before. + assert_eq!(grab_at(&[upper, upper.translate(egui::vec2(5.0, 0.0))], upper.center(), true), Some((1, Grip::Move))); + // A body under p beats an edge reaching over from a box above or below it. + let body = Rect::from_min_max(Pos2::new(150.0, 146.0), Pos2::new(260.0, 160.0)); + assert_eq!(grab_at(&[body, upper], Pos2::new(205.0, 147.0), true), Some((0, Grip::Move))); + } + /// Formatting applied restarts the rows' alignments by text with the box's history: a Redo /// past the format can't bring back an alignment from before it (FredApps on bfc6f8b1). #[test] diff --git a/crates/ui-egui/tests/it/editing.rs b/crates/ui-egui/tests/it/editing.rs index 9752f0ff24..8b0bf25973 100644 --- a/crates/ui-egui/tests/it/editing.rs +++ b/crates/ui-egui/tests/it/editing.rs @@ -5426,6 +5426,67 @@ fn alt_arrows_navigate_instead_of_nudging_a_selected_image() { assert!((moved[0] - before[0] - 1.0).abs() < 0.01, "a plain → nudged a point: {before:?} → {moved:?}"); } +/// A paragraph's edge and an open paragraph's frame are caught a few pixels either side of +/// their hairline, and the cursor shows exactly where a press takes hold (#2203). +#[test] +fn paragraph_edges_and_frames_are_caught_without_aiming_for_the_hairline() { + let start = |h: &mut Harness<'static, PdfCraftApp>| { + assert!(h.state_mut().execute("edit.edit_text")); + h.run_steps(2); + let id = h.state().views[0].id; + let b = h.state().session.get(id).unwrap().text_blocks(0)[0].clone(); + let r = h.state().views[0].page_screen_rect(0).expect("on screen"); + let k = r.width() / 200.0; + // The drawn box: the text on screen, 2 px larger each side. + let bx = egui::Rect::from_min_max( + egui::pos2(r.left() + b.rect[0] as f32 * k, r.top() + (300.0 - b.rect[3] as f32) * k), + egui::pos2(r.left() + b.rect[2] as f32 * k, r.top() + (300.0 - b.rect[1] as f32) * k), + ) + .expand(2.0); + (id, bx) + }; + // A closed paragraph's right edge, 7 px inside and 7 px outside: the rewrap arrow, and a + // drag from there changes the width rather than moving the box. + for dx in [-7.0f32, 7.0] { + let mut h = harness(1, |_| {}); + let (id, bx) = start(&mut h); + let p = egui::pos2(bx.right() + dx, bx.center().y); + h.hover_at(p); + h.run_steps(2); + assert_eq!(h.output().platform_output.cursor_icon, egui::CursorIcon::ResizeHorizontal, "{dx} px from the edge"); + drag(&mut h, p, p + egui::vec2(40.0, 0.0)); + assert_eq!(h.state().session.get(id).unwrap().can_undo(), Some("Edit text"), "{dx} px from the edge rewraps"); + } + // An open paragraph's frame, 7 px beyond its drawn line: the move arrow, and a press there + // selects the box instead of closing the paragraph. + let mut h = harness(1, |_| {}); + let (_, bx) = start(&mut h); + click_at(&mut h, bx.center()); + assert!(h.state().views[0].line_editor.is_some(), "a click on the text opens it for typing"); + let p = egui::pos2(bx.center().x, bx.top() - 7.0); + h.hover_at(p); + h.run_steps(2); + assert_eq!(h.output().platform_output.cursor_icon, egui::CursorIcon::Move, "the pointer shows the frame takes hold"); + click_at(&mut h, p); + assert!(h.state().views[0].line_editor.is_none()); + h.get_by_label("1 object selected"); + // Once the text is changed, a press on the frame doesn't take the box (selecting it would + // drop the typing): it applies the typing like any click off the text, so the pointer doesn't + // offer the move arrow there either. + let mut h = harness(1, |_| {}); + let (_, bx) = start(&mut h); + click_at(&mut h, bx.center()); + h.state_mut().views[0].line_editor.as_mut().expect("open for typing").text = "Page one".into(); + h.run_steps(2); + h.hover_at(p); + h.run_steps(2); + assert_ne!(h.output().platform_output.cursor_icon, egui::CursorIcon::Move, "no move arrow where a press can't take the box"); + click_at(&mut h, p); + let id = h.state().views[0].id; + assert_eq!(h.state().session.get(id).unwrap().text_blocks(0)[0].text, "Page one", "the click applies the typing, as any click off the text does"); + assert!(h.query_all_by_label_contains("object selected").next().is_none(), "and doesn't take the box"); +} + #[test] fn an_open_paragraph_moves_by_its_frame_and_by_the_arrow_keys() { let mut h = harness(2, |_| {}); From e24780c7bd733c3d294070309dccaedeab959d67 Mon Sep 17 00:00:00 2001 From: Livio Oswald <125505497+Lcoding15@users.noreply.github.com> Date: Mon, 9 Nov 2026 11:25:41 +0100 Subject: [PATCH 31/64] packaging: Flatpak may talk to the accessibility bus (#4612) (#4641) --- packaging/linux/flatpak/ai.storyteller.pdfcraft.bundle.yml | 4 ++++ packaging/linux/flatpak/ai.storyteller.pdfcraft.yml | 4 ++++ 2 files changed, 8 insertions(+) diff --git a/packaging/linux/flatpak/ai.storyteller.pdfcraft.bundle.yml b/packaging/linux/flatpak/ai.storyteller.pdfcraft.bundle.yml index 6e9f9fa607..d781ad6c7c 100644 --- a/packaging/linux/flatpak/ai.storyteller.pdfcraft.bundle.yml +++ b/packaging/linux/flatpak/ai.storyteller.pdfcraft.bundle.yml @@ -25,6 +25,10 @@ finish-args: - --share=ipc # GPU access for the wgpu renderer (Vulkan, or GL as a fallback). - --device=dri + # Screen readers: AccessKit asks the accessibility bus (org.a11y.Bus) whether one is running + # and publishes the window there over AT-SPI. Without it the sandbox hides the bus and the + # app is invisible to Orca and other assistive technology (#4612). + - --talk-name=org.a11y.Bus # Printing: lpstat/lp (the cups-client module) reach the host's CUPS through its socket and # find driverless network printers through Avahi (`lpstat -e`). Without these the Print # dialog lists no printers. diff --git a/packaging/linux/flatpak/ai.storyteller.pdfcraft.yml b/packaging/linux/flatpak/ai.storyteller.pdfcraft.yml index c9086ab077..fba62fe382 100644 --- a/packaging/linux/flatpak/ai.storyteller.pdfcraft.yml +++ b/packaging/linux/flatpak/ai.storyteller.pdfcraft.yml @@ -26,6 +26,10 @@ finish-args: - --share=ipc # GPU access for the wgpu renderer (Vulkan, or GL as a fallback). - --device=dri + # Screen readers: AccessKit asks the accessibility bus (org.a11y.Bus) whether one is running + # and publishes the window there over AT-SPI. Without it the sandbox hides the bus and the + # app is invisible to Orca and other assistive technology (#4612). + - --talk-name=org.a11y.Bus # Printing: lpstat/lp (the cups-client module) reach the host's CUPS through its socket and # find driverless network printers through Avahi (`lpstat -e`). Without these the Print # dialog lists no printers. From d4828fe541efb65a88e2018875d95440c51b2482 Mon Sep 17 00:00:00 2001 From: jonathanwxh-cell Date: Mon, 9 Nov 2026 18:26:07 +0800 Subject: [PATCH 32/64] fix(app): set Windows taskbar and Alt+Tab big icon (ICON_BIG) (#3818) (#4681) --- apps/pdfcraft/Cargo.toml | 4 +++ apps/pdfcraft/src/main.rs | 53 ++++++++++++++++++++++++++++++++++++++ apps/pdfcraft/src/tests.rs | 25 ++++++++++++++++++ 3 files changed, 82 insertions(+) diff --git a/apps/pdfcraft/Cargo.toml b/apps/pdfcraft/Cargo.toml index 94e1de896c..5e4010fcad 100644 --- a/apps/pdfcraft/Cargo.toml +++ b/apps/pdfcraft/Cargo.toml @@ -64,6 +64,10 @@ windows = { version = "0.62", default-features = false, features = ["std", "UI_V # socket. `std` has no Unix sockets on Windows, and calling Winsock directly needs unsafe, which # our workspace forbids. Already in the lockfile through zbus; pinned exactly, review before bumping. uds_windows = "=1.2.1" +# Full-resolution taskbar and Alt+Tab icon (`ICON_BIG`, issue #3818). Set both window icons +# directly so eframe cannot replace ICON_BIG with a system-sized copy. Already in the tree +# through eframe and covered by the workspace's winit patch. +winit = { version = "0.30", default-features = false } [build-dependencies] # Icon + VERSIONINFO for pdfcraft.exe; build.rs only uses it when targeting Windows. diff --git a/apps/pdfcraft/src/main.rs b/apps/pdfcraft/src/main.rs index d777ddc269..837a803e22 100644 --- a/apps/pdfcraft/src/main.rs +++ b/apps/pdfcraft/src/main.rs @@ -340,7 +340,15 @@ fn native_options(integrated: bool, app_frame: bool, renderer: eframe::Renderer) .with_drag_and_drop(true) // Wayland app id: matches packaging/linux/ai.storyteller.pdfcraft.desktop. .with_app_id(APP_ID); + // eframe replaces ICON_BIG with a system-sized icon on its first active frame. + // An explicitly empty icon disables that setter (None selects eframe's default). + // Windows installs both sizes directly through winit in app_creator instead. + #[cfg(target_os = "windows")] + { + viewport = viewport.with_icon(egui::IconData::default()); + } // Dock, taskbar, Alt-Tab and launcher icon when running unbundled. + #[cfg(not(target_os = "windows"))] match eframe::icon_data::from_png_bytes(APP_ICON_PNG) { Ok(icon) => viewport = viewport.with_icon(icon), Err(e) => log::warn!("app icon: {e}"), @@ -391,6 +399,48 @@ fn native_wayland(_: &eframe::CreationContext<'_>) -> bool { false } +/// Decode `png` as a [`winit::window::Icon`] for the Windows taskbar and Alt+Tab (`ICON_BIG`). +#[cfg(target_os = "windows")] +fn taskbar_icon(png: &[u8]) -> Option { + let icon = match eframe::icon_data::from_png_bytes(png) { + Ok(icon) => icon, + Err(e) => { + log::warn!("taskbar icon: {e}"); + return None; + } + }; + match winit::window::Icon::from_rgba(icon.rgba, icon.width, icon.height) { + Ok(icon) => Some(icon), + Err(e) => { + log::warn!("taskbar icon: {e}"); + None + } + } +} + +/// Install the decoded [`winit::window::Icon`] from `png` via `set` when decoding succeeds. +#[cfg(target_os = "windows")] +fn install_taskbar_icon(png: &[u8], set: impl FnOnce(winit::window::Icon)) { + if let Some(icon) = taskbar_icon(png) { + set(icon); + } +} + +/// Install both window icons before the first frame. eframe's icon setter is disabled in +/// `native_options`, so it cannot replace the full-resolution taskbar/Alt+Tab icon (#3818). +#[cfg(target_os = "windows")] +fn set_taskbar_icon(window: &winit::window::Window) { + use winit::platform::windows::{IconExtWindows as _, WindowExtWindows as _}; + install_taskbar_icon(APP_ICON_PNG, |icon| window.set_taskbar_icon(Some(icon))); + // The title bar's icon: the small image drawn for this scale in the exe's own icon (build.rs). + let side = (16.0 * window.scale_factor()).round().clamp(16.0, 256.0) as u32; + match winit::window::Icon::from_resource(1, Some(winit::dpi::PhysicalSize::new(side, side))) { + Ok(icon) => window.set_window_icon(Some(icon)), + Err(e) => log::warn!("window icon: {e}"), + } +} + +/// Build the [`eframe::AppCreator`] closure that initializes [`PdfCraftApp`] once the window and renderer are ready. fn app_creator<'a>( launch: Launch, started: Rc>, @@ -418,6 +468,9 @@ fn app_creator<'a>( // it is set to On or Off. #[cfg(target_os = "windows")] { + if let Some(window) = cc.winit_window() { + set_taskbar_icon(window); + } app.motion.source = Some(motion::source()); } app.set_startup_update_check(true); diff --git a/apps/pdfcraft/src/tests.rs b/apps/pdfcraft/src/tests.rs index ebd13dccc8..7c1f111b96 100644 --- a/apps/pdfcraft/src/tests.rs +++ b/apps/pdfcraft/src/tests.rs @@ -137,6 +137,31 @@ fn the_desktop_frame_is_turned_off_only_when_the_app_draws_one() { assert_eq!(native_options(false, true, eframe::Renderer::Glow).viewport.decorations, Some(false)); } +#[test] +fn the_window_and_windows_taskbar_icons_load_from_the_bundled_png() { + let icon = eframe::icon_data::from_png_bytes(super::APP_ICON_PNG).expect("the bundled PNG decodes as the window icon"); + assert!(icon.width > 0 && icon.height > 0); + assert_eq!(icon.rgba.len(), (icon.width as usize) * (icon.height as usize) * 4); + #[cfg(not(target_os = "windows"))] + assert_eq!(super::native_options(false, false, eframe::Renderer::Glow).viewport.icon.as_deref(), Some(&icon)); + #[cfg(target_os = "windows")] + { + // None would load eframe's default icon; only the explicit empty sentinel prevents + // its first-frame setter from overwriting our full-resolution ICON_BIG. + for renderer in [eframe::Renderer::Glow, eframe::Renderer::Wgpu] { + let viewport = super::native_options(false, false, renderer).viewport; + assert_eq!(viewport.icon.as_deref(), Some(&egui::IconData::default())); + } + assert_eq!((icon.width, icon.height), (256, 256)); + let mut installed = None; + super::install_taskbar_icon(super::APP_ICON_PNG, |icon| installed = Some(icon)); + assert!(installed.is_some(), "issue #3818: ICON_BIG loads and installs from APP_ICON_PNG"); + let mut bad = None; + super::install_taskbar_icon(b"not a png", |icon| bad = Some(icon)); + assert!(bad.is_none()); + } +} + #[test] fn a_software_renderer_is_shown_to_the_user() { use eframe::wgpu::DeviceType; From 2baf95fcefd42d4e1051afc5f63ee626d1d8d476 Mon Sep 17 00:00:00 2001 From: ".CooCoo" Date: Mon, 9 Nov 2026 02:26:23 -0800 Subject: [PATCH 33/64] Tests: highlighting an area off the text waits up to 30 s for the page text (#4683) --- crates/ui-egui/tests/it/comments.rs | 17 +++++++---------- 1 file changed, 7 insertions(+), 10 deletions(-) diff --git a/crates/ui-egui/tests/it/comments.rs b/crates/ui-egui/tests/it/comments.rs index 599f1863e1..67ea715fc2 100644 --- a/crates/ui-egui/tests/it/comments.rs +++ b/crates/ui-egui/tests/it/comments.rs @@ -2668,17 +2668,14 @@ fn highlighting_an_area_off_the_text() { let q = c[0].quads[0]; assert!((q[0] - 40.0).abs() < 3.0 && (q[1] - 100.0).abs() < 3.0, "{q:?}"); // Dragging over text still highlights the text (once the text layer is in). - let mut glyphs = None; - for _ in 0..200 { - h.run_steps(1); + support::wait_until(&mut h, "the page's text layer", |app| { + let v = &app.views[0]; + v.glyph_screen_pos(0, 4).is_some() && v.glyph_screen_pos(0, 14).is_some() + }); + let (a, b) = { let v = &h.state().views[0]; - if let (Some(a), Some(b)) = (v.glyph_screen_pos(0, 4), v.glyph_screen_pos(0, 14)) { - glyphs = Some((a, b)); - break; - } - std::thread::sleep(std::time::Duration::from_millis(5)); - } - let (a, b) = glyphs.expect("text layer"); + (v.glyph_screen_pos(0, 4).expect("text layer"), v.glyph_screen_pos(0, 14).expect("glyph")) + }; drag(&mut h, a, b); assert_eq!(comments(&h).len(), 2); } From b04823573b6893e500f33637c2acabda157ae718 Mon Sep 17 00:00:00 2001 From: jonathanwxh-cell Date: Mon, 9 Nov 2026 18:26:53 +0800 Subject: [PATCH 34/64] fix(optimize): merge side-by-side and 2D grid image fragments (#4818) --- crates/optimize/src/fragments.rs | 423 +++++++++++++++++++++++++----- crates/optimize/tests/optimize.rs | 46 ++++ 2 files changed, 407 insertions(+), 62 deletions(-) diff --git a/crates/optimize/src/fragments.rs b/crates/optimize/src/fragments.rs index 34c8f9ca76..aa52c20750 100644 --- a/crates/optimize/src/fragments.rs +++ b/crates/optimize/src/fragments.rs @@ -9,8 +9,9 @@ //! between them, each drawing an image upright (no rotation, skew or flip); //! - the images are 8-bit grey or RGB in the same colour space, without masks or a decode array //! that changes anything, in a codec PdfCraft decodes; -//! - they share their left and right edges and are stacked edge to edge with no gap or overlap -//! (in any drawing order), at least [`MIN_FRAGMENTS`] of them. +//! - they tile one rectangle edge to edge with no gap or overlap — stacked top to bottom, placed +//! side by side, or arranged in a grid of rows and columns (in any drawing order), at least +//! [`MIN_FRAGMENTS`] of them. //! //! The merged image keeps the finest resolution of its fragments both ways, so nothing is lost //! until the image settings downsample it; it is stored losslessly. A page whose content @@ -89,53 +90,152 @@ fn fragment_image(doc: &Document, xobjects: &Dict, name: &[u8]) -> Option<(ObjRe (w > 0 && h > 0).then_some((r, w, h, cs, n)) } -/// Whether `run` tiles one rectangle top to bottom: same left and right edges, edge to edge. -/// Sorts it from the top down. -fn stacked(run: &mut [Fragment]) -> bool { - let Some(first) = run.first() else { return false }; - let [x0, _, x1, _] = first.rect; - let width = x1 - x0; - if !(width.is_finite() && width > 0.0) { - return false; - } - let tol = 1e-3 * width.max(1.0); - if run.iter().any(|f| { - (f.rect[0] - x0).abs() > tol || (f.rect[2] - x1).abs() > tol || f.rect[3].partial_cmp(&f.rect[1]) != Some(std::cmp::Ordering::Greater) - }) { - return false; - } - run.sort_by(|a, b| b.rect[3].total_cmp(&a.rect[3])); - run.windows(2).all(|w| match w { - [a, b] => (a.rect[1] - b.rect[3]).abs() <= 0.01 * (a.rect[3] - a.rect[1]).min(b.rect[3] - b.rect[1]), - _ => true, - }) +/// Whether `a` and `b` share their top and bottom edges within row tolerance. +fn same_y(a: &Fragment, b: &Fragment) -> bool { + let tol = 1e-3 * (a.rect[3] - a.rect[1]).min(b.rect[3] - b.rect[1]); + (a.rect[1] - b.rect[1]).abs() <= tol && (a.rect[3] - b.rect[3]).abs() <= tol } -/// The fragment groups from `i` on while each is drawn right under (or right over) the one -/// before it, in the same direction: a picture drawn row by row, up to a gap or a jump. -fn drawn_in_sequence(ops: &[Op], i: usize, xobjects: &Dict, doc: &Document) -> Vec { +/// Whether `run` tiles one rectangle edge to edge (stacked top to bottom, placed side by side, +/// or arranged in a grid of rows and columns). Sorts it from the top row down, and left to right +/// within each row. Returns that exact row partition: tolerance-based membership is not +/// transitive, so assembly must not regroup after the horizontal sort changes each row's anchor. +fn stacked(run: &mut [Fragment]) -> Option>> { + if run.is_empty() + || run.iter().any(|f| { + let [x0, y0, x1, y1] = f.rect; + let (w, h) = (x1 - x0, y1 - y0); + !(x0.is_finite() && y0.is_finite() && x1.is_finite() && y1.is_finite() && w.is_finite() && w > 0.0 && h.is_finite() && h > 0.0) + }) + { + return None; + } + run.sort_by(|a, b| b.rect[3].total_cmp(&a.rect[3])); + let mut row_bounds: Vec<[f64; 4]> = Vec::new(); + let mut rows = Vec::new(); + let mut start = 0; + while start < run.len() { + let mut end = start + 1; + while end < run.len() && same_y(&run[start], &run[end]) { + end += 1; + } + let row = &mut run[start..end]; + row.sort_by(|a, b| a.rect[0].total_cmp(&b.rect[0])); + if !row.windows(2).all(|w| match w { + [a, b] => (a.rect[2] - b.rect[0]).abs() <= 0.01 * (a.rect[2] - a.rect[0]).min(b.rect[2] - b.rect[0]), + _ => true, + }) { + return None; + } + let (Some(first), Some(last)) = (row.first(), row.last()) else { return None }; + row_bounds.push([first.rect[0], first.rect[1], last.rect[2], first.rect[3]]); + rows.push(start..end); + start = end; + } + let (Some(&[x0, _, x1, y1]), Some(&[_, y0, _, _])) = (row_bounds.first(), row_bounds.last()) else { return None }; + let (width, height) = (x1 - x0, y1 - y0); + if !(width.is_finite() && width > 0.0 && height.is_finite() && height > 0.0) { + return None; + } + let tol_x = 1e-3 * width.max(1.0); + if row_bounds.iter().any(|r| (r[0] - x0).abs() > tol_x || (r[2] - x1).abs() > tol_x) { + return None; + } + row_bounds + .windows(2) + .all(|w| match w { + [a, b] => (a[1] - b[3]).abs() <= 0.01 * (a[3] - a[1]).min(b[3] - b[1]), + _ => true, + }) + .then_some(rows) +} + +/// The fragment groups from `i` on (at most `limit`) while they form a picture drawn row by row +/// or strip by strip in one direction, up to a gap or a jump. +fn drawn_in_sequence(ops: &[Op], i: usize, limit: usize, xobjects: &Dict, doc: &Document) -> Vec { let mut run: Vec = Vec::new(); + let mut row_x = [0.0f64, 0.0]; + let mut cur_x = [0.0f64, 0.0]; + let mut cur_y = [0.0f64, 0.0]; + let mut first_row = true; + let mut right: Option = None; let mut down: Option = None; + let mut complete = 0usize; let mut j = i; - while let Some((next, m, name)) = group_at(ops, j) { + while run.len() < limit { + let Some((next, m, name)) = group_at(ops, j) else { break }; let Some((image, width, height, ..)) = fragment_image(doc, xobjects, &name) else { break }; let [a, _, _, d, e, f] = m.0; let f2 = Fragment { image, rect: [e, f, e + a, f + d], width, height }; if let Some(prev) = run.last() { - let tol = 0.01 * (prev.rect[3] - prev.rect[1]).min(f2.rect[3] - f2.rect[1]); - let below = (prev.rect[1] - f2.rect[3]).abs() <= tol; - let above = (f2.rect[1] - prev.rect[3]).abs() <= tol; - let same_x = (prev.rect[0] - f2.rect[0]).abs() <= 1e-3 * a.abs().max(1.0) && (prev.rect[2] - f2.rect[2]).abs() <= 1e-3 * a.abs().max(1.0); - match (down, below, above) { - _ if !same_x => break, - (None | Some(true), true, _) => down = Some(true), - (None | Some(false), _, true) => down = Some(false), - _ => break, + let tol_y = 1e-3 * (cur_y[1] - cur_y[0]).min(f2.rect[3] - f2.rect[1]); + let same_row = (cur_y[0] - f2.rect[1]).abs() <= tol_y && (cur_y[1] - f2.rect[3]).abs() <= tol_y; + if same_row { + let tol_x = 0.01 * (prev.rect[2] - prev.rect[0]).min(f2.rect[2] - f2.rect[0]); + let step_right = (f2.rect[0] - prev.rect[2]).abs() <= tol_x; + let step_left = (prev.rect[0] - f2.rect[2]).abs() <= tol_x; + match (right, step_right, step_left) { + (None | Some(true), true, _) => { + right = Some(true); + cur_x[1] = f2.rect[2]; + } + (None | Some(false), _, true) => { + right = Some(false); + cur_x[0] = f2.rect[0]; + } + _ => break, + } + if first_row { + row_x = cur_x; + complete = run.len() + 1; + } else { + let tol_span = 1e-3 * (row_x[1] - row_x[0]).max(1.0); + if cur_x[0] < row_x[0] - tol_span || cur_x[1] > row_x[1] + tol_span { + break; + } + if (cur_x[0] - row_x[0]).abs() <= tol_span && (cur_x[1] - row_x[1]).abs() <= tol_span { + complete = run.len() + 1; + } + } + } else { + let tol_span = 1e-3 * (row_x[1] - row_x[0]).max(1.0); + if (cur_x[0] - row_x[0]).abs() > tol_span || (cur_x[1] - row_x[1]).abs() > tol_span { + break; + } + let tol_v = 0.01 * (cur_y[1] - cur_y[0]).min(f2.rect[3] - f2.rect[1]); + let below = (cur_y[0] - f2.rect[3]).abs() <= tol_v; + let above = (f2.rect[1] - cur_y[1]).abs() <= tol_v; + match (down, below, above) { + (None | Some(true), true, _) => down = Some(true), + (None | Some(false), _, true) => down = Some(false), + _ => break, + } + let starts_left = (f2.rect[0] - row_x[0]).abs() <= tol_span && f2.rect[2] <= row_x[1] + tol_span; + let starts_right = (f2.rect[2] - row_x[1]).abs() <= tol_span && f2.rect[0] >= row_x[0] - tol_span; + if starts_left && starts_right { + right = None; + complete = run.len() + 1; + } else if starts_left { + right = Some(true); + } else if starts_right { + right = Some(false); + } else { + break; + } + first_row = false; + cur_x = [f2.rect[0], f2.rect[2]]; + cur_y = [f2.rect[1], f2.rect[3]]; } + } else { + row_x = [f2.rect[0], f2.rect[2]]; + cur_x = row_x; + cur_y = [f2.rect[1], f2.rect[3]]; + complete = 1; } run.push(f2); j = next; } + run.truncate(complete); run } @@ -152,38 +252,61 @@ fn run_end(ops: &[Op], i: usize, count: usize) -> usize { } /// The run's pixels as one image, top row first: `(pixels, width, height)`. -fn merge(doc: &Document, run: &[Fragment], n: usize, budget: &mut ImageBudget) -> Option<(Vec, u32, u32)> { - let (top, bottom) = (run.first()?.rect[3], run.last()?.rect[1]); - let width = run.iter().map(|f| f.width).max()?; - // Pixel rows per unit of height: the finest fragment's. - let density = run.iter().map(|f| f.height as f64 / (f.rect[3] - f.rect[1])).fold(0.0, f64::max); - let height = ((top - bottom) * density).round(); +fn merge(doc: &Document, run: &[Fragment], partition: &[std::ops::Range], n: usize, budget: &mut ImageBudget) -> Option<(Vec, u32, u32)> { + let (first, last) = (run.first()?, run.last()?); + let (left, right, top, bottom) = (first.rect[0], last.rect[2], first.rect[3], last.rect[1]); + let rows: Vec<&[Fragment]> = partition.iter().map(|range| run.get(range.clone())).collect::>()?; + // Pixels per unit along each axis: the finest fragment's. + let density_x = run.iter().map(|f| f.width as f64 / (f.rect[2] - f.rect[0])).fold(0.0, f64::max); + let density_y = run.iter().map(|f| f.height as f64 / (f.rect[3] - f.rect[1])).fold(0.0, f64::max); + let width = if rows.len() == run.len() { f64::from(run.iter().map(|f| f.width).max()?) } else { ((right - left) * density_x).round() }; + let height = if rows.len() == 1 { f64::from(run.iter().map(|f| f.height).max()?) } else { ((top - bottom) * density_y).round() }; + if !(width.is_finite() && width >= 1.0 && width <= f64::from(u32::MAX)) { + return None; + } if !(height.is_finite() && height >= 1.0 && height <= f64::from(u32::MAX)) { return None; } - // In range and whole, so the cast is exact. - let height = height as u32; + // In range and whole, so the casts are exact. + let (width, height) = (width as u32, height as u32); if u64::from(width) * u64::from(height) > MAX_PIXELS { return None; } - let row = (width as usize).checked_mul(n)?; + let stride = (width as usize).checked_mul(n)?; // What this builds isn't what its strips cost to read: four small ones can ask for all of it. - let bytes = row.checked_mul(height as usize)?; + let bytes = stride.checked_mul(height as usize)?; if !budget.pay_pixels(u64::try_from(bytes).ok()?) { return None; } let mut out = vec![255u8; bytes]; - for f in run { - let r0 = (((top - f.rect[3]) * density).round().max(0.0) as u32).min(height); - let r1 = (((top - f.rect[1]) * density).round().max(0.0) as u32).min(height); + let mut r0 = 0u32; + for (row_idx, row) in rows.iter().enumerate() { + let r1 = if row_idx + 1 == rows.len() { height } else { (((top - row.first()?.rect[1]) * density_y).round().max(0.0) as u32).min(height) }; if r1 <= r0 { - continue; + return None; } - let obj = doc.get(f.image); - let Object::Stream(s) = &*obj else { return None }; - let px = pixels(doc, s, n, budget)?; - let px = if (f.width, f.height) == (width, r1 - r0) { px } else { resample(&px, n, f.width, f.height, width, r1 - r0)? }; - out.get_mut(r0 as usize * row..r1 as usize * row)?.copy_from_slice(px.get(..(r1 - r0) as usize * row)?); + let th = r1 - r0; + let mut c0 = 0u32; + for (col_idx, f) in row.iter().enumerate() { + let c1 = if col_idx + 1 == row.len() { width } else { (((f.rect[2] - left) * density_x).round().max(0.0) as u32).min(width) }; + if c1 <= c0 { + return None; + } + let tw = c1 - c0; + let obj = doc.get(f.image); + let Object::Stream(s) = &*obj else { return None }; + let px = pixels(doc, s, n, budget)?; + let px = if (f.width, f.height) == (tw, th) { px } else { resample(&px, n, f.width, f.height, tw, th)? }; + let tile_row = (tw as usize).checked_mul(n)?; + let col_off = (c0 as usize).checked_mul(n)?; + for r in 0..th as usize { + let dst = (r0 as usize + r) * stride + col_off; + let src = r * tile_row; + out.get_mut(dst..dst + tile_row)?.copy_from_slice(px.get(src..src + tile_row)?); + } + c0 = c1; + } + r0 = r1; } Some((out, width, height)) } @@ -288,13 +411,15 @@ fn merge_page( j = next; } scanned = scanned.saturating_add(run.len()).saturating_add(1); - // Not one picture as a whole: the rows drawn one under the other from the start may be. - if run.len() >= MIN_FRAGMENTS && !stacked(&mut run) { - run = drawn_in_sequence(&ops, i, &xobjects, doc); + // Not one picture as a whole: a prefix drawn row by row or strip by strip may be. + let mut rows = if run.len() >= MIN_FRAGMENTS { stacked(&mut run) } else { None }; + if run.len() >= MIN_FRAGMENTS && rows.is_none() { + run = drawn_in_sequence(&ops, i, run.len(), &xobjects, doc); j = run_end(&ops, i, run.len()); scanned = scanned.saturating_add(run.len().saturating_mul(2)).saturating_add(2); + rows = stacked(&mut run); } - let Some((cs, n)) = kind.filter(|_| run.len() >= MIN_FRAGMENTS && stacked(&mut run)) else { + let Some(((cs, n), rows)) = kind.zip(rows).filter(|_| run.len() >= MIN_FRAGMENTS) else { // Not a run: keep this op and look again from the next one. if let Some(op) = ops.get(i) { out.push(op.clone()); @@ -305,7 +430,7 @@ fn merge_page( if !progress(Stage::Fragments) { return Err(OptimizeError::Cancelled); } - let Some((px, w, h)) = merge(doc, &run, n, images) else { + let Some((px, w, h)) = merge(doc, &run, &rows, n, images) else { out.extend(ops.get(i..j).unwrap_or_default().iter().cloned()); i = j; continue; @@ -322,7 +447,7 @@ fn merge_page( xobjects.set(name.clone(), Object::Ref(image)); add_xobject(doc, page, &name, image)?; let (Some(first), Some(last)) = (run.first(), run.last()) else { break }; - let [x0, y0, x1, y1] = [first.rect[0], last.rect[1], first.rect[2], first.rect[3]]; + let [x0, y0, x1, y1] = [first.rect[0], last.rect[1], last.rect[2], first.rect[3]]; let num = Object::Real; out.push(Op::new("q", Vec::new())); out.push(Op::new("cm", vec![num(x1 - x0), Object::Int(0), Object::Int(0), num(y1 - y0), num(x0), num(y0)])); @@ -339,6 +464,7 @@ fn merge_page( Ok(merged) } +/// Merge consecutive tiled image fragments across `pages`. pub(crate) fn run( doc: &mut Document, pages: &[ObjRef], @@ -361,17 +487,45 @@ mod tests { use super::*; + /// Merged dimensions exceeding `u32::MAX` are rejected before allocating pixel buffers. #[test] fn a_merged_height_beyond_u32_is_refused_before_allocating() { // Two fragments, one pixel row each, the top one 2^-48 high: the merged image would be // 2^16 × 2^48 pixels, whose product wraps to 0 in u64. let image = ObjRef { num: 1, generation: 0 }; let edge = 1.0 - (-48f64).exp2(); - let run = [ + let vertical = [ Fragment { image, rect: [0.0, edge, 1.0, 1.0], width: 1 << 16, height: 1 }, Fragment { image, rect: [0.0, 0.0, 1.0, edge], width: 1 << 16, height: 1 }, ]; - assert!(merge(&Document::new_empty(), &run, 3, &mut ImageBudget::with_work(u64::MAX)).is_none()); + assert!(merge(&Document::new_empty(), &vertical, &[0..1, 1..2], 3, &mut ImageBudget::with_work(u64::MAX)).is_none()); + let horizontal = [ + Fragment { image, rect: [0.0, 0.0, edge, 1.0], width: 1, height: 1 << 16 }, + Fragment { image, rect: [edge, 0.0, 1.0, 1.0], width: 1, height: 1 << 16 }, + ]; + assert!(merge(&Document::new_empty(), &horizontal, std::slice::from_ref(&(0..2)), 3, &mut ImageBudget::with_work(u64::MAX)).is_none()); + } + + /// Even an inconsistent partition must not silently omit a row or tile from its report. + #[test] + fn assembly_refuses_collapsed_output_rows_and_tiles() { + let mut doc = Document::new_empty(); + let mut dict = Dict::new(); + dict.set(b"Width".to_vec(), Object::Int(10)); + dict.set(b"Height".to_vec(), Object::Int(10)); + dict.set(b"BitsPerComponent".to_vec(), Object::Int(8)); + dict.set(b"ColorSpace".to_vec(), Object::name("DeviceGray")); + let image = doc.add(Object::Stream(Stream::flate(dict, &[80; 100]))); + let mut run: Vec<_> = (0..4) + .map(|col| Fragment { image, rect: [10.0 * f64::from(col), 0.0, 10.0 * f64::from(col + 1), 10.0], width: 10, height: 10 }) + .collect(); + // The first supposed row consumes the entire height; the second cannot be discarded. + assert!(merge(&doc, &run, &[0..2, 2..4], 1, &mut ImageBudget::with_work(u64::MAX)).is_none()); + // Likewise, a tile whose right edge does not advance cannot simply vanish. + if let Some(tile) = run.get_mut(1) { + tile.rect = [0.0, 0.0, 10.0, 10.0]; + } + assert!(merge(&doc, &run, std::slice::from_ref(&(0..4)), 1, &mut ImageBudget::with_work(u64::MAX)).is_none()); } /// Merging reads the images the pass after it reads, from the same allowance: with nothing @@ -474,4 +628,149 @@ mod tests { let reads = GROUP_READS.with(|c| c.get()); assert!(reads <= 40 * 4 * groups, "{reads} group reads"); } + + /// Side-by-side vertical strips and 2D tile grids merge into one image XObject while invalid + /// or separated runs are handled per run. + #[test] + fn side_by_side_and_grid_fragments_merge_into_one_image() { + let tile = |doc: &mut Document, w: u32, h: u32, px: Vec| { + let mut d = Dict::new(); + d.set(b"Type".to_vec(), Object::name("XObject")); + d.set(b"Subtype".to_vec(), Object::name("Image")); + d.set(b"Width".to_vec(), Object::Int(i64::from(w))); + d.set(b"Height".to_vec(), Object::Int(i64::from(h))); + d.set(b"BitsPerComponent".to_vec(), Object::Int(8)); + d.set(b"ColorSpace".to_vec(), Object::name("DeviceGray")); + doc.add(Object::Stream(Stream::flate(d, &px))) + }; + let build_page = |doc: &mut Document, xobjects: Dict, ops: &str| { + let content = doc.add(Object::Stream(Stream::flate(Dict::new(), ops.as_bytes()))); + let mut resources = Dict::new(); + resources.set(b"XObject".to_vec(), Object::Dict(xobjects)); + let mut page = Dict::new(); + page.set(b"Contents".to_vec(), Object::Ref(content)); + page.set(b"Resources".to_vec(), Object::Dict(resources)); + doc.add(Object::Dict(page)) + }; + + // Four vertical strips placed side by side (drawn out of order), alternating 1×3 and 2×3 + // pixels so the merged image keeps the finest horizontal resolution (8×3). + let mut doc = Document::new_empty(); + let mut xobjects = Dict::new(); + for c in 0..4u8 { + let w = if c % 2 == 0 { 1 } else { 2 }; + let im = tile(&mut doc, w, 3, vec![c * 50; (w * 3) as usize]); + xobjects.set(format!("C{c}").into_bytes(), Object::Ref(im)); + } + let ops = [2usize, 0, 3, 1].into_iter().map(|c| format!("q 10 0 0 30 {} 100 cm /C{c} Do Q\n", 15.0 + c as f64 * 10.0)).collect::(); + let page = build_page(&mut doc, xobjects, &ops); + let mut report = Report::default(); + let merged = merge_page(&mut doc, page, &mut report, &mut |_| true, &mut ImageBudget::with_work(u64::MAX)).unwrap(); + assert_eq!((merged, report.merged_images), (4, 1)); + let xo = doc + .get(page) + .as_dict() + .and_then(|d| d.get(b"Resources")) + .and_then(Object::as_dict) + .and_then(|d| d.get(b"XObject")) + .and_then(Object::as_dict) + .cloned() + .unwrap(); + let merged_ref = xo.get(b"PCMerged1").and_then(Object::as_ref).unwrap(); + let Object::Stream(s) = &*doc.get(merged_ref) else { panic!("expected stream") }; + assert_eq!((s.dict.int(b"Width"), s.dict.int(b"Height")), (Some(8), Some(3))); + assert_eq!(s.decoded().unwrap(), [0, 0, 50, 50, 100, 100, 150, 150].repeat(3)); + let c_ref = doc.get(page).as_dict().and_then(|d| d.reference(b"Contents")).unwrap(); + let Object::Stream(cs) = &*doc.get(c_ref) else { panic!("expected contents") }; + let parsed = parse(&cs.decoded().unwrap()); + let cm = parsed.ops.iter().find(|o| o.is("cm")).and_then(|o| Matrix::from_operands(&o.operands)).unwrap(); + assert_eq!(cm.0, [40.0, 0.0, 0.0, 30.0, 15.0, 100.0]); + + // A 2×3 grid of tiles (2 rows, 3 columns), each 2×2 pixels, drawn in mixed order. + let mut doc = Document::new_empty(); + let mut xobjects = Dict::new(); + for r in 0..2u8 { + for c in 0..3u8 { + let v = r * 100 + c * 20; + let im = tile(&mut doc, 2, 2, vec![v; 4]); + xobjects.set(format!("T{r}{c}").into_bytes(), Object::Ref(im)); + } + } + let ops = [(1u8, 2u8), (0, 1), (1, 0), (0, 2), (0, 0), (1, 1)] + .into_iter() + .map(|(r, c)| { + let x = 5.0 + f64::from(c) * 10.0; + let y = 50.0 + f64::from(1 - r) * 10.0; + format!("q 10 0 0 10 {x} {y} cm /T{r}{c} Do Q\n") + }) + .collect::(); + let page = build_page(&mut doc, xobjects, &ops); + let mut report = Report::default(); + let merged = merge_page(&mut doc, page, &mut report, &mut |_| true, &mut ImageBudget::with_work(u64::MAX)).unwrap(); + assert_eq!((merged, report.merged_images), (6, 1)); + let xo = doc + .get(page) + .as_dict() + .and_then(|d| d.get(b"Resources")) + .and_then(Object::as_dict) + .and_then(|d| d.get(b"XObject")) + .and_then(Object::as_dict) + .cloned() + .unwrap(); + let Object::Stream(s) = &*doc.get(xo.get(b"PCMerged1").and_then(Object::as_ref).unwrap()) else { panic!("expected stream") }; + assert_eq!((s.dict.int(b"Width"), s.dict.int(b"Height")), (Some(6), Some(4))); + let mut expected = Vec::new(); + expected.extend([0, 0, 20, 20, 40, 40].repeat(2)); + expected.extend([100, 100, 120, 120, 140, 140].repeat(2)); + assert_eq!(s.decoded().unwrap(), expected); + + // Two side-by-side runs of 4 strips separated by a horizontal gap merge into 2 images, + // whether drawn left to right or right to left. + for right_to_left in [false, true] { + let mut doc = Document::new_empty(); + let mut xobjects = Dict::new(); + let im = tile(&mut doc, 2, 2, vec![80; 4]); + xobjects.set(b"S".to_vec(), Object::Ref(im)); + let xs: Vec = if right_to_left { + vec![110.0, 100.0, 90.0, 80.0, 30.0, 20.0, 10.0, 0.0] + } else { + vec![0.0, 10.0, 20.0, 30.0, 80.0, 90.0, 100.0, 110.0] + }; + let ops = xs.into_iter().map(|x| format!("q 10 0 0 20 {x} 0 cm /S Do Q\n")).collect::(); + let page = build_page(&mut doc, xobjects, &ops); + let mut report = Report::default(); + let merged = merge_page(&mut doc, page, &mut report, &mut |_| true, &mut ImageBudget::with_work(u64::MAX)).unwrap(); + assert_eq!((merged, report.merged_images), (8, 2), "right_to_left={right_to_left}"); + } + + // A 2×2 grid followed immediately by a lone fifth tile below only the first column merges + // the 4 grid tiles and leaves the fifth tile alone. + let mut doc = Document::new_empty(); + let mut xobjects = Dict::new(); + let im = tile(&mut doc, 2, 2, vec![90; 4]); + xobjects.set(b"G".to_vec(), Object::Ref(im)); + let ops = [(0.0, 20.0), (10.0, 20.0), (0.0, 10.0), (10.0, 10.0), (0.0, 0.0)] + .into_iter() + .map(|(x, y)| format!("q 10 0 0 10 {x} {y} cm /G Do Q\n")) + .collect::(); + let page = build_page(&mut doc, xobjects, &ops); + let mut report = Report::default(); + let merged = merge_page(&mut doc, page, &mut report, &mut |_| true, &mut ImageBudget::with_work(u64::MAX)).unwrap(); + assert_eq!((merged, report.merged_images), (4, 1)); + + // Overlapping side-by-side strips or rows of different widths are left alone. + for bad_ops in [ + "q 10 0 0 20 0 0 cm /G Do Q\nq 10 0 0 20 5 0 cm /G Do Q\nq 10 0 0 20 15 0 cm /G Do Q\nq 10 0 0 20 25 0 cm /G Do Q\n", + "q 10 0 0 10 0 10 cm /G Do Q\nq 10 0 0 10 10 10 cm /G Do Q\nq 10 0 0 10 0 0 cm /G Do Q\nq 15 0 0 10 10 0 cm /G Do Q\n", + ] { + let mut doc = Document::new_empty(); + let mut xobjects = Dict::new(); + let im = tile(&mut doc, 2, 2, vec![90; 4]); + xobjects.set(b"G".to_vec(), Object::Ref(im)); + let page = build_page(&mut doc, xobjects, bad_ops); + let mut report = Report::default(); + let merged = merge_page(&mut doc, page, &mut report, &mut |_| true, &mut ImageBudget::with_work(u64::MAX)).unwrap(); + assert_eq!((merged, report.merged_images), (0, 0)); + } + } } diff --git a/crates/optimize/tests/optimize.rs b/crates/optimize/tests/optimize.rs index c4fb865198..2b8f513a24 100644 --- a/crates/optimize/tests/optimize.rs +++ b/crates/optimize/tests/optimize.rs @@ -3096,6 +3096,52 @@ fn drawn_images(doc: &Document) -> Vec { .collect() } +/// A tolerated row must not be regrouped after its highest tile moves during X sorting. +#[test] +fn fragment_row_tolerance_preserves_every_tiles_pixels() { + for aligned in [true, false] { + for merge_fragments in [false, true] { + let mut doc = Document::new_empty(); + let mut refs = Vec::new(); + for gray in [20u8, 80, 140, 220] { + let mut d = Dict::new(); + d.set(b"Type".to_vec(), Object::name("XObject")); + d.set(b"Subtype".to_vec(), Object::name("Image")); + d.set(b"Width".to_vec(), Object::Int(10)); + d.set(b"Height".to_vec(), Object::Int(10)); + d.set(b"BitsPerComponent".to_vec(), Object::Int(8)); + d.set(b"ColorSpace".to_vec(), Object::name("DeviceGray")); + refs.push(doc.add(Object::Stream(Stream::flate(d, &[gray; 100])))); + } + let content = if aligned { + "q 10 0 0 10 0 0 cm /T0 Do Q q 10 0 0 10 10 0 cm /T1 Do Q q 10 0 0 10 20 0 cm /T2 Do Q q 10 0 0 10 30 0 cm /T3 Do Q" + } else { + "q 10 0 0 10 0 0 cm /T0 Do Q q 10 0 0 9.992 10 0.009 cm /T1 Do Q q 10 0 0 9.982 20 0.018 cm /T2 Do Q q 10 0 0 9.991 30 0.009 cm /T3 Do Q" + }; + page(&mut doc, &[("T0", refs[0]), ("T1", refs[1]), ("T2", refs[2]), ("T3", refs[3])], content); + let off = ImageSettings { downsample: false, compression: Compression::Retain, ..ImageSettings::REDUCE }; + let report = optimize(&mut doc, &Settings { merge_fragments, color: off, gray: off, ..Settings::default() }).unwrap(); + // Save and reopen, then inspect only images still drawn by the optimized page. + let doc = Document::open(Arc::new(write_full(&doc, &SaveOptions::default()).unwrap())).unwrap(); + let drawn = drawn_images(&doc); + if report.fragments_merged == 0 { + assert_eq!(report.merged_images, 0); + assert_eq!(drawn.len(), 4); + for (image, gray) in drawn.iter().zip([20u8, 80, 140, 220]) { + assert_eq!(image.decoded().unwrap(), [gray; 100]); + } + } else { + assert!(merge_fragments); + assert_eq!((report.fragments_merged, report.merged_images, drawn.len()), (4, 1, 1)); + let image = &drawn[0]; + assert_eq!((image.dict.int(b"Width"), image.dict.int(b"Height")), (Some(40), Some(10))); + let row: Vec<_> = [20u8, 80, 140, 220].into_iter().flat_map(|gray| [gray; 10]).collect(); + assert_eq!(image.decoded().unwrap(), row.repeat(10), "aligned={aligned}"); + } + } + } +} + #[test] fn a_page_picture_printed_as_one_pixel_strips_is_merged_into_one_image() { // 120 strips, every other one JPEG and half as wide (as one PDF printer writes them). From f4e195215b896ec5150979d6cb75abadc9847320 Mon Sep 17 00:00:00 2001 From: Eric Kaden <608341+EricKaden@users.noreply.github.com> Date: Mon, 9 Nov 2026 05:27:09 -0500 Subject: [PATCH 35/64] M7: the Preferences language test opens the app menu on macOS (#4882) --- crates/ui-egui/tests/it/control.rs | 31 ++++++++++++++++++++++++++---- 1 file changed, 27 insertions(+), 4 deletions(-) diff --git a/crates/ui-egui/tests/it/control.rs b/crates/ui-egui/tests/it/control.rs index 764d4c6d24..5bbc26c991 100644 --- a/crates/ui-egui/tests/it/control.rs +++ b/crates/ui-egui/tests/it/control.rs @@ -953,6 +953,8 @@ fn inverted_middle_drag_moves_the_view_the_way_the_pointer_goes() { assert!(!h.state().session.get(h.state().views[0].id).unwrap().dirty); } +/// The control channel reports the open document, its page count and current page, and its +/// view options can be set: the page, and a right-hand panel by name (an unknown one is refused). #[test] fn state_and_view_options() { let (mut h, c) = harness(); @@ -969,6 +971,9 @@ fn state_and_view_options() { assert!(call(&mut h, &c, "ui.set", json!({ "key": "panel", "value": "nonsense" })).is_err()); } +/// In a Japanese interface, the mode tabs and Preferences show Japanese labels, the command +/// palette finds commands by their Japanese or English names or their ids, and switching back to +/// English keeps the tabs working. #[test] fn japanese_controls_and_search_keep_command_ids() { if crate::support::skip_without_catalogs("japanese_controls_and_search_keep_command_ids") { @@ -1004,6 +1009,9 @@ fn japanese_controls_and_search_keep_command_ids() { assert_eq!(ok(&mut h, &c, "ui.state", json!({}))["mode"], "AllTools"); } +/// In a German interface, the mode tabs and Preferences show German labels, the language +/// selector switches to English and back, and the command palette finds Split document by its +/// German name, its English name or its command id. #[test] fn german_preferences_and_search_keep_command_ids() { if crate::support::skip_without_catalogs("german_preferences_and_search_keep_command_ids") { @@ -1039,6 +1047,9 @@ fn german_preferences_and_search_keep_command_ids() { assert_eq!(ok(&mut h, &c, "ui.state", json!({}))["documents"][0]["name"], "doc.pdf"); } +/// In a Ukrainian interface, Preferences and dialogs show Ukrainian labels, the command palette +/// finds Split document by its Ukrainian name, its English name or its command id, and a refused +/// edit's notice names the command in Ukrainian. #[test] fn ukrainian_preferences_and_search_keep_command_ids() { if crate::support::skip_without_catalogs("ukrainian_preferences_and_search_keep_command_ids") { @@ -1075,6 +1086,10 @@ fn ukrainian_preferences_and_search_keep_command_ids() { assert_eq!(state["documents"][0]["name"], "doc.pdf"); } +/// In a Japanese interface, Preferences opened from the menu bar switches the language to English, +/// Ukrainian and back, and opens again with Cmd+,; the shortcuts list then reads in Japanese and +/// the document stays open. Preferences is in Edit on Windows and Linux, and in the app menu (as +/// Settings…) on macOS. #[test] fn preferences_menu_and_shortcut_allow_switching_interface_languages() { if crate::support::skip_without_catalogs("preferences_menu_and_shortcut_allow_switching_interface_languages") { @@ -1085,16 +1100,24 @@ fn preferences_menu_and_shortcut_allow_switching_interface_languages() { } let (mut h, c) = harness(); ok(&mut h, &c, "ui.set", json!({ "key": "language", "value": "ja" })); - // The menu bar's Edit menu holds Preferences. Titles and rows carry their mnemonic - // ("編集(E)", "環境設定(P)…"), so match by the start of the label. + // The menu bar's Edit menu holds Preferences on Windows and Linux. On macOS the in-window bar + // takes the Mac layout (`Target::current()`), which moves it to the app menu as Settings…. + // Titles and rows carry their mnemonic ("編集(E)", "環境設定(P)…"), so match by the start of + // the label. + let (menu, preferences) = if cfg!(target_os = "macos") { + let ja = pdfcraft_ui_egui::i18n::Lang::from_code("ja").unwrap(); + ("PdfCraft", pdfcraft_ui_egui::i18n::tr(ja, "Settings…").trim_end_matches('…')) + } else { + ("編集", "環境設定") + }; let menu_row = |text: &'static str| { move |n: &egui_kittest::kittest::AccessKitNode<'_>| { n.role() == egui::accesskit::Role::MenuItem && n.label().is_some_and(|l| l.starts_with(text)) } }; - h.get_by(menu_row("編集")).click(); + h.get_by(menu_row(menu)).click(); h.run_steps(2); - h.get_by(menu_row("環境設定")).click(); + h.get_by(menu_row(preferences)).click(); h.run_steps(2); for (current, next, code) in [("日本語", "English", "en"), ("English", "Українська", "uk"), ("Українська", "日本語", "ja")] { From 1bffa8d72dafb85801500a967ab1e86907a26e96 Mon Sep 17 00:00:00 2001 From: Harrison Booth <73434116+Harrison-Booth@users.noreply.github.com> Date: Mon, 9 Nov 2026 04:27:25 -0600 Subject: [PATCH 36/64] Tests: a retained PDF version is the later of header and catalog (#3227 follow-up) (#4933) --- crates/automation/tests/it/automation.rs | 29 ++++++++++++++++++++++++ 1 file changed, 29 insertions(+) diff --git a/crates/automation/tests/it/automation.rs b/crates/automation/tests/it/automation.rs index 046179ffaf..de9e996fcd 100644 --- a/crates/automation/tests/it/automation.rs +++ b/crates/automation/tests/it/automation.rs @@ -7677,6 +7677,35 @@ fn retained_pdf_version_reports_the_header_raised_for_object_streams() { assert_eq!(r["pdf_version"].as_str(), Some("1.5"), "{r}"); } +/// #3227: retaining the version, the later of the written header and the catalog `/Version` is +/// reported: a catalog below the header cannot hide it, and equal versions are reported as is. +#[test] +fn retained_pdf_version_is_the_later_of_header_and_catalog() { + let dir = workdir("optimize-retain-version-later"); + let mut a = auto(&dir); + let blank = ok(&mut a, "doc_create", json!({ "from": "blank", "width": 300, "height": 400 }))["doc"].as_u64().unwrap(); + ok(&mut a, "doc_optimize", json!({ "doc": blank, "path": "v14.pdf", "compatibility": "1.4" })); + let mut cos = pdfcraft_cos::Document::open(std::sync::Arc::new(std::fs::read(dir.join("v14.pdf")).unwrap())).unwrap(); + let root = cos.root().unwrap(); + for (label, header, catalog, saved, reported) in [ + ("higher-header", "1.7", "1.4", "1.7", "1.7"), + // PDF versions compare as major/minor numbers: 2.0 is newer than 1.7. + // Retain keeps the header. The catalog /Version only sets the reported version. + ("higher-catalog", "1.7", "2.0", "1.7", "2.0"), + ("equal", "1.4", "1.4", "1.4", "1.4"), + ] { + cos.update_dict(root, |c| c.set(b"Version".to_vec(), pdfcraft_cos::Object::name(catalog))).unwrap(); + cos.set_version(header); + let input = pdfcraft_cos::write_full(&cos, &pdfcraft_cos::SaveOptions { object_streams: false, ..Default::default() }).unwrap(); + std::fs::write(dir.join(format!("{label}-input.pdf")), input).unwrap(); + let doc = ok(&mut a, "doc_open", json!({ "path": format!("{label}-input.pdf") }))["doc"].as_u64().unwrap(); + let out = format!("{label}-output.pdf"); + let r = ok(&mut a, "doc_optimize", json!({ "doc": doc, "path": out, "compatibility": "retain", "object_streams": false })); + assert!(std::fs::read(dir.join(&out)).unwrap().starts_with(format!("%PDF-{saved}\n").as_bytes()), "{label}: written header"); + assert_eq!(r["pdf_version"].as_str(), Some(reported), "{label}: {r}"); + } +} + /// `data` as LZW with one 9-bit literal code per byte (clear-table first, EOD last): valid for /// fewer than 250 bytes, before the code width grows. fn lzw_literals(data: &[u8]) -> Vec { From b44257b92b0c3bb71e58954cfbf0e5e22c6a4e87 Mon Sep 17 00:00:00 2001 From: Harrison Booth <73434116+Harrison-Booth@users.noreply.github.com> Date: Mon, 9 Nov 2026 04:27:50 -0600 Subject: [PATCH 37/64] Test preservation of PDF 2.0 structure metadata (#5073) --- crates/cos/tests/it/writer.rs | 71 ++++++++++++++++++++++++++++++ crates/organize/src/tests.rs | 81 +++++++++++++++++++++++++++++++++++ 2 files changed, 152 insertions(+) diff --git a/crates/cos/tests/it/writer.rs b/crates/cos/tests/it/writer.rs index 2716b9242e..1dc6275b5f 100644 --- a/crates/cos/tests/it/writer.rs +++ b/crates/cos/tests/it/writer.rs @@ -544,3 +544,74 @@ fn incremental_saves_of_a_file_whose_offsets_count_from_its_first_byte_keep_coun // No qpdf check here: qpdf counts every offset from the header in a file with bytes before // it, so it already reconstructs this input before anything is appended. } + +/// One page whose catalog carries a PDF 2.0 document part tree (`/DPartRoot`, ISO 32000-2 +/// 14.12) and whose structure tree root lists structure namespaces (`/Namespaces`, 14.7.4), +/// both through indirect objects that nothing else references. +fn pdf2_fixture() -> Vec { + Pdf::new() + .version("2.0") + .obj("<< /Type /Catalog /Pages 2 0 R /DPartRoot 6 0 R /StructTreeRoot 8 0 R >>") + .obj("<< /Type /Pages /Kids [4 0 R] /Count 1 /MediaBox [0 0 200 300] >>") + .obj("<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>") + .obj("<< /Type /Page /Parent 2 0 R /Contents 5 0 R /Resources << /Font << /F1 3 0 R >> >> >>") + .stream("", "BT /F1 24 Tf 20 150 Td (Part) Tj ET") + .obj("<< /Type /DPartRoot /DPartRootNode 7 0 R /NodeNameList [/Chapter] >>") + .obj("<< /Type /DPart /Parent 6 0 R /Start 4 0 R /End 4 0 R /DPM << /Kind (cover) >> >>") + .obj("<< /Type /StructTreeRoot /K [] /Namespaces [9 0 R] >>") + .obj("<< /Type /Namespace /NS (http://iso.org/pdf2/ssn) /Schema << /Type /Filespec /F (ssn.rnc) >> >>") + .build() +} + +/// What `pdf2_fixture` declares, read back through the catalog. +fn assert_dpart_and_namespaces(doc: &Document) { + let catalog = doc.get(doc.root().unwrap()); + let catalog = catalog.as_dict().unwrap(); + let root = doc.resolve(catalog.get(b"DPartRoot").expect("catalog keeps /DPartRoot")); + let root = root.as_dict().unwrap(); + let node = doc.resolve(root.get(b"DPartRootNode").expect("/DPartRootNode kept")); + let node = node.as_dict().unwrap(); + assert_eq!(node.name(b"Type"), Some(&b"DPart"[..])); + // Both ends of the part are still the one page, found through /Pages (references are + // renumbered by a full save, so they are compared inside this document). + let pages = doc.resolve(catalog.get(b"Pages").unwrap()); + let kids = pages.as_dict().unwrap().get(b"Kids").unwrap().as_array().unwrap(); + let page = kids.first().and_then(|k| k.as_ref()).expect("the page is still the first kid"); + assert_eq!(node.reference(b"Start"), Some(page), "the part still starts at its page"); + assert_eq!(node.reference(b"End"), Some(page), "the part still ends at its page"); + assert!(doc.resolve(node.get(b"DPM").unwrap()).as_dict().unwrap().get(b"Kind").is_some()); + let tree = doc.resolve(catalog.get(b"StructTreeRoot").unwrap()); + let namespaces = doc.resolve(tree.as_dict().unwrap().get(b"Namespaces").expect("/Namespaces kept")); + let namespace = doc.resolve(&namespaces.as_array().unwrap()[0]); + let namespace = namespace.as_dict().unwrap(); + assert_eq!(namespace.get(b"NS").unwrap().as_string().unwrap().bytes, b"http://iso.org/pdf2/ssn"); + assert!(namespace.get(b"Schema").is_some(), "/Schema is retained"); +} + +#[test] +fn document_parts_and_structure_namespaces_survive_full_and_incremental_saves() { + let original = Arc::new(pdf2_fixture()); + let doc = Document::open(original.clone()).unwrap(); + assert_dpart_and_namespaces(&doc); + + for object_streams in [true, false] { + let opts = SaveOptions { object_streams, ..SaveOptions::default() }; + let full = write_full(&doc, &opts).unwrap(); + let back = Document::open(Arc::new(full.clone())).unwrap(); + assert!(back.repair_log().is_empty(), "{:?}", back.repair_log()); + assert_dpart_and_namespaces(&back); + // A second generation, rewritten from the rewritten file. + let second = Document::open(Arc::new(write_full(&back, &opts).unwrap())).unwrap(); + assert!(second.repair_log().is_empty(), "{:?}", second.repair_log()); + assert_dpart_and_namespaces(&second); + } + + // An incremental save of an unrelated edit leaves the file's bytes, and so both, alone. + let mut edited = Document::open(original.clone()).unwrap(); + edited.add(Object::Int(1)); + let updated = write_incremental(&edited, &SaveOptions::default()).unwrap(); + assert!(updated.starts_with(&original)); + let reopened = Document::open(Arc::new(updated)).unwrap(); + assert!(reopened.repair_log().is_empty(), "{:?}", reopened.repair_log()); + assert_dpart_and_namespaces(&reopened); +} diff --git a/crates/organize/src/tests.rs b/crates/organize/src/tests.rs index 0aa6d149d9..451a6dc397 100644 --- a/crates/organize/src/tests.rs +++ b/crates/organize/src/tests.rs @@ -4505,6 +4505,87 @@ fn bookmark_style_and_colour_are_set_as_the_table_says() { assert_eq!(got, [(Some([0.0, 0.0, 1.0]), true, true), (None, false, false)]); } +/// Gives the fixture a PDF 2.0 document part tree (`/DPartRoot`, ISO 32000-2 14.12) that starts +/// at its first page, and a structure tree root listing a structure namespace (14.7.4). +fn with_document_parts_and_namespaces() -> Document { + // PDF 2.0, the version these structures belong to. + let mut bytes = fixture(); + let header = bytes.get_mut(..8).expect("the fixture starts with its header"); + assert!(header.starts_with(b"%PDF-1.")); + header.copy_from_slice(b"%PDF-2.0"); + let mut doc = open(bytes); + let first = pages(&doc).unwrap()[0].obj; + let mut namespace = pdfcraft_cos::Dict::new(); + namespace.set(b"Type".to_vec(), Object::name("Namespace")); + namespace.set(b"NS".to_vec(), Object::String(pdfcraft_cos::PdfString { bytes: b"http://iso.org/pdf2/ssn".to_vec(), hex: false })); + let namespace = doc.add(Object::Dict(namespace)); + let mut tree = pdfcraft_cos::Dict::new(); + tree.set(b"Type".to_vec(), Object::name("StructTreeRoot")); + tree.set(b"Namespaces".to_vec(), Object::Array(vec![Object::Ref(namespace)])); + let tree = doc.add(Object::Dict(tree)); + let mut part = pdfcraft_cos::Dict::new(); + part.set(b"Type".to_vec(), Object::name("DPart")); + part.set(b"Start".to_vec(), Object::Ref(first)); + part.set(b"End".to_vec(), Object::Ref(first)); + let part = doc.add(Object::Dict(part)); + let mut root = pdfcraft_cos::Dict::new(); + root.set(b"Type".to_vec(), Object::name("DPartRoot")); + root.set(b"DPartRootNode".to_vec(), Object::Ref(part)); + let root = doc.add(Object::Dict(root)); + doc.update_dict(doc.root().unwrap(), |c| { + c.set(b"DPartRoot".to_vec(), Object::Ref(root)); + c.set(b"StructTreeRoot".to_vec(), Object::Ref(tree)); + }) + .unwrap(); + doc +} + +fn assert_document_parts_and_namespaces_kept(doc: &Document, what: &str) { + let catalog = doc.get(doc.root().unwrap()); + let catalog = catalog.as_dict().unwrap(); + let root = doc.resolve(catalog.get(b"DPartRoot").unwrap_or_else(|| panic!("{what}: /DPartRoot dropped"))); + let node = doc.resolve(root.as_dict().unwrap().get(b"DPartRootNode").unwrap_or_else(|| panic!("{what}: /DPartRootNode dropped"))); + let part = node.as_dict().unwrap(); + assert_eq!(part.name(b"Type"), Some(&b"DPart"[..]), "{what}"); + assert!(part.get(b"Start").is_some(), "{what}: /Start dropped"); + assert!(part.get(b"End").is_some(), "{what}: /End dropped"); + let tree = doc.resolve(catalog.get(b"StructTreeRoot").unwrap_or_else(|| panic!("{what}: /StructTreeRoot dropped"))); + let namespaces = doc.resolve(tree.as_dict().unwrap().get(b"Namespaces").unwrap_or_else(|| panic!("{what}: /Namespaces dropped"))); + let namespace = doc.resolve(&namespaces.as_array().unwrap()[0]); + assert_eq!(namespace.as_dict().unwrap().name(b"Type"), Some(&b"Namespace"[..]), "{what}"); + assert_eq!(namespace.as_dict().unwrap().get(b"NS").unwrap().as_string().unwrap().bytes, b"http://iso.org/pdf2/ssn", "{what}: /NS changed"); + assert_eq!(doc.version(), "2.0", "{what}: the PDF 2.0 version is kept"); +} + +#[test] +fn page_edits_keep_document_parts_and_structure_namespaces() { + type Edit = fn(&mut Document); + let edits: [(&str, Edit); 6] = [ + ("rotate", |d| rotate_pages(d, &[0], 90).unwrap()), + ("move", |d| move_pages(d, &[0], 2).unwrap()), + ("reverse", |d| reverse_pages(d, &[0, 1, 2]).unwrap()), + ("duplicate", |d| duplicate_pages(d, &[0], None).unwrap()), + ("insert blank", |d| { + let _ = insert_blank_page(d, 0, 100.0, 100.0).unwrap(); + }), + // The part's start page goes: the keys stay, the page reference is the part's to repair. + ("delete", |d| delete_pages(d, &[0]).unwrap()), + ]; + for (what, edit) in edits { + let mut doc = with_document_parts_and_namespaces(); + let base = write_full(&doc, &SaveOptions::default()).unwrap(); + assert_document_parts_and_namespaces_kept(&open(base.clone()), "setup"); + doc = open(base); + edit(&mut doc); + assert_document_parts_and_namespaces_kept(&doc, what); + assert_document_parts_and_namespaces_kept( + &open(write_incremental(&doc, &SaveOptions::default()).unwrap()), + &format!("{what}, incremental save"), + ); + assert_document_parts_and_namespaces_kept(&open(write_full(&doc, &SaveOptions::default()).unwrap()), &format!("{what}, full save")); + } +} + /// #3502: a `/Contents` array's pieces are joined with a space, as the renderer joins them, so a /// `%` comment at the end of a piece without a line break continues through the first line of the /// next piece in both resource pruning (`extract_pages`) and `page_as_form`. From 048386eea156393a1449448983c85187c9d740c1 Mon Sep 17 00:00:00 2001 From: Eric Kaden <608341+EricKaden@users.noreply.github.com> Date: Mon, 9 Nov 2026 05:28:14 -0500 Subject: [PATCH 38/64] M12: XFA date pictures read a two-digit year with the century split of 30 (#5074) --- crates/js/src/formcalc/dates.rs | 7 ++++++- crates/js/src/tests.rs | 10 ++++++++++ crates/xfa/src/data/dates.rs | 9 +++++++-- crates/xfa/src/tests.rs | 23 ++++++++++++++++++++--- parity/acrobat-features.toml | 6 +++--- 5 files changed, 46 insertions(+), 9 deletions(-) diff --git a/crates/js/src/formcalc/dates.rs b/crates/js/src/formcalc/dates.rs index bfb6383cdd..4d9befeab2 100644 --- a/crates/js/src/formcalc/dates.rs +++ b/crates/js/src/formcalc/dates.rs @@ -49,6 +49,11 @@ pub(super) fn civil_from_days(n: i64) -> (i64, u32, u32) { /// Day numbers (and years) beyond this are refused rather than computed. pub(super) const MAX_DAYS: f64 = 1e8; +/// The century split of a two-digit year (`YY`), XFA picture clauses' default: `00` to `29` are +/// 2000 to 2029 and `30` to `99` are 1930 to 1999 (XFA 3.3, chapter 26, Picture Clause Reference, +/// p. 1171, which makes 30 the default and lets the century split be set otherwise). +const CENTURY_SPLIT: i64 = 30; + const MONTHS: [&str; 12] = ["January", "February", "March", "April", "May", "June", "July", "August", "September", "October", "November", "December"]; /// Parse `text` as a date by `picture` (`YYYY-MM-DD`, `MM/DD/YYYY`, `MMM D, YYYY`, …). @@ -89,7 +94,7 @@ pub(super) fn parse_date(text: &str, picture: &str) -> Option<(i64, u32, u32)> { } let v: i64 = digits.parse().ok()?; match c { - 'Y' => y = Some(if run <= 2 { if v < 50 { 2000 + v } else { 1900 + v } } else { v }), + 'Y' => y = Some(if run <= 2 { if v < CENTURY_SPLIT { 2000 + v } else { 1900 + v } } else { v }), 'M' => m = Some(v as u32), _ => d = Some(v as u32), } diff --git a/crates/js/src/tests.rs b/crates/js/src/tests.rs index a12b78dcb6..46316f90ad 100644 --- a/crates/js/src/tests.rs +++ b/crates/js/src/tests.rs @@ -1346,6 +1346,16 @@ mod formcalc { assert_eq!(calc(r#"Date2Num("2024-12-31") <> 0"#), "1"); } + /// A `YY` year in a date picture splits its century at 30, the XFA picture clause default: + /// `00` to `29` are 2000 to 2029 and `30` to `99` are 1930 to 1999. + #[test] + fn a_two_digit_year_splits_its_century_at_30() { + for (text, iso) in [("12/31/29", "2029-12-31"), ("01/01/30", "1930-01-01"), ("12/31/49", "1949-12-31"), ("01/01/50", "1950-01-01")] { + assert_eq!(calc(&format!(r#"Num2Date(Date2Num("{text}", "MM/DD/YY"), "YYYY-MM-DD")"#)), iso, "{text}"); + assert_eq!(calc(&format!(r#"Parse("MM/DD/YY", "{text}")"#)), iso, "{text}"); + } + } + #[test] fn control_flow_variables_and_functions_work() { assert_eq!(calc("var x = 3\nif (x > 2) then 10 elseif (x > 1) then 20 else 30 endif"), "10"); diff --git a/crates/xfa/src/data/dates.rs b/crates/xfa/src/data/dates.rs index 565c44117d..e1c30f3f5a 100644 --- a/crates/xfa/src/data/dates.rs +++ b/crates/xfa/src/data/dates.rs @@ -37,7 +37,7 @@ pub(crate) fn data_to_iso(value: &str, data_pattern: Option<&str>) -> String { } /// An ISO date as the data should hold it: in `data_pattern` when the field has one and the -/// date reads back from it unchanged (a two-digit year holds only 1950 to 2049), else as ISO. +/// date reads back from it unchanged (a two-digit year holds only 1930 to 2029), else as ISO. pub(crate) fn iso_to_data(iso: String, data_pattern: Option<&str>) -> String { match data_pattern { Some(p) => { @@ -53,6 +53,11 @@ pub(super) fn data_date_pattern(tpl: Option<&Template>, som: &str) -> Option Option { rest = &rest[digits.len()..]; match t.as_str() { "yyyy" => y = Some(v), - "yy" => y = Some(if v < 50 { 2000 + v } else { 1900 + v }), + "yy" => y = Some(if v < CENTURY_SPLIT { 2000 + v } else { 1900 + v }), "mm" | "m" => m = Some(v), _ => d = Some(v), } diff --git a/crates/xfa/src/tests.rs b/crates/xfa/src/tests.rs index 632f6a3f0d..af3cb094bd 100644 --- a/crates/xfa/src/tests.rs +++ b/crates/xfa/src/tests.rs @@ -1192,19 +1192,34 @@ fn node_at<'a>(live: &'a crate::script::FormNode, som: &str) -> Option<&'a crate live.children.iter().find_map(|c| node_at(c, som)) } -/// A date its data picture can't hold (2050 in a two-digit year) is written as ISO and reads +/// A date its data picture can't hold (2030 in a two-digit year) is written as ISO and reads /// back as written. #[test] fn a_date_its_data_picture_cant_hold_is_written_as_iso() { use crate::data::{data_to_iso, iso_to_data}; assert_eq!(iso_to_data("2026-01-02".into(), Some("yymmdd")), "260102"); - // A two-digit year reads back as 1950 to 2049: 2050 and 1949 keep ISO, and read back as written. - for iso in ["2050-01-02", "1949-12-31"] { + // A two-digit year reads back as 1930 to 2029, so those years are written in it. + for (iso, data) in [("2029-12-31", "291231"), ("1930-01-01", "300101"), ("1949-12-31", "491231"), ("1950-01-01", "500101")] { + assert_eq!(iso_to_data(iso.into(), Some("yymmdd")), data, "{iso}"); + } + // 2030 and 1929 keep ISO, and read back as written. + for iso in ["2030-01-01", "1929-12-31", "2050-01-02"] { assert_eq!(iso_to_data(iso.into(), Some("yymmdd")), iso); assert_eq!(data_to_iso(iso, Some("yymmdd")), iso); } } +/// A two-digit year splits its century at 30, the XFA picture clause default: `00` to `29` are +/// 2000 to 2029 and `30` to `99` are 1930 to 1999, typed and in the data alike. +#[test] +fn a_two_digit_year_splits_its_century_at_30() { + use crate::data::data_to_iso; + for (text, iso) in [("291231", "2029-12-31"), ("300101", "1930-01-01"), ("491231", "1949-12-31"), ("500101", "1950-01-01")] { + assert_eq!(pattern_to_iso(text, "yymmdd").as_deref(), Some(iso), "{text}"); + assert_eq!(data_to_iso(text, Some("yymmdd")), iso, "{text}"); + } +} + /// The datasets fill the fields and add the rows of repeating subforms. #[test] fn month_and_weekday_names_in_date_patterns() { @@ -4221,6 +4236,8 @@ fn a_typed_date_is_rewritten_only_by_date_clauses_into_a_first_pattern_that_keep let short = "form[0].short[0]"; assert_eq!(typed(short, "20261018").as_deref(), Some("261018"), "a year the first pattern keeps"); assert_eq!(typed(short, "19011018"), None, "1901 as 01 would read back as 2001"); + assert_eq!(typed(short, "19451018").as_deref(), Some("451018"), "45 reads back as 1945"); + assert_eq!(typed(short, "20301018"), None, "2030 as 30 would read back as 1930"); // DDMMYYYY reads 20011203 as 1203-01-20, which YYMMDD can't keep; YYYYMMDD reads 2001-12-03. assert_eq!(typed("form[0].pick[0]", "20011203").as_deref(), Some("011203"), "the next pattern that the first keeps"); } diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index 714ba4814f..761a0a44d7 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -5459,7 +5459,7 @@ milestone = "M12" status = "partial" commands = ["file.open"] tools = ["doc_open", "form_fill", "js_run"] -evidence = ["crates/js/src/tests.rs::arithmetic_strings_and_comparisons_follow_formcalc_rules", "crates/js/src/tests.rs::built_in_functions_cover_the_common_set", "crates/js/src/tests.rs::control_flow_variables_and_functions_work", "crates/js/src/tests.rs::form_objects_resolve_assign_and_record_effects", "crates/js/src/tests.rs::hostile_formcalc_scripts_end_with_an_error_and_bounded_output", "crates/js/src/tests.rs::formcalc_values_results_and_errors_stay_bounded", "crates/engine/src/tests.rs::dynamic_xfa_forms_run_formcalc_scripts", "crates/engine/src/tests.rs::hostile_formcalc_scripts_cannot_hang_or_bloat_the_document", "crates/engine/src/tests.rs::a_formcalc_calculate_result_is_cut_before_it_is_saved", "crates/js/src/tests.rs::formcalc_message_box_keeps_its_title_icon_and_buttons"] +evidence = ["crates/js/src/tests.rs::arithmetic_strings_and_comparisons_follow_formcalc_rules", "crates/js/src/tests.rs::built_in_functions_cover_the_common_set", "crates/js/src/tests.rs::control_flow_variables_and_functions_work", "crates/js/src/tests.rs::form_objects_resolve_assign_and_record_effects", "crates/js/src/tests.rs::hostile_formcalc_scripts_end_with_an_error_and_bounded_output", "crates/js/src/tests.rs::formcalc_values_results_and_errors_stay_bounded", "crates/js/src/tests.rs::a_two_digit_year_splits_its_century_at_30", "crates/engine/src/tests.rs::dynamic_xfa_forms_run_formcalc_scripts", "crates/engine/src/tests.rs::hostile_formcalc_scripts_cannot_hang_or_bloat_the_document", "crates/engine/src/tests.rs::a_formcalc_calculate_result_is_cut_before_it_is_saved", "crates/js/src/tests.rs::formcalc_message_box_keeps_its_title_icon_and_buttons"] notes = "a native FormCalc interpreter (crates/js/src/formcalc.rs) over the same form table, effects and budgets as the XFA JavaScript model: expressions and FormCalc coercions, var, if/while/for/foreach/func, accessors ($, $host, $form, $record, $layout, $event, xfa., .., [n], [*], _name instance managers) and the arithmetic, logical, string, date/time, financial and unit built-ins. Bounded: steps and loop iterations, statement and expression nesting, operator/accessor chain length, strings (1 MiB; messages and thrown errors clipped to 4 KiB, field values and calculate results to 64 KiB, noted; copying and comparing long strings is charged to the step budget, so it ends on wasm, which has no clock), lists (each object once), clamped dates, the instance cap, and a deadline the script itself honours after the engine stops waiting. Missing: locale-aware pictures beyond simple number and date ones, Format/Parse time and text pictures, Get/Post/Put (refused), $template/$connectionSet, user-defined functions across scripts, a cap on a script's total memory (each string and list is capped, but many variables can each hold one), real Uuid() values (deterministic, from the step count) and the wall clock on wasm (Date() reads 1970-01-01 and Time() midnight there), and checking against Acrobat on real Designer forms beyond the fixtures. $host.messageBox keeps its title, icon and buttons; it answers OK (1) for now (#4291)." [[feature]] @@ -5471,8 +5471,8 @@ milestone = "M12" status = "partial" commands = ["file.open", "file.save", "form.export_data", "form.import_data"] tools = ["doc_open", "form_fill", "doc_save", "doc_export_data", "doc_import_data", "form_set_image"] -evidence = ["crates/xfa/src/tests.rs::the_datasets_packet_is_written_from_the_fields_and_read_back", "crates/xfa/src/tests.rs::a_date_is_read_from_the_data_in_its_data_picture", "crates/xfa/src/tests.rs::scripts_read_a_date_as_iso_whatever_its_data_picture_writes", "crates/engine/src/tests.rs::xfa_dates_are_written_and_read_back_in_their_data_picture", "crates/engine/src/tests.rs::each_xfa_date_field_is_written_in_its_own_data_picture_and_a_partial_one_keeps_iso", "crates/xfa/src/tests.rs::som_paths_name_the_template_field_their_numbers_count_to", "crates/xfa/src/tests.rs::a_refreshed_field_takes_its_value_from_its_own_template_field", "crates/xfa/src/tests.rs::a_date_its_data_picture_cant_hold_is_written_as_iso", "crates/xfa/src/tests.rs::data_fills_the_fields_and_adds_rows", "crates/engine/src/tests.rs::dynamic_xfa_forms_are_laid_out_on_open_filled_and_saved_incrementally", "crates/xfa/src/tests.rs::data_paths_leave_areas_out", "crates/engine/src/tests.rs::xfa_rows_in_areas_are_laid_out_read_calculated_and_written_where_adobe_binds_them", "crates/xfa/src/tests.rs::data_exports_as_datasets_xml_or_an_xdp_and_imports_from_any_of_them", "crates/xfa/src/tests.rs::a_picture_chosen_for_an_image_field_is_kept_in_the_data_with_its_content_type", "crates/engine/src/tests.rs::xfa_data_exports_imports_and_pictures_round_trip_through_the_session", "crates/engine/src/tests.rs::xfa_data_import_adds_and_removes_rows_of_a_dynamic_form", "crates/automation/tests/it/automation.rs::xfa_data_files_export_and_import_through_tools"] -notes = "the datasets packet inside the PDF: read on open (values, dates in ISO form, or in the field's data picture (``, such as date{YYYYMMDD}) when it has one holding a whole date (one without a day, such as date{YYYYMM}, keeps ISO, as does a date the picture can't hold, such as 2050 in a two-digit year), each field its own even beside same-named siblings, which saving writes them in too, while scripts still read the ISO date as rawValue, as another viewer gives it, check and radio states, extra rows for repeating subforms) and rewritten after every form edit from the fields' SOM paths (/PCSom), with areas left out of data paths as Adobe binds them. Export Data writes it as XML (the data alone, rooted at the form's root data node, as Acrobat does) or an XDP with the whole datasets and a reference to the PDF; Import Data takes an XDP, datasets or data XML file (or a bare data document) as the form's whole data, keeping data descriptions, lays a dynamic form out again from it (rows added and removed, more than 50 rows reported) and runs its calculations, undoable; a static form's fields take the data and the rest are emptied; the file's data is re-serialized with the packet's own prefix and every packet rewrite is parsed back before it is written; XFDF/FDF/CSV still go the AcroForm way. Files are UTF-8, UTF-16 or ISO-8859-1, at most 16 MB and 64 levels deep; pictures at most 8 MB (PNG, JPEG, GIF, BMP, TIFF). Pictures chosen for image fields are kept in the data as base64 with xfa:contentType, as Designer saves them. Missing: explicit bind refs, data descriptions are kept but not checked, no data connection (WSDL/ODBC)" +evidence = ["crates/xfa/src/tests.rs::the_datasets_packet_is_written_from_the_fields_and_read_back", "crates/xfa/src/tests.rs::a_date_is_read_from_the_data_in_its_data_picture", "crates/xfa/src/tests.rs::scripts_read_a_date_as_iso_whatever_its_data_picture_writes", "crates/engine/src/tests.rs::xfa_dates_are_written_and_read_back_in_their_data_picture", "crates/engine/src/tests.rs::each_xfa_date_field_is_written_in_its_own_data_picture_and_a_partial_one_keeps_iso", "crates/xfa/src/tests.rs::som_paths_name_the_template_field_their_numbers_count_to", "crates/xfa/src/tests.rs::a_refreshed_field_takes_its_value_from_its_own_template_field", "crates/xfa/src/tests.rs::a_date_its_data_picture_cant_hold_is_written_as_iso", "crates/xfa/src/tests.rs::a_two_digit_year_splits_its_century_at_30", "crates/xfa/src/tests.rs::data_fills_the_fields_and_adds_rows", "crates/engine/src/tests.rs::dynamic_xfa_forms_are_laid_out_on_open_filled_and_saved_incrementally", "crates/xfa/src/tests.rs::data_paths_leave_areas_out", "crates/engine/src/tests.rs::xfa_rows_in_areas_are_laid_out_read_calculated_and_written_where_adobe_binds_them", "crates/xfa/src/tests.rs::data_exports_as_datasets_xml_or_an_xdp_and_imports_from_any_of_them", "crates/xfa/src/tests.rs::a_picture_chosen_for_an_image_field_is_kept_in_the_data_with_its_content_type", "crates/engine/src/tests.rs::xfa_data_exports_imports_and_pictures_round_trip_through_the_session", "crates/engine/src/tests.rs::xfa_data_import_adds_and_removes_rows_of_a_dynamic_form", "crates/automation/tests/it/automation.rs::xfa_data_files_export_and_import_through_tools"] +notes = "the datasets packet inside the PDF: read on open (values, dates in ISO form, or in the field's data picture (``, such as date{YYYYMMDD}) when it has one holding a whole date (one without a day, such as date{YYYYMM}, keeps ISO, as does a date the picture can't hold, such as 2030 in a two-digit year, which holds 1930 to 2029 as XFA pictures split the century at 30), each field its own even beside same-named siblings, which saving writes them in too, while scripts still read the ISO date as rawValue, as another viewer gives it, check and radio states, extra rows for repeating subforms) and rewritten after every form edit from the fields' SOM paths (/PCSom), with areas left out of data paths as Adobe binds them. Export Data writes it as XML (the data alone, rooted at the form's root data node, as Acrobat does) or an XDP with the whole datasets and a reference to the PDF; Import Data takes an XDP, datasets or data XML file (or a bare data document) as the form's whole data, keeping data descriptions, lays a dynamic form out again from it (rows added and removed, more than 50 rows reported) and runs its calculations, undoable; a static form's fields take the data and the rest are emptied; the file's data is re-serialized with the packet's own prefix and every packet rewrite is parsed back before it is written; XFDF/FDF/CSV still go the AcroForm way. Files are UTF-8, UTF-16 or ISO-8859-1, at most 16 MB and 64 levels deep; pictures at most 8 MB (PNG, JPEG, GIF, BMP, TIFF). Pictures chosen for image fields are kept in the data as base64 with xfa:contentType, as Designer saves them. Missing: explicit bind refs, data descriptions are kept but not checked, no data connection (WSDL/ODBC)" [[feature]] id = "form.xfa-flatten" From 423cc239a143c3cd62dba094995c4e2cc7035d97 Mon Sep 17 00:00:00 2001 From: mtlcl410 <2368091+mtlcl410@users.noreply.github.com> Date: Mon, 9 Nov 2026 18:28:53 +0800 Subject: [PATCH 39/64] M12: an XFA breakBefore or breakAfter without a targetType starts no new page (form.xfa-dynamic) (#5145) --- crates/xfa/src/parse.rs | 4 ++- crates/xfa/src/tests.rs | 61 +++++++++++++++++++++++++++++++++++++++++ 2 files changed, 64 insertions(+), 1 deletion(-) diff --git a/crates/xfa/src/parse.rs b/crates/xfa/src/parse.rs index cec433f469..7f8a080be4 100644 --- a/crates/xfa/src/parse.rs +++ b/crates/xfa/src/parse.rs @@ -662,7 +662,9 @@ fn parse_subform(n: XmlNode, warnings: &mut Vec, depth: usize) -> Subfor } let column_widths: Vec = n.attribute("columnWidths").map(|s| s.split_whitespace().filter_map(measure).map(|w| w.max(0.0)).take(256).collect()).unwrap_or_default(); - let break_to_page = |name: &str| children(n, name).any(|b| b.attribute("targetType").is_none_or(|t| t == "pageArea" || t == "contentArea")); + // A break to a page or content area. With no targetType, the default is `auto`: "layout + // continues using the current layout container" (XFA 3.3 p. 638), so no break. + let break_to_page = |name: &str| children(n, name).any(|b| b.attribute("targetType").is_some_and(|t| t == "pageArea" || t == "contentArea")); Subform { common, layout: Layout::parse(n.attribute("layout")), diff --git a/crates/xfa/src/tests.rs b/crates/xfa/src/tests.rs index af3cb094bd..35a25077f0 100644 --- a/crates/xfa/src/tests.rs +++ b/crates/xfa/src/tests.rs @@ -4525,6 +4525,67 @@ fn clear_password_values_clears_static_xfa_password_fields() { assert!(!packets.xdp.contains("hunter2"), "{}", packets.xdp); } +/// The pages of `form[0].last[0].f[0]` and of the field `after` that follows `last`, when `last` +/// holds `before` ahead of `f` and `after` behind it. A breakAfter acts after its subform, so the +/// page of `after` shows whether it broke. +fn pages_around_break(before: &str, after: &str) -> (Option, Option) { + let xml = format!( + r#""# + ); + let t = crate::parse(&xml).unwrap().0; + let form = crate::layout::layout(&t, None).unwrap(); + let page_of = |som: &str| form.pages.iter().position(|p| widgets(p).iter().any(|w| w.som == som)); + (page_of("form[0].last[0].f[0]"), page_of("form[0].after[0]")) +} + +/// A `` without a targetType breaks nothing: its default is `auto`, layout goes on +/// in the current container (XFA 3.3 p. 638), as a public form's last section expects. Written +/// out as `auto` it breaks nothing either. One to a page area, or a content area, still starts a +/// new page; so does a `breakAfter` to one. +#[test] +fn a_break_before_without_a_target_type_does_not_start_a_page() { + let page_before = |brk: &str| pages_around_break(brk, "").0; + assert_eq!(page_before(""), Some(0), "auto: no break"); + assert_eq!(page_before(r#""#), Some(0)); + assert_eq!(page_before(r#""#), Some(1)); + assert_eq!(page_before(r#""#), Some(1)); + let page_after = |brk: &str| pages_around_break("", brk).1; + assert_eq!(page_after(""), Some(0), "auto: no break"); + assert_eq!(page_after(r#""#), Some(0)); + assert_eq!(page_after(r#""#), Some(1)); + assert_eq!(page_after(r#""#), Some(1)); +} + +/// targetType is one of `auto`, `contentArea` and `pageArea` (XFA 3.3 pp. 636-638). An empty or +/// unknown value, in either direction, names neither a page nor a content area, so it breaks +/// nothing; the names are case-sensitive. +#[test] +fn a_break_with_an_empty_or_unknown_target_type_does_not_start_a_page() { + for value in ["", " ", "page", "pagearea", "PageArea", "next"] { + let before = format!(r#""#); + assert_eq!(pages_around_break(&before, "").0, Some(0), "breakBefore targetType={value:?}"); + let after = format!(r#""#); + assert_eq!(pages_around_break("", &after).1, Some(0), "breakAfter targetType={value:?}"); + } +} + +/// The deprecated `` form (XFA 3.3 pp. 633-634): `auto`, its default, forces +/// no transition; `pageArea` and `contentArea` start a new page; an empty or unknown value breaks +/// nothing. +#[test] +fn the_deprecated_break_before_form_breaks_only_to_a_page_or_content_area() { + let page = |brk: &str| pages_around_break(brk, "").0; + assert_eq!(page(""), Some(0), "auto: no break"); + assert_eq!(page(r#""#), Some(0)); + assert_eq!(page(r#""#), Some(0)); + assert_eq!(page(r#""#), Some(0)); + assert_eq!(page(r#""#), Some(1)); + assert_eq!(page(r#""#), Some(1)); +} + /// A form with `draws` in a subform placed by position, on a letter page whose content area is a /// quarter inch in, with `master` on the page itself. fn form_of_draws(master: &str, draws: &str) -> String { From 4f0b926894f9861e7178e3729b2972dd7f6722d0 Mon Sep 17 00:00:00 2001 From: jonathanwxh-cell Date: Mon, 9 Nov 2026 18:29:31 +0800 Subject: [PATCH 40/64] fix(measure): resolve indirect scale objects and validate closed area edges (#5270) --- crates/measure/src/lib.rs | 46 +++++++++++++++----- crates/measure/src/tests.rs | 83 +++++++++++++++++++++++++++++++++++++ 2 files changed, 118 insertions(+), 11 deletions(-) diff --git a/crates/measure/src/lib.rs b/crates/measure/src/lib.rs index 7ff8bfdcf1..bd60a192c1 100644 --- a/crates/measure/src/lib.rs +++ b/crates/measure/src/lib.rs @@ -177,7 +177,7 @@ impl Scale { pub fn read(doc: &Document, object: &Object) -> Result { let o = doc.resolve(object); let d = o.as_dict().ok_or_else(|| invalid("invalid measure dictionary"))?; - if d.name(b"Subtype").is_some_and(|n| n != b"RL") { + if d.get(b"Subtype").map(|o| doc.resolve(o)).is_some_and(|o| !matches!(*o, Object::Null) && o.as_name() != Some(b"RL")) { return Err(invalid("only rectilinear measurement scales are supported")); } let format = |key: &[u8]| -> Result<(String, f64, u8)> { @@ -188,20 +188,25 @@ impl Scale { } let o = doc.resolve(a.first().ok_or_else(|| invalid("empty number format"))?); let f = o.as_dict().ok_or_else(|| invalid("invalid number format dictionary"))?; - let precision = match f.name(b"F") { + let fmt = f.get(b"F").map(|o| doc.resolve(o)); + let precision = match fmt.as_deref().and_then(Object::as_name) { Some(b"R") => 0, None | Some(b"D") => { - let denom = f.int(b"D").unwrap_or(100); + let denom = f.get(b"D").and_then(|o| doc.resolve(o).as_int()).unwrap_or(100); (0..=6).find(|p| 10_i64.pow(*p) == denom).ok_or_else(|| invalid("unsupported measurement precision"))? } _ => return Err(invalid("fractional measurement formats are not supported yet")), }; - Ok((text(f, b"U"), f.get(b"C").and_then(Object::as_f64).ok_or_else(|| invalid("missing conversion factor"))?, precision as u8)) + Ok(( + text(doc, f, b"U"), + f.get(b"C").and_then(|o| doc.resolve(o).as_f64()).ok_or_else(|| invalid("missing conversion factor"))?, + precision as u8, + )) }; let (unit, x, _) = format(b"X")?; let y = if d.contains(b"Y") { let (_, y, _) = format(b"Y")?; - y * d.get(b"CYX").and_then(Object::as_f64).ok_or_else(|| invalid("Y scale requires CYX for lengths and areas"))? + y * d.get(b"CYX").and_then(|o| doc.resolve(o).as_f64()).ok_or_else(|| invalid("Y scale requires CYX for lengths and areas"))? } else { x }; @@ -210,7 +215,7 @@ impl Scale { return Err(invalid("different coordinate and distance units are not supported yet")); } let (area_unit, area_factor, _) = format(b"A")?; - let out = Self { x, y, unit, distance_factor, area_factor, area_unit, precision, ratio: text(d, b"R") }; + let out = Self { x, y, unit, distance_factor, area_factor, area_unit, precision, ratio: text(doc, d, b"R") }; out.validate()?; Ok(out) } @@ -279,12 +284,19 @@ pub struct Reading { pub fn reading(kind: Kind, points: &[Point], scale: &Scale) -> Result { check_points(points)?; scale.validate()?; + let points = if kind == Kind::Area { open_polygon(points) } else { points }; let mut length = 0.0; for w in points.windows(2) { if let [a, b] = w { length += ((b[0] - a[0]) * scale.x).hypot((b[1] - a[1]) * scale.y); } } + if kind == Kind::Area + && points.len() >= 3 + && let (Some(a), Some(b)) = (points.last(), points.first()) + { + length += ((b[0] - a[0]) * scale.x).hypot((b[1] - a[1]) * scale.y); + } let area = if points.len() >= 3 { let origin = points.first().copied().unwrap_or_default(); points @@ -340,7 +352,9 @@ fn validate_geometry(kind: Kind, points: &[Point]) -> Result<()> { Kind::Area if points.len() < 3 => return Err(invalid("area needs at least three points")), _ => {} } - if points.windows(2).any(|p| matches!(p,[a,b] if distance(*a,*b)<1e-6)) { + if points.windows(2).any(|p| matches!(p, [a, b] if distance(*a, *b) < 1e-6)) + || (kind == Kind::Area && matches!((points.first(), points.last()), (Some(a), Some(b)) if distance(*a, *b) < 1e-6)) + { return Err(invalid("adjacent measurement vertices must be distinct")); } if kind == Kind::Area { @@ -357,8 +371,8 @@ fn validate_geometry(kind: Kind, points: &[Point]) -> Result<()> { } Ok(()) } -fn text(d: &Dict, k: &[u8]) -> String { - d.get(k).and_then(Object::as_string).map(PdfString::to_text).unwrap_or_default() +fn text(doc: &Document, d: &Dict, k: &[u8]) -> String { + d.get(k).and_then(|o| doc.resolve(o).as_string().map(PdfString::to_text)).unwrap_or_default() } fn nums(v: impl IntoIterator) -> Object { Object::Array(v.into_iter().map(Object::Real).collect()) @@ -491,7 +505,16 @@ fn read_measurement(doc: &Document, d: &Dict, kind: Kind, page: usize, index: us let points: Vec = v.as_chunks::<2>().0.to_vec(); validate_geometry(kind, &points)?; let reading = reading(kind, &points, &scale)?; - Ok(Measurement { page, index, id: text(d, b"NM"), author: text(d, b"T"), label: text(d, b"PCMeasureLabel"), points, scale, reading }) + Ok(Measurement { + page, + index, + id: text(doc, d, b"NM"), + author: text(doc, d, b"T"), + label: text(doc, d, b"PCMeasureLabel"), + points, + scale, + reading, + }) } /// Every measurement annotation. One that can't be read is reported in `unsupported` /// rather than failing the whole list. @@ -520,7 +543,8 @@ pub fn list(doc: &Document) -> Listing { } let o = doc.resolve(a); let Some(d) = o.as_dict() else { continue }; - let kind = match d.name(b"IT") { + let it = d.get(b"IT").map(|o| doc.resolve(o)); + let kind = match it.as_deref().and_then(Object::as_name) { Some(b"LineDimension") => Kind::Distance, Some(b"PolyLineDimension") => Kind::Perimeter, Some(b"PolygonDimension") => Kind::Area, diff --git a/crates/measure/src/tests.rs b/crates/measure/src/tests.rs index b039f7c57f..80d30fdefe 100644 --- a/crates/measure/src/tests.rs +++ b/crates/measure/src/tests.rs @@ -549,3 +549,86 @@ fn perimeters_are_charged_linear_work() { assert!(!l.truncated, "{} listed", l.measurements.len()); assert_eq!(l.measurements.len() + l.unsupported.len(), lines, "{:?}", l.unsupported.first()); } + +#[test] +fn indirect_scale_fields_and_closed_area_edges_are_handled() { + let mut d = fixture("", "", &[]); + let mut m = new(Kind::Area, vec![[0.0, 0.0], [3.0, 0.0], [3.0, 4.0], [0.0, 4.0]]); + m.scale = Scale { y: 3.0, ..Scale::new(2.0, "m", 2).unwrap() }; + add(&mut d, &m, &Meta { date: None, id: "area-1".into() }).unwrap(); + + // Open and closed vertex lists for the same rectangle produce identical readings (including perimeter length). + let open = reading(Kind::Area, &[[0.0, 0.0], [3.0, 0.0], [3.0, 4.0], [0.0, 4.0]], &m.scale).unwrap(); + let closed = reading(Kind::Area, &[[0.0, 0.0], [3.0, 0.0], [3.0, 4.0], [0.0, 4.0], [0.0, 0.0]], &m.scale).unwrap(); + close(open.length, 36.0); + close(closed.length, 36.0); + + // A doubled closing vertex leaves a zero-length closing edge after stripping one closing duplicate, + // and an area with fewer than 3 distinct vertices or a NaN coordinate is also rejected. + assert!(add(&mut d, &new(Kind::Area, vec![[0.0, 0.0], [3.0, 0.0], [3.0, 4.0], [0.0, 0.0], [0.0, 0.0]]), &Meta::default()).is_err()); + assert!(add(&mut d, &new(Kind::Area, vec![[0.0, 0.0], [3.0, 0.0], [0.0, 0.0]]), &Meta::default()).is_err()); + assert!(add(&mut d, &new(Kind::Area, vec![[0.0, 0.0], [3.0, 0.0], [f64::NAN, 4.0]]), &Meta::default()).is_err()); + assert!(reading(Kind::Area, &[[0.0, 0.0], [3.0, 0.0], [f64::NAN, 4.0]], &m.scale).is_err()); + + // Indirect PDF objects inside /Measure, /NumberFormat, and the annotation dictionary still resolve cleanly. + let (r, mut annot_dict) = annot(&d, 0, 0).unwrap(); + let it_ref = d.add(Object::name("PolygonDimension")); + let nm_ref = d.add(PdfString::text("area-indirect")); + let author_ref = d.add(PdfString::text("Architect")); + let label_ref = d.add(PdfString::text("Floor Plan")); + let rl_ref = d.add(Object::name("RL")); + let ratio_ref = d.add(PdfString::text("1 pt = 2 m")); + let cyx_ref = d.add(Object::Real(1.5)); + let unit_ref = d.add(PdfString::text("m")); + let area_unit_ref = d.add(PdfString::text("m^2")); + let factor_x_ref = d.add(Object::Real(2.0)); + let factor_y_ref = d.add(Object::Real(2.0)); + let factor_d_ref = d.add(Object::Real(1.0)); + let factor_a_ref = d.add(Object::Real(1.0)); + let fmt_d_ref = d.add(Object::name("D")); + let denom_ref = d.add(Object::Int(1000)); + + let nf = |u: ObjRef, c: ObjRef| { + let mut f = Dict::new(); + f.set(b"Type".to_vec(), Object::name("NumberFormat")); + f.set(b"U".to_vec(), Object::Ref(u)); + f.set(b"C".to_vec(), Object::Ref(c)); + f.set(b"F".to_vec(), Object::Ref(fmt_d_ref)); + f.set(b"D".to_vec(), Object::Ref(denom_ref)); + Object::Array(vec![Object::Dict(f)]) + }; + let mut measure = Dict::new(); + measure.set(b"Type".to_vec(), Object::name("Measure")); + measure.set(b"Subtype".to_vec(), Object::Ref(rl_ref)); + measure.set(b"R".to_vec(), Object::Ref(ratio_ref)); + measure.set(b"CYX".to_vec(), Object::Ref(cyx_ref)); + measure.set(b"X".to_vec(), nf(unit_ref, factor_x_ref)); + measure.set(b"Y".to_vec(), nf(unit_ref, factor_y_ref)); + measure.set(b"D".to_vec(), nf(unit_ref, factor_d_ref)); + measure.set(b"A".to_vec(), nf(area_unit_ref, factor_a_ref)); + + annot_dict.set(b"IT".to_vec(), Object::Ref(it_ref)); + annot_dict.set(b"NM".to_vec(), Object::Ref(nm_ref)); + annot_dict.set(b"T".to_vec(), Object::Ref(author_ref)); + annot_dict.set(b"PCMeasureLabel".to_vec(), Object::Ref(label_ref)); + annot_dict.set(b"Measure".to_vec(), Object::Dict(measure.clone())); + d.update_dict(r, |target| *target = annot_dict).unwrap(); + + // Non-RL indirect subtypes and numeric subtypes are rejected by Scale::read. + let geo_ref = d.add(Object::name("GEO")); + let mut bad_geo = measure.clone(); + bad_geo.set(b"Subtype".to_vec(), Object::Ref(geo_ref)); + assert!(Scale::read(&d, &Object::Dict(bad_geo)).is_err()); + let mut bad_num = measure; + bad_num.set(b"Subtype".to_vec(), Object::Int(42)); + assert!(Scale::read(&d, &Object::Dict(bad_num)).is_err()); + + let listing = list(&d); + assert!(listing.unsupported.is_empty(), "{:?}", listing.unsupported); + assert_eq!(listing.measurements.len(), 1); + let item = &listing.measurements[0]; + assert_eq!(item.id, "area-indirect"); + assert_eq!(item.author, "Architect"); + assert_eq!(item.label, "Floor Plan"); + assert_eq!(item.scale, Scale { y: 3.0, ..Scale::new(2.0, "m", 3).unwrap() }); +} From 6764809929106da908277c9d317fa62397b0640a Mon Sep 17 00:00:00 2001 From: jonathanwxh-cell Date: Mon, 9 Nov 2026 18:29:47 +0800 Subject: [PATCH 41/64] fix(geom): normalize quad ordering for perspective foreshortening and scale (#5273) --- crates/geom/src/perspective.rs | 75 ++++++++++++++++++++++++++++------ 1 file changed, 63 insertions(+), 12 deletions(-) diff --git a/crates/geom/src/perspective.rs b/crates/geom/src/perspective.rs index 96aa663e25..726d58326b 100644 --- a/crates/geom/src/perspective.rs +++ b/crates/geom/src/perspective.rs @@ -81,29 +81,49 @@ fn solve8(mut m: [[f64; 9]; 8]) -> Option<[f64; 8]> { Some(out) } +/// Axis-aligned `[min_x, min_y, max_x, max_y]` bounding box of finite `points`. +fn bounds(points: &[Point]) -> Option<[f64; 4]> { + let &[first, ref rest @ ..] = points else { return None }; + if points.iter().flatten().any(|v| !v.is_finite()) { + return None; + } + Some(rest.iter().fold([first[0], first[1], first[0], first[1]], |[x0, y0, x1, y1], &[x, y]| [x0.min(x), y0.min(y), x1.max(x), y1.max(y)])) +} + /// The four corners in reading order for a y-down space (view or raster pixels): top-left, /// top-right, bottom-right, bottom-left, whatever order they were given in. `None` unless they /// form a convex quadrilateral with a non-negligible area. pub fn order_quad(points: [Point; 4]) -> Option<[Point; 4]> { - if points.iter().flatten().any(|v| !v.is_finite()) { + let [min_x, min_y, max_x, max_y] = bounds(&points)?; + let span = max_x - min_x; + let rise = max_y - min_y; + let size = span.max(rise); + if span <= 0.0 || rise <= 0.0 || !size.is_finite() { return None; } - let cx = points.iter().map(|p| p[0]).sum::() / 4.0; - let cy = points.iter().map(|p| p[1]).sum::() / 4.0; + let norm = |p: Point| [(p[0] - min_x) / size, (p[1] - min_y) / size]; + let cx = points.iter().map(|p| norm(*p)[0]).sum::() / 4.0; + let cy = points.iter().map(|p| norm(*p)[1]).sum::() / 4.0; let mut q = points; // Ascending angle about the centre is clockwise on screen (y down): top, right, bottom, left. - q.sort_by(|a, b| (a[1] - cy).atan2(a[0] - cx).total_cmp(&(b[1] - cy).atan2(b[0] - cx))); - let first = (0..4).min_by(|&a, &b| (q[a][0] + q[a][1]).total_cmp(&(q[b][0] + q[b][1])))?; + q.sort_by(|a, b| { + let (na, nb) = (norm(*a), norm(*b)); + (na[1] - cy).atan2(na[0] - cx).total_cmp(&(nb[1] - cy).atan2(nb[0] - cx)) + }); + // Score each candidate rotation by its normalised left-edge x and top-edge y. + let score = |i: usize| { + let (tl, tr, bl) = (q[i], q[(i + 1) % 4], q[(i + 3) % 4]); + ((tl[0] - min_x) + (bl[0] - min_x)) / span + ((tl[1] - min_y) + (tr[1] - min_y)) / rise + }; + let first = (0..4).min_by(|&a, &b| score(a).total_cmp(&score(b)))?; q.rotate_left(first); // Convex, with every turn the same way, and not a sliver. + let nq = q.map(norm); let cross = |o: Point, a: Point, b: Point| (a[0] - o[0]) * (b[1] - o[1]) - (a[1] - o[1]) * (b[0] - o[0]); - let turns: Vec = (0..4).map(|i| cross(q[i], q[(i + 1) % 4], q[(i + 2) % 4])).collect(); + let turns: [f64; 4] = std::array::from_fn(|i| cross(nq[i], nq[(i + 1) % 4], nq[(i + 2) % 4])); let area = turns.iter().sum::() / 2.0; - let span = q.iter().map(|p| p[0]).fold(f64::MIN, f64::max) - q.iter().map(|p| p[0]).fold(f64::MAX, f64::min); - let rise = q.iter().map(|p| p[1]).fold(f64::MIN, f64::max) - q.iter().map(|p| p[1]).fold(f64::MAX, f64::min); - let size = span.max(rise); - let convex = turns.iter().all(|&t| t > 1e-9 * size * size); - (convex && area > 1e-6 * size * size).then_some(q) + let convex = turns.iter().all(|&t| t > 1e-9); + (convex && area > 1e-6).then_some(q) } #[cfg(test)] @@ -129,6 +149,10 @@ mod tests { let r = [[0.0, 0.0], [10.0, 0.0], [10.0, 20.0], [0.0, 20.0]]; let h = Homography::from_points(r, r).unwrap(); assert!(close(h.apply([3.0, 7.0]).unwrap(), [3.0, 7.0])); + + let narrow = [[0.0, 0.0], [1e10, 0.0], [1e10, 1.0], [0.0, 1.0]]; + let h_narrow = Homography::from_points(narrow, narrow).unwrap(); + assert!(close(h_narrow.apply([5e9, 0.5]).unwrap(), [5e9, 0.5])); } #[test] @@ -144,6 +168,8 @@ mod tests { fn collinear_or_non_finite_points_have_no_transform() { let to = [[0.0, 0.0], [1.0, 0.0], [1.0, 1.0], [0.0, 1.0]]; assert!(Homography::from_points([[0.0, 0.0], [1.0, 1.0], [2.0, 2.0], [3.0, 3.0]], to).is_none()); + assert!(Homography::from_points(to, [[0.0, 0.0], [1.0, 1.0], [2.75, 2.75], [3.0, 3.0]]).is_none()); + assert!(Homography::from_points(to, [[0.0, 0.0], [1.0, 0.0], [2.0, 0.0], [0.0, 1.0]]).is_none()); assert!(Homography::from_points([[0.0, 0.0]; 4], to).is_none()); assert!(Homography::from_points([[f64::NAN, 0.0], [1.0, 0.0], [1.0, 1.0], [0.0, 1.0]], to).is_none()); assert!(Homography::from_points([[f64::INFINITY, 0.0], [1.0, 0.0], [1.0, 1.0], [0.0, 1.0]], to).is_none()); @@ -155,7 +181,7 @@ mod tests { let tr = [200.0, 5.0]; let br = [210.0, 300.0]; let bl = [0.0, 290.0]; - for q in [[br, tl, bl, tr], [bl, br, tr, tl], [tl, tr, br, bl], [tr, bl, tl, br]] { + for q in [[br, tl, bl, tr], [bl, br, tr, tl], [tl, tr, br, bl], [tl, bl, br, tr], [tr, bl, tl, br]] { assert_eq!(order_quad(q), Some([tl, tr, br, bl]), "{q:?}"); } } @@ -164,8 +190,33 @@ mod tests { fn concave_degenerate_or_non_finite_quads_are_refused() { // A dart: one corner inside the triangle of the other three. assert_eq!(order_quad([[0.0, 0.0], [100.0, 0.0], [20.0, 20.0], [0.0, 100.0]]), None); + assert_eq!(order_quad([[0.0, 0.0], [50.0, 0.0], [100.0, 0.0], [0.0, 100.0]]), None); + assert_eq!(order_quad([[0.0, 0.0], [100.0, 0.0], [100.0, 0.0], [0.0, 100.0]]), None); assert_eq!(order_quad([[0.0, 0.0], [10.0, 0.0], [20.0, 0.0], [30.0, 0.0]]), None); assert_eq!(order_quad([[5.0, 5.0]; 4]), None); assert_eq!(order_quad([[f64::NAN, 0.0], [1.0, 0.0], [1.0, 1.0], [0.0, 1.0]]), None); } + + #[test] + fn foreshortened_tilted_and_large_scale_quads_order_correctly() { + // A landscape page photographed from below has a foreshortened top edge where + // bl[0] + bl[1] (500) < tl[0] + tl[1] (550), yet tl is still top-left. + let (tl, tr, br, bl) = ([250.0, 300.0], [550.0, 300.0], [800.0, 500.0], [0.0, 500.0]); + assert_eq!(order_quad([br, tl, bl, tr]), Some([tl, tr, br, bl])); + + // A narrow portrait strip tilted counter-clockwise has tr[0] + tr[1] (90) < tl[0] + tl[1] (120), + // yet tl is still top-left. + let (tl, tr, br, bl) = ([20.0, 100.0], [80.0, 10.0], [140.0, 400.0], [80.0, 490.0]); + assert_eq!(order_quad([tr, bl, tl, br]), Some([tl, tr, br, bl])); + + // A foreshortened portrait quad where bl sits at x = 0 and y = 60 while tl sits at [40, 0] + // still orders [40, 0] as top-left. + let (tl, tr, br, bl) = ([40.0, 0.0], [100.0, 10.0], [100.0, 200.0], [0.0, 60.0]); + assert_eq!(order_quad([bl, br, tr, tl]), Some([tl, tr, br, bl])); + + // Large finite coordinates where `size * size` would overflow f64 still order cleanly. + let s = 1e160; + let (tl, tr, br, bl) = ([0.0, 0.0], [s, 0.0], [s, s], [0.0, s]); + assert_eq!(order_quad([bl, tr, br, tl]), Some([tl, tr, br, bl])); + } } From 6d2bd31b2399d49054c87d8eb4b784284759a34f Mon Sep 17 00:00:00 2001 From: Livio Oswald <125505497+Lcoding15@users.noreply.github.com> Date: Mon, 9 Nov 2026 11:30:10 +0100 Subject: [PATCH 42/64] M3: a pinned folder on Home collapses to its heading, and stays collapsed (#5295) (#5341) --- crates/ui-egui/src/app/control.rs | 1 + crates/ui-egui/src/app/prefs.rs | 2 + crates/ui-egui/src/control.rs | 2 + crates/ui-egui/src/control/host.rs | 2 + crates/ui-egui/src/home/folders_ui.rs | 73 ++++++++++++++++++++++++++- crates/ui-egui/tests/it/control.rs | 41 +++++++++++++++ crates/ui-egui/tests/it/folders.rs | 65 ++++++++++++++++++++++++ parity/acrobat-features.toml | 4 +- 8 files changed, 187 insertions(+), 3 deletions(-) diff --git a/crates/ui-egui/src/app/control.rs b/crates/ui-egui/src/app/control.rs index 065bd7a228..aaf5c009f8 100644 --- a/crates/ui-egui/src/app/control.rs +++ b/crates/ui-egui/src/app/control.rs @@ -357,6 +357,7 @@ impl PdfCraftApp { }; } ("author", _) => self.comment_prefs.author = value.to_string(), + ("folder-collapsed", _) => self.set_folder_collapsed(value)?, ("comment-tool-pinned", _) => { self.comment_prefs.pinned = match value { "true" => true, diff --git a/crates/ui-egui/src/app/prefs.rs b/crates/ui-egui/src/app/prefs.rs index 1ec33884fe..6a6452d463 100644 --- a/crates/ui-egui/src/app/prefs.rs +++ b/crates/ui-egui/src/app/prefs.rs @@ -87,6 +87,7 @@ impl PdfCraftApp { "reload_changed_files": self.reload_changed_files, "system_title_bar": self.system_title_bar, "pinned_folders": self.pinned.folders, + "pinned_folders_collapsed": self.pinned.collapsed, "theme": self.theme_preference, "scroll_speed": self.scroll_speed, "invert_middle_drag": self.invert_middle_drag, @@ -252,6 +253,7 @@ impl PdfCraftApp { self.text_links = on; } self.pinned.restore(&v["pinned_folders"]); + self.pinned.restore_collapsed(&v["pinned_folders_collapsed"]); if let Some(items) = v.get("pinned_tools").and_then(serde_json::Value::as_array) { let mut tools = Vec::new(); for item in items { diff --git a/crates/ui-egui/src/control.rs b/crates/ui-egui/src/control.rs index 44e5481a11..59fb97d7d1 100644 --- a/crates/ui-egui/src/control.rs +++ b/crates/ui-egui/src/control.rs @@ -45,6 +45,8 @@ //! `hotbar-size=24..64` controls its button size in logical pixels; `ui.state.hotbar` //! reports its tools, relative position, orientation and strip preference. These options persist across restarts. //! `ui.state.comment_tool_pinned` reports the same preference. +//! `folder-collapsed==on|off` folds a pinned folder on Home down to its heading or +//! opens it again; `ui.state.pinned_folders` lists each pinned folder and whether it is. //! - `ui.open {path}`: open a file. A file whose tab is already open focuses it //! (`"focused": true`) instead of adding a tab; native opens report `pending: true` until //! they complete and are listed in `ui.state.pending_opens`; only a failed open replies `Err`. diff --git a/crates/ui-egui/src/control/host.rs b/crates/ui-egui/src/control/host.rs index acccf5ead3..cf8e5e7fcc 100644 --- a/crates/ui-egui/src/control/host.rs +++ b/crates/ui-egui/src/control/host.rs @@ -146,6 +146,8 @@ impl Host for crate::PdfCraftApp { "default_mode": self.default_mode, "hotbar": self.hotbar, "comment_tool_pinned": self.comment_prefs.pinned, + // Home's pinned folders, in order, and whether each is folded down to its heading. + "pinned_folders": self.pinned.folders.iter().map(|f| json!({ "folder": f, "collapsed": self.pinned.collapsed.contains(f) })).collect::>(), "left_panel": if self.left_open { json!(format!("{:?}", self.left)) } else { Value::Null }, "tools_side": if self.side_panels.tools_on_right() { "right" } else { "left" }, "rail_side": if self.side_panels.rail_on_right() { "right" } else { "left" }, diff --git a/crates/ui-egui/src/home/folders_ui.rs b/crates/ui-egui/src/home/folders_ui.rs index fe9b2c3706..60e130420b 100644 --- a/crates/ui-egui/src/home/folders_ui.rs +++ b/crates/ui-egui/src/home/folders_ui.rs @@ -55,6 +55,8 @@ pub struct PinnedFolders { pub listings: Vec, /// Folders whose "Show more" is open. pub expanded: Vec, + /// Folders folded down to their heading (#5295); kept in settings. + pub collapsed: Vec, /// A listing in progress: the worker fills the slot. scan: Option>>>>, /// When (egui time) the last listing started. @@ -86,8 +88,34 @@ impl PinnedFolders { self.folders = folders; self.listings.clear(); self.expanded.clear(); + self.collapsed.clear(); self.stale = true; } + + /// Restore which folders are collapsed, after [`Self::restore`]: only folders that are + /// pinned, each once, so untrusted settings can't grow the list. + pub(crate) fn restore_collapsed(&mut self, value: &serde_json::Value) { + let Some(items) = value.as_array() else { return }; + self.collapsed.clear(); + for folder in items.iter().filter_map(serde_json::Value::as_str) { + if self.folders.iter().any(|f| f == folder) && !self.collapsed.iter().any(|f| f == folder) { + self.collapsed.push(folder.to_string()); + } + } + } + + /// Fold pinned `folder` down to its heading (`collapsed`) or open it again. Returns false, + /// changing nothing, when `folder` isn't pinned. + pub(crate) fn set_collapsed(&mut self, folder: &str, collapsed: bool) -> bool { + if !self.folders.iter().any(|f| f == folder) { + return false; + } + self.collapsed.retain(|f| f != folder); + if collapsed { + self.collapsed.push(folder.to_string()); + } + true + } } /// `path` as a folder to pin: trimmed, without a trailing separator, and absolute; `None` if it @@ -221,6 +249,25 @@ impl PdfCraftApp { self.pinned.folders.retain(|f| f != folder); self.pinned.listings.retain(|l| l.folder != folder); self.pinned.expanded.retain(|f| f != folder); + self.pinned.collapsed.retain(|f| f != folder); + } + + /// `ui.set folder-collapsed =on|off` (CodeRabbit on #5341): the control channel's + /// way to fold a pinned folder down to its heading or open it again, as its chevron does. + /// `ui.state` lists the pinned folders and which are collapsed. The folder is named by its + /// path as pinned; the last `=` splits it from the state, so a path may hold `=` too. + pub(crate) fn set_folder_collapsed(&mut self, value: &str) -> Result<(), String> { + let (folder, state) = value.rsplit_once('=').ok_or("folder-collapsed must be =on or =off")?; + let collapsed = match state { + "on" => true, + "off" => false, + _ => return Err("folder-collapsed must be =on or =off".into()), + }; + if self.pinned.set_collapsed(folder, collapsed) { + Ok(()) + } else { + Err(format!("folder-collapsed: {folder} is not a pinned folder (ui.state lists them)")) + } } /// List the pinned folders again if the last listing is older than a few seconds or the @@ -308,10 +355,27 @@ pub(crate) fn section(app: &mut PdfCraftApp, ui: &mut egui::Ui) { let mut open = None; let mut unpin = None; let mut toggle = None; + let mut fold = None; for folder in &app.pinned.folders { let name = folder_name(folder); + // Buttons name their folder; two pinned folders of the same name add where each is. + let shared = app.pinned.folders.iter().any(|f| f != folder && folder_name(f) == name); + let named = if shared { format!("{name} ({})", location(folder)) } else { name.clone() }; + let collapsed = app.pinned.collapsed.contains(folder); ui.add_space(6.0); ui.horizontal(|ui| { + let (chevron, tip, label) = if collapsed { + ("chevron-right", tl!("Expand"), tl!("Expand {folder}")) + } else { + ("chevron-down", tl!("Collapse"), tl!("Collapse {folder}")) + }; + let resp = icons::button(ui, chevron, 22.0, false, tip); + // Every folder has one: say which folder it folds. + let label = crate::i18n::fmt(label, &[("folder", &named)]); + resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, true, &label)); + if resp.clicked() { + fold = Some(folder.clone()); + } let (icon, _) = ui.allocate_exact_size(vec2(22.0, 22.0), Sense::hover()); icons::paint(ui, icon, "folder", 20.0, t.icon); // Folder names and paths are file/path chrome: explicitly left to right, so an @@ -327,13 +391,16 @@ pub(crate) fn section(app: &mut PdfCraftApp, ui: &mut egui::Ui) { ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { let resp = widgets::ghost_button(ui, "x", tl!("Unpin")); // Every folder has an Unpin button: say which one it is. - let label = crate::i18n::fmt(tl!("Unpin {folder}"), &[("folder", &name)]); + let label = crate::i18n::fmt(tl!("Unpin {folder}"), &[("folder", &named)]); resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, true, &label)); if resp.clicked() { unpin = Some(folder.clone()); } }); }); + if collapsed { + continue; + } let note = |ui: &mut egui::Ui, text: &str| { ui.add_space(2.0); ui.label(egui::RichText::new(text).color(t.text_muted)); @@ -405,6 +472,10 @@ pub(crate) fn section(app: &mut PdfCraftApp, ui: &mut egui::Ui) { app.pinned.expanded.push(folder); } } + if let Some(folder) = fold { + let collapsed = app.pinned.collapsed.contains(&folder); + app.pinned.set_collapsed(&folder, !collapsed); + } if let Some(path) = open { app.open_recent(&path); } diff --git a/crates/ui-egui/tests/it/control.rs b/crates/ui-egui/tests/it/control.rs index 5bbc26c991..8e397c803b 100644 --- a/crates/ui-egui/tests/it/control.rs +++ b/crates/ui-egui/tests/it/control.rs @@ -2176,6 +2176,47 @@ fn routine_continuous_scrolling_does_not_add_navigation_history() { assert!(h.state().views[0].forward.is_empty()); } +/// CodeRabbit on #5341: the control channel folds a pinned folder on Home down to its heading +/// and opens it again, as its chevron does, and `ui.state` says which are folded. Folders that +/// aren't pinned, and states other than on and off, are refused with the reason. +#[test] +fn control_collapses_and_expands_a_pinned_folder() { + let dir = std::env::temp_dir().join(format!("pdfcraft-ui-fold-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + std::fs::write(dir.join("inside.pdf"), fixture(1)).unwrap(); + let folder = dir.to_string_lossy().into_owned(); + let slot: Arc>> = Arc::default(); + let (s, pin) = (slot.clone(), folder.clone()); + let mut h = Harness::builder().with_size(egui::vec2(1200.0, 900.0)).build_eframe(move |cc| { + let mut app = PdfCraftApp::new(); + app.set_option("language", "en").unwrap(); + app.run_inline = true; + app.pin_folder(&pin); + *s.lock().unwrap() = Some(app.attach_control(&cc.egui_ctx)); + app + }); + h.run_steps(4); + let c = slot.lock().unwrap().take().unwrap(); + let folded = |h: &mut Harness<'static, PdfCraftApp>| ok(h, &c, "ui.state", json!({}))["pinned_folders"].clone(); + assert_eq!(folded(&mut h), json!([{ "folder": folder, "collapsed": false }])); + h.get_by_label("inside.pdf"); + ok(&mut h, &c, "ui.set", json!({ "key": "folder-collapsed", "value": format!("{folder}=on") })); + h.run_steps(2); + assert_eq!(folded(&mut h), json!([{ "folder": folder, "collapsed": true }])); + assert!(h.query_by_label("inside.pdf").is_none(), "a collapsed folder lists nothing"); + let unpinned = call(&mut h, &c, "ui.set", json!({ "key": "folder-collapsed", "value": "/not/pinned=on" })).unwrap_err(); + assert!(unpinned.contains("not a pinned folder"), "{unpinned}"); + let bad = call(&mut h, &c, "ui.set", json!({ "key": "folder-collapsed", "value": format!("{folder}=yes") })).unwrap_err(); + assert!(bad.contains("=on or"), "{bad}"); + assert_eq!(folded(&mut h), json!([{ "folder": folder, "collapsed": true }]), "refusals change nothing"); + ok(&mut h, &c, "ui.set", json!({ "key": "folder-collapsed", "value": format!("{folder}=off") })); + h.run_steps(2); + assert_eq!(folded(&mut h), json!([{ "folder": folder, "collapsed": false }])); + h.get_by_label("inside.pdf"); + let _ = std::fs::remove_dir_all(&dir); +} + #[test] fn accessible_labels_on_format_text_and_pin_dialog() { let (mut h, c) = harness(); diff --git a/crates/ui-egui/tests/it/folders.rs b/crates/ui-egui/tests/it/folders.rs index 583cc35eaa..a3e954b6a2 100644 --- a/crates/ui-egui/tests/it/folders.rs +++ b/crates/ui-egui/tests/it/folders.rs @@ -140,6 +140,71 @@ fn unpinning_removes_the_folder_but_not_its_files() { assert!(path.exists(), "unpinning never touches the files"); } +/// #5295: a pinned folder folds down to its heading and back, the other folders stay as they +/// are, and the folded state is kept across a restart. +#[test] +fn a_pinned_folder_collapses_to_its_heading_and_stays_collapsed_after_a_restart() { + let (first, second) = (TempFolder::new("fold-a"), TempFolder::new("fold-b")); + first.file("alpha.pdf", FIXTURE, Duration::from_secs(10)); + second.file("beta.pdf", FIXTURE, Duration::from_secs(10)); + let (a, b) = (first.path(), second.path()); + let pins = (a.clone(), b.clone()); + let mut h = harness(move |app| { + app.pin_folder(&pins.0); + app.pin_folder(&pins.1); + }); + h.run_steps(2); + let name = pdfcraft_ui_egui::folders_ui::folder_name(&a); + h.get_by_label(&format!("Collapse {name}")).click(); + h.run_steps(2); + assert!(h.query_by_label("alpha.pdf").is_none(), "a collapsed folder lists nothing"); + h.get_by_label("beta.pdf"); + h.get_by_label(&format!("Unpin {name}")); + assert_eq!(h.state().pinned.collapsed, std::slice::from_ref(&a)); + + let mut restarted = PdfCraftApp::new(); + restarted.restore(&h.state().persist()); + assert_eq!(restarted.pinned.collapsed, std::slice::from_ref(&a)); + + h.get_by_label(&format!("Expand {name}")).click(); + h.run_steps(2); + h.get_by_label("alpha.pdf"); + assert!(h.state().pinned.collapsed.is_empty()); + + // Settings are untrusted: only pinned folders, each once. + let settings = serde_json::json!({ "pinned_folders": [a, b], "pinned_folders_collapsed": [b, b, "/not/pinned", 5] }); + restarted.restore(&settings.to_string()); + assert_eq!(restarted.pinned.collapsed, std::slice::from_ref(&b)); + restarted.unpin_folder(&b); + assert!(restarted.pinned.collapsed.is_empty(), "unpinning forgets the folded state"); +} + +/// Two pinned folders of the same name: their buttons say where each one is, so a screen reader +/// can tell them apart. +#[test] +fn folders_of_the_same_name_have_buttons_that_tell_them_apart() { + let root = TempFolder::new("same-name"); + let (a, b) = (root.0.join("work").join("Scans"), root.0.join("home").join("Scans")); + std::fs::create_dir_all(&a).unwrap(); + std::fs::create_dir_all(&b).unwrap(); + let (a, b) = (a.to_string_lossy().into_owned(), b.to_string_lossy().into_owned()); + let pins = (a.clone(), b.clone()); + let mut h = harness(move |app| { + app.pin_folder(&pins.0); + app.pin_folder(&pins.1); + }); + h.run_steps(2); + for (folder, other) in [(&a, &b), (&b, &a)] { + let named = format!("Scans ({})", pdfcraft_ui_egui::folders_ui::location(folder)); + assert_ne!(named, format!("Scans ({})", pdfcraft_ui_egui::folders_ui::location(other))); + h.get_by_label(&format!("Collapse {named}")); + h.get_by_label(&format!("Unpin {named}")); + } + h.get_by_label(&format!("Collapse Scans ({})", pdfcraft_ui_egui::folders_ui::location(&a))).click(); + h.run_steps(2); + assert_eq!(h.state().pinned.collapsed, std::slice::from_ref(&a), "the button folds its own folder"); +} + #[test] fn pinned_folders_survive_a_restart() { let dir = TempFolder::new("restart"); diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index 761a0a44d7..d7a6a406eb 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -1706,8 +1706,8 @@ milestone = "M3" status = "shipped" commands = ["file.pin_folder"] tools = [] -evidence = ["crates/ui-egui/tests/it/ui.rs::home_shows_welcome_and_tools", "crates/ui-egui/tests/it/folders.rs::a_pinned_folder_lists_its_newest_pdfs_on_home", "crates/ui-egui/tests/it/folders.rs::clicking_a_pinned_pdf_opens_it_and_adds_it_to_recent", "crates/ui-egui/tests/it/folders.rs::pinned_folders_survive_a_restart"] -notes = "Desktop: folders pinned to Home (File ▸ Pin folder to Home…, up to 8) list their newest PDFs, read on a worker thread and refreshed every few seconds while Home shows; a folder that can't be read (moved, offline synced drive) stays pinned and says so. Pinned folders are local paths, so a cloud drive works through its synced folder (iCloud Drive, OneDrive, Dropbox). Not yet: subfolders, search within a pinned folder, the web build." +evidence = ["crates/ui-egui/tests/it/ui.rs::home_shows_welcome_and_tools", "crates/ui-egui/tests/it/folders.rs::a_pinned_folder_lists_its_newest_pdfs_on_home", "crates/ui-egui/tests/it/folders.rs::clicking_a_pinned_pdf_opens_it_and_adds_it_to_recent", "crates/ui-egui/tests/it/folders.rs::pinned_folders_survive_a_restart", "crates/ui-egui/tests/it/folders.rs::a_pinned_folder_collapses_to_its_heading_and_stays_collapsed_after_a_restart", "crates/ui-egui/tests/it/control.rs::control_collapses_and_expands_a_pinned_folder"] +notes = "Desktop: folders pinned to Home (File ▸ Pin folder to Home…, up to 8) list their newest PDFs, read on a worker thread and refreshed every few seconds while Home shows; a folder that can't be read (moved, offline synced drive) stays pinned and says so. Each folder collapses to its heading, and stays collapsed across restarts. Pinned folders are Home-view settings, not a document's, so the engine tools have none; the control channel folds them (`ui.set folder-collapsed =on|off`) and `ui.state` lists them. Pinned folders are local paths, so a cloud drive works through its synced folder (iCloud Drive, OneDrive, Dropbox). Not yet: subfolders, search within a pinned folder, the web build." [[feature]] id = "view.all-tools" From 008b035326488d20b9f45e98f6432cfdd14a7167 Mon Sep 17 00:00:00 2001 From: jonathanwxh-cell Date: Mon, 9 Nov 2026 18:30:27 +0800 Subject: [PATCH 43/64] fix(optimize): validate named destination targets and ignore orphaned refs (#5362) --- crates/optimize/src/audit.rs | 2 +- crates/optimize/src/links.rs | 107 +++++++++++++++++------------- crates/optimize/tests/optimize.rs | 101 ++++++++++++++++++++++++++++ 3 files changed, 163 insertions(+), 47 deletions(-) diff --git a/crates/optimize/src/audit.rs b/crates/optimize/src/audit.rs index afa95f555e..f5da17c45d 100644 --- a/crates/optimize/src/audit.rs +++ b/crates/optimize/src/audit.rs @@ -262,7 +262,7 @@ pub fn audit_space(doc: &Document, file_len: u64) -> Vec { a.claim(o, SpaceCategory::Bookmarks); } let names = a.resolve_dict(cat.get(b"Names")); - if let Some(d) = names.get(b"Dests").or_else(|| cat.get(b"Dests")) { + for d in [names.get(b"Dests"), cat.get(b"Dests")].into_iter().flatten() { a.claim(d, SpaceCategory::NamedDestinations); } if let Some(e) = names.get(b"EmbeddedFiles") { diff --git a/crates/optimize/src/links.rs b/crates/optimize/src/links.rs index cbd64eb928..5b74ad7023 100644 --- a/crates/optimize/src/links.rs +++ b/crates/optimize/src/links.rs @@ -5,16 +5,19 @@ use std::collections::{HashSet, VecDeque}; use pdfcraft_cos::{Dict, Document, ObjRef, Object, PdfString}; -/// Named destinations: the `/Names /Dests` tree (entries) and the old `/Dests` dictionary. -fn named_dests(doc: &Document) -> HashSet> { +/// Named destinations whose target page exists: the `/Names /Dests` tree and the old `/Dests` +/// dictionary. +fn named_dests(doc: &Document, pages: &HashSet, count: usize) -> HashSet> { let mut out = HashSet::new(); let Some(cat) = doc.root().and_then(|r| doc.get(r).as_dict().cloned()) else { return out }; if let Some(d) = cat.get(b"Dests").map(|d| doc.resolve(d)).and_then(|d| d.as_dict().cloned()) { - out.extend(d.iter().map(|(k, _)| k.clone())); + out.extend(d.iter().filter(|(_, v)| valid_explicit(doc, v, pages, count)).map(|(k, _)| k.clone())); } if let Some(tree) = names_tree(doc, &cat) { - for (k, _) in tree_entries(doc, &tree) { - out.insert(k); + for (k, v) in tree_entries(doc, &tree) { + if valid_explicit(doc, &v, pages, count) { + out.insert(k); + } } } out @@ -74,22 +77,33 @@ fn dest_name(o: &Object) -> Option> { } } -fn valid(o: &Object, pages: &HashSet, count: usize, names: &HashSet>) -> bool { - match o { - Object::Array(a) => match a.first() { - Some(Object::Ref(r)) => pages.contains(r), - Some(Object::Int(i)) => *i >= 0 && (*i as usize) < count, - _ => false, - }, - other => dest_name(other).is_some_and(|n| names.contains(&n)), +/// The `[page /Fit …]` array of an explicit destination (an array or a `<< /D […] >>` dictionary). +fn explicit_array(doc: &Document, o: &Object) -> Option> { + match doc.resolve(o).as_ref() { + Object::Array(a) => Some(a.clone()), + Object::Dict(d) => d.get(b"D").and_then(|x| doc.resolve(x).as_array().cloned()), + _ => None, } } +/// Whether an explicit destination targets one of the document's pages. +fn valid_explicit(doc: &Document, o: &Object, pages: &HashSet, count: usize) -> bool { + explicit_array(doc, o).is_some_and(|a| match a.first() { + Some(Object::Ref(r)) => pages.contains(r), + Some(Object::Int(i)) => usize::try_from(*i).is_ok_and(|i| i < count), + _ => false, + }) +} + +fn valid(doc: &Document, o: &Object, pages: &HashSet, count: usize, names: &HashSet>) -> bool { + valid_explicit(doc, o, pages, count) || dest_name(o).is_some_and(|n| names.contains(&n)) +} + /// Remove links whose destination is missing, and clear the destination of such bookmarks /// (they stay, so their children do too). Returns (links, bookmarks). pub(crate) fn remove_invalid(doc: &mut Document, pages: &[ObjRef]) -> Result<(usize, usize), pdfcraft_cos::CosError> { let set: HashSet = pages.iter().copied().collect(); - let names = named_dests(doc); + let names = named_dests(doc, &set, pages.len()); let mut links = 0; for p in pages { let Some(pd) = doc.get(*p).as_dict().cloned() else { continue }; @@ -101,7 +115,7 @@ pub(crate) fn remove_invalid(doc: &mut Document, pages: &[ObjRef]) -> Result<(us if d.name(b"Subtype") != Some(b"Link") { return true; } - destination(doc, &d).is_none_or(|dest| valid(&dest, &set, pages.len(), &names)) + destination(doc, &d).is_none_or(|dest| valid(doc, &dest, &set, pages.len(), &names)) }) .cloned() .collect(); @@ -130,7 +144,7 @@ pub(crate) fn remove_invalid(doc: &mut Document, pages: &[ObjRef]) -> Result<(us stack.push(f); } if let Some(dest) = destination(doc, &d) - && !valid(&dest, &set, pages.len(), &names) + && !valid(doc, &dest, &set, pages.len(), &names) { doc.update_dict(r, |d| { d.remove(b"Dest"); @@ -142,39 +156,40 @@ pub(crate) fn remove_invalid(doc: &mut Document, pages: &[ObjRef]) -> Result<(us Ok((links, bookmarks)) } -/// Every named destination some object refers to (links, bookmarks, actions, open action). +/// Every named destination some reachable object refers to (links, bookmarks, actions, open action). fn referenced_names(doc: &Document) -> HashSet> { let mut out = HashSet::new(); - let mut visit = |o: &Object| { - let mut stack = vec![o.clone()]; - while let Some(o) = stack.pop() { - match o { - Object::Dict(d) => { - // Resolve first: /Dest and a GoTo's /D may be an indirect reference to the name - // string, and an unresolved ref has no name, so the destination it uses would - // look unreferenced and be removed. A GoToR or GoToE /D names a destination in - // another document, so it does not count as a use of this one's. - if let Some(n) = d.get(b"Dest").and_then(|x| dest_name(doc.resolve(x).as_ref())) { - out.insert(n); - } - if d.name(b"S") == Some(b"GoTo") - && let Some(n) = d.get(b"D").and_then(|x| dest_name(doc.resolve(x).as_ref())) - { - out.insert(n); - } - stack.extend(d.iter().map(|(_, v)| v.clone())); - } - Object::Array(a) => stack.extend(a), - Object::Stream(s) => stack.push(Object::Dict(s.dict.clone())), - _ => {} - } - } - }; - for num in doc.object_numbers() { - visit(&doc.get(ObjRef::new(num, doc.generation(num)))); - } + let mut seen = HashSet::new(); + let mut stack = vec![Object::Dict(doc.trailer().clone())]; if let Some(r) = doc.root() { - visit(&doc.get(r)); + stack.push(Object::Ref(r)); + } + while let Some(o) = stack.pop() { + match o { + Object::Ref(r) => { + if seen.insert(r) { + stack.push(doc.get(r).as_ref().clone()); + } + } + Object::Dict(d) => { + // Resolve first: /Dest and a GoTo's /D may be an indirect reference to the name + // string, and an unresolved ref has no name, so the destination it uses would + // look unreferenced and be removed. A GoToR or GoToE /D names a destination in + // another document, so it does not count as a use of this one's. + if let Some(n) = d.get(b"Dest").and_then(|x| dest_name(doc.resolve(x).as_ref())) { + out.insert(n); + } + if d.name(b"S") == Some(b"GoTo") + && let Some(n) = d.get(b"D").and_then(|x| dest_name(doc.resolve(x).as_ref())) + { + out.insert(n); + } + stack.extend(d.iter().map(|(_, v)| v.clone())); + } + Object::Array(a) => stack.extend(a), + Object::Stream(s) => stack.push(Object::Dict(s.dict)), + _ => {} + } } out } diff --git a/crates/optimize/tests/optimize.rs b/crates/optimize/tests/optimize.rs index 2b8f513a24..b6fe624082 100644 --- a/crates/optimize/tests/optimize.rs +++ b/crates/optimize/tests/optimize.rs @@ -4242,6 +4242,107 @@ fn mixed_text_and_small_halftone_regions_keep_their_tones_with_wide_white_margin } } +#[test] +fn dictionary_destinations_broken_named_destinations_and_both_dest_tables() { + let mut doc = Document::new_empty(); + let p = page(&mut doc, &[], "BT ET"); + let s = |t: &str| Object::String(pdfcraft_cos::PdfString::literal(t.as_bytes().to_vec())); + let dict_dest = |target: ObjRef| { + let mut d = Dict::new(); + d.set(b"D".to_vec(), Object::Array(vec![Object::Ref(target), Object::name("Fit")])); + Object::Dict(d) + }; + let link = |doc: &mut Document, dest: Object| { + let mut d = Dict::new(); + d.set(b"Type".to_vec(), Object::name("Annot")); + d.set(b"Subtype".to_vec(), Object::name("Link")); + d.set(b"Rect".to_vec(), Object::Array(vec![0.into(), 0.into(), 10.into(), 10.into()])); + d.set(b"Dest".to_vec(), dest); + Object::Ref(doc.add(Object::Dict(d))) + }; + let missing = ObjRef::new(999, 0); + // Links: (1) explicit dictionary destination to `p` (valid), (2) explicit dictionary + // destination to `missing` (invalid), (3) named destination "dict_ok" whose value is + // `<< /D [p /Fit] >>` (valid), (4) named destination "broken_tree" whose target page is + // `missing` (invalid). + let annots = vec![ + link(&mut doc, dict_dest(p)), + link(&mut doc, dict_dest(missing)), + link(&mut doc, Object::Array(vec![Object::Int(i64::from(u32::MAX) + 1), Object::name("Fit")])), + link(&mut doc, s("dict_ok")), + link(&mut doc, s("broken_tree")), + ]; + doc.update_dict(p, |d| d.set(b"Annots".to_vec(), Object::Array(annots))).unwrap(); + + // Bookmark with an indirect GoTo action pointing to "broken_old" in catalog /Dests (whose + // page is `missing`), and a second bookmark pointing to "old_ok" in catalog /Dests. + let outlines = doc.add(Object::Null); + let mut goto_broken = Dict::new(); + goto_broken.set(b"S".to_vec(), Object::name("GoTo")); + goto_broken.set(b"D".to_vec(), Object::name("broken_old")); + let goto_broken = doc.add(Object::Dict(goto_broken)); + let mut bm_broken = Dict::new(); + bm_broken.set(b"Title".to_vec(), s("Broken")); + bm_broken.set(b"Parent".to_vec(), Object::Ref(outlines)); + bm_broken.set(b"A".to_vec(), Object::Ref(goto_broken)); + let bm_broken = doc.add(Object::Dict(bm_broken)); + let mut bm_ok = Dict::new(); + bm_ok.set(b"Title".to_vec(), s("Ok")); + bm_ok.set(b"Parent".to_vec(), Object::Ref(outlines)); + bm_ok.set(b"Dest".to_vec(), Object::name("old_ok")); + let bm_ok = doc.add(Object::Dict(bm_ok)); + doc.update_dict(bm_broken, |d| d.set(b"Next".to_vec(), Object::Ref(bm_ok))).unwrap(); + let mut o = Dict::new(); + o.set(b"First".to_vec(), Object::Ref(bm_broken)); + o.set(b"Last".to_vec(), Object::Ref(bm_ok)); + doc.set(outlines, Object::Dict(o)); + + // /Names /Dests has "broken_tree" (pointing to `missing`) and "dict_ok" (`<< /D [p /Fit] >>`). + let broken_dest = doc.add(dict_dest(missing)); + let ok_dest = doc.add(dict_dest(p)); + let mut tree = Dict::new(); + tree.set(b"Names".to_vec(), Object::Array(vec![s("broken_tree"), Object::Ref(broken_dest), s("dict_ok"), Object::Ref(ok_dest)])); + let tree = doc.add(Object::Dict(tree)); + let mut names = Dict::new(); + names.set(b"Dests".to_vec(), Object::Ref(tree)); + + // Catalog /Dests has "broken_old" (pointing to `missing`) and "old_ok" (`<< /D [p /Fit] >>`). + let mut old_dests = Dict::new(); + old_dests.set(b"broken_old".to_vec(), Object::Array(vec![Object::Ref(missing), Object::name("Fit")])); + old_dests.set(b"old_ok".to_vec(), dict_dest(p)); + let old_dests = doc.add(Object::Dict(old_dests)); + + let root = doc.root().unwrap(); + doc.update_dict(root, |c| { + c.set(b"Names".to_vec(), Object::Dict(names)); + c.set(b"Dests".to_vec(), Object::Ref(old_dests)); + c.set(b"Outlines".to_vec(), Object::Ref(outlines)); + }) + .unwrap(); + + // Space audit claims both /Names /Dests and catalog /Dests under NamedDestinations. + let only_tree = { + let mut d = doc.clone(); + d.update_dict(root, |c| { + c.remove(b"Dests"); + }) + .unwrap(); + audit_space(&d, 0).into_iter().find(|u| u.category == SpaceCategory::NamedDestinations).unwrap().bytes + }; + let both_tables = audit_space(&doc, 0).into_iter().find(|u| u.category == SpaceCategory::NamedDestinations).unwrap().bytes; + assert!(both_tables > only_tree, "catalog /Dests is counted alongside /Names /Dests: {both_tables} vs {only_tree}"); + + let report = optimize(&mut doc, &Settings::default()).unwrap(); + assert_eq!((report.invalid_links, report.invalid_bookmarks, report.unreferenced_dests), (3, 1, 2), "{report:?}"); + let left_links = doc.get(p).as_dict().unwrap().get(b"Annots").unwrap().as_array().unwrap().len(); + assert_eq!(left_links, 2, "the two valid dictionary/named links remain"); + assert!(doc.get(bm_broken).as_dict().unwrap().get(b"A").is_none(), "broken bookmark action is cleared"); + assert!(doc.get(bm_ok).as_dict().unwrap().get(b"Dest").is_some(), "valid bookmark destination stays"); + assert_eq!(named_dest_keys(&doc), vec![b"dict_ok".to_vec()]); + let left_old = doc.get(old_dests).as_dict().cloned().unwrap(); + assert!(left_old.contains(b"old_ok") && !left_old.contains(b"broken_old"), "{left_old:?}"); +} + /// A one-bit stencil of `w` × `h`, solid but for a hole in its middle. fn stencil(doc: &mut Document, w: u32, h: u32) -> ObjRef { let row = (w as usize).div_ceil(8); From 61bc0871a5f97c9de4fe76d94fcee20fbee25569 Mon Sep 17 00:00:00 2001 From: Tiago Kiel <186439951+TiagoCK@users.noreply.github.com> Date: Mon, 9 Nov 2026 05:31:04 -0500 Subject: [PATCH 44/64] fix(ui): selecting in an unchanged paragraph lands on the words the page shows (refs #5185) (#5390) --- crates/ui-egui/src/tools/edit_text_ui.rs | 9 ++ .../ui-egui/src/tools/edit_text_ui/layout.rs | 132 +++++++++++++++++- crates/ui-egui/tests/it/editing.rs | 100 +++++++++++++ 3 files changed, 239 insertions(+), 2 deletions(-) diff --git a/crates/ui-egui/src/tools/edit_text_ui.rs b/crates/ui-egui/src/tools/edit_text_ui.rs index 942b39674c..fc2cf29471 100644 --- a/crates/ui-egui/src/tools/edit_text_ui.rs +++ b/crates/ui-egui/src/tools/edit_text_ui.rs @@ -986,6 +986,15 @@ pub(crate) fn overlay(ctx: &egui::Context, view: &mut DocView, info: &DocInfo) - } else { caret_color_on(fill) }; + // An unchanged paragraph's galley is unseen over the page's own text. Selecting + // part of it must not paint its stand-in glyphs over that text in the selection + // colour (a serif paragraph turned sans when highlighted), nor hide it under an + // opaque fill: the selection is a tint the page shows through. + if unchanged { + let selection = &mut ui.visuals_mut().selection; + selection.stroke.color = Color32::TRANSPARENT; + selection.bg_fill = selection.bg_fill.gamma_multiply(0.45); + } let rows = 1; let snapshot = ed.clone(); let mut layouter = |ui: &egui::Ui, text: &dyn egui::TextBuffer, wrap_width: f32| { diff --git a/crates/ui-egui/src/tools/edit_text_ui/layout.rs b/crates/ui-egui/src/tools/edit_text_ui/layout.rs index 9acca88022..50a514f90e 100644 --- a/crates/ui-egui/src/tools/edit_text_ui/layout.rs +++ b/crates/ui-egui/src/tools/edit_text_ui/layout.rs @@ -231,8 +231,6 @@ pub(super) fn editor_galley(ui: &egui::Ui, ed: &LineEditor, text: &str, scale: f sources.windows(2).map(|r| (r[0].0 - r[1].0).abs()).rfind(|v| *v > 0.0).unwrap_or(fallback) }; let g = Arc::make_mut(&mut galley); - let mut bounds = Rect::NOTHING; - let mut meshes = Rect::NOTHING; // Which line of the text (between `\n`s) each row is on: rows aligned on their own go where // the rewrite will put them, in the paragraph's width. let mut line = 0usize; @@ -274,6 +272,14 @@ pub(super) fn editor_galley(ui: &egui::Ui, ed: &LineEditor, text: &str, scale: f } } row.size.y = (ascent + descent).max(1.0); + } + if text == ed.original && ed.look == ed.look0 && ed.extras == ed.extras0 && block.rotation.abs() < 0.5 { + place_on_source_words(g, block, lines, text, scale); + } + let mut bounds = Rect::NOTHING; + let mut meshes = Rect::NOTHING; + for placed in &mut g.rows { + let row = Arc::make_mut(&mut placed.row); row.visuals.mesh_bounds = row.visuals.mesh.vertices.iter().fold(Rect::NOTHING, |r, v| r.union(Rect::from_min_max(v.pos, v.pos))); bounds = bounds.union(Rect::from_min_size(placed.pos, row.size)); meshes = meshes.union(row.visuals.mesh_bounds.translate(placed.pos.to_vec2())); @@ -283,6 +289,128 @@ pub(super) fn editor_galley(ui: &egui::Ui, ed: &LineEditor, text: &str, scale: f galley } +/// While a paragraph is unchanged the page shows its own text, and the editor's galley is drawn +/// unseen: it only places the caret and the selection. Put each source word's glyphs across that +/// word's box on the page, so a click or a drag lands on the word under the pointer. The editor's +/// face is often a stand-in whose advances differ from the PDF's (a font program it can't load, or +/// an OCR layer whose every word has its own size), and stretching each run to its width still let +/// every word boundary drift, so a drag over one word selected another two words along. +/// +/// A word takes its `ink` box when the run's words and boxes pair up, else the run's box spans all +/// its words. Within a word the stand-in's proportions are kept; spaces fill the gaps between +/// words. A row with a character this can't place, or with boxes out of reading order, is left as +/// laid out. +fn place_on_source_words(g: &mut egui::Galley, block: &pdfcraft_engine::TextBlock, lines: &[pdfcraft_engine::TextLine], text: &str, scale: f32) { + let starts: Vec = text.char_indices().map(|(at, _)| at).collect(); + let chars: Vec = text.chars().collect(); + let x = |user: f64| ((user - block.rect[0]) * f64::from(scale)) as f32; + // Each character's glyph advance, in the galley's character order (a newline has no glyph). + let mut advance = vec![0.0f32; chars.len()]; + let mut at = 0usize; + for placed in &g.rows { + for (glyph, slot) in placed.row.glyphs.iter().zip(advance.iter_mut().skip(at)) { + *slot = glyph.advance_width.max(0.0); + } + at = at.saturating_add(placed.row.glyphs.len()).saturating_add(usize::from(placed.ends_with_newline)); + } + if at != chars.len() { + return; + } + // Where each character goes: its left edge and width, in galley coordinates. + let mut target: Vec> = vec![None; chars.len()]; + let place = |range: std::ops::Range, left: f32, right: f32, target: &mut [Option<(f32, f32)>]| { + let natural: f32 = advance.get(range.clone()).map_or(0.0, |a| a.iter().sum()); + let count = range.len().max(1) as f32; + let mut pos = left; + for i in range { + let share = if natural > 0.0 { advance.get(i).copied().unwrap_or(0.0) / natural } else { 1.0 / count }; + let width = ((right - left) * share).max(0.0); + if let Some(slot) = target.get_mut(i) { + *slot = Some((pos, width)); + } + pos += width; + } + }; + for (range, index) in &block.spans { + let Some(line) = lines.get(*index) else { continue }; + let (first, end) = (starts.partition_point(|b| *b < range.start), starts.partition_point(|b| *b < range.end)); + let mut words = Vec::new(); + let mut i = first; + while i < end { + if chars.get(i).is_some_and(|c| c.is_whitespace()) { + i += 1; + continue; + } + let from = i; + while i < end && chars.get(i).is_some_and(|c| !c.is_whitespace()) { + i += 1; + } + words.push(from..i); + } + let (Some(head), Some(tail)) = (words.first().cloned(), words.last().cloned()) else { continue }; + if line.ink.len() == words.len() { + for (word, ink) in words.iter().zip(&line.ink) { + place(word.clone(), x(ink[0]), x(ink[2]), &mut target); + } + } else { + place(head.start..tail.end, x(line.rect[0]), x(line.rect[2]), &mut target); + } + } + let mut first = 0usize; + for placed in &mut g.rows { + let row = Arc::make_mut(&mut placed.row); + let range = first..first.saturating_add(row.glyphs.len()); + first = range.end.saturating_add(usize::from(placed.ends_with_newline)); + let Some(mut spots) = target.get(range.clone()).map(<[_]>::to_vec) else { continue }; + let row_chars = chars.get(range).unwrap_or_default(); + // Spaces between two placed characters share the gap between them; at the row's ends + // they sit against the nearest placed character. + let mut i = 0; + while i < spots.len() { + if spots.get(i).is_some_and(Option::is_some) { + i += 1; + continue; + } + let from = i; + while i < spots.len() && spots.get(i).is_some_and(Option::is_none) { + i += 1; + } + if row_chars.get(from..i).is_none_or(|c| !c.iter().all(|c| c.is_whitespace())) { + break; + } + let left = from.checked_sub(1).and_then(|p| spots.get(p).copied().flatten()).map(|(at, w)| at + w); + let right = spots.get(i).copied().flatten().map(|(at, _)| at); + let (left, right) = match (left, right) { + (Some(l), Some(r)) => (l, r.max(l)), + (Some(l), None) => (l, l), + (None, Some(r)) => (r, r), + (None, None) => break, + }; + let width = (right - left) / (i - from) as f32; + for (k, spot) in spots.iter_mut().enumerate().take(i).skip(from) { + *spot = Some((left + width * (k - from) as f32, width)); + } + } + let placed_all: Option> = spots.into_iter().collect(); + let Some(spots) = placed_all else { continue }; + if spots.iter().any(|(at, w)| !at.is_finite() || !w.is_finite()) || spots.windows(2).any(|p| p[1].0 + 0.01 < p[0].0) { + continue; + } + for (glyph, (at, width)) in row.glyphs.iter_mut().zip(spots) { + let new_x = at - placed.pos.x; + let dx = new_x - glyph.pos.x; + glyph.pos.x = new_x; + glyph.advance_width = width; + if !glyph.uv_rect.is_nothing() { + for vertex in row.visuals.mesh.vertices.iter_mut().skip(glyph.first_vertex as usize).take(4) { + vertex.pos.x += dx; + } + } + } + row.size.x = row.glyphs.iter().map(|g| g.pos.x + g.advance_width).fold(row.size.x.min(0.0), f32::max); + } +} + /// Caret colour that stays visible on a field filled with `fill`: black on light fills, white on dark ones. pub(crate) fn caret_color_on(fill: Color32) -> Color32 { let luma = 299 * u32::from(fill.r()) + 587 * u32::from(fill.g()) + 114 * u32::from(fill.b()); diff --git a/crates/ui-egui/tests/it/editing.rs b/crates/ui-egui/tests/it/editing.rs index 8b0bf25973..40a2da9b44 100644 --- a/crates/ui-egui/tests/it/editing.rs +++ b/crates/ui-egui/tests/it/editing.rs @@ -6473,6 +6473,106 @@ fn the_caret_on_a_small_row_is_as_tall_as_that_row() { assert!(caret > row_height * 0.8 && caret < row_height + 4.0, "caret {caret} px on a {row_height} px row"); } +/// One paragraph whose every word is its own run at its own size, in a font the editor can't load +/// (not embedded, and not a standard font), with glyphs much wider than its stand-in face's: the +/// shape of an OCR layer, and of a Type 1 paragraph from LaTeX. Returns the PDF and its words. +fn stand_in_words_fixture() -> (Vec, Vec<&'static str>) { + let words: Vec<(&str, f64)> = vec![ + ("In", 10.0), + ("poker", 10.3), + ("a", 15.1), + ("full", 11.7), + ("house", 10.7), + ("is", 9.7), + ("a", 15.1), + ("set", 9.0), + ("of", 10.4), + ("cards", 10.0), + ("that", 9.4), + ("make", 9.0), + ("a", 12.4), + ("pair", 11.7), + ]; + let mut content = String::new(); + let mut x = 20.0; + for (word, size) in &words { + content.push_str(&format!("BT /F1 {size} Tf {x:.2} 200 Td ({word}) Tj ET\n")); + x += word.len() as f64 * 0.7 * size + 4.0; + } + let widths = vec!["700"; 95].join(" "); + let objs: Vec = vec![ + "<< /Type /Catalog /Pages 2 0 R >>".into(), + "<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 700 300] >>".into(), + "<< /Type /Page /Parent 2 0 R /Contents 4 0 R /Resources << /Font << /F1 5 0 R >> >> >>".into(), + format!("<< /Length {} >>\nstream\n{content}\nendstream", content.len()), + format!("<< /Type /Font /Subtype /Type1 /BaseFont /CMR10 /FirstChar 32 /LastChar 126 /Widths [{widths}] >>"), + ]; + 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, words.into_iter().map(|(w, _)| w).collect()) +} + +/// A drag over a word of an unchanged paragraph selects that word. The editor lays the paragraph +/// out unseen in a stand-in face, and stretching each run to its width let every word boundary +/// drift: over an OCR layer, dragging across "kind" selected "of a", two words before it. +#[test] +fn dragging_over_a_word_selects_that_word_when_the_editor_uses_a_stand_in_face() { + let (bytes, words) = stand_in_words_fixture(); + 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("ocr-like.pdf", None, bytes).unwrap(); + app + }); + h.run_steps(4); + assert!(h.state_mut().execute("edit.edit_text")); + h.run_steps(2); + let page = h.state().views[0].page_screen_rect(0).unwrap(); + let k = page.width() / 700.0; + let screen = |x: f64, y: f64| egui::pos2(page.left() + x as f32 * k, page.top() + (300.0 - y) as f32 * k); + let lines = h.state().session.get(h.state().views[0].id).unwrap().text_lines(0); + assert_eq!(lines.len(), words.len(), "one run per word: {:?}", lines.iter().map(|l| &l.text).collect::>()); + let first = lines[0].rect; + let at = screen((first[0] + first[2]) / 2.0, (first[1] + first[3]) / 2.0); + h.hover_at(at); + h.run_steps(1); + h.drag_at(at); + h.run_steps(1); + h.drop_at(at); + h.run_steps(4); + let opened = h.state().views[0].line_editor.as_ref().map(|e| e.text.clone()); + assert_eq!(opened.as_deref(), Some(words.join(" ").as_str()), "the words open as one paragraph"); + // Drag across each of the last words, just inside its box, as a reader aims at what the page shows. + for target in [words.len() - 1, words.len() - 4, 9] { + let r = lines[target].rect; + let middle = (r[1] + r[3]) / 2.0; + let (from, to) = (screen(r[0] + 0.5, middle), screen(r[2] - 0.5, middle)); + h.hover_at(from); + h.run_steps(1); + h.drag_at(from); + h.run_steps(1); + for step in 1..=6 { + h.hover_at(from + (to - from) * (step as f32 / 6.0)); + h.run_steps(1); + } + h.drop_at(to); + h.run_steps(3); + let ed = h.state().views[0].line_editor.as_ref().expect("the paragraph stays open"); + let selected = ed.selection.clone().and_then(|s| ed.text.get(s)).unwrap_or_default(); + assert_eq!(selected, words[target], "dragging over word {target} ({:?})", words[target]); + } +} + /// Opt-in private-document regression. This never captures images or saves the source file. /// Run with PDFCRAFT_TEXT_REGRESSION_PDF=/path/to/document.pdf and --ignored. #[test] From 12a9b233942fc48e3584bd14269b114cf54f75fe Mon Sep 17 00:00:00 2001 From: oracola91 <118899664+oracola91@users.noreply.github.com> Date: Mon, 9 Nov 2026 12:31:29 +0200 Subject: [PATCH 45/64] Create PDF from text takes Cyrillic and Greek, and reports no longer escape them (#5403) --- crates/automation/src/assemble.rs | 5 +- crates/automation/src/tools.rs | 2 +- crates/create/README.md | 8 ++- crates/create/src/lib.rs | 53 ++++++++------ crates/create/src/tests.rs | 95 ++++++++++++++++++++++++- crates/create/src/text_font.rs | 108 +++++++++++++++++++++++++++++ crates/engine/src/session/files.rs | 8 ++- crates/engine/src/tests.rs | 29 ++++++++ parity/acrobat-features.toml | 4 +- 9 files changed, 278 insertions(+), 34 deletions(-) create mode 100644 crates/create/src/text_font.rs diff --git a/crates/automation/src/assemble.rs b/crates/automation/src/assemble.rs index deb2efa18a..5fd9d9b0be 100644 --- a/crates/automation/src/assemble.rs +++ b/crates/automation/src/assemble.rs @@ -36,8 +36,9 @@ impl Automation { } (None, None) => return Err(ToolError::InvalidArgs("text needs `text` or `path`".into())), }; - // The page font is Helvetica/WinAnsi: text it would turn into `?` is refused (with - // its line) by `pdfcraft_create::from_text`, as for every other caller. + // The page font is Helvetica/WinAnsi, or the bundled Inter face for Latin, Greek and + // Cyrillic letters WinAnsi lacks: text neither can draw is refused (with its line) + // by `pdfcraft_create::from_text`, as for every other caller. let bytes = self.session.create_from_text(&title, &text).map_err(|e| match e { pdfcraft_engine::EditError::Create(pdfcraft_engine::CreateError::Invalid(m)) => ToolError::InvalidArgs(m), e => failed(e), diff --git a/crates/automation/src/tools.rs b/crates/automation/src/tools.rs index eaf6117447..81be15b3c9 100644 --- a/crates/automation/src/tools.rs +++ b/crates/automation/src/tools.rs @@ -1543,7 +1543,7 @@ pub fn tools() -> Vec { t( "doc_create", "Create a PDF", - "Create a new, unsaved document and return it like doc_open: `blank` (pages, width, height in points; default 1 US Letter page), `images` (paths of PNG, JPEG, TIFF (every page), GIF or BMP files, one page each at the image's resolution) or `text` (a .txt path, or `text` directly; Western European (WinAnsi) characters only, others are refused). Save it with doc_save and a path.", + "Create a new, unsaved document and return it like doc_open: `blank` (pages, width, height in points; default 1 US Letter page), `images` (paths of PNG, JPEG, TIFF (every page), GIF or BMP files, one page each at the image's resolution) or `text` (a .txt path, or `text` directly; set in Helvetica, or in the bundled Inter face when it has Latin, Greek or Cyrillic letters outside Western European (WinAnsi); other characters are refused). Save it with doc_save and a path.", ) .with(schema( json!({ diff --git a/crates/create/README.md b/crates/create/README.md index f72f70aa26..95d47d27a9 100644 --- a/crates/create/README.md +++ b/crates/create/README.md @@ -19,10 +19,12 @@ Layer L4. Create a PDF (Acrobat's Create a PDF tool), execution plan M10.2: for one point per pixel, or `ImageResolution::Embedded` for the same behavior as `from_images`. The largest page side is still capped at 14,400 points; - `from_text(text, …)`: plain text set in Helvetica, wrapped and paginated. Text Helvetica - can't draw is refused, naming the line and the character, never set as `?`. + can't draw is set in the bundled Inter face instead (Latin, Greek and Cyrillic letters), with + the same page, margins, wrapping and form feeds; text Inter can't draw either is refused, + naming the line and the character, never set as `?`. - `from_text_escaped(text, …)`: the same for reports PdfCraft generates from documents - (comment summaries, compare reports): such characters are written as `[U+XXXX]` and the first - line says so (`ESCAPED_NOTE`). + (comment summaries, compare reports): text Inter draws is set in it; otherwise the characters + Helvetica can't draw are written as `[U+XXXX]` and the first line says so (`ESCAPED_NOTE`). Every function returns a `pdfcraft_cos::Document`; the caller writes it. diff --git a/crates/create/src/lib.rs b/crates/create/src/lib.rs index ef46ccb713..389173d848 100644 --- a/crates/create/src/lib.rs +++ b/crates/create/src/lib.rs @@ -5,9 +5,11 @@ use pdfcraft_cos::{Dict, Document, ObjRef, Object, PdfString, Stream, serialize}; mod extract; +mod text_font; mod upright_image; pub use extract::{ExtractedImage, ImageExport, extract_images, image_file, image_file_within}; -use pdfcraft_fonts::{literal, win_ansi, wrap}; +use pdfcraft_fonts::wrap_with; +use text_font::TextFont; #[derive(Debug, thiserror::Error, PartialEq)] pub enum CreateError { @@ -912,14 +914,20 @@ fn turkish_code_page(bytes: &[u8]) -> bool { } /// Plain text set in Helvetica on pages of `page` size with 1-inch margins. Text Helvetica can't -/// draw (outside Western European) is refused with [`CreateError::Invalid`] naming the line and -/// the character; text PdfCraft generates goes through [`from_text_escaped`] instead. +/// draw (outside Western European) is set in the bundled Inter face instead, whole, when Inter +/// draws all of it (Latin, Greek and Cyrillic letters); anything else is refused with +/// [`CreateError::Invalid`] naming the line and the first character neither face draws. Text +/// PdfCraft generates goes through [`from_text_escaped`] instead. pub fn from_text(title: &str, text: &str, page: (f64, f64), font_size: f64) -> Result { - // Helvetica in WinAnsiEncoding: text it can't draw is refused with where it is, not set as `?`. - if let Some((line, c)) = first_undrawable_line(text) { - return Err(CreateError::Invalid(pdfcraft_fonts::undrawable_message(&format!("the text (line {line})"), c))); + // Helvetica in WinAnsiEncoding draws it: set as it always was. + if first_undrawable_line(text).is_none() { + return set_text(title, text, page, font_size, false); } - set_text(title, text, page, font_size) + // Otherwise the bundled face, or a refusal with where it is, never `?`. + if let Some((line, c)) = text_font::first_missing(text) { + return Err(CreateError::Invalid(text_font::undrawable_message(line, c))); + } + set_text(title, &text.chars().map(text_font::drawn_as).collect::(), page, font_size, true) } /// The line (1-based) and character of the first one Helvetica can't draw. Line breaks, tabs and @@ -933,10 +941,15 @@ fn first_undrawable_line(text: &str) -> Option<(usize, char)> { pub const ESCAPED_NOTE: &str = "Characters Helvetica can't draw are written as [U+XXXX] code points."; /// [`from_text`] for text PdfCraft generates from documents (a comment summary, a compare -/// report), which may quote any language. Each character Helvetica can't draw is written as -/// `[U+XXXX]` instead of being refused or set as `?`, so nothing is lost, and the first line says -/// so ([`ESCAPED_NOTE`]). Returns the document and how many characters were written that way. +/// report), which may quote any language. Text the bundled Inter face draws all of is set as +/// [`from_text`] sets it, nothing escaped. Otherwise each character Helvetica can't draw is +/// written as `[U+XXXX]` instead of being refused or set as `?`, so nothing is lost, and the first +/// line says so ([`ESCAPED_NOTE`]). Returns the document and how many characters were written +/// that way. pub fn from_text_escaped(title: &str, text: &str, page: (f64, f64), font_size: f64) -> Result<(Document, usize), CreateError> { + if first_undrawable_line(text).is_some() && text_font::first_missing(text).is_none() { + return Ok((set_text(title, &text.chars().map(text_font::drawn_as).collect::(), page, font_size, true)?, 0)); + } let mut escaped = 0usize; let mut out = String::with_capacity(text.len()); for c in text.chars() { @@ -951,29 +964,25 @@ pub fn from_text_escaped(title: &str, text: &str, page: (f64, f64), font_size: f out = format!("{ESCAPED_NOTE}\n\n{out}"); } // The title is a text string in /Info, which holds any character. - Ok((set_text(title, &out, page, font_size)?, escaped)) + Ok((set_text(title, &out, page, font_size, false)?, escaped)) } -fn set_text(title: &str, text: &str, page: (f64, f64), font_size: f64) -> Result { +/// `text` set on pages of `page` size, in Helvetica or, with `inter`, the bundled Inter face. +fn set_text(title: &str, text: &str, page: (f64, f64), font_size: f64, inter: bool) -> Result { let size = if font_size.is_finite() && font_size > 0.0 { font_size.clamp(4.0, 72.0) } else { 11.0 }; let (w, h) = page; let margin = 72.0; let line_h = size * 1.25; let width = (w - 2.0 * margin).max(36.0); + let mut doc = Document::new_empty(); + let font = if inter { TextFont::inter(&mut doc)? } else { TextFont::helvetica(&mut doc) }; let lines: Vec = text .replace("\r\n", "\n") .replace('\t', " ") .split('\u{c}') - .flat_map(|p| wrap(p, size, width).into_iter().chain(std::iter::once("\u{c}".to_string()))) + .flat_map(|p| wrap_with(p, size, width, |s, size| font.width(s, size)).into_iter().chain(std::iter::once("\u{c}".to_string()))) .collect(); let per_page = (((h - 2.0 * margin) / line_h).floor() as usize).max(1); - let mut doc = Document::new_empty(); - let mut font = Dict::new(); - font.set(b"Type".to_vec(), Object::name("Font")); - font.set(b"Subtype".to_vec(), Object::name("Type1")); - font.set(b"BaseFont".to_vec(), Object::name("Helvetica")); - font.set(b"Encoding".to_vec(), Object::name("WinAnsiEncoding")); - let fr = doc.add(font); let mut tree = Pages::of(&doc)?; let mut page_lines: Vec> = vec![Vec::new()]; for l in lines.iter().take(lines.len().saturating_sub(1)) { @@ -990,12 +999,12 @@ fn set_text(title: &str, text: &str, page: (f64, f64), font_size: f64) -> Result for lines in page_lines { let mut c = format!("BT /F1 {size} Tf {line_h:.3} TL {margin} {:.3} Td\n", h - margin - size).into_bytes(); for l in lines { - c.extend(literal(&win_ansi(&l))); + c.extend(font.show(&l)?); c.extend_from_slice(b" Tj T*\n"); } c.extend_from_slice(b"ET\n"); let mut fonts = Dict::new(); - fonts.set(b"F1".to_vec(), Object::Ref(fr)); + fonts.set(b"F1".to_vec(), Object::Ref(font.font())); let mut res = Dict::new(); res.set(b"Font".to_vec(), Object::Dict(fonts)); tree.add(&mut doc, w, h, res, Some(c)); diff --git a/crates/create/src/tests.rs b/crates/create/src/tests.rs index 584b013503..d2a8c143e7 100644 --- a/crates/create/src/tests.rs +++ b/crates/create/src/tests.rs @@ -163,7 +163,7 @@ fn text_keeps_leading_spaces_and_tabs() { fn text_helvetica_cannot_draw_is_refused_or_escaped() { let err = from_text("notes", "Café —\tok\r\n\u{c}第二页 notes", LETTER, 11.0).unwrap_err(); let CreateError::Invalid(msg) = &err else { panic!("{err:?}") }; - assert!(msg.contains("(line 2)") && msg.contains("\"第\" (U+7B2C)") && msg.contains("Western European"), "{msg}"); + assert!(msg.contains("(line 2)") && msg.contains("\"第\" (U+7B2C)") && msg.contains("Cyrillic letters"), "{msg}"); // Western European text, tabs, CRLF and form feeds included, still converts. from_text("notes", "Café —\tok\r\n\u{c}Seite zwei: Grüße", LETTER, 11.0).unwrap(); let (doc, escaped) = from_text_escaped("Summary of Comments on 报告.pdf", "Comment: 签名 ok\nPage 2", LETTER, 11.0).unwrap(); @@ -183,6 +183,99 @@ fn text_helvetica_cannot_draw_is_refused_or_escaped() { assert!(!String::from_utf8_lossy(&s.decoded().unwrap()).contains("U+XXXX")); } +/// The decoded content of each page. +fn page_contents(doc: &Document) -> Vec { + pages(doc) + .iter() + .map(|p| { + let Object::Stream(s) = &*doc.resolve(p.get(b"Contents").unwrap()) else { panic!("no content stream") }; + String::from_utf8_lossy(&s.decoded().unwrap()).into_owned() + }) + .collect() +} + +/// The text each page shows, read back through its font's `ToUnicode`, one line per `Tj`. +fn page_text(doc: &Document, page: &Dict) -> Vec { + let res = doc.resolve(page.get(b"Resources").unwrap()).as_dict().cloned().unwrap(); + let fonts = doc.resolve(res.get(b"Font").unwrap()).as_dict().cloned().unwrap(); + let font = doc.resolve(fonts.get(b"F1").unwrap()).as_dict().cloned().unwrap(); + let metrics = pdfcraft_fonts::pdf::Metrics::from_dict(doc, &font); + let Object::Stream(s) = &*doc.resolve(page.get(b"Contents").unwrap()) else { panic!("no content stream") }; + let content = String::from_utf8_lossy(&s.decoded().unwrap()).into_owned(); + content + .split("> Tj") + .filter_map(|part| part.rsplit_once('<').map(|(_, hex)| hex)) + .map(|hex| { + let bytes: Vec = (0..hex.len()).step_by(2).map(|i| u8::from_str_radix(&hex[i..i + 2], 16).unwrap()).collect(); + metrics.decode(&bytes) + }) + .collect() +} + +const PANGRAM: &str = "Жълтата дюля беше щастлива, че пухът, който цъфна, замръзна като гьон."; + +/// Cyrillic text was refused (Helvetica can't draw it). Text with letters Helvetica lacks is set +/// in the bundled Inter face when that draws all of it, its Latin lines too, with Helvetica's +/// page, margins, line height, wrapping, tabs and form feeds; it reads back as typed. +#[test] +fn text_with_cyrillic_is_set_in_the_bundled_face() { + let long: String = (0..120).map(|i| format!("Ред {i}: {PANGRAM}\n")).collect(); + let text = format!("{long}\tТаб, „кавички“, № 5 и Ελληνικά\u{c}Нова страница"); + let doc = reopen(&from_text("бележки", &text, LETTER, 11.0).unwrap()); + let fonts: Vec<_> = doc.scan_objects().filter_map(|(_, o)| o.as_dict().and_then(|d| d.name(b"BaseFont").map(<[u8]>::to_vec))).collect(); + assert!(fonts.iter().any(|f| f == b"Inter") && !fonts.iter().any(|f| f == b"Helvetica"), "{fonts:?}"); + let programs = doc.scan_objects().filter(|(_, o)| matches!(o.as_ref(), Object::Stream(s) if s.dict.get(b"PCFullFont").is_some())).count(); + assert_eq!(programs, 1, "one copy of the face for every page"); + let p = pages(&doc); + let contents = page_contents(&doc); + assert!(p.len() >= 4, "{} pages", p.len()); + assert_eq!(media(&p[0]), [0.0, 0.0, 612.0, 792.0]); + // The same page set-up as Helvetica's: 11 pt, 1.25 line height, 1-inch margins. + assert!(contents[0].starts_with("BT /F1 11 Tf 13.750 TL 72 709.000 Td\n<"), "{}", contents[0]); + assert!(contents.iter().all(|c| !c.contains('?') && !c.contains("[U+"))); + // Each line fits between the margins in Inter's widths, and long lines wrap. + let lines: Vec = p.iter().flat_map(|page| page_text(&doc, page)).collect(); + assert!(lines.len() > 120, "{} lines", lines.len()); + assert!(lines.iter().all(|l| pdfcraft_fonts::inter_width(l, 11.0) <= 612.0 - 144.0 + 1e-6), "{lines:?}"); + let joined = lines.join(" "); + assert!(joined.contains("Ред 0: Жълтата дюля") && joined.contains("Ред 119:"), "{joined}"); + assert!(joined.contains(" Таб, „кавички“, № 5 и Ελληνικά"), "a tab is four spaces: {joined}"); + assert_eq!(page_text(&doc, p.last().unwrap()), ["Нова страница"], "a form feed starts a page"); + // As many lines on a page as in Helvetica. + let latin: String = (0..120).map(|i| format!("Row {i}\n")).collect(); + let cyrillic: String = (0..120).map(|i| format!("Ред {i}\n")).collect(); + let per_page = + |text: &str| page_contents(&from_text("t", text, LETTER, 11.0).unwrap()).iter().map(|c| c.matches("Tj T*").count()).collect::>(); + assert_eq!(per_page(&cyrillic), per_page(&latin)); +} + +/// Text the bundled face can't draw all of keeps the Helvetica way: refused, naming the first +/// character neither face draws, or, for text PdfCraft generates, escaped as before. Text it draws +/// all of is set in it with nothing escaped, and Western European text stays in Helvetica. +#[test] +fn text_the_bundled_face_cannot_draw_is_refused_or_escaped_as_before() { + let err = from_text("t", "Бележка\nПодпис: 签名", LETTER, 11.0).unwrap_err(); + let CreateError::Invalid(msg) = &err else { panic!("{err:?}") }; + assert!(msg.contains("(line 2)") && msg.contains("\"签\" (U+7B7E)"), "{msg}"); + let (doc, escaped) = from_text_escaped("r", "Бележка: 签名", LETTER, 11.0).unwrap(); + assert_eq!(escaped, 9, "every character Helvetica can't draw, as before"); + let shown = page_contents(&reopen(&doc)).join("\n"); + assert!(shown.contains(&format!("({ESCAPED_NOTE}) Tj")) && shown.contains("([U+0411][U+0435]"), "{shown}"); + let (doc, escaped) = from_text_escaped("Обобщение на коментарите", &format!("Коментар: {PANGRAM}\nPage 2"), LETTER, 11.0).unwrap(); + assert_eq!(escaped, 0); + let doc = reopen(&doc); + let shown = page_contents(&doc).join("\n"); + assert!(!shown.contains("[U+") && !shown.contains(ESCAPED_NOTE), "{shown}"); + assert_eq!(page_text(&doc, &pages(&doc)[0]), [format!("Коментар: {PANGRAM}"), "Page 2".to_string()]); + // A soft hyphen, which Inter lacks, is drawn as the hyphen Helvetica draws for it. + let doc = reopen(&from_text("t", "при\u{ad}мер", LETTER, 11.0).unwrap()); + assert_eq!(page_text(&doc, &pages(&doc)[0]), ["при-мер"]); + // Western European text is still set in Helvetica, exactly as before. + let doc = from_text("t", "Café 5€ ™", LETTER, 11.0).unwrap(); + let fonts: Vec<_> = doc.scan_objects().filter_map(|(_, o)| o.as_dict().and_then(|d| d.name(b"BaseFont").map(<[u8]>::to_vec))).collect(); + assert_eq!(fonts, [b"Helvetica".to_vec()]); +} + fn image_of(doc: &Document, page: usize) -> Dict { let p = &pages(doc)[page]; let res = doc.resolve(p.get(b"Resources").unwrap()).as_dict().cloned().unwrap(); diff --git a/crates/create/src/text_font.rs b/crates/create/src/text_font.rs new file mode 100644 index 0000000000..555ed3be73 --- /dev/null +++ b/crates/create/src/text_font.rs @@ -0,0 +1,108 @@ +//! The font a text document is set in: standard Helvetica, or, for text Helvetica can't draw, the +//! bundled Inter face (`pdfcraft_fonts::document_latin_fonts`), which every build has and which +//! carries the Latin, Greek and Cyrillic letters WinAnsiEncoding lacks. Text Helvetica draws is +//! set in Helvetica exactly as before. + +use pdfcraft_cos::{Dict, Document, ObjRef, Object}; +use pdfcraft_fonts::pdf::Metrics; +use pdfcraft_fonts::{helvetica_width, literal, win_ansi}; + +use crate::CreateError; + +/// The bundled face, Regular. +fn face() -> Option<&'static pdfcraft_fonts::CraftFont> { + pdfcraft_fonts::document_latin_fonts(false).into_iter().next() +} + +/// Line breaks, tabs and form feeds lay the text out; they are not drawn. +fn layout(c: char) -> bool { + matches!(c, '\n' | '\r' | '\t' | '\u{c}') +} + +/// The character the bundled face draws for `c`: a soft hyphen as a hyphen, as Helvetica draws it +/// (Inter has no glyph for it). +pub(crate) fn drawn_as(c: char) -> char { + if c == '\u{ad}' { '-' } else { c } +} + +/// The line (1-based, lines split at `\n`) and character of the first one the bundled face can't +/// draw, or `None` when it draws all of `text`. +pub(crate) fn first_missing(text: &str) -> Option<(usize, char)> { + let face = face(); + let draws = |s: &str| !s.chars().any(char::is_control) && face.is_some_and(|f| pdfcraft_fonts::embedding::covers(f.bytes, s)); + let mut buf = [0u8; 4]; + text.split('\n').enumerate().find_map(|(i, line)| { + // A whole line at once; the character only for a line the face can't draw. + let drawn: String = line.chars().filter(|c| !layout(*c)).map(drawn_as).collect(); + if draws(&drawn) { + return None; + } + line.chars().find(|&c| !layout(c) && !draws(drawn_as(c).encode_utf8(&mut buf))).map(|c| (i + 1, c)) + }) +} + +/// The refusal for text with `c` on `line`, which neither Helvetica nor the bundled face can draw. +pub(crate) fn undrawable_message(line: usize, c: char) -> String { + format!( + "neither Helvetica nor the bundled Inter face can draw \"{}\" (U+{:04X}) in the text (line {line}); only Latin, Greek and Cyrillic letters can be written there for now", + c.escape_debug(), + u32::from(c) + ) +} + +/// The font a text document is set in. +pub(crate) struct TextFont { + font: ObjRef, + /// The bundled face's metrics; `None` for Helvetica. + inter: Option, +} + +impl TextFont { + /// Standard Helvetica in WinAnsiEncoding, added to `doc`. + pub(crate) fn helvetica(doc: &mut Document) -> Self { + let mut font = Dict::new(); + font.set(b"Type".to_vec(), Object::name("Font")); + font.set(b"Subtype".to_vec(), Object::name("Type1")); + font.set(b"BaseFont".to_vec(), Object::name("Helvetica")); + font.set(b"Encoding".to_vec(), Object::name("WinAnsiEncoding")); + Self { font: doc.add(font), inter: None } + } + + /// The bundled face, embedded in `doc` once for every page. + pub(crate) fn inter(doc: &mut Document) -> Result { + let face = face().ok_or_else(|| CreateError::Invalid("this build has no bundled Latin, Greek and Cyrillic face".into()))?; + let mut fonts = Dict::new(); + let name = pdfcraft_fonts::embedding::embed(doc, &mut fonts, face.bytes, face.family).map_err(CreateError::Invalid)?; + let font = fonts.reference(name.as_bytes()).ok_or_else(|| CreateError::Invalid("the bundled face could not be added".into()))?; + let dict = doc.dict(&Object::Ref(font)).ok_or_else(|| CreateError::Invalid("the bundled face could not be read".into()))?; + Ok(Self { font, inter: Some(Metrics::from_dict(doc, &dict)) }) + } + + /// The font object every page names. + pub(crate) fn font(&self) -> ObjRef { + self.font + } + + /// The advance of `s` at `size`; characters the face lacks count as nothing ([`Self::show`] + /// refuses them). + pub(crate) fn width(&self, s: &str, size: f64) -> f64 { + match &self.inter { + None => helvetica_width(s, size), + Some(m) => m.encode(s).map_or(0.0, |codes| m.codes(&codes).iter().map(|(code, _)| m.width(*code)).sum::() * size), + } + } + + /// The string operand that shows `line`. Helvetica writes what WinAnsiEncoding can't hold as + /// `?`, so its callers check first; the bundled face refuses a character it lacks. + pub(crate) fn show(&self, line: &str) -> Result, CreateError> { + let Some(m) = &self.inter else { return Ok(literal(&win_ansi(line))) }; + let codes = m.encode(line).ok_or_else(|| CreateError::Invalid(format!("the bundled Inter face can't draw \"{}\"", line.escape_debug())))?; + let mut out = Vec::with_capacity(codes.len().saturating_mul(2).saturating_add(2)); + out.push(b'<'); + for b in codes { + out.extend_from_slice(format!("{b:02X}").as_bytes()); + } + out.push(b'>'); + Ok(out) + } +} diff --git a/crates/engine/src/session/files.rs b/crates/engine/src/session/files.rs index b5db2312f8..40b4a4e050 100644 --- a/crates/engine/src/session/files.rs +++ b/crates/engine/src/session/files.rs @@ -25,15 +25,17 @@ impl Session { self.write_new(&pdfcraft_create::from_images_with_resolution(images, resolution)?) } - /// A new document from plain text (US Letter, 11 pt Helvetica). Text Helvetica can't draw is + /// A new document from plain text (US Letter, 11 pt Helvetica, or the bundled Inter face for + /// text with Latin, Greek or Cyrillic letters Helvetica can't draw). Text neither can draw is /// refused, naming the line and the character (never set as `?`). pub fn create_from_text(&self, title: &str, text: &str) -> Result>, EditError> { self.write_new(&pdfcraft_create::from_text(title, text, pdfcraft_create::LETTER, 11.0)?) } /// [`Self::create_from_text`] for a report PdfCraft writes from documents (comment summaries, - /// compare reports), which can quote any language: characters Helvetica can't draw are written - /// as `[U+XXXX]`, and the report's first line says so, rather than the report being refused. + /// compare reports), which can quote any language: when the bundled Inter face can't draw it + /// all, characters Helvetica can't draw are written as `[U+XXXX]`, and the report's first line + /// says so, rather than the report being refused. pub(crate) fn create_report(&self, title: &str, text: &str) -> Result>, EditError> { let (doc, _escaped) = pdfcraft_create::from_text_escaped(title, text, pdfcraft_create::LETTER, 11.0)?; self.write_new(&doc) diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index f41d033abd..28976be9fa 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -1420,6 +1420,35 @@ fn utf16_and_code_page_text_files_convert_to_their_text() { assert_eq!(page_texts(&s, id), ["Crème brûlée"]); } +/// Create ▸ PDF from a text file in Cyrillic was refused, and a comment summary quoting Cyrillic +/// wrote each letter as `[U+XXXX]`. Both are set in the bundled Inter face now and read back as +/// typed. +#[test] +fn cyrillic_text_files_and_comment_summaries_read_back_as_typed() { + let mut s = Session::new(); + let text = "Договор № 12\nЖълтата дюля беше щастлива, че пухът, който цъфна, замръзна като гьон."; + let (_, pdf) = s.convert_to_pdf("бележки.txt", &Arc::new(text.as_bytes().to_vec())).unwrap(); + let id = s.open_new("бележки.pdf", pdf).unwrap(); + assert_eq!(page_texts(&s, id), [text]); + // A sticky note in Russian, summarized. + let note = "Проверьте сумму в разделе 3"; + let hex: String = note.encode_utf16().map(|u| format!("{u:04X}")).collect(); + 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 200] /Annots [4 0 R] >> endobj +4 0 obj << /Type /Annot /Subtype /Text /Rect [20 140 40 160] /T (Reviewer) /Contents >> endobj +trailer << /Root 1 0 R >> +%%EOF" + ); + let id = s.open("note.pdf", None, Arc::new(pdf.into_bytes()), None).expect("opens"); + let summary = s.summarize_comments(id, SummarySort::Page).unwrap(); + let id = s.open_new("summary.pdf", summary).unwrap(); + let shown = page_texts(&s, id).join("\n"); + assert!(shown.contains(note) && !shown.contains("[U+"), "{shown}"); +} + #[test] fn a_file_split_around_another_combines_in_order_with_one_bookmark() { let mut s = Session::new(); diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index d7a6a406eb..a6caffa329 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -7059,8 +7059,8 @@ milestone = "M10" status = "shipped" commands = ["create.file"] tools = ["doc_create"] -evidence = ["crates/create/src/tests.rs::text_is_wrapped_and_paginated", "crates/automation/tests/it/automation.rs::creating_and_reducing_through_tools"] -notes = "Helvetica 11 pt on US Letter; form feeds start pages" +evidence = ["crates/create/src/tests.rs::text_is_wrapped_and_paginated", "crates/automation/tests/it/automation.rs::creating_and_reducing_through_tools", "crates/create/src/tests.rs::text_with_cyrillic_is_set_in_the_bundled_face", "crates/engine/src/tests.rs::cyrillic_text_files_and_comment_summaries_read_back_as_typed"] +notes = "Helvetica 11 pt on US Letter; text with letters Helvetica can't draw (Cyrillic, Greek, Central European) is set whole in the bundled Inter face, embedded once; text Inter can't draw either (CJK, Arabic, Hebrew, Thai) is refused, naming the line and character; form feeds start pages" [[feature]] id = "create.from-markdown" From 476effc1c8deccb4d0c8f250bb7b42969fdb52b9 Mon Sep 17 00:00:00 2001 From: oracola91 <118899664+oracola91@users.noreply.github.com> Date: Mon, 9 Nov 2026 12:31:47 +0200 Subject: [PATCH 46/64] Text from a justified line keeps one space between words (#5496) --- crates/edit/src/tests.rs | 26 +++++++++++++++++++++++++- crates/edit/src/text/interp.rs | 23 ++++++++++++++++++++--- crates/edit/src/text/lines.rs | 9 ++++++++- 3 files changed, 53 insertions(+), 5 deletions(-) diff --git a/crates/edit/src/tests.rs b/crates/edit/src/tests.rs index eb80c02c39..ec235c9d7e 100644 --- a/crates/edit/src/tests.rs +++ b/crates/edit/src/tests.rs @@ -1903,6 +1903,30 @@ fn tracked_text_preserves_larger_word_gaps() { } } +/// LibreOffice stretches a justified line on both sides of each space glyph, so a gap that reads +/// as a space comes right before the space glyph it widens: one space, not two. A gap after the +/// space glyph, a space glyph with no gap before it, and two space glyphs after a gap read as +/// before. +#[test] +fn a_gap_before_a_space_glyph_reads_as_one_space() { + for show in ["[(Word) -250 ( next) -250 ( last)] TJ", "[(Word ) -250 (next ) -250 (last)] TJ", "[(Word) -250 (next) 0 ( last)] TJ"] { + let doc = text_page(&format!("BT /F1 10 Tf 72 700 Td {show} ET")); + assert_eq!(text::text_lines(&doc, 0).unwrap()[0].text, "Word next last", "{show}"); + } + // Only the first space glyph after the gap is the gap's: two real spaces stay two. + let doc = text_page("BT /F1 10 Tf 72 700 Td [(Word) -250 ( next)] TJ ET"); + assert_eq!(text::text_lines(&doc, 0).unwrap()[0].text, "Word next"); + // The same line split over operators: the gap ends one run, its space glyph starts the next. + let doc = text_page("BT /F1 10 Tf 72 700 Td [(Word) -250] TJ [( next) -250] TJ ( last) Tj ET"); + assert_eq!(text::text_lines(&doc, 0).unwrap()[0].text, "Word next last"); + // Space glyphs on both sides of a break between runs, with no gap, are two spaces. + let doc = text_page("BT /F1 10 Tf 72 700 Td (Word ) Tj ( next) Tj ET"); + assert_eq!(text::text_lines(&doc, 0).unwrap()[0].text, "Word next"); + // Down a vertical column the same. + let doc = vertical_page("BT /V1 20 Tf 500 720 Td [<0041> 300 <00200042> 300 <00200043>] TJ ET"); + assert_eq!(text::text_lines(&doc, 0).unwrap()[0].text, "A B C"); +} + #[test] fn tracked_text_positions_do_not_count_character_spacing_twice() { let expected = "TRACKED SAMPLE TEXT"; @@ -9742,7 +9766,7 @@ fn vertical_page(content: &str) -> Document { ]); let unicode = doc.add(Object::Stream(Stream::flate( Dict::new(), - b"1 begincodespacerange <0000> endcodespacerange 6 beginbfchar <0041> <0041> <0042> <0042> <0043> <0043> <0044> <0044> <0045> <0045> <0046> <0046> endbfchar", + b"1 begincodespacerange <0000> endcodespacerange 7 beginbfchar <0020> <0020> <0041> <0041> <0042> <0042> <0043> <0043> <0044> <0044> <0045> <0045> <0046> <0046> endbfchar", ))); font.set(b"ToUnicode".to_vec(), Object::Ref(unicode)); font.set( diff --git a/crates/edit/src/text/interp.rs b/crates/edit/src/text/interp.rs index 019edf982a..bc0bce0c7b 100644 --- a/crates/edit/src/text/interp.rs +++ b/crates/edit/src/text/interp.rs @@ -493,6 +493,9 @@ pub(super) struct Carry { pub(super) layers: Vec>>, /// Bytes of glyph text the rest of the page may still produce (see [`MAX_PAGE_TEXT`]). pub(super) text_left: usize, + /// Whether the last run ended in a space read from a gap, with the next operator a `Tj` or + /// `TJ` that may start with the space glyph that gap widened. + pub(super) gap_space: bool, } /// The most glyph text one page's extraction produces, in bytes. A ToUnicode destination is @@ -523,7 +526,7 @@ impl Carry { dash: (Vec::new(), 0.0), line_cap: 0, }; - Carry { ts, stack: Vec::new(), marked: Vec::new(), layers: Vec::new(), text_left: MAX_PAGE_TEXT } + Carry { ts, stack: Vec::new(), marked: Vec::new(), layers: Vec::new(), text_left: MAX_PAGE_TEXT, gap_space: false } } } @@ -661,6 +664,9 @@ fn show_text(op: &Op, i: usize, state: &mut TextRunState<'_>) -> Result<(), Edit // the next glyph continues the last word. let mut words: Vec<(f64, f64)> = Vec::new(); let mut in_word = false; + // Whether the text ends in a space read from a gap rather than from a glyph. The run just + // before may have ended in one: a justified line can be split over `TJ` and `Tj`. + let mut gap_space = std::mem::take(&mut carry.gap_space); let mut glyph_count = 0_usize; for p in &pieces { match p { @@ -669,13 +675,17 @@ fn show_text(op: &Op, i: usize, state: &mut TextRunState<'_>) -> Result<(), Edit glyph_count = glyph_count.saturating_add(1); let shown = m.text_of(code); let blank = m.is_space(code, len) || shown.as_deref().is_some_and(|t| !t.is_empty() && t.chars().all(char::is_whitespace)); + // A space glyph right after a gap that read as a space is that space: + // LibreOffice stretches a justified line on both sides of each space glyph. + let after_gap = std::mem::take(&mut gap_space); match shown { - Some(t) => { + Some(shown) => { + let t: &str = if after_gap { shown.strip_prefix(' ').unwrap_or(&shown) } else { &shown }; carry.text_left = carry .text_left .checked_sub(t.len()) .ok_or_else(|| EditError::Invalid("the page's text is too large to read".into()))?; - text.push_str(&t); + text.push_str(t); } None => decodable = false, } @@ -716,6 +726,7 @@ fn show_text(op: &Op, i: usize, state: &mut TextRunState<'_>) -> Result<(), Edit let dy = -n / 1000.0 * ts.size; if -dy > ts.size * 0.2 && !text.ends_with(' ') { text.push(' '); + gap_space = true; } if dy.abs() > ts.size.abs() * 0.2 { in_word = false; @@ -727,6 +738,7 @@ fn show_text(op: &Op, i: usize, state: &mut TextRunState<'_>) -> Result<(), Edit // A large gap inside TJ reads as a space. if dx - tracking > (ts.size * ts.scale).abs() * 0.2 && !text.ends_with(' ') { text.push(' '); + gap_space = true; } // Tracking and kerning keep a word together; a larger jump // ends it (whatever the sign of the size or `Tz`). @@ -821,6 +833,7 @@ fn show_text(op: &Op, i: usize, state: &mut TextRunState<'_>) -> Result<(), Edit soft_gap: state.soft_gap, edited: marked.edited.last().copied().flatten(), }); + carry.gap_space = gap_space; Ok(()) } @@ -854,6 +867,10 @@ pub(super) fn interpret( // as 1, the single space a rejoin always used before #2529. let mut soft_gap = 1usize; for (i, op) in ops.iter().enumerate() { + // A gap's space carries only into a `Tj` or `TJ` right after it. + if !matches!(op.op.as_slice(), b"Tj" | b"TJ") { + carry.gap_space = false; + } match op.op.as_slice() { // No depth cap: every `EMC` pops, so a capped push would pair it with the enclosing // sequence (#3573). Both stacks stay bounded by the number of operators. diff --git a/crates/edit/src/text/lines.rs b/crates/edit/src/text/lines.rs index 54c249dbf1..8d0ad80cba 100644 --- a/crates/edit/src/text/lines.rs +++ b/crates/edit/src/text/lines.rs @@ -705,7 +705,14 @@ pub(super) fn reading_lines(doc: &Document, page: usize) -> Result<(Vec Date: Mon, 9 Nov 2026 04:32:11 -0600 Subject: [PATCH 47/64] Show form tab-order numbers (#5509) --- ATTRIBUTION.toml | 1 - crates/ui-egui/src/canvas.rs | 3 + crates/ui-egui/src/canvas/page_input.rs | 3 +- crates/ui-egui/src/panels/tools.rs | 4 + crates/ui-egui/src/tools/forms_ui.rs | 93 +++++++++++++++--- crates/ui-egui/src/tools/prepare.rs | 93 +++++++++++++++++- crates/ui-egui/tests/it/control.rs | 16 +++ crates/ui-egui/tests/it/forms.rs | 125 ++++++++++++++++++++++++ crates/ui-egui/tests/it/prepare.rs | 104 ++++++++++++++++++++ parity/acrobat-features.toml | 6 +- 10 files changed, 426 insertions(+), 22 deletions(-) diff --git a/ATTRIBUTION.toml b/ATTRIBUTION.toml index 67b588f25e..be935422ce 100644 --- a/ATTRIBUTION.toml +++ b/ATTRIBUTION.toml @@ -3244,4 +3244,3 @@ licence_file = "assets/icons/LICENSE-lucide.txt" kind = "icon" usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs" sha256 = "864ab2f19254e6ac7240bc6a98323197332e8f714d4d3f94831f41309e5ef2f7" - diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 8cfd56bc05..5af43213de 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -1321,6 +1321,7 @@ struct PageEnv<'a> { precise_scroll: Vec2, prefs: &'a comments::CommentPrefs, preparing: bool, + prepare_tab_order: Option<&'a [(usize, usize)]>, rects: &'a Vec, redact_mark: ([f64; 3], f64), scale: f32, @@ -3210,6 +3211,7 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) { scroll = refine_goto_point(scroll, view, &ScrollGeometry { rects: &rects, y_shift, content_w, content_h, avail, middle_gesture, hand }); let selection_wheel = view.selection_drag && navigation_enabled && unobstructed && !single_paging && ui.rect_contains_pointer(avail); + let prepare_tab_order = (preparing && view.prepare.show_tab_numbers).then(|| crate::forms_ui::tab_order(&form, info)); let env = PageEnv { added: &added, allowed, @@ -3251,6 +3253,7 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) { precise_scroll, prefs, preparing, + prepare_tab_order: prepare_tab_order.as_deref(), rects: &rects, redact_mark, scale, diff --git a/crates/ui-egui/src/canvas/page_input.rs b/crates/ui-egui/src/canvas/page_input.rs index 793211fec7..f96ff4e91e 100644 --- a/crates/ui-egui/src/canvas/page_input.rs +++ b/crates/ui-egui/src/canvas/page_input.rs @@ -840,6 +840,7 @@ pub(super) fn page_overlays(ui: &mut egui::Ui, view: &mut DocView, frame: &mut P form, generation, info, + prepare_tab_order, prefs, preparing, redact_mark, @@ -892,7 +893,7 @@ pub(super) fn page_overlays(ui: &mut egui::Ui, view: &mut DocView, frame: &mut P crate::link_ui::paint(painter, &xf, i, info, doc_links, view, pointer); } if preparing { - crate::prepare::paint_page(tool_cx, form, view); + crate::prepare::paint_page(tool_cx, form, prepare_tab_order.unwrap_or_default(), view); if let Some(preview) = &table_preview { crate::field_table_ui::paint_preview(painter, &xf, i, preview); } diff --git a/crates/ui-egui/src/panels/tools.rs b/crates/ui-egui/src/panels/tools.rs index fce62fb2ab..98063e6ef4 100644 --- a/crates/ui-egui/src/panels/tools.rs +++ b/crates/ui-egui/src/panels/tools.rs @@ -229,6 +229,10 @@ fn tool_detail(app: &mut PdfCraftApp, ui: &mut egui::Ui, t: &Tokens, g: &'static } // Prepare a form: Preview fills the form as a reader would; Edit goes back. if g.id == "form" { + if let Some(view) = app.active.and_then(|i| app.views.get_mut(i)) { + ui.add(widgets::checkbox(&mut view.prepare.show_tab_numbers, tl!("Show tab numbers"))); + ui.add_space(4.0); + } ui.horizontal(|ui| { let label = if app.form_preview { "Edit fields" } else { "Preview" }; if widgets::pill_button(ui, tl!(label), app.form_preview).on_hover_text(tl!("Try the form as people filling it in will see it")).clicked() diff --git a/crates/ui-egui/src/tools/forms_ui.rs b/crates/ui-egui/src/tools/forms_ui.rs index cf774bd048..e365f3d191 100644 --- a/crates/ui-egui/src/tools/forms_ui.rs +++ b/crates/ui-egui/src/tools/forms_ui.rs @@ -95,6 +95,38 @@ fn fillable(f: &FormField) -> bool { !f.read_only() && !matches!(f.kind, FormFieldKind::PushButton | FormFieldKind::Signature) } +/// The fillable widgets in the order Tab and Shift+Tab visit them. +pub fn tab_order(form: &[FormField], info: &DocInfo) -> Vec<(usize, usize)> { + let mut order: Vec<(usize, usize, usize)> = form + .iter() + .enumerate() + .filter(|(_, field)| fillable(field)) + .flat_map(|(fi, field)| { + field + .widgets + .iter() + .enumerate() + .filter(|(_, widget)| { + let Some(page) = widget.page.and_then(|page| info.pages.get(page)) else { return false }; + let [x0, y0, x1, y1] = widget.rect; + let [cx0, cy0, cx1, cy1] = page.crop.map(f64::from); + !widget.hidden + && !widget.read_only + && [x0, y0, x1, y1].into_iter().all(f64::is_finite) + && x0 < x1 + && y0 < y1 + && x1 > cx0 + && x0 < cx1 + && y1 > cy0 + && y0 < cy1 + }) + .map(move |(wi, widget)| (widget.tab, fi, wi)) + }) + .collect(); + order.sort_by_key(|(tab, _, _)| *tab); + order.into_iter().map(|(_, fi, wi)| (fi, wi)).collect() +} + /// Whether a combo box takes typed text as well as a choice from its list: the Edit flag /// (ISO 32000-2 Table 233), which only means something with Combo set. fn editable_combo(f: &FormField) -> bool { @@ -724,27 +756,56 @@ fn settle_text_editor( /// Tab / Shift+Tab: commits the field and moves to the next fillable widget in the document's tab /// order, returning the edit to apply (the commit's, or `edit`). -fn tab_to_next_field(view: &mut DocView, form: &[FormField], f: &FormField, widget: usize, forward: bool, edit: Option) -> Option { +fn tab_to_next_field( + view: &mut DocView, + info: &DocInfo, + form: &[FormField], + f: &FormField, + widget: usize, + forward: bool, + edit: Option, +) -> Option { commit(view, form); let pending = view.pending_edit.take(); // The next field that can be filled in, check boxes and radio buttons included (#2223), in // the document's tab order (each page's /Tabs: rows, columns or structure), wrapping. - let mut order: Vec<(usize, &FormField, usize)> = form - .iter() - .filter(|x| fillable(x)) - .flat_map(|x| x.widgets.iter().enumerate().filter(|(_, w)| w.page.is_some() && !w.hidden).map(move |(wi, w)| (w.tab, x, wi))) - .collect(); - order.sort_by_key(|o| o.0); - if let Some(pos) = - order.iter().position(|(_, x, wi)| x.name == f.name && *wi == widget).or_else(|| order.iter().position(|(_, x, _)| x.name == f.name)) - { - let k = if forward { (pos + 1) % order.len() } else { (pos + order.len() - 1) % order.len() }; - let (_, target, wi) = order[k]; + let order = tab_order(form, info); + let exact = order.iter().position(|(fi, wi)| form.get(*fi).is_some_and(|x| x.name == f.name && *wi == widget)); + let same_field = order.iter().position(|(fi, _)| form.get(*fi).is_some_and(|x| x.name == f.name)); + let next_after_removed = if exact.is_none() && same_field.is_none() { + form.iter().position(|field| field.name == f.name).and_then(|fi| { + let tab = form.get(fi)?.widgets.get(widget)?.tab; + let key = (tab, fi, widget); + let tab_key = |(fi, wi): &(usize, usize)| form.get(*fi).and_then(|field| field.widgets.get(*wi)).map(|other| (other.tab, *fi, *wi)); + if forward { + order.iter().position(|entry| tab_key(entry).is_some_and(|other| other > key)).or_else(|| (!order.is_empty()).then_some(0)) + } else { + order.iter().rposition(|entry| tab_key(entry).is_some_and(|other| other < key)).or_else(|| order.len().checked_sub(1)) + } + }) + } else { + None + }; + if let Some(pos) = exact.or(same_field).or(next_after_removed) { + // A focused widget that left the order is replaced by the field's first remaining stop. + let k = if next_after_removed.is_some() || (forward && exact.is_none()) { + pos + } else if forward { + (pos + 1) % order.len() + } else { + (pos + order.len() - 1) % order.len() + }; + let Some((fi, wi)) = order.get(k).copied() else { return edit.or(pending) }; + let Some(target) = form.get(fi) else { return edit.or(pending) }; view.forms.focus = Some(Focus { select_all: true, ..open_focus(target, wi) }); - if let Some(p) = target.widgets[wi].page - && view.page_screen_rect(p).is_none() + if let Some(widget) = target.widgets.get(wi) + && let Some(page) = widget.page + && !field_screen_rect(view, info, target, wi).is_some_and(|rect| { + let viewport = view.viewport_rect(); + viewport.contains(rect.min) && viewport.contains(rect.max) + }) { - view.go_to_page(p); + view.reveal_rect(page, widget.rect.map(|coord| coord as f32), info); } } edit.or(pending) @@ -1011,7 +1072,7 @@ pub(crate) fn overlay( view.forms.focus = None; } if let Some(forward) = next { - edit = tab_to_next_field(view, form, f, focus.widget, forward, edit); + edit = tab_to_next_field(view, info, form, f, focus.widget, forward, edit); } edit.or_else(|| view.pending_edit.take()) } diff --git a/crates/ui-egui/src/tools/prepare.rs b/crates/ui-egui/src/tools/prepare.rs index 128e033dc2..1ab23a7fd6 100644 --- a/crates/ui-egui/src/tools/prepare.rs +++ b/crates/ui-egui/src/tools/prepare.rs @@ -148,6 +148,8 @@ enum Grab { #[derive(Clone, Debug, Default, PartialEq)] pub struct PrepareView { + /// Show each fillable widget's position in the same order used by Tab and Shift+Tab. + pub show_tab_numbers: bool, /// The selected field and which of its widgets (the anchor of Align and Match Size). pub selected: Option<(String, usize)>, /// More fields selected with Shift- or ⌘-click. @@ -279,6 +281,39 @@ fn dragged(r: Rect, grab: Grab, delta: egui::Vec2, shift: bool) -> Rect { } } +fn tab_badge_rect(widget: Rect, page: Rect, size: egui::Vec2) -> Option { + if !page.is_positive() || !size.is_finite() || size.x < 0.0 || size.y < 0.0 || size.x > page.width() || size.y > page.height() { + return None; + } + let max_x = page.right() - size.x; + let max_y = page.bottom() - size.y; + let min = egui::pos2((widget.left() + 3.0).clamp(page.left(), max_x), (widget.top() + 3.0).clamp(page.top(), max_y)); + Some(Rect::from_min_size(min, size)) +} + +fn place_tab_badge(widget: Rect, page: Rect, size: egui::Vec2, placed: &[Rect]) -> Option { + let preferred = tab_badge_rect(widget, page, size)?; + if placed.iter().all(|badge| !badge.intersects(preferred)) { + return Some(preferred); + } + + let gap = 2.0; + let step = size + vec2(gap, gap); + let columns = (((page.width() - size.x) / step.x).floor().max(0.0) as usize).saturating_add(1); + let rows = (((page.height() - size.y) / step.y).floor().max(0.0) as usize).saturating_add(1); + let attempts = columns.saturating_mul(rows).min(placed.len().saturating_add(1)); + for slot in 0..attempts { + let col = slot % columns; + let row = slot / columns; + let min = page.min + vec2(col as f32 * step.x, row as f32 * step.y); + let candidate = Rect::from_min_size(min, size); + if placed.iter().all(|badge| !badge.intersects(candidate)) { + return Some(candidate); + } + } + None +} + pub(crate) struct Outcome { /// The click or drag belonged to Prepare a form. pub consumed: bool, @@ -620,7 +655,7 @@ pub fn arrange(form: &[FormField], anchor: &(String, usize), others: &[(String, /// Fields with their light-blue fill and name tags, the selection with its handles, and the /// rectangle being drawn or dragged to the pages' `pointer`. -pub(crate) fn paint_page(cx: PageToolContext<'_>, form: &[FormField], view: &DocView) { +pub(crate) fn paint_page(cx: PageToolContext<'_>, form: &[FormField], tab_order: &[(usize, usize)], view: &DocView) { let PageToolContext { ui, xf, page, info, ptr, .. } = cx; let painter = ui.painter(); let pointer = ptr.pos; @@ -664,6 +699,29 @@ pub(crate) fn paint_page(cx: PageToolContext<'_>, form: &[FormField], view: &Doc } } } + if prep.show_tab_numbers { + let mut placed = Vec::new(); + for (number, (fi, wi)) in tab_order.iter().copied().enumerate() { + let Some(field) = form.get(fi) else { continue }; + let Some(widget) = field.widgets.get(wi) else { continue }; + if widget.page != Some(page) { + continue; + } + let mut r = screen_rect(xf, info, page, widget.rect); + let selected = prep.selected.as_ref().is_some_and(|(name, index)| name == &field.name && *index == wi); + if selected && let (Some((_, grab, start)), Some(pointer)) = (grab, pointer) { + r = dragged(r, grab, pointer - start, ui.input(|i| i.modifiers.shift)); + } + let label = number.saturating_add(1).to_string(); + let font = theme::semibold((10.0 * zoom).clamp(8.0, 14.0)); + let galley = painter.layout_no_wrap(label, font, Color32::WHITE); + let Some(badge) = place_tab_badge(r, xf.rect, galley.size() + vec2(8.0, 5.0), &placed) else { continue }; + placed.push(badge); + let clipped = painter.with_clip_rect(painter.clip_rect().intersect(xf.rect)); + clipped.rect_filled(badge, CornerRadius::same(3), SELECT_BLUE); + clipped.galley(badge.min + vec2(4.0, 2.0), galley, Color32::WHITE); + } + } if let (Some((_, Grab::Draw(from) | Grab::Select(from), _)), Some(p)) = (grab, pointer) { let r = Rect::from_two_pos(from, p).intersect(xf.rect); painter.rect_filled(r, CornerRadius::ZERO, FIELD_FILL); @@ -855,4 +913,37 @@ mod tests { assert_eq!(lock_axis(None, vec2(40.0, 42.0), true), Some(Axis::Vertical), "and pressing it again chooses afresh"); assert_eq!(lock_axis(None, vec2(f32::NAN, 0.0), true), None, "a non-finite delta chooses nothing"); } + + #[test] + fn tab_badge_rect_tracks_dragged_rect_and_clamps_to_page_edges() { + let page = Rect::from_min_size(pos2(10.0, 20.0), vec2(100.0, 80.0)); + let widget = Rect::from_min_size(pos2(30.0, 40.0), vec2(20.0, 15.0)); + let size = vec2(24.0, 14.0); + let original = tab_badge_rect(widget, page, size).unwrap(); + let moved = dragged(widget, Grab::Move, vec2(12.0, 8.0), false); + assert_eq!(tab_badge_rect(moved, page, size).unwrap().min, original.min + vec2(12.0, 8.0)); + + let near_edge = Rect::from_min_size(pos2(100.0, 95.0), vec2(8.0, 8.0)); + let badge = tab_badge_rect(near_edge, page, size).unwrap(); + assert!(page.contains_rect(badge)); + } + + #[test] + fn tab_badge_is_omitted_when_it_cannot_fit_on_the_page() { + let page = Rect::from_min_size(pos2(10.0, 20.0), vec2(20.0, 12.0)); + let widget = Rect::from_min_size(pos2(10.0, 20.0), vec2(4.0, 4.0)); + assert_eq!(tab_badge_rect(widget, page, vec2(24.0, 14.0)), None); + } + + #[test] + fn tab_badges_near_the_right_edge_do_not_overlap() { + let page = Rect::from_min_size(pos2(10.0, 20.0), vec2(100.0, 80.0)); + let size = vec2(24.0, 14.0); + let first_widget = Rect::from_min_size(pos2(104.0, 45.0), vec2(8.0, 8.0)); + let second_widget = Rect::from_min_size(pos2(106.0, 45.0), vec2(8.0, 8.0)); + let first = place_tab_badge(first_widget, page, size, &[]).unwrap(); + let second = place_tab_badge(second_widget, page, size, &[first]).unwrap(); + assert!(!first.intersects(second), "{first:?} overlaps {second:?}"); + assert!(page.contains_rect(first) && page.contains_rect(second)); + } } diff --git a/crates/ui-egui/tests/it/control.rs b/crates/ui-egui/tests/it/control.rs index 8e397c803b..dcb795f130 100644 --- a/crates/ui-egui/tests/it/control.rs +++ b/crates/ui-egui/tests/it/control.rs @@ -140,6 +140,22 @@ fn thumbnail_size_is_settable_and_inspectable_through_the_control_channel() { assert!(ok(&mut h, &c, "ui.state", json!({}))["active"]["thumbnail_zoom"].as_f64().unwrap() > 0.75); } +#[test] +fn form_tab_numbers_can_be_shown_and_captured_through_the_control_channel() { + let (mut h, c) = harness(); + h.state_mut().open_bytes("form.pdf", None, include_bytes!("../data/form.pdf").to_vec()).unwrap(); + h.run_steps(3); + ok(&mut h, &c, "ui.command", json!({"id": "form.prepare"})); + let control = ok(&mut h, &c, "ui.inspect", json!({"query": "Show tab numbers"})); + assert!(control["widgets"].as_array().unwrap().iter().any(|widget| widget["label"] == "Show tab numbers")); + ok(&mut h, &c, "ui.click", json!({"label": "Show tab numbers"})); + h.run_steps(2); + assert!(h.state().views[h.state().active.unwrap()].prepare.show_tab_numbers); + assert_eq!(ok(&mut h, &c, "ui.state", json!({}))["documents"][1]["dirty"], false); + let shot = ok(&mut h, &c, "ui.screenshot", json!({})); + assert!(shot["png_base64"].as_str().is_some_and(|png| !png.is_empty()), "the control channel returns a nonempty screenshot"); +} + #[test] fn page_shadows_are_an_observable_app_wide_preference() { let (mut h, c) = harness(); diff --git a/crates/ui-egui/tests/it/forms.rs b/crates/ui-egui/tests/it/forms.rs index 3baa14bc53..0c64b3001e 100644 --- a/crates/ui-egui/tests/it/forms.rs +++ b/crates/ui-egui/tests/it/forms.rs @@ -18,6 +18,18 @@ fn harness() -> Harness<'static, PdfCraftApp> { h } +fn harness_with_bytes(name: &'static str, bytes: Vec) -> Harness<'static, PdfCraftApp> { + let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).build_eframe(move |_cc| { + let mut app = PdfCraftApp::new(); + app.set_option("language", "en").unwrap(); + app.open_bytes(name, None, bytes).unwrap(); + app.set_option("zoom", "150").unwrap(); + app + }); + crate::support::settle(&mut h); + h +} + fn value(h: &Harness<'static, PdfCraftApp>, name: &str) -> Vec { let s = h.state(); s.session.get(s.views[0].id).unwrap().form.iter().find(|f| f.name == name).unwrap().value.clone() @@ -87,6 +99,41 @@ fn a_read_only_fields_tooltip_shows_on_hover() { assert!(h.query_all_by_label("Where you live").next().is_some()); } +#[test] +fn read_only_widgets_are_skipped_by_tab_navigation() { + let bytes = pdf_from_objects(&[ + "<< /Type /Catalog /Pages 2 0 R /AcroForm << /Fields [4 0 R 5 0 R 7 0 R 8 0 R 9 0 R] >> >>", + "<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 200 120] >>", + "<< /Type /Page /Parent 2 0 R /Annots [4 0 R 6 0 R 10 0 R 7 0 R 8 0 R 9 0 R] >>", + "<< /Type /Annot /Subtype /Widget /FT /Tx /T (first) /Rect [20 80 150 100] /P 3 0 R /F 4 >>", + "<< /FT /Tx /T (locked) /Kids [6 0 R 10 0 R] >>", + "<< /Type /Annot /Subtype /Widget /Parent 5 0 R /Ff 1 /Rect [20 50 150 70] /P 3 0 R /F 4 >>", + "<< /Type /Annot /Subtype /Widget /FT /Tx /T (empty) /Rect [20 50 20 50] /P 3 0 R /F 4 >>", + "<< /Type /Annot /Subtype /Widget /FT /Tx /T (off-page) /Rect [220 50 240 70] /P 3 0 R /F 4 >>", + "<< /Type /Annot /Subtype /Widget /FT /Tx /T (last) /Rect [20 20 150 40] /P 3 0 R /F 4 >>", + "<< /Type /Annot /Subtype /Widget /Parent 5 0 R /Rect [20 35 150 45] /P 3 0 R /F 4 >>", + ]); + let mut h = harness_with_bytes("readonly-widget.pdf", bytes); + { + let doc = h.state().session.get(h.state().views[0].id).unwrap(); + let locked = doc.form.iter().find(|field| field.name == "locked").unwrap(); + assert!(!locked.read_only(), "the field itself is editable"); + assert!(locked.widgets[0].read_only, "only its widget is read-only"); + let order = pdfcraft_ui_egui::forms_ui::tab_order(&doc.form, &doc.info) + .into_iter() + .filter_map(|(fi, wi)| doc.form.get(fi).map(|field| (field.name.as_str(), wi))) + .collect::>(); + assert_eq!(order, vec![("first", 0), ("locked", 1), ("last", 0)], "read-only, empty and off-page widgets are excluded"); + } + click_field(&mut h, "first", 0); + h.key_press(egui::Key::Tab); + h.run_steps(3); + assert_eq!(focused(&h), Some(("locked".into(), 1)), "the writable widget in the same field remains in tab order"); + h.key_press(egui::Key::Tab); + h.run_steps(3); + assert_eq!(focused(&h), Some(("last".into(), 0))); +} + /// A `/TU` of only spaces shows no tooltip (no empty box under the pointer). #[test] fn a_blank_tooltip_shows_nothing() { @@ -277,6 +324,84 @@ fn tab_and_space_fill_in_a_check_box_between_text_fields() { assert_eq!(focused(&h), Some(("agree".into(), 0)), "Shift+Tab comes back to it"); } +#[test] +fn tab_uses_the_first_remaining_widget_when_focus_leaves_the_order() { + let mut h = check_box_harness(); + click_field(&mut h, "pick", 0); + h.state_mut().apply_edit(pdfcraft_engine::Edit::SetFieldProps { + name: "pick".into(), + props: Box::new(pdfcraft_engine::FieldProps { rect: Some((0, [320.0, 230.0, 336.0, 246.0])), ..Default::default() }), + }); + h.run_steps(3); + + h.key_press(egui::Key::Tab); + h.run_steps(3); + assert_eq!(focused(&h), Some(("pick".into(), 1)), "Tab replaces the off-page focus with the group's remaining widget"); +} + +#[test] +fn shift_tab_uses_the_previous_remaining_widget_when_focus_leaves_the_order() { + let mut h = check_box_harness(); + click_field(&mut h, "last", 0); + h.state_mut().apply_edit(pdfcraft_engine::Edit::SetFieldProps { + name: "last".into(), + props: Box::new(pdfcraft_engine::FieldProps { rect: Some((0, [320.0, 270.0, 340.0, 292.0])), ..Default::default() }), + }); + h.run_steps(3); + + h.key_press_modifiers(egui::Modifiers::SHIFT, egui::Key::Tab); + h.run_steps(3); + assert_eq!(focused(&h), Some(("agree".into(), 0)), "Shift+Tab restores focus to the last stop before the removed widget"); +} + +#[test] +fn shift_tab_wraps_to_the_last_widget_when_focus_leaves_the_order() { + let mut h = check_box_harness(); + click_field(&mut h, "first", 0); + h.state_mut().apply_edit(pdfcraft_engine::Edit::SetFieldProps { + name: "first".into(), + props: Box::new(pdfcraft_engine::FieldProps { rect: Some((0, [320.0, 350.0, 340.0, 372.0])), ..Default::default() }), + }); + h.run_steps(3); + + h.key_press_modifiers(egui::Modifiers::SHIFT, egui::Key::Tab); + h.run_steps(3); + assert_eq!(focused(&h), Some(("pick".into(), 1)), "Shift+Tab wraps to the last remaining stop"); +} + +#[test] +fn tab_and_shift_tab_follow_widget_order_across_pages() { + let mut h = check_box_harness(); + h.state_mut().apply_edit(pdfcraft_engine::Edit::AddField { + page: 1, + rect: [20.0, 350.0, 180.0, 372.0], + kind: pdfcraft_engine::NewField::Text { multiline: false }, + name: Some("second_page".into()), + }); + h.run_steps(4); + let order = { + let s = h.state(); + let doc = s.session.get(s.views[0].id).unwrap(); + pdfcraft_ui_egui::forms_ui::tab_order(&doc.form, &doc.info) + .into_iter() + .filter_map(|(fi, wi)| doc.form.get(fi).map(|field| (field.name.clone(), wi))) + .collect::>() + }; + assert_eq!(order.first().map(|entry| entry.0.as_str()), Some("first")); + assert_eq!(order.last().map(|entry| entry.0.as_str()), Some("second_page")); + click_field(&mut h, "first", 0); + for expected in order.iter().skip(1) { + h.key_press(egui::Key::Tab); + h.run_steps(4); + assert_eq!(focused(&h), Some(expected.clone()), "forward traversal: {order:?}"); + } + for expected in order.iter().rev().skip(1) { + h.key_press_modifiers(egui::Modifiers::SHIFT, egui::Key::Tab); + h.run_steps(4); + assert_eq!(focused(&h), Some(expected.clone()), "reverse traversal: {order:?}"); + } +} + #[test] fn clicking_a_check_box_while_typing_keeps_the_text_and_focuses_the_box() { // Issue #2223: the click ticked the box but left no field focused, so Tab started over. diff --git a/crates/ui-egui/tests/it/prepare.rs b/crates/ui-egui/tests/it/prepare.rs index d6abe1f8b3..50845e064a 100644 --- a/crates/ui-egui/tests/it/prepare.rs +++ b/crates/ui-egui/tests/it/prepare.rs @@ -55,6 +55,17 @@ fn rect_of(h: &Harness<'static, PdfCraftApp>, name: &str) -> [f64; 4] { s.session.get(s.views[0].id).unwrap().form.iter().find(|f| f.name == name).unwrap().widgets[0].rect } +fn text_center_near(h: &Harness<'static, PdfCraftApp>, label: &str, expected: Pos2) -> Option { + h.output() + .shapes + .iter() + .filter_map(|clipped| match &clipped.shape { + egui::Shape::Text(text) if text.galley.text() == label => Some(text.visual_bounding_rect().center()), + _ => None, + }) + .min_by(|a, b| a.distance(expected).total_cmp(&b.distance(expected))) +} + #[test] fn shift_selected_small_widgets_have_blue_tags_and_visible_outlines() { use egui::{Color32, Event, Modifiers, PointerButton, Shape, StrokeKind}; @@ -648,6 +659,99 @@ fn the_fields_panel_orders_tabs_manually() { assert_eq!(h.state().session.get(h.state().views[0].id).unwrap().can_undo(), Some("Set tab order")); } +#[test] +fn tab_numbers_follow_fill_order_without_dirtying_the_document() { + let mut h = harness(); + h.state_mut().execute("form.prepare"); + h.run_steps(2); + h.get_by_label("Show tab numbers").click(); + h.run_steps(3); + + let (order, rects, undo) = { + let s = h.state(); + let doc = s.session.get(s.views[0].id).unwrap(); + let order = pdfcraft_ui_egui::forms_ui::tab_order(&doc.form, &doc.info); + let rects = order + .iter() + .map(|(fi, wi)| { + let field = doc.form.get(*fi).expect("the traversal field exists"); + pdfcraft_ui_egui::forms_ui::field_screen_rect(&s.views[0], &doc.info, field, *wi).unwrap() + }) + .collect::>(); + (order, rects, doc.can_undo()) + }; + assert!(h.state().views[0].prepare.show_tab_numbers); + assert_eq!(undo, None, "displaying the numbers does not edit the PDF"); + let size_widgets = order + .iter() + .filter(|(fi, _)| h.state().session.get(h.state().views[0].id).unwrap().form.get(*fi).is_some_and(|field| field.name == "size")) + .count(); + assert_eq!(size_widgets, 2, "each radio widget has its own tab stop"); + + let drawn: Vec<(String, egui::Pos2, egui::Rect, egui::Rect)> = h + .output() + .shapes + .iter() + .filter_map(|clipped| match &clipped.shape { + egui::Shape::Text(text) => { + Some((text.galley.text().to_owned(), text.visual_bounding_rect().center(), text.visual_bounding_rect(), clipped.clip_rect)) + } + _ => None, + }) + .collect(); + for (i, rect) in rects.iter().enumerate() { + let expected = i.saturating_add(1).to_string(); + let badge_center = rect.min + egui::vec2(3.0 + 4.0 + 3.0, 3.0 + 2.0 + 5.0); + let badge = drawn.iter().find(|(label, center, _, _)| label == &expected && center.distance(badge_center) < 8.0); + let Some((_, _, text_rect, clip_rect)) = badge else { + panic!("widget {:?} should show tab number {expected} near {:?}; drawn: {drawn:?}", order.get(i), rect.min); + }; + assert!(clip_rect.contains_rect(*text_rect), "the whole tab number {expected} should be visible"); + } +} + +#[test] +fn tab_badge_tracks_its_widget_during_a_drag() { + let mut h = harness(); + h.state_mut().execute("form.prepare"); + h.run_steps(2); + h.get_by_label("Show tab numbers").click(); + h.run_steps(3); + + let (name, widget, rect) = { + let s = h.state(); + let doc = s.session.get(s.views[0].id).unwrap(); + let order = pdfcraft_ui_egui::forms_ui::tab_order(&doc.form, &doc.info); + let (fi, wi) = *order.first().expect("the form has a fillable widget"); + let field = doc.form.get(fi).expect("the first fillable field exists"); + (field.name.clone(), wi, pdfcraft_ui_egui::forms_ui::field_screen_rect(&s.views[0], &doc.info, field, wi).unwrap()) + }; + let start = rect.center(); + let badge_before = text_center_near(&h, "1", rect.min + egui::vec2(10.0, 10.0)).expect("the first badge is painted"); + + h.hover_at(start); + h.run_steps(1); + h.drag_at(start); + h.run_steps(1); + h.drop_at(start); + h.run_steps(3); + assert_eq!(h.state().views[0].prepare.selected.as_ref().map(|selected| (selected.0.as_str(), selected.1)), Some((name.as_str(), widget))); + + let delta = egui::vec2(24.0, 16.0); + h.hover_at(start); + h.run_steps(1); + h.drag_at(start); + h.run_steps(1); + h.hover_at(start + delta); + h.run_steps(3); + + let badge_during_drag = text_center_near(&h, "1", badge_before + delta).expect("the badge stays painted during the drag"); + assert!(badge_during_drag.distance(badge_before + delta) < 8.0, "badge {badge_before:?} moved to {badge_during_drag:?}"); + assert!(badge_during_drag.distance(badge_before) > 12.0, "the badge follows the dragged widget"); + h.drop_at(start + delta); + h.run_steps(3); +} + #[test] fn duplicating_a_field_onto_every_page() { let mut h = harness(); diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index a6caffa329..a1520f1b88 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -5288,11 +5288,11 @@ title = "Show tab numbers" area = "F" tier = "P2" milestone = "M6" -status = "planned" +status = "partial" commands = [] tools = [] -evidence = [] -notes = "" +evidence = ["crates/ui-egui/tests/it/prepare.rs::tab_numbers_follow_fill_order_without_dirtying_the_document", "crates/ui-egui/tests/it/control.rs::form_tab_numbers_can_be_shown_and_captured_through_the_control_channel", "crates/ui-egui/tests/it/forms.rs::tab_and_shift_tab_follow_widget_order_across_pages"] +notes = "The GUI's Prepare a form ▸ Show tab numbers overlays each visible, fillable widget with its 1-based position in the order used by Tab and Shift+Tab; repeated widgets receive separate numbers. This is a view-only toggle and does not dirty the PDF. Missing: headless engine and automation access to the setting." [[feature]] id = "form.preview-form" From b8d9497b93b8a4a2f7decb8c44db9756db536b23 Mon Sep 17 00:00:00 2001 From: mtlcl410 <2368091+mtlcl410@users.noreply.github.com> Date: Mon, 9 Nov 2026 18:32:44 +0800 Subject: [PATCH 48/64] M5: cloudy squares, circles and text boxes are drawn (comment.cloud) (#4195) --- crates/annot/src/appearance.rs | 150 ++++++++++++-- crates/annot/src/create.rs | 21 +- crates/annot/src/modify.rs | 22 +- crates/annot/src/tests.rs | 367 ++++++++++++++++++++++++++++++++- crates/edit/src/tests.rs | 13 +- parity/acrobat-features.toml | 4 +- 6 files changed, 528 insertions(+), 49 deletions(-) diff --git a/crates/annot/src/appearance.rs b/crates/annot/src/appearance.rs index 011656c7e7..0083761288 100644 --- a/crates/annot/src/appearance.rs +++ b/crates/annot/src/appearance.rs @@ -3,8 +3,8 @@ //! [`build`] draws a normal appearance (`/AP /N`) from the annotation dictionary and the //! objects it points at, so the same code serves new comments and restyled ones. Drawings are in page space with //! `/BBox = /Rect` (identity matrix), except note icons, which are drawn in a 20 × 20 box. -//! It returns `None` for anything it cannot draw faithfully (cloudy borders, unknown line -//! endings), so callers never replace an appearance with a worse one. It resolves `/QuadPoints` +//! It returns `None` for anything it cannot draw faithfully (unknown line endings, a cloudy +//! callout), so callers never replace an appearance with a worse one. It resolves `/QuadPoints` //! itself; for the other values, callers that hold the document follow references first //! (`draw::with_direct_values`). //! @@ -55,7 +55,9 @@ pub fn arrow_size(w: f64) -> f64 { 6.0 + 3.0 * w.max(0.0) } -/// Nominal radius of a cloudy border's bumps for a line of width `w` (intensity 1). +/// Nominal radius of a cloudy border's bumps for a line of width `w` (intensity 1). ISO 32000-2 +/// Table 169 leaves the size open; this is the size PdfCraft's polygon clouds already had, so +/// squares, circles and text boxes match them. pub fn cloud_radius(w: f64) -> f64 { 6.0 + 1.5 * w.max(0.0) } @@ -180,6 +182,80 @@ fn smooth_curve(pts: &[(f64, f64)]) -> String { /// Bumps one cloudy outline draws at most (plus one per edge); past it the bumps grow. pub(crate) const MAX_CLOUD_BUMPS: usize = 20_000; +/// A cloudy border's intensity (ISO 32000-2 §12.5.4, Table 169: `/BE /I`, "in the range 0 to 2. +/// Default value: 0"), or `None` for no cloud: no `/BE`, a style other than `/C`, or intensity 0, +/// which draws the border plain. +pub(crate) fn cloud_intensity(d: &Dict) -> Option { + let be = d.get(b"BE").and_then(|b| b.as_dict())?; + if be.name(b"S") != Some(b"C") { + return None; + } + let i = be.get(b"I").and_then(|i| i.as_f64()).unwrap_or(0.0); + (i.is_finite() && i > 0.0).then(|| i.min(2.0)) +} + +/// The bump radius for a cloudy border of width `w` at `intensity` (0–2]. +pub(crate) fn cloud_bump(w: f64, intensity: f64) -> f64 { + cloud_radius(w) * intensity.clamp(0.5, 2.0) +} + +/// How far past its outline a cloud with bumps of radius `bump` around an outline `perimeter` +/// long can reach. `cloud_path` rounds each edge to whole bumps, so one may span up to three +/// radii and bulge 1.5 radii; it also grows the radius to keep within its bump budget. +pub(crate) fn cloud_reach(perimeter: f64, bump: f64) -> f64 { + 1.5 * bump.max(perimeter / (2.0 * MAX_CLOUD_BUMPS as f64)) +} + +/// `/RD` when it is four finite, non-negative numbers (Tables 170 and 180). +fn rd_of(d: &Dict) -> Option> { + nums(d, b"RD").filter(|r| r.len() == 4 && r.iter().all(|x| x.is_finite() && *x >= 0.0)) +} + +/// `rect` less `rd`, whose four numbers are the left, top, right and bottom differences. +fn less_rd(rect: [f64; 4], rd: &[f64]) -> [f64; 4] { + let at = |i: usize| rd.get(i).copied().unwrap_or(0.0); + [rect[0] + at(0), rect[1] + at(3), rect[2] - at(2), rect[3] - at(1)] +} + +/// The box a plain (not cloudy) square or circle is drawn in: `/Rect` less a usable `/RD` (Table +/// 180: the differences between `/Rect` and "the actual boundaries of the underlying square or +/// circle"), or `None` when there is no `/RD`, it isn't four finite non-negative numbers, or it +/// leaves less than a point either way, so the caller draws in `/Rect` as without one. +fn rect_less_rd(d: &Dict, rect: [f64; 4]) -> Option<[f64; 4]> { + let inner = less_rd(rect, &rd_of(d)?); + (inner[2] - inner[0] >= 1.0 && inner[3] - inner[1] >= 1.0).then_some(inner) +} + +/// The shape a cloudy square, circle or text box draws its bumps around: `/Rect` less `/RD` +/// (Table 180: the differences between "the left, top, right, and bottom coordinates of Rect and +/// those of the square or circle, respectively"), but at least the bumps' greatest reach and half +/// the line from each side, so they stay inside `/Rect`; without a usable `/RD`, that margin. +fn cloud_inner(d: &Dict, rect: [f64; 4], bump: f64, w: f64) -> Option<[f64; 4]> { + // The cloud's reach and half the line keep it inside `/Rect`; an `/RD` leaving more is + // followed, one leaving less is not. + let perimeter = 2.0 * ((rect[2] - rect[0]) + (rect[3] - rect[1])); + let m = cloud_reach(perimeter, bump) + w / 2.0; + let safe = [rect[0] + m, rect[1] + m, rect[2] - m, rect[3] - m]; + let inner = match rd_of(d) { + Some(rd) => { + let r = less_rd(rect, &rd); + [r[0].max(safe[0]), r[1].max(safe[1]), r[2].min(safe[2]), r[3].min(safe[3])] + } + None => safe, + }; + (inner[2] - inner[0] >= 1.0 && inner[3] - inner[1] >= 1.0).then_some(inner) +} + +/// Points around the ellipse inscribed in `r`, counter-clockwise, one per bump of radius `bump` +/// (each edge of the outline becomes one bump), for a cloudy circle. +fn ellipse_points(r: [f64; 4], bump: f64) -> Vec<(f64, f64)> { + let (cx, cy, rx, ry) = ((r[0] + r[2]) / 2.0, (r[1] + r[3]) / 2.0, (r[2] - r[0]) / 2.0, (r[3] - r[1]) / 2.0); + // Ramanujan's approximation of the perimeter; the count stays within the bump budget. + let perimeter = std::f64::consts::PI * (3.0 * (rx + ry) - ((3.0 * rx + ry) * (rx + 3.0 * ry)).max(0.0).sqrt()); + let count = (perimeter / (2.0 * bump.max(0.5))).round().clamp(8.0, MAX_CLOUD_BUMPS as f64) as u32; + (0..count).map(|i| f64::from(i) * std::f64::consts::TAU / f64::from(count)).map(|a| (cx + rx * a.cos(), cy + ry * a.sin())).collect() +} + /// A closed cloudy outline through `pts` (§12.5.4 `/BE /S /C`): each edge becomes a row of /// half-circle bumps of about `r`, bulging outwards. pub fn cloud_path(pts: &[(f64, f64)], r: f64) -> String { @@ -536,7 +612,8 @@ struct AppearanceContext<'a> { dict: &'a Dict, subtype: &'a [u8], rect: [f64; 4], - cloudy: bool, + /// A cloudy border's intensity ([`cloud_intensity`]), or `None` for a plain one. + cloud: Option, /// The line's opacity (`parts.line`). opacity: f64, parts: PartOpacity, @@ -599,11 +676,13 @@ fn appearance_context<'a>(doc: &'a Document, d: &'a Dict, page: Option<[f64; 4]> return None; } let subtype = d.name(b"Subtype")?; - let rect = normalised_rect(d)?; + // A number too long for a double reads as infinite, and two near its limit are an infinite + // width apart: nothing is drawn rather than "inf" written into the appearance (review on #4195). + let rect = normalised_rect(d).filter(|r| r.iter().all(|x| x.is_finite()) && (r[2] - r[0]).is_finite() && (r[3] - r[1]).is_finite())?; let rect = page.and_then(|page| point_reach(doc, d, rect, page)).unwrap_or(rect); - // Cloudy borders (§12.5.4) are drawn for polygons only. - let cloudy = d.get(b"BE").and_then(|b| b.as_dict()).is_some_and(|b| b.name(b"S") == Some(b"C")); - if cloudy && subtype != b"Polygon" { + // Cloudy borders (§12.5.4, Table 169) apply to squares, circles, polygons and text boxes. + let cloud = cloud_intensity(d); + if cloud.is_some() && !matches!(subtype, b"Square" | b"Circle" | b"Polygon" | b"FreeText") { return None; } Some(AppearanceContext { @@ -611,7 +690,7 @@ fn appearance_context<'a>(doc: &'a Document, d: &'a Dict, page: Option<[f64; 4]> dict: d, subtype, rect, - cloudy, + cloud, opacity: part_opacity(d).line, parts: part_opacity(d), stroke: color(d, b"C")?, @@ -698,14 +777,18 @@ fn drawn_points(doc: &Document, d: &Dict) -> Option<(Vec, f64)> { if count < 4 || count % 2 != 0 || count / 2 > MAX_REACH_POINTS { return None; } - let cloudy = d.get(b"BE").and_then(Object::as_dict).is_some_and(|b| b.name(b"S") == Some(b"C")); - // A cloud's bumps are drawn at up to twice their nominal radius (`draw_polygon`). - let reach = match subtype { - b"Polygon" if cloudy => 2.0 * cloud_radius(w), - b"PolyLine" if has_ending() => arrow_size(w), + let vertices = nums(d, b"Vertices")?; + // A cloud's bumps (`draw_polygon`) reach as far past the outline as `cloud_reach` says. + let reach = match (subtype, cloud_intensity(d)) { + (b"Polygon", Some(intensity)) => { + let pts: Vec<(f64, f64)> = vertices.as_chunks::<2>().0.iter().map(|p| (p[0], p[1])).collect(); + let perimeter: f64 = pts.iter().zip(pts.iter().cycle().skip(1)).map(|(a, b)| (b.0 - a.0).hypot(b.1 - a.1)).sum(); + cloud_reach(if perimeter.is_finite() { perimeter } else { 0.0 }, cloud_bump(w, intensity)) + } + (b"PolyLine", _) if has_ending() => arrow_size(w), _ => 0.0, }; - (nums(d, b"Vertices")?, w / 2.0 + reach) + (vertices, w / 2.0 + reach) } b"Line" => { let [x0, y0, x1, y1] = <[f64; 4]>::try_from(nums(d, b"L")?).ok()?; @@ -921,15 +1004,27 @@ fn draw_box(context: &AppearanceContext<'_>, drawing: &mut AppearanceDrawing) -> if stroke.is_none() && fill.is_none() { return Some(()); } + // A plain square or circle lies within /Rect less /RD (Table 180), its stroke centred + // half the width inside that box. + let shape = rect_less_rd(d, rect).unwrap_or(rect); let inset = w / 2.0; - let [x0, y0, x1, y1] = [rect[0] + inset, rect[1] + inset, rect[2] - inset, rect[3] - inset]; + let [x0, y0, x1, y1] = [shape[0] + inset, shape[1] + inset, shape[2] - inset, shape[3] - inset]; if let Some(f) = fill { c.push_str(&rg(f)); } if let Some(s) = stroke { c.push_str(&format!("{}{} w\n{}", rg_stroke(s), n(w), dash(d))); } - if subtype == b"Square" { + if let Some(intensity) = context.cloud { + let bump = cloud_bump(w, intensity); + let inner = cloud_inner(d, rect, bump, w)?; + let pts = if subtype == b"Square" { + vec![(inner[0], inner[1]), (inner[2], inner[1]), (inner[2], inner[3]), (inner[0], inner[3])] + } else { + ellipse_points(inner, bump) + }; + c.push_str(&format!("1 j\n{}", cloud_path(&pts, bump))); + } else if subtype == b"Square" { c.push_str(&format!("{} {} {} {} re\n", n(x0), n(y0), n(x1 - x0), n(y1 - y0))); } else { c.push_str(&ellipse(x0, y0, x1, y1)); @@ -990,7 +1085,6 @@ fn draw_polygon(context: &AppearanceContext<'_>, drawing: &mut AppearanceDrawing let subtype = context.subtype; let stroke = context.stroke; let w = context.width; - let cloudy = context.cloudy; let c = &mut drawing.content; let v = nums(d, b"Vertices").filter(|v| v.len() >= 4 && v.len() % 2 == 0 && v.iter().all(|x| x.is_finite()))?; let pts: Vec<(f64, f64)> = v.as_chunks::<2>().0.iter().map(|p| (p[0], p[1])).collect(); @@ -1018,9 +1112,8 @@ fn draw_polygon(context: &AppearanceContext<'_>, drawing: &mut AppearanceDrawing if let Some(s) = col { c.push_str(&format!("{}{} w 1 J 1 j\n{}", rg_stroke(s), n(w), dash(d))); } - if cloudy { - let intensity = d.get(b"BE").and_then(|b| b.as_dict()).and_then(|b| b.get(b"I")).and_then(|i| i.as_f64()).unwrap_or(1.0); - c.push_str(&cloud_path(&pts, cloud_radius(w) * intensity.clamp(0.5, 2.0))); + if let Some(intensity) = context.cloud { + c.push_str(&cloud_path(&pts, cloud_bump(w, intensity))); } else { for (i, p) in pts.iter().enumerate() { c.push_str(&format!("{} {} {}\n", n(p.0), n(p.1), if i == 0 { "m" } else { "l" })); @@ -1332,7 +1425,20 @@ fn draw_free_text(context: &AppearanceContext<'_>, mut drawing: AppearanceDrawin // `/RD` and keeps its place: background and border take the shape, the text is laid out // in the text box as in a rectangle. let shape = crate::BorderShape::of(d); - if shape != crate::BorderShape::Rectangle { + // A cloudy box (not a callout): its background and border follow the cloud around + // the text, which keeps to the cloud's inner rectangle. + if let Some(intensity) = context.cloud.filter(|_| !d.contains(b"CL")) { + let cw = if bw > 0.0 { bw } else { 1.0 }; + let bump = cloud_bump(cw, intensity); + let inner = cloud_inner(d, rect, bump, cw)?; + let pts = [(inner[0], inner[1]), (inner[2], inner[1]), (inner[2], inner[3]), (inner[0], inner[3])]; + c.push_str(&format!("{}{}{} w 1 j\n{}", rg_stroke(line_color), rg(bg.unwrap_or([1.0; 3])), n(cw), dash(d))); + c.push_str(&cloud_path(&pts, bump)); + c.push_str(if bg.is_some() { "B\n[] 0 d\n" } else { "S\n[] 0 d\n" }); + rect = inner; + } else if context.cloud.is_some() { + return None; + } else if shape != crate::BorderShape::Rectangle { rect = shaped_border(d, shape, full, turn, bw, bg, line_color, &mut c)?; } else { if let Some(bg) = bg { diff --git a/crates/annot/src/create.rs b/crates/annot/src/create.rs index 6dbb7842e6..1b55e48932 100644 --- a/crates/annot/src/create.rs +++ b/crates/annot/src/create.rs @@ -103,12 +103,8 @@ pub(crate) fn rect_for(shape: &Shape, style: &Style) -> Result<[f64; 4], AnnotEr } grow(ink_bounds(strokes).unwrap_or_default(), half + 1.0) } - Shape::Polygon { vertices, cloud } => { - let b = bounds(vertices.iter().copied()) - .filter(|b| vertices.len() >= 3 && vertices.iter().all(|p| finite(p)) && b[2] - b[0] >= 1.0 && b[3] - b[1] >= 1.0); - let b = b.ok_or_else(|| bad("polygon (it needs three points)"))?; - grow(b, half + 1.0 + if *cloud { 1.5 * appearance::cloud_radius(style.width) } else { 0.0 }) - } + // A new cloud has intensity 1 (`/BE /I 1`). + Shape::Polygon { vertices, cloud } => polygon_rect(vertices, style.width, cloud.then_some(1.0))?, Shape::PolyLine { vertices, start, end } => { let b = bounds(vertices.iter().copied()) .filter(|b| vertices.len() >= 2 && vertices.iter().all(|p| finite(p)) && (b[2] - b[0]).max(b[3] - b[1]) >= 1.0); @@ -135,6 +131,19 @@ pub(crate) fn rect_for(shape: &Shape, style: &Style) -> Result<[f64; 4], AnnotEr Ok(r) } +/// Validate a polygon's corners and compute its `/Rect`: room for half the line, and with a +/// cloud of `cloud` intensity, for the farthest its bumps reach (`appearance::cloud_reach`). +pub(crate) fn polygon_rect(vertices: &[[f64; 2]], width: f64, cloud: Option) -> Result<[f64; 4], AnnotError> { + let b = bounds(vertices.iter().copied()) + .filter(|b| vertices.len() >= 3 && vertices.iter().all(|p| finite(p)) && b[2] - b[0] >= 1.0 && b[3] - b[1] >= 1.0); + let b = b.ok_or_else(|| AnnotError::Invalid("invalid polygon (it needs three points)".into()))?; + let reach = cloud.map_or(0.0, |intensity| { + let perimeter: f64 = vertices.iter().zip(vertices.iter().cycle().skip(1)).map(|(a, b)| (b[0] - a[0]).hypot(b[1] - a[1])).sum(); + appearance::cloud_reach(perimeter, appearance::cloud_bump(width, intensity)) + }); + Ok(grow(b, width.max(0.0) / 2.0 + 1.0 + reach)) +} + pub(crate) fn base_dict(subtype: &str, rect: [f64; 4], page: ObjRef, contents: &str, author: &str, meta: &Meta) -> Dict { let mut d = Dict::new(); d.set(b"Type".to_vec(), Object::name("Annot")); diff --git a/crates/annot/src/modify.rs b/crates/annot/src/modify.rs index 2156ad045a..153be15346 100644 --- a/crates/annot/src/modify.rs +++ b/crates/annot/src/modify.rs @@ -1,5 +1,6 @@ use super::create::{ - add_annotation, base_dict, bounds, callout_attach, ending_draws, ending_pair, finite, grow, ink_bounds, normalize, num_array, rect_for, rgb, + add_annotation, base_dict, bounds, callout_attach, ending_draws, ending_pair, finite, grow, ink_bounds, normalize, num_array, polygon_rect, + rect_for, rgb, }; use super::draw::{draw_appearance, redraw_or_restore, set_appearance}; use super::info::{line_endings_of, text_value}; @@ -625,22 +626,21 @@ pub fn set_points(doc: &mut Document, page: usize, index: usize, points: &[[f64; if points.len() > MAX_POINTS { return Err(AnnotError::Invalid(format!("at most {MAX_POINTS} points"))); } - let style = Style { width: border_width_of(&d), ..Style::default() }; + // The width the appearance draws with (`/BS`, else `/Border`), so the room is the drawn stroke's. + let style = Style { width: appearance::border_width(&d), ..Style::default() }; let ends = line_endings_of(&d).unwrap_or_default(); let (start, end) = (ends.first().copied().unwrap_or(LineEnding::None), ends.get(1).copied().unwrap_or(LineEnding::None)); let subtype = String::from_utf8_lossy(d.name(b"Subtype").unwrap_or_default()).into_owned(); - let (shape, key): (Shape, &[u8]) = match (subtype.as_str(), points) { - ("Line", [from, to]) => (Shape::Line { from: *from, to: *to, start, end }, b"L"), + // Each checks the points too: finite, and far enough apart to draw. + let (rect, key): ([f64; 4], &[u8]) = match (subtype.as_str(), points) { + ("Line", [from, to]) => (rect_for(&Shape::Line { from: *from, to: *to, start, end }, &style)?, b"L"), ("Line", _) => return Err(AnnotError::Invalid("a line has two ends".into())), - ("PolyLine", _) => (Shape::PolyLine { vertices: points.to_vec(), start, end }, b"Vertices"), - ("Polygon", _) => { - let cloud = d.get(b"BE").and_then(|b| b.as_dict()).is_some_and(|b| b.name(b"S") == Some(b"C")); - (Shape::Polygon { vertices: points.to_vec(), cloud }, b"Vertices") - } + ("PolyLine", _) => (rect_for(&Shape::PolyLine { vertices: points.to_vec(), start, end }, &style)?, b"Vertices"), + // Room for the cloud as the appearance draws it: `/BE /S /C` at its own intensity + // (Table 169), which may reach further than a new cloud's intensity 1. + ("Polygon", _) => (polygon_rect(points, style.width, appearance::cloud_intensity(&d))?, b"Vertices"), (other, _) => return Err(AnnotError::Invalid(format!("{other} comments have no points to move"))), }; - // Checks the points too: finite, and far enough apart to draw. - let rect = rect_for(&shape, &style)?; let before = d; doc.update_dict(r, |d| { d.set(key.to_vec(), num_array(&points.concat())); diff --git a/crates/annot/src/tests.rs b/crates/annot/src/tests.rs index 9d9a71dd34..9651263171 100644 --- a/crates/annot/src/tests.rs +++ b/crates/annot/src/tests.rs @@ -3166,6 +3166,366 @@ fn degenerate_and_absurd_leader_and_caption_values_do_not_panic() { assert!(!long.contains(&"w".repeat(257))); } +/// A comment's built appearance for the dictionary `body` (no `/AP`), as text, or `None` when +/// PdfCraft can't draw it. +fn built(body: &str) -> Option { + let doc = document_of(&[ + b"<< /Type /Catalog /Pages 2 0 R >>", + b"<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 600 600] >>", + b"<< /Type /Page /Parent 2 0 R /Annots [4 0 R] >>", + body.as_bytes(), + ]); + let d = list(&doc, 0).into_iter().next().unwrap(); + crate::appearance::build(&doc, &d).map(|s| String::from_utf8_lossy(&s.decoded().unwrap()).into_owned()) +} + +/// The x and y range a drawing's path covers: its points, and each curve's middle (a curve's +/// control points lie outside it, so they aren't counted). +fn extent(ops: &str) -> [f64; 4] { + let mut b = [f64::MAX, f64::MAX, f64::MIN, f64::MIN]; + let mut at = (0.0, 0.0); + let add = |p: (f64, f64), b: &mut [f64; 4]| *b = [b[0].min(p.0), b[1].min(p.1), b[2].max(p.0), b[3].max(p.1)]; + for line in ops.lines() { + let t: Vec<&str> = line.split_whitespace().collect(); + let Some((op, nums)) = t.split_last() else { continue }; + let v: Vec = nums.iter().filter_map(|n| n.parse().ok()).collect(); + match (*op, v.as_slice()) { + ("m" | "l", [x, y]) => { + at = (*x, *y); + add(at, &mut b); + } + ("c", [x1, y1, x2, y2, x3, y3]) => { + let mid = ((at.0 + 3.0 * x1 + 3.0 * x2 + x3) / 8.0, (at.1 + 3.0 * y1 + 3.0 * y2 + y3) / 8.0); + add(mid, &mut b); + at = (*x3, *y3); + add(at, &mut b); + } + _ => {} + } + } + b +} + +/// ISO 32000-2 §12.5.4, Table 169: a cloudy border (`/BE /S /C`) applies to squares, circles, +/// polygons and (PDF 1.6) text boxes, honours the `/BS` width and dashes, and its intensity +/// `/I` defaults to 0. Squares, circles and text boxes without an appearance were not drawn at +/// all, so a reviewer's marks vanished; intensity 0 (or none) drew a cloud on polygons. +#[test] +fn cloudy_borders_are_drawn_on_squares_circles_polygons_and_text_boxes() { + let base = "/Type /Annot /Rect [100 100 300 250] /C [1 0 0] /BS << /W 2 >>"; + for kind in ["Square", "Circle"] { + let ops = built(&format!("<< {base} /Subtype /{kind} /BE << /S /C /I 1 >> >>")).unwrap_or_else(|| panic!("{kind} is drawn")); + assert!(ops.matches(" c\n").count() > 8 && !ops.contains(" re\n"), "{kind} as bumps: {ops}"); + let e = extent(&ops); + assert!(e[0] >= 100.0 && e[1] >= 100.0 && e[2] <= 300.0 && e[3] <= 250.0, "{kind} stays in /Rect: {e:?}"); + } + let dashed = built(&format!("<< {base} /Subtype /Square /BS << /W 2 /S /D /D [6 3] >> /BE << /S /C /I 1 >> >>")).unwrap(); + assert!(dashed.contains("[6 3] 0 d") && dashed.contains(" c\n"), "dashes honoured: {dashed}"); + let text = built(&format!("<< {base} /Subtype /FreeText /DA (0 0 1 rg /Helv 10 Tf) /Contents (Look here) /BE << /S /C /I 1 >> >>")).unwrap(); + assert!(text.contains(" c\n") && text.contains("(Look here) Tj"), "a cloud around the text: {text}"); + let dashed_text = built( + "<< /Type /Annot /Rect [100 100 300 250] /C [1 0 0] /BS << /W 2 /S /D /D [4 2] >> /Subtype /FreeText /DA (0 g /Helv 10 Tf) /Contents (x) /BE << /S /C /I 1 >> >>", + ) + .unwrap(); + assert!(dashed_text.contains("[4 2] 0 d"), "a text box's cloud is dashed too: {dashed_text}"); + // Intensity 0, or none: a plain border (the default is 0). + for be in ["<< /S /C /I 0 >>", "<< /S /C >>", "<< /S /S /I 2 >>"] { + let square = built(&format!("<< {base} /Subtype /Square /BE {be} >>")).unwrap(); + assert!(square.contains(" re\n") && !square.contains(" c\n"), "{be}: {square}"); + let polygon = + built(&format!("<< /Type /Annot /Subtype /Polygon /Rect [0 0 100 100] /C [1 0 0] /Vertices [10 10 90 10 90 90 10 90] /BE {be} >>")) + .unwrap(); + assert!(!polygon.contains(" c\n"), "{be}: polygon edges stay straight: {polygon}"); + } + let cloudy_polygon = + built("<< /Type /Annot /Subtype /Polygon /Rect [0 0 100 100] /C [1 0 0] /Vertices [10 10 90 10 90 90 10 90] /BE << /S /C /I 1 >> >>") + .unwrap(); + assert!(cloudy_polygon.contains(" c\n")); + // A cloudy callout is still left to its own appearance. + assert!( + built(&format!("<< {base} /Subtype /FreeText /DA (0 g /Helv 10 Tf) /CL [10 10 100 100] /RD [5 5 5 5] /BE << /S /C /I 1 >> >>")).is_none() + ); +} + +/// Table 180: `/RD` gives the differences between "the left, top, right, and bottom coordinates +/// of Rect and those of the square or circle, respectively"; a cloudy square's bumps sit on that +/// inner square. +#[test] +fn a_cloudy_square_follows_rd_left_top_right_bottom() { + let ops = built("<< /Type /Annot /Subtype /Square /Rect [100 100 300 300] /C [1 0 0] /BS << /W 1 >> /BE << /S /C /I 1 >> /RD [20 40 30 25] >>") + .unwrap(); + // The corners of the inner square are path points: left 120, bottom 125, right 270, top 260. + for corner in ["120 125", "270 125", "270 260", "120 260"] { + assert!(ops.contains(corner), "corner {corner} in {ops}"); + } +} + +/// Review on #4195: without `/RD`, a small cloudy square still keeps its bumps inside `/Rect`. +/// Its edges are short enough that each rounds to a single bump about three radii long, which +/// bulges further than one radius. An `/RD` leaving less than that margin is widened to it, so +/// the cloud stays inside `/Rect` too. +#[test] +fn a_small_cloudy_square_without_rd_stays_inside_its_rect() { + for side in [40.0, 48.8, 60.0, 82.0] { + let ops = built(&format!( + "<< /Type /Annot /Subtype /Square /Rect [100 100 {x} {x}] /C [1 0 0] /BS << /W 2 >> /BE << /S /C /I 1 >> >>", + x = 100.0 + side + )) + .unwrap_or_else(|| panic!("side {side} is drawn")); + let e = extent(&ops); + assert!(e[0] >= 99.0 && e[1] >= 99.0 && e[2] <= 101.0 + side && e[3] <= 101.0 + side, "side {side}: {e:?}"); + } + let small_rd = + built("<< /Type /Annot /Subtype /Square /Rect [100 100 200 200] /C [1 0 0] /BS << /W 2 >> /BE << /S /C /I 1 >> /RD [1 1 1 1] >>").unwrap(); + let e = extent(&small_rd); + assert!(e[0] >= 99.0 && e[1] >= 99.0 && e[2] <= 201.0 && e[3] <= 201.0, "/RD [1 1 1 1] keeps the cloud inside /Rect: {e:?}"); +} + +/// ISO 32000-2 Table 180: `/RD` gives the differences between `/Rect` and "the actual boundaries +/// of the underlying square or circle", so a plain square or circle (no cloudy border) is drawn +/// inside `/Rect` less `/RD` (left, top, right, bottom) too, its stroke centred half the width +/// inside that box. An `/RD` that isn't four finite non-negative numbers, or leaves no box, is +/// ignored and the shape fills `/Rect` as before. +#[test] +fn plain_squares_and_circles_are_drawn_inside_rect_less_rd() { + let square = |rd: &str| built(&format!("<< /Type /Annot /Subtype /Square /Rect [100 100 200 200] /C [1 0 0] /BS << /W 3 >> {rd} >>")).unwrap(); + // Rect + 15 + 1.5 on every side: a 67 × 67 square from 116.5. + assert!(square("/RD [15 15 15 15]").contains("116.5 116.5 67 67 re"), "{}", square("/RD [15 15 15 15]")); + // Left 10, top 40, right 20, bottom 30: the box is 110..180 × 130..160. + assert!(square("/RD [10 40 20 30]").contains("111.5 131.5 67 27 re"), "{}", square("/RD [10 40 20 30]")); + for ignored in ["", "/RD [-1 0 0 0]", "/RD [1 2 3]", "/RD [60 0 60 0]", "/RD [0 50 0 49.5]", "/RD (x)"] { + assert!(square(ignored).contains("101.5 101.5 97 97 re"), "{ignored}: {}", square(ignored)); + } + let circle = built("<< /Type /Annot /Subtype /Circle /Rect [100 100 200 200] /C [1 0 0] /BS << /W 3 >> /RD [15 15 15 15] >>").unwrap(); + // The ellipse starts at its rightmost point: 200 - 15 - 1.5, at mid-height. + assert!(circle.starts_with("1 0 0 RG\n3 w\n183.5 150 m"), "{circle}"); +} + +/// The circles and rectangles PdfCraft draws write no `/RD`, so their appearance is unchanged by +/// it: the stroke still sits half its width inside `/Rect`. +#[test] +fn pdfcraft_rectangles_and_ellipses_still_fill_their_rect() { + let mut doc = fixture(); + let rect = [10.0, 10.0, 110.0, 60.0]; + for shape in [Shape::Rectangle { rect }, Shape::Oval { rect }] { + let oval = matches!(shape, Shape::Oval { .. }); + let i = add_annotation(&mut doc, &new(0, shape), &meta("r")).unwrap(); + let d = list(&doc, 0).remove(i); + assert!(!d.contains(b"RD")); + let w = appearance::border_width(&d); + let ap = ap_content(&doc, &d); + let r: Vec = d.get(b"Rect").and_then(Object::as_array).unwrap().iter().filter_map(Object::as_f64).collect(); + let expected = if oval { + format!("{} {} m", n(r[2] - w / 2.0), n((r[1] + r[3]) / 2.0)) + } else { + format!("{} {} {} {} re", n(r[0] + w / 2.0), n(r[1] + w / 2.0), n(r[2] - r[0] - w), n(r[3] - r[1] - w)) + }; + assert!(ap.contains(&expected), "{expected} in {ap}"); + } +} + +/// Review on #4195: a polygon reshaped with `/BE /S /C` and no `/I` (or `/I 0`) is a plain one, +/// as its appearance is drawn (Table 169: intensity 0 is no cloud), so its `/Rect` gets no room +/// for bumps; with an intensity above 0 it does. +#[test] +fn a_reshaped_polygon_pads_its_rect_only_for_a_drawn_cloud() { + // `/BE << /S /C >>` with `/I` when given; no `/BE` at all for `None`. + let reshaped = |be: Option>| { + let mut doc = fixture(); + let shape = Shape::Polygon { vertices: vec![[100.0, 100.0], [200.0, 100.0], [150.0, 200.0]], cloud: false }; + let i = add_annotation(&mut doc, &new(0, shape), &meta("p")).unwrap(); + let (_, r) = annot_ref(&mut doc, 0, i).unwrap(); + if let Some(intensity) = be { + let mut b = pdfcraft_cos::Dict::new(); + b.set(b"S".to_vec(), Object::name("C")); + if let Some(i) = intensity { + b.set(b"I".to_vec(), Object::Int(i)); + } + doc.update_dict(r, |d| d.set(b"BE".to_vec(), Object::Dict(b))).unwrap(); + } + set_points(&mut doc, 0, i, &[[100.0, 100.0], [300.0, 100.0], [200.0, 250.0]], &meta("")).unwrap(); + rect(&list(&doc, 0)[i]) + }; + let plain = reshaped(None); + for be in [None, Some(0)] { + assert_eq!(reshaped(Some(be)), plain, "/I {be:?}: no cloud drawn, no room for one"); + } + let cloudy = reshaped(Some(Some(1))); + assert!(cloudy[0] < plain[0] && cloudy[2] > plain[2], "room for the bumps: {cloudy:?} around {plain:?}"); +} + +/// Review on #4195: a reshaped polygon whose cloud has intensity 2 gets room for its bumps at +/// that intensity. With room only for intensity 1, the bumps on a 200 pt edge reached past +/// `/Rect` and the appearance's `/BBox` clipped them. +#[test] +fn a_reshaped_intensity_two_cloud_stays_inside_its_rect() { + let mut doc = fixture(); + let shape = Shape::Polygon { vertices: vec![[100.0, 100.0], [200.0, 100.0], [150.0, 200.0]], cloud: true }; + let i = add_annotation(&mut doc, &new(0, shape), &meta("p")).unwrap(); + let (_, r) = annot_ref(&mut doc, 0, i).unwrap(); + let mut be = pdfcraft_cos::Dict::new(); + be.set(b"S".to_vec(), Object::name("C")); + be.set(b"I".to_vec(), Object::Int(2)); + doc.update_dict(r, |d| d.set(b"BE".to_vec(), Object::Dict(be))).unwrap(); + set_points(&mut doc, 0, i, &[[100.0, 100.0], [300.0, 100.0], [200.0, 250.0]], &meta("")).unwrap(); + let d = &list(&doc, 0)[i]; + let bbox = rect(d); + // Every point along the drawn bumps, with half the 2 pt line, is inside `/Rect`. + let content = ap_content(&doc, d); + let (mut at, mut lowest, mut leftmost) = ((0.0, 0.0), f64::MAX, f64::MAX); + for line in content.lines() { + let v: Vec = line.split_whitespace().filter_map(|t| t.parse().ok()).collect(); + if line.ends_with(" m") && v.len() == 2 { + at = (v[0], v[1]); + } else if line.ends_with(" c") && v.len() == 6 { + for step in 0..=20 { + let t = f64::from(step) / 20.0; + let u = 1.0 - t; + let (a, b, c, e) = (u * u * u, 3.0 * u * u * t, 3.0 * u * t * t, t * t * t); + lowest = lowest.min(a * at.1 + b * v[1] + c * v[3] + e * v[5]); + leftmost = leftmost.min(a * at.0 + b * v[0] + c * v[2] + e * v[4]); + } + at = (v[4], v[5]); + } + } + assert!(lowest < 100.0 - 8.0, "the bumps bulge below the bottom edge: {lowest}"); + assert!(lowest - 1.0 >= bbox[1], "the bottom bumps ({lowest}) and the line fit in /Rect {bbox:?}"); + assert!(leftmost - 1.0 >= bbox[0], "the left bumps ({leftmost}) and the line fit in /Rect {bbox:?}"); +} + +/// Review on #4195: a reshaped polygon gets room for the border it is drawn with. With no `/BS`, +/// a `/Border [0 0 5]` is drawn 5 pt wide (`appearance::border_width`), so the new `/Rect` is the +/// one `/BS /W 5` gets; it was sized for a 1-pt border, and the cloud's outer bumps were clipped. +#[test] +fn a_reshaped_polygon_has_room_for_the_border_it_is_drawn_with() { + let reshaped = |border: &[u8], width: Object| { + let mut doc = fixture(); + let shape = Shape::Polygon { vertices: vec![[100.0, 100.0], [200.0, 100.0], [150.0, 200.0]], cloud: true }; + let i = add_annotation(&mut doc, &new(0, shape), &meta("p")).unwrap(); + let (_, r) = annot_ref(&mut doc, 0, i).unwrap(); + doc.update_dict(r, |d| { + d.remove(b"BS"); + if border == b"BS" { + let mut bs = pdfcraft_cos::Dict::new(); + bs.set(b"W".to_vec(), width); + d.set(b"BS".to_vec(), Object::Dict(bs)); + } else { + d.set(b"Border".to_vec(), Object::Array(vec![Object::Int(0), Object::Int(0), width])); + } + }) + .unwrap(); + set_points(&mut doc, 0, i, &[[100.0, 100.0], [300.0, 100.0], [200.0, 250.0]], &meta("")).unwrap(); + rect(&list(&doc, 0)[i]) + }; + let bs = reshaped(b"BS", Object::Int(5)); + assert_eq!(reshaped(b"Border", Object::Int(5)), bs, "the same 5-pt border, the same room"); + assert_ne!(reshaped(b"BS", Object::Int(1)), bs, "a 1-pt border needs less"); +} + +/// Every number a drawing writes, which must all be finite. +fn drawn_numbers(ops: &str) -> Vec { + ops.split_whitespace().filter(|t| t.starts_with(|c: char| c.is_ascii_digit() || c == '-' || c == '.')).filter_map(|t| t.parse().ok()).collect() +} + +/// Review on #4195: a cloud around a zero-size, inverted, sliver or huge `/Rect`, at any border +/// width and any `/BE /I` (tiny, negative, far above Table 169's 2), is either drawn inside +/// `/Rect` with finite numbers and a bounded number of bumps, or not built at all, never a +/// panic, a division by zero or a NaN. +#[test] +fn hostile_cloud_sizes_widths_and_intensities_draw_inside_rect_or_not_at_all() { + let rects = [ + "0 0 0 0", + "100 100 100 100", + "100 100 100.5 300", + "100 100 101 101", + "300 250 100 100", + "100 100 300 250", + "100 100 110 110", + "-1000000000000000 -1000000000000000 1000000000000000 1000000000000000", + "0 0 99999999999999999999999 1", + &format!("0 0 {} 100", "9".repeat(400)), + &format!("-{n} -{n} {n} {n}", n = "9".repeat(308)), + ]; + let (mut drawn, mut refused) = (0, 0); + for rect in rects { + let r: Vec = rect.split_whitespace().filter_map(|t| t.parse().ok()).collect(); + let bounds = [r[0].min(r[2]), r[1].min(r[3]), r[0].max(r[2]), r[1].max(r[3])]; + for width in ["0", "0.001", "2", "1000000", "-3"] { + for intensity in ["0.0000001", "2", "1000000000000000000000000000000", "-1"] { + for kind in ["Square", "Circle", "FreeText"] { + let body = format!( + "<< /Type /Annot /Subtype /{kind} /Rect [{rect}] /C [1 0 0] /BS << /W {width} >> /BE << /S /C /I {intensity} >> /DA (0 g /Helv 10 Tf) /Contents (x) >>" + ); + let Some(ops) = built(&body) else { + refused += 1; + continue; + }; + drawn += 1; + assert!(!ops.contains("NaN") && !ops.contains("inf"), "{body}: {ops}"); + assert!(drawn_numbers(&ops).iter().all(|v| v.is_finite()), "{body}"); + assert!(ops.matches(" c\n").count() <= crate::appearance::MAX_CLOUD_BUMPS + 8, "{body}: bumps are capped"); + // A cloud (intensity above 0); a negative one is a plain border (Table 169). + if !intensity.starts_with('-') && ops.contains(" c\n") { + let e = extent(&ops); + let slack = 0.5 + 1e-9 * (bounds[2] - bounds[0]).abs().max((bounds[3] - bounds[1]).abs()); + assert!( + e[0] >= bounds[0] - slack && e[1] >= bounds[1] - slack && e[2] <= bounds[2] + slack && e[3] <= bounds[3] + slack, + "{body}: the cloud {e:?} stays in /Rect" + ); + } + } + } + } + } + assert!(drawn > 0 && refused > 0, "both outcomes are exercised: {drawn} drawn, {refused} refused"); +} + +/// Review on #4195: a cloud around a rotated rectangle (a polygon turned 45°, its corners in +/// either order) bulges outwards the same way and stays inside its `/Rect`, and `cloud_path` +/// itself takes a zero, negative, infinite or NaN bump size and repeated corners without +/// dividing by zero. +#[test] +fn a_cloud_around_a_rotated_rectangle_bulges_outwards_either_way_round() { + let ccw = "150 100 200 150 150 200 100 150"; + let cw = "150 100 100 150 150 200 200 150"; + let extents: Vec<[f64; 4]> = [ccw, cw] + .iter() + .map(|v| { + let ops = built(&format!("<< /Type /Annot /Subtype /Polygon /Rect [80 80 220 220] /C [1 0 0] /Vertices [{v}] /BE << /S /C /I 1 >> >>")) + .unwrap(); + assert!(ops.contains(" c\n"), "{ops}"); + let e = extent(&ops); + assert!(e[0] >= 80.0 && e[1] >= 80.0 && e[2] <= 220.0 && e[3] <= 220.0, "inside /Rect: {e:?}"); + // Each bump's middle lies outside the turned square |x − 150| + |y − 150| = 50. + let mut at = (0.0, 0.0); + for line in ops.lines() { + let v: Vec = line.split_whitespace().filter_map(|t| t.parse().ok()).collect(); + if line.ends_with(" m") && v.len() == 2 { + at = (v[0], v[1]); + } else if line.ends_with(" c") && v.len() == 6 { + let mid = ((at.0 + 3.0 * v[0] + 3.0 * v[2] + v[4]) / 8.0, (at.1 + 3.0 * v[1] + 3.0 * v[3] + v[5]) / 8.0); + assert!((mid.0 - 150.0).abs() + (mid.1 - 150.0).abs() > 50.0 + 1.0, "the bump at {mid:?} bulges outwards"); + at = (v[4], v[5]); + } + } + e + }) + .collect(); + for k in 0..4 { + assert!((extents[0][k] - extents[1][k]).abs() < 0.5, "the same cloud either way round: {extents:?}"); + } + let square = [(0.0, 0.0), (10.0, 0.0), (10.0, 10.0), (0.0, 10.0)]; + for r in [0.0, -1.0, f64::INFINITY, f64::NAN, 1e-300, 1e300] { + let path = crate::appearance::cloud_path(&square, r); + assert!(!path.contains("NaN") && !path.contains("inf"), "r {r}: {path}"); + assert!(path.ends_with("h\n")); + } + for pts in [vec![], vec![(5.0, 5.0)], vec![(5.0, 5.0); 4], vec![(0.0, 0.0), (10.0, 0.0), (10.0, 0.0), (0.0, 0.0)]] { + let path = crate::appearance::cloud_path(&pts, 2.0); + assert!(!path.contains("NaN") && !path.contains("inf"), "{pts:?}: {path}"); + } +} + #[test] fn freehand_highlight_is_compatible_ink_with_marker_blending() { let shape = Shape::FreehandHighlight { strokes: vec![vec![[20.0, 100.0], [120.0, 100.0]]] }; @@ -3657,15 +4017,16 @@ fn polyline_whose_vertices_reach_past_its_rect_is_drawn_through_them() { fn polygon_whose_vertices_reach_past_its_rect_is_drawn_through_them() { let (_, display) = displayed(&[ b"<< /Type /Annot /Subtype /Polygon /Rect [10 10 20 20] /BS << /W 1 >> /C [0 0 1] /IC [1 1 0] /Vertices [100 100 200 100 150 180] >>", - b"<< /Type /Annot /Subtype /Polygon /Rect [10 10 20 20] /BS << /W 2 >> /C [0 0 1] /BE << /S /C >> /Vertices [100 100 200 100 150 180] >>", + b"<< /Type /Annot /Subtype /Polygon /Rect [10 10 20 20] /BS << /W 2 >> /C [0 0 1] /BE << /S /C /I 1 >> /Vertices [100 100 200 100 150 180] >>", ]); let shown = list(&display, 0); let rect = |d: &Dict| nums_of(d, b"Rect"); assert_eq!(rect(&shown[0]), [10.0, 10.0, 200.5, 180.5], "the polygon's /Rect takes in its vertices"); assert_eq!(ap_bbox(&display, &shown[0]), rect(&shown[0])); assert!(ap_content(&display, &shown[0]).contains("100 100 m"), "the polygon is drawn"); - // Half the width (1) and twice the cloud radius (2 × (6 + 1.5 × 2)) past the vertices. - assert_eq!(rect(&shown[1]), [10.0, 10.0, 219.0, 199.0], "and its cloudy border"); + // Half the width (1) and the cloud's reach (`cloud_reach`: 1.5 bumps of 6 + 1.5 × 2 at + // intensity 1) past the vertices. + assert_eq!(rect(&shown[1]), [10.0, 10.0, 214.5, 194.5], "and its cloudy border"); } /// A line's `/L` (Table 178) outside its `/Rect` is drawn, with its line ending and its leader diff --git a/crates/edit/src/tests.rs b/crates/edit/src/tests.rs index ec235c9d7e..242659f531 100644 --- a/crates/edit/src/tests.rs +++ b/crates/edit/src/tests.rs @@ -549,7 +549,7 @@ fn flattening_keeps_a_no_rotate_widget_upright_on_a_rotated_page() { /// #1317: a cloudy square this builder cannot draw stays on the page. A solid square, a hidden /// cloudy square and a cloudy square that already has an appearance keep their previous results. #[test] -fn flattening_keeps_a_cloudy_square_it_cannot_draw() { +fn flattening_draws_cloudy_squares_and_keeps_one_it_cannot_draw() { let page = stream("", " "); let form = "<< /Type /XObject /Subtype /Form /BBox [100 600 200 650] /Length 28 >>\nstream\n1 0 0 RG 100 600 100 50 re S\nendstream"; let case = |extra: &str, drawn: usize, left: bool| { @@ -571,10 +571,13 @@ fn flattening_keeps_a_cloudy_square_it_cannot_draw() { assert_eq!(streams(&doc, 0), before, "nothing was drawn in place of the comment"); } }; - case("/BE << /S /C /I 2 >>", 0, true); + // A cloudy square is drawn (ISO 32000-2 Table 169); one whose /RD leaves no square inside + // its /Rect (Table 180: the sums "shall be less than" the width and height) cannot be, and stays. + case("/BE << /S /C /I 2 >>", 1, false); + case("/BE << /S /C /I 2 >> /RD [60 30 60 30]", 0, true); case("/BE << /S /S >>", 1, false); - case("/BE << /S /C /I 2 >> /F 2", 0, true); - case("/BE << /S /C /I 2 >> /AP << /N 6 0 R >>", 1, false); + case("/BE << /S /C /I 2 >> /RD [60 30 60 30] /F 2", 0, true); + case("/BE << /S /C /I 2 >> /RD [60 30 60 30] /AP << /N 6 0 R >>", 1, false); } /// #3213: flattening wraps transferable optional-content memberships (`/OC` indirect OCGs and @@ -815,7 +818,7 @@ fn flattening_takes_an_undrawable_reply_with_its_comment() { "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 600 800] /Resources << >> /Contents 4 0 R /Annots [5 0 R 6 0 R 7 0 R] >>", &page, "<< /Type /Annot /Subtype /Square /Rect [100 600 200 650] /C [1 0 0] /Contents (Synthetic comment) /AP << /N 8 0 R >> /Popup 7 0 R >>", - "<< /Type /Annot /Subtype /Square /Rect [100 500 200 550] /C [1 0 0] /Contents (Synthetic reply) /BE << /S /C /I 2 >> /IRT 5 0 R >>", + "<< /Type /Annot /Subtype /Square /Rect [100 500 200 550] /C [1 0 0] /Contents (Synthetic reply) /BE << /S /C /I 2 >> /RD [60 30 60 30] /IRT 5 0 R >>", "<< /Type /Annot /Subtype /Popup /Rect [300 600 400 700] /Parent 5 0 R >>", "<< /Type /XObject /Subtype /Form /BBox [100 600 200 650] /Length 28 >>\nstream\n1 0 0 RG 100 600 100 50 re S\nendstream", ]); diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index a1520f1b88..951ef61b38 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -3914,8 +3914,8 @@ milestone = "M5" status = "shipped" commands = ["comment.cloud"] tools = ["comment_add"] -evidence = ["crates/annot/src/tests.rs::polygons_clouds_connected_lines_callouts_and_carets_are_drawn", "crates/annot/src/tests.rs::cloud_paths_bulge_outwards_either_way_round", "crates/ui-egui/tests/it/comments.rs::polygons_clouds_connected_lines_callouts_and_inserted_text", "crates/automation/tests/it/automation.rs::drawing_comments_through_tools"] -notes = "polygon with a cloudy border (/BE /S /C, /IT /PolygonCloud)" +evidence = ["crates/annot/src/tests.rs::polygons_clouds_connected_lines_callouts_and_carets_are_drawn", "crates/annot/src/tests.rs::cloud_paths_bulge_outwards_either_way_round", "crates/ui-egui/tests/it/comments.rs::polygons_clouds_connected_lines_callouts_and_inserted_text", "crates/automation/tests/it/automation.rs::drawing_comments_through_tools", "crates/annot/src/tests.rs::cloudy_borders_are_drawn_on_squares_circles_polygons_and_text_boxes", "crates/annot/src/tests.rs::a_cloudy_square_follows_rd_left_top_right_bottom", "crates/annot/src/tests.rs::plain_squares_and_circles_are_drawn_inside_rect_less_rd"] +notes = "polygon with a cloudy border (/BE /S /C, /IT /PolygonCloud). Cloudy borders other PDFs carry are drawn too (ISO 32000-2 Table 169): on squares, circles and text boxes as well as polygons, with the /BS width and dashes, inside /Rect less /RD (left, top, right, bottom), where plain squares and circles without an appearance are drawn too (Table 180); /I defaults to 0, which draws the border plain. Not yet: a cloudy callout keeps its own appearance only, and the bump size is PdfCraft's own (the standard leaves it open)." [[feature]] id = "comment.area-highlight" From 8f5373a7d11f7269914526a8589ba0c1e2544851 Mon Sep 17 00:00:00 2001 From: pat26-dotcom <185797667+pat26-dotcom@users.noreply.github.com> Date: Mon, 9 Nov 2026 11:33:01 +0100 Subject: [PATCH 49/64] M12: turn a layout table into Div and P tags in one call (#4105) (#4250) --- crates/a11y/src/layout_table.rs | 359 +++++++++++++++++++++++ crates/a11y/src/lib.rs | 2 + crates/automation/src/a11y.rs | 14 + crates/automation/src/dispatch.rs | 1 + crates/automation/src/tools.rs | 5 + crates/automation/tests/it/automation.rs | 53 ++++ crates/engine/src/edit.rs | 5 + crates/engine/src/edit/labels.rs | 1 + crates/engine/src/edit/run.rs | 4 + parity/acrobat-features.toml | 6 +- 10 files changed, 447 insertions(+), 3 deletions(-) create mode 100644 crates/a11y/src/layout_table.rs diff --git a/crates/a11y/src/layout_table.rs b/crates/a11y/src/layout_table.rs new file mode 100644 index 0000000000..6b2bab2d09 --- /dev/null +++ b/crates/a11y/src/layout_table.rs @@ -0,0 +1,359 @@ +//! A layout table as plain blocks: a table used only to place content (label and value pairs, a +//! letterhead grid) is read as a data table, with headers and cell navigation it doesn't have. One +//! edit turns the table, its row groups and rows into Div elements and its cells into P elements +//! (Div when a cell holds tags of its own), keeping the content, its order and everything inside +//! the cells, and drops the Table attributes (summary, scope, spans) they no longer have (#4105). + +use std::collections::HashSet; + +use pdfcraft_cos::{CosError, Dict, Document, ObjRef, Object}; + +use crate::alt; + +/// The most structure elements one table may have: past it the edit is refused. +const MAX_ELEMENTS: usize = 100_000; +/// The deepest nesting of direct (inline) elements followed. +const MAX_DEPTH: usize = 256; + +#[derive(Debug, thiserror::Error, PartialEq)] +pub enum LayoutTableError { + #[error("object {0} is not a reachable indirect structure tag")] + NotATag(ObjRef), + #[error("the tag is not a table")] + NotATable, + #[error("the table has too many tags, or tags nested too deeply")] + TooLarge, + #[error(transparent)] + Cos(#[from] CosError), +} + +/// What a table part becomes; `None` for anything else (which keeps its type and contents). +fn block_type(ty: &[u8], has_tags: bool, root: bool) -> Option<&'static str> { + match ty { + b"Table" if root => Some("Div"), + b"THead" | b"TBody" | b"TFoot" | b"TR" => Some("Div"), + b"TH" | b"TD" | b"Caption" => Some(if has_tags { "Div" } else { "P" }), + _ => None, + } +} + +struct Retag<'a> { + doc: &'a Document, + role_map: Dict, + /// Indirect elements under the table still to look at. + queue: Vec, + /// How many elements changed. + changed: usize, +} + +impl Retag<'_> { + fn standard(&self, d: &Dict) -> Option> { + Some(alt::standard(self.doc, &self.role_map, d.name(b"S")?)) + } + + /// Whether children `k` include structure elements (indirect or direct, also in an indirect + /// array of children). + fn has_tags(&self, k: &Object, depth: usize) -> bool { + if depth > MAX_DEPTH { + return false; + } + match k { + Object::Array(items) => items.iter().any(|i| self.has_tags(i, depth + 1)), + Object::Ref(r) => match &*self.doc.get(*r) { + Object::Dict(d) => d.contains(b"S"), + array @ Object::Array(_) => self.has_tags(array, depth + 1), + _ => false, + }, + Object::Dict(d) => d.contains(b"S"), + _ => false, + } + } + + /// Element `d` as a block when it is a part of the table (`root`: the table itself), with its + /// direct children converted the same way and its indirect ones queued; `None` otherwise. + fn convert(&mut self, d: &Dict, root: bool, depth: usize) -> Result, LayoutTableError> { + if depth > MAX_DEPTH { + return Err(LayoutTableError::TooLarge); + } + let Some(ty) = self.standard(d) else { return Ok(None) }; + // As written: a single child is often a reference to it, which must stay one. + let kids = d.get(b"K").cloned(); + let has_tags = kids.as_ref().is_some_and(|k| self.has_tags(k, 0)); + let Some(block) = block_type(&ty, has_tags, root) else { return Ok(None) }; + let mut out = d.clone(); + out.set(b"S".to_vec(), Object::name(block)); + self.drop_table_attributes(&mut out); + if let Some(kids) = kids { + let kids = self.convert_kids(&kids, depth + 1)?; + out.set(b"K".to_vec(), kids); + } + self.changed = self.changed.saturating_add(1); + Ok(Some(out)) + } + + /// Children `k` with direct table parts converted and indirect elements queued (references + /// stay references; an indirect array of children is written out only when a direct table + /// part in it changed). + fn convert_kids(&mut self, k: &Object, depth: usize) -> Result { + if depth > MAX_DEPTH { + return Err(LayoutTableError::TooLarge); + } + Ok(match k { + Object::Array(items) => Object::Array(items.iter().map(|i| self.convert_kids(i, depth)).collect::>()?), + Object::Ref(r) => match &*self.doc.get(*r) { + Object::Dict(d) if d.contains(b"S") => { + self.queue.push(*r); + k.clone() + } + array @ Object::Array(_) => { + let converted = self.convert_kids(array, depth + 1)?; + if converted == *array { k.clone() } else { converted } + } + _ => k.clone(), + }, + Object::Dict(d) if d.contains(b"S") => match self.convert(d, false, depth)? { + Some(block) => Object::Dict(block), + None => k.clone(), + }, + _ => k.clone(), + }) + } + + /// Remove the Table attribute objects (`/O /Table`) from `d`'s `/A`, keeping the others and + /// their revision numbers; an `/A` left empty goes. + fn drop_table_attributes(&self, d: &mut Dict) { + let Some(a) = d.get(b"A").cloned() else { return }; + let is_table = |o: &Object| self.doc.resolve(o).as_dict().is_some_and(|x| x.name(b"O") == Some(b"Table")); + let resolved = self.doc.resolve(&a); + let Object::Array(items) = &*resolved else { + if is_table(&a) { + d.remove(b"A"); + } + return; + }; + let mut kept = Vec::with_capacity(items.len()); + let mut dropping = false; + for item in items { + match item { + // A revision number goes with the attribute object before it. + Object::Int(_) if dropping => {} + _ => { + dropping = is_table(item); + if !dropping { + kept.push(item.clone()); + } + } + } + } + if kept.is_empty() { + d.remove(b"A"); + } else if kept.len() != items.len() { + d.set(b"A".to_vec(), Object::Array(kept)); + } + } +} + +/// Turn layout table `table` (a reachable indirect tag whose type is Table after the role map) into +/// Div and P elements as the module documentation says; returns how many elements changed. Tables +/// nested in its cells stay tables. +pub fn layout_table_to_blocks(doc: &mut Document, table: ObjRef) -> Result { + let Some(element) = alt::elements(doc).into_iter().find(|e| e.object == table) else { + return Err(LayoutTableError::NotATag(table)); + }; + let role_map = alt::tree_root(doc).and_then(|r| r.get(b"RoleMap").map(|o| doc.resolve(o))).and_then(|o| o.as_dict().cloned()).unwrap_or_default(); + let mut retag = Retag { doc: &*doc, role_map, queue: Vec::new(), changed: 0 }; + if retag.standard(&element.dict).as_deref() != Some(b"Table".as_slice()) { + return Err(LayoutTableError::NotATable); + } + // Read everything first, then write: a refused table changes nothing. + let mut updates: Vec<(ObjRef, Dict)> = Vec::new(); + let mut seen = HashSet::new(); + if let Some(block) = retag.convert(&element.dict, true, 0)? { + seen.insert(table); + updates.push((table, block)); + } + while let Some(r) = retag.queue.pop() { + if !seen.insert(r) { + continue; + } + if seen.len() > MAX_ELEMENTS { + return Err(LayoutTableError::TooLarge); + } + let Some(d) = retag.doc.get(r).as_dict().cloned() else { continue }; + if let Some(block) = retag.convert(&d, false, 0)? { + updates.push((r, block)); + } + } + let changed = retag.changed; + for (r, block) in updates { + doc.update_dict(r, |d| *d = block)?; + } + Ok(changed) +} + +#[cfg(test)] +mod tests { + use std::sync::Arc; + + use pdfcraft_cos::{Document, ObjRef, Object}; + + use super::*; + + /// A tagged document: the catalog (1), the structure tree root (2, `root` its other entries) + /// over the Document element (3), then `objects` from 4 on. + fn doc(root: &str, objects: &[&str]) -> Document { + let mut bodies = vec!["<< /Type /Catalog /StructTreeRoot 2 0 R >>".to_string(), format!("<< /Type /StructTreeRoot /K 3 0 R {root} >>")]; + bodies.extend(objects.iter().map(|s| s.to_string())); + let mut out = b"%PDF-1.7\n".to_vec(); + let mut offsets = Vec::new(); + for (i, b) in bodies.iter().enumerate() { + offsets.push(out.len()); + out.extend_from_slice(format!("{} 0 obj\n{b}\nendobj\n", i + 1).as_bytes()); + } + let xref = out.len(); + out.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", bodies.len() + 1).as_bytes()); + for o in offsets { + out.extend_from_slice(format!("{o:010} 00000 n \n").as_bytes()); + } + out.extend_from_slice(format!("trailer << /Size {} /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n", bodies.len() + 1).as_bytes()); + Document::open(Arc::new(out)).unwrap() + } + + fn r(n: u32) -> ObjRef { + ObjRef::new(n, 0) + } + + fn dict(doc: &Document, n: u32) -> Dict { + doc.get(r(n)).as_dict().cloned().unwrap() + } + + fn ty(doc: &Document, n: u32) -> Vec { + dict(doc, n).name(b"S").unwrap().to_vec() + } + + #[test] + fn a_label_and_value_table_becomes_divs_and_paragraphs() { + let mut d = doc( + "", + &[ + "<< /S /Document /K [4 0 R] >>", + // 4: the table, with a layout attribute and a summary (revision 1). + "<< /S /Table /K [5 0 R 8 0 R] /A [ << /O /Layout /Placement /Block >> << /O /Table /Summary (s) >> 1 ] >>", + "<< /S /TR /K [6 0 R 7 0 R] >>", + "<< /S /TH /K 0 /A << /O /Table /Scope /Row >> >>", + "<< /S /TD /K 1 >>", + "<< /S /TR /K [9 0 R 10 0 R] >>", + "<< /S /TD /K 2 >>", + "<< /S /TD /K 3 /A [ << /O /Table /ColSpan 1 >> << /O /Layout /TextAlign /End >> ] >>", + ], + ); + let before: Vec = crate::tags(&d).iter().map(|t| t.object).collect(); + assert_eq!(layout_table_to_blocks(&mut d, r(4)), Ok(7)); + for (n, want) in [(4, "Div"), (5, "Div"), (6, "P"), (7, "P"), (8, "Div"), (9, "P"), (10, "P")] { + assert_eq!(ty(&d, n), want.as_bytes(), "object {n}"); + } + // The content and the order stay; Table attributes go, layout ones stay. + assert_eq!(crate::tags(&d).iter().map(|t| t.object).collect::>(), before); + assert_eq!(dict(&d, 6).get(b"K"), Some(&Object::Int(0))); + assert_eq!(dict(&d, 6).get(b"A"), None); + let Some(Object::Array(a)) = dict(&d, 4).get(b"A").cloned() else { panic!("{:?}", dict(&d, 4)) }; + assert_eq!(a.len(), 1, "the summary and its revision number go: {a:?}"); + assert_eq!(a[0].as_dict().and_then(|x| x.name(b"O")), Some(&b"Layout"[..])); + let Some(Object::Array(a)) = dict(&d, 10).get(b"A").cloned() else { panic!() }; + assert_eq!(a.len(), 1); + } + + #[test] + fn what_the_cells_hold_stays_and_a_table_in_a_cell_stays_a_table() { + let mut d = doc( + "", + &[ + "<< /S /Document /K [4 0 R] >>", + "<< /S /Table /K [5 0 R] >>", + "<< /S /TR /K [6 0 R 7 0 R] >>", + // 6: a cell holding a paragraph; 7: a cell holding a table of its own. + "<< /S /TD /K [8 0 R] >>", + "<< /S /TD /K [9 0 R] >>", + "<< /S /P /K 0 >>", + "<< /S /Table /K [10 0 R] >>", + "<< /S /TR /K [11 0 R] >>", + "<< /S /TD /K 1 >>", + ], + ); + layout_table_to_blocks(&mut d, r(4)).unwrap(); + for (n, want) in [(4, "Div"), (5, "Div"), (6, "Div"), (7, "Div"), (8, "P"), (9, "Table"), (10, "TR"), (11, "TD")] { + assert_eq!(ty(&d, n), want.as_bytes(), "object {n}"); + } + } + + #[test] + fn direct_rows_and_cells_are_converted_in_place() { + let mut d = doc( + "/RoleMap << /Grid /Table /Row /TR >>", + &[ + "<< /S /Document /K [4 0 R] >>", + "<< /S /Grid /K [ << /S /Row /K [ << /S /TD /K 0 >> << /S /TD /K [5 0 R] >> ] >> ] >>", + "<< /S /Span /K 1 >>", + ], + ); + assert_eq!(layout_table_to_blocks(&mut d, r(4)), Ok(4)); + let table = dict(&d, 4); + assert_eq!(table.name(b"S"), Some(&b"Div"[..]), "the role map makes /Grid a table"); + let Some(Object::Array(rows)) = table.get(b"K").cloned() else { panic!() }; + let row = rows[0].as_dict().unwrap(); + assert_eq!(row.name(b"S"), Some(&b"Div"[..])); + let Some(Object::Array(cells)) = row.get(b"K").cloned() else { panic!() }; + assert_eq!(cells[0].as_dict().and_then(|c| c.name(b"S")), Some(&b"P"[..])); + assert_eq!(cells[1].as_dict().and_then(|c| c.name(b"S")), Some(&b"Div"[..]), "it holds a Span tag"); + assert_eq!(ty(&d, 5), b"Span"); + } + + #[test] + fn only_a_reachable_table_is_changed() { + let mut d = doc("", &["<< /S /Document /K [4 0 R] >>", "<< /S /P /K 0 >>", "<< /S /Table >>"]); + assert_eq!(layout_table_to_blocks(&mut d, r(4)), Err(LayoutTableError::NotATable)); + assert_eq!(layout_table_to_blocks(&mut d, r(5)), Err(LayoutTableError::NotATag(r(5))), "not in the structure tree"); + assert_eq!(layout_table_to_blocks(&mut d, ObjRef::new(4, 1)), Err(LayoutTableError::NotATag(ObjRef::new(4, 1)))); + assert_eq!(ty(&d, 5), b"Table"); + } + + #[test] + fn a_single_child_written_as_a_reference_stays_its_own_object() { + // /K 5 0 R: the table's one row, the row's one cell; 8: a row whose cells are an indirect + // array (object 9) holding a direct cell and a reference. + let mut d = doc( + "", + &[ + "<< /S /Document /K [4 0 R 7 0 R] >>", + "<< /S /Table /K 5 0 R >>", + "<< /S /TR /K 6 0 R >>", + "<< /S /TD /K 0 >>", + "<< /S /Table /K 8 0 R >>", + "<< /S /TR /K 9 0 R >>", + "[ << /S /TD /K 1 >> 10 0 R ]", + "<< /S /TD /K 2 >>", + ], + ); + assert_eq!(layout_table_to_blocks(&mut d, r(4)), Ok(3)); + assert_eq!(dict(&d, 4).get(b"K"), Some(&Object::Ref(r(5))), "the row stays a reference"); + assert_eq!(dict(&d, 5).get(b"K"), Some(&Object::Ref(r(6)))); + for (n, want) in [(4, "Div"), (5, "Div"), (6, "P")] { + assert_eq!(ty(&d, n), want.as_bytes(), "object {n}"); + } + assert!(crate::tags(&d).iter().any(|t| t.object == r(6)), "the cell is still listed"); + assert_eq!(layout_table_to_blocks(&mut d, r(7)), Ok(4)); + let Some(Object::Array(cells)) = dict(&d, 8).get(b"K").cloned() else { panic!("{:?}", dict(&d, 8)) }; + assert_eq!(cells[0].as_dict().and_then(|c| c.name(b"S")), Some(&b"P"[..])); + assert_eq!(cells[1], Object::Ref(r(10))); + assert_eq!(ty(&d, 10), b"P"); + } + + #[test] + fn rows_that_point_at_each_other_end() { + // A row listed twice and a cell that lists its own row: each is converted once. + let mut d = + doc("", &["<< /S /Document /K [4 0 R] >>", "<< /S /Table /K [5 0 R 5 0 R] >>", "<< /S /TR /K [6 0 R] >>", "<< /S /TD /K [5 0 R] >>"]); + assert_eq!(layout_table_to_blocks(&mut d, r(4)), Ok(3)); + assert_eq!((ty(&d, 5), ty(&d, 6)), (b"Div".to_vec(), b"Div".to_vec())); + } +} diff --git a/crates/a11y/src/lib.rs b/crates/a11y/src/lib.rs index 280f4e845f..50e632dec8 100644 --- a/crates/a11y/src/lib.rs +++ b/crates/a11y/src/lib.rs @@ -17,6 +17,7 @@ mod autotag; mod autotag_fields; mod content; mod headings; +mod layout_table; mod report; mod structure; mod tags; @@ -41,6 +42,7 @@ pub use alt::{AltError, ArtifactError, Figure, figures, mark_decorative, mark_ta pub use autotag::{AutoTagError, AutoTagResult, autotag_text}; pub use autotag_fields::tag_form_fields; pub use headings::{Heading, headings}; +pub use layout_table::{LayoutTableError, layout_table_to_blocks}; pub use report::{REPORT_WORDS, report_html}; pub use tags::{TagContentItem, TagError, TagInfo, TagPlacement, TagProperty, move_tag, set_tag_property, tag_content, tags}; diff --git a/crates/automation/src/a11y.rs b/crates/automation/src/a11y.rs index f47e8a2527..c5692ff9a7 100644 --- a/crates/automation/src/a11y.rs +++ b/crates/automation/src/a11y.rs @@ -557,6 +557,20 @@ impl Automation { Ok(with_warnings(self.accessibility_tags(a)?, warnings)) } + pub(crate) fn accessibility_layout_table_to_blocks(&mut self, a: &Args) -> Result { + let object = u32::try_from(a.int("object")?).map_err(|_| ToolError::InvalidArgs("object must be a positive tag object number".into()))?; + if object == 0 { + return Err(ToolError::InvalidArgs("object must be a positive tag object number".into())); + } + let generation = u16::try_from(a.int("generation")?) + .map_err(|_| ToolError::InvalidArgs("generation must be a PDF object generation from accessibility_tags".into()))?; + let id = self.doc(a)?.id; + self.session + .apply(id, pdfcraft_engine::Edit::LayoutTableToBlocks { object: pdfcraft_cos::ObjRef::new(object, generation) }) + .map_err(failed)?; + self.accessibility_tags(a) + } + pub(crate) fn accessibility_move_tag(&mut self, a: &Args) -> Result { let reference = |number: &str, generation: &str| -> Result { let n = u32::try_from(a.int(number)?).map_err(|_| ToolError::InvalidArgs(format!("{number} must be a positive tag object number")))?; diff --git a/crates/automation/src/dispatch.rs b/crates/automation/src/dispatch.rs index 276642b930..43eacc7f95 100644 --- a/crates/automation/src/dispatch.rs +++ b/crates/automation/src/dispatch.rs @@ -835,6 +835,7 @@ impl Automation { "accessibility_tag_content" => self.accessibility_tag_content(&a)?, "accessibility_set_tag_property" => self.accessibility_set_tag_property(&a)?, "accessibility_mark_tag_artifact" => self.accessibility_mark_tag_artifact(&a)?, + "accessibility_layout_table_to_blocks" => self.accessibility_layout_table_to_blocks(&a)?, "accessibility_move_tag" => self.accessibility_move_tag(&a)?, "sign_id_create" => self.sign_id_create(&a)?, "sign_document" => self.sign_document(&a)?, diff --git a/crates/automation/src/tools.rs b/crates/automation/src/tools.rs index 81be15b3c9..d33313052e 100644 --- a/crates/automation/src/tools.rs +++ b/crates/automation/src/tools.rs @@ -1332,6 +1332,11 @@ pub fn tools() -> Vec { "doc": doc(), "object": { "type": "integer", "minimum": 1 }, "generation": { "type": "integer", "minimum": 0, "maximum": 65535 }, }), &["doc", "object", "generation"])), + t("accessibility_layout_table_to_blocks", "Change layout table to blocks", "Turn a table used only for layout (label and value pairs, a letterhead grid) into plain blocks in one step: the Table tag, its THead/TBody/TFoot and TR tags become Div, its TH, TD and Caption tags P (Div when they hold tags of their own). Content, reading order and the tags inside cells stay; a table nested in a cell stays a table; Table attributes (Summary, Scope, spans) are removed, other attributes kept. The tag's type must be Table after the role map; use object and generation from accessibility_tags. Undoable. Returns the tags as accessibility_tags does.") + .with(schema(json!({ + "doc": doc(), "object": { "type": "integer", "minimum": 1 }, + "generation": { "type": "integer", "minimum": 0, "maximum": 65535 }, + }), &["doc", "object", "generation"])), t("accessibility_move_tag", "Move structure tag", "Move a reachable indirect structure tag before or after a direct sibling in their parent's /K array, changing tagged reading order without changing page appearance. Use object and generation values from accessibility_tags. Rejects distinct parents, duplicate references and positions that are already current. Undoable.") .with(schema(json!({ "doc": doc(), "object": { "type": "integer", "minimum": 1 }, diff --git a/crates/automation/tests/it/automation.rs b/crates/automation/tests/it/automation.rs index de9e996fcd..ac6b2374e5 100644 --- a/crates/automation/tests/it/automation.rs +++ b/crates/automation/tests/it/automation.rs @@ -3653,6 +3653,59 @@ trailer << /Root 1 0 R >> assert_eq!(saved.iter().find(|t| t.kind == "TH").and_then(|t| t.scope.as_deref()), Some("Column")); } +/// A layout table becomes plain blocks through `accessibility_layout_table_to_blocks` (#4105): the +/// table findings go, the content keeps its order, and the change is undone and saved. +#[test] +fn a_layout_table_becomes_blocks_through_automation() { + let dir = workdir("a11y-layout-table"); + let content = "/TD << /MCID 0 >> BDC 10 150 40 20 re f EMC /TD << /MCID 1 >> BDC 60 150 40 20 re f EMC"; + let pdf = format!( + "%PDF-1.7 +1 0 obj << /Type /Catalog /Pages 2 0 R /MarkInfo << /Marked true >> /StructTreeRoot 5 0 R >> endobj +2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 >> endobj +3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 200 200] /Contents 4 0 R /StructParents 0 >> endobj +4 0 obj << /Length {} >> stream +{content} +endstream endobj +5 0 obj << /Type /StructTreeRoot /K 6 0 R /ParentTree 11 0 R >> endobj +6 0 obj << /S /Document /P 5 0 R /K [7 0 R] >> endobj +7 0 obj << /S /Table /P 6 0 R /K [8 0 R] >> endobj +8 0 obj << /S /TR /P 7 0 R /K [9 0 R 10 0 R] >> endobj +9 0 obj << /S /TD /P 8 0 R /Pg 3 0 R /K 0 >> endobj +10 0 obj << /S /TD /P 8 0 R /Pg 3 0 R /K 1 >> endobj +11 0 obj << /Nums [0 [9 0 R 10 0 R]] >> endobj +trailer << /Root 1 0 R >> +%%EOF", + content.len() + ); + std::fs::write(dir.join("layout.pdf"), pdf).unwrap(); + let mut a = auto(&dir); + let fd = ok(&mut a, "doc_open", json!({ "path": "layout.pdf" }))["doc"].as_u64().unwrap(); + let table_rules = |a: &mut Automation| -> Vec { + let r = ok(a, "accessibility_check", json!({ "doc": fd, "rules": ["table-summary", "table-headers"] })); + ["table-summary", "table-headers"] + .map(|id| r["results"].as_array().unwrap().iter().find(|x| x["rule"] == id).unwrap()["status"].as_str().unwrap().to_owned()) + .to_vec() + }; + assert_eq!(table_rules(&mut a), ["failed", "failed"]); + let types = |tags: &Value| tags["tags"].as_array().unwrap().iter().map(|t| t["type"].as_str().unwrap().to_owned()).collect::>(); + let tags = ok(&mut a, "accessibility_layout_table_to_blocks", json!({ "doc": fd, "object": 7, "generation": 0 })); + assert_eq!(types(&tags), ["Document", "Div", "Div", "P", "P"]); + assert_eq!(table_rules(&mut a), ["passed", "passed"]); + let content = |a: &mut Automation, object: u32| { + ok(a, "accessibility_tag_content", json!({ "doc": fd, "object": object, "generation": 0 }))["content"][0]["mcid"].clone() + }; + assert_eq!((content(&mut a, 9), content(&mut a, 10)), (json!(0), json!(1)), "each cell keeps its content"); + // Only a table: a cell is refused. + assert!(matches!(err(&mut a, "accessibility_layout_table_to_blocks", json!({ "doc": fd, "object": 9, "generation": 0 })), ToolError::Failed(_))); + assert_eq!(ok(&mut a, "edit_undo", json!({ "doc": fd }))["undone"], "Change tag properties"); + assert_eq!(types(&ok(&mut a, "accessibility_tags", json!({ "doc": fd }))), ["Document", "Table", "TR", "TD", "TD"]); + ok(&mut a, "edit_redo", json!({ "doc": fd })); + ok(&mut a, "doc_save", json!({ "doc": fd, "path": "blocks.pdf" })); + let saved = pdfcraft_cos::Document::open(std::sync::Arc::new(std::fs::read(dir.join("blocks.pdf")).unwrap())).unwrap(); + assert_eq!(pdfcraft_engine::a11y::tags(&saved).into_iter().map(|t| t.kind).collect::>(), ["Document", "Div", "Div", "P", "P"]); +} + #[test] fn marking_a_leaf_tag_as_an_artifact_through_automation_is_undoable_and_saved() { let dir = workdir("a11y-artifact"); diff --git a/crates/engine/src/edit.rs b/crates/engine/src/edit.rs index af34efff47..b4b8e56ffd 100644 --- a/crates/engine/src/edit.rs +++ b/crates/engine/src/edit.rs @@ -397,6 +397,11 @@ pub enum Edit { MarkTagArtifact { object: pdfcraft_cos::ObjRef, }, + /// A layout table as plain blocks: the table, its row groups and rows become Div tags and its + /// cells P tags (Div when they hold tags), without their Table attributes. + LayoutTableToBlocks { + object: pdfcraft_cos::ObjRef, + }, /// Document JavaScripts: add, replace (`script`) or remove (`None`) the document-level /// script `name`. SetDocumentScript { diff --git a/crates/engine/src/edit/labels.rs b/crates/engine/src/edit/labels.rs index 879601488a..1979c86348 100644 --- a/crates/engine/src/edit/labels.rs +++ b/crates/engine/src/edit/labels.rs @@ -260,6 +260,7 @@ impl Edit { Edit::MoveTag { .. } => Label::of("Move object"), Edit::MarkDecorative { .. } => Label::of("Mark figure as decorative"), Edit::MarkTagArtifact { .. } => Label::of("Mark content as artifact"), + Edit::LayoutTableToBlocks { .. } => Label::of("Change tag properties"), Edit::AddOcrText { .. } => Label::of("Recognize text"), Edit::EnhanceScan { .. } => Label::of("Enhance scanned file"), Edit::ApplyScriptChanges { .. } => Label::of("Run JavaScript"), diff --git a/crates/engine/src/edit/run.rs b/crates/engine/src/edit/run.rs index e2b8a43e14..e584ac2413 100644 --- a/crates/engine/src/edit/run.rs +++ b/crates/engine/src/edit/run.rs @@ -144,6 +144,7 @@ pub(crate) fn check_permission(edit: &Edit, p: &pdfcraft_cos::Permissions) -> Re | Edit::MoveTag { .. } | Edit::MarkDecorative { .. } | Edit::MarkTagArtifact { .. } + | Edit::LayoutTableToBlocks { .. } | Edit::AddOcrText { .. } | Edit::EnhanceScan { .. } | Edit::SetDocumentScript { .. } @@ -799,6 +800,9 @@ pub(crate) fn run_edit(doc: &mut pdfcraft_cos::Document, edit: &Edit, cx: &mut E Edit::MarkTagArtifact { object } => { a11y::mark_tag_artifact(doc, *object).map_err(|e| EditError::Accessibility(e.to_string()))?; } + Edit::LayoutTableToBlocks { object } => { + a11y::layout_table_to_blocks(doc, *object).map_err(|e| EditError::Accessibility(e.to_string()))?; + } Edit::AddHeaderFooter { pages, settings, replace } => { let date = cx.today; pdfcraft_edit::add_header_footer(doc, pages, settings, *replace, &pdfcraft_edit::Context { date })?; diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index 951ef61b38..0991c7cf1d 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -9248,9 +9248,9 @@ tier = "P2" milestone = "M12" status = "planned" commands = [] -tools = [] -evidence = [] -notes = "" +tools = ["accessibility_layout_table_to_blocks"] +evidence = ["crates/a11y/src/layout_table.rs::a_label_and_value_table_becomes_divs_and_paragraphs", "crates/a11y/src/layout_table.rs::what_the_cells_hold_stays_and_a_table_in_a_cell_stays_a_table", "crates/a11y/src/layout_table.rs::direct_rows_and_cells_are_converted_in_place", "crates/automation/tests/it/automation.rs::a_layout_table_becomes_blocks_through_automation"] +notes = "Headless only so far: accessibility_layout_table_to_blocks turns a layout table into Div and P tags in one undoable step (cells holding tags become Div, nested tables stay, Table attributes go). The editor itself (header cells, Scope, Headers/IDs, spans) and a Tags panel command remain planned." [[feature]] id = "a11y.alt-text-workflow" From 5588db4b629b31569349fb9553084e9e796feeb9 Mon Sep 17 00:00:00 2001 From: mtlcl410 <2368091+mtlcl410@users.noreply.github.com> Date: Mon, 9 Nov 2026 18:33:17 +0800 Subject: [PATCH 50/64] M10: (n) names the page at position n, whatever its label, in page ranges and the page box (print.page-range) (#4282) --- crates/automation/src/tools.rs | 2 +- crates/print/src/lib.rs | 2 +- crates/print/src/range.rs | 28 +++++++++++---- crates/print/src/tests.rs | 48 +++++++++++++++++++++++++ crates/ui-egui/src/canvas/navigation.rs | 5 +-- crates/ui-egui/tests/it/viewer.rs | 18 ++++++++++ parity/acrobat-features.toml | 8 ++--- 7 files changed, 97 insertions(+), 14 deletions(-) diff --git a/crates/automation/src/tools.rs b/crates/automation/src/tools.rs index d33313052e..c2eed3651c 100644 --- a/crates/automation/src/tools.rs +++ b/crates/automation/src/tools.rs @@ -719,7 +719,7 @@ pub fn tools() -> Vec { t( "doc_print", "Print", - "Print with Acrobat's Print dialog options, or save the print-ready PDF. pages: a range such as \"1-3, 6, 9-\" (page labels allowed; default all); subset odd/even (by page number, as Acrobat counts them: pages 2, 7-10 with even prints 2, 8 and 10, not every second page of the range); reverse. layout: fit (default), actual, shrink, custom (scale %), multiple (per_sheet 2/4/6/9/16, which turns the sheet to suit the grid, or a custom grid of cols across by rows down, each 1-99, on the sheet as oriented (auto prints portrait); order, border, auto_rotate; cut-stack order arranges single-sided sheets for cutting into piles and stacking left to right, top to bottom, keeping sheet order within each pile; duplex must be off), booklet (booklet_subset both/front/back, binding left/right; a booklet sets two-sided itself, as Acrobat does: both flips on the short edge, front or back prints one-sided), poster (scale %, overlap pt, cut_marks). orientation auto/portrait/landscape; comments_forms document / document-and-markups (default) / document-and-stamps / form-fields-only; paper Letter/Legal/Tabloid, ARCH A/B/C/D/E1/E, ANSI C/D/E, A0/A1/A2/A3/A4/A5/A6, or US #10 Envelope (short names or full preset labels); use layout actual for 100% in the print-ready PDF; printer drivers may still substitute paper or scale. Then path (save) or printer (a name or \"default\") with copies, collate, duplex off/long-edge/short-edge, grayscale, and options (driver options from printer_options, CUPS).", + "Print with Acrobat's Print dialog options, or save the print-ready PDF. pages: a range such as \"1-3, 6, 9-\" (page labels allowed, \"(n)\" for the nth page whatever its label, unless a page is labelled exactly \"(n)\", since labels are looked up first; default all); subset odd/even (by page number, as Acrobat counts them: pages 2, 7-10 with even prints 2, 8 and 10, not every second page of the range); reverse. layout: fit (default), actual, shrink, custom (scale %), multiple (per_sheet 2/4/6/9/16, which turns the sheet to suit the grid, or a custom grid of cols across by rows down, each 1-99, on the sheet as oriented (auto prints portrait); order, border, auto_rotate; cut-stack order arranges single-sided sheets for cutting into piles and stacking left to right, top to bottom, keeping sheet order within each pile; duplex must be off), booklet (booklet_subset both/front/back, binding left/right; a booklet sets two-sided itself, as Acrobat does: both flips on the short edge, front or back prints one-sided), poster (scale %, overlap pt, cut_marks). orientation auto/portrait/landscape; comments_forms document / document-and-markups (default) / document-and-stamps / form-fields-only; paper Letter/Legal/Tabloid, ARCH A/B/C/D/E1/E, ANSI C/D/E, A0/A1/A2/A3/A4/A5/A6, or US #10 Envelope (short names or full preset labels); use layout actual for 100% in the print-ready PDF; printer drivers may still substitute paper or scale. Then path (save) or printer (a name or \"default\") with copies, collate, duplex off/long-edge/short-edge, grayscale, and options (driver options from printer_options, CUPS).", ) .with(schema( json!({ diff --git a/crates/print/src/lib.rs b/crates/print/src/lib.rs index 8fffbdf649..3564b86687 100644 --- a/crates/print/src/lib.rs +++ b/crates/print/src/lib.rs @@ -25,7 +25,7 @@ pub mod spool; #[cfg(test)] mod tests; -pub use range::{Subset, select_listed, select_pages}; +pub use range::{Subset, position, select_listed, select_pages}; mod error; mod imposition; diff --git a/crates/print/src/range.rs b/crates/print/src/range.rs index 027158df01..e68d1d31d9 100644 --- a/crates/print/src/range.rs +++ b/crates/print/src/range.rs @@ -1,4 +1,5 @@ -//! Pages to Print: all, a range ("1-3, 6, 9-", page labels allowed), odd or even pages, reverse. +//! Pages to Print: all, a range ("1-3, 6, 9-", page labels allowed, "(n)" for the page at +//! position n), odd or even pages, reverse. use std::collections::HashMap; @@ -44,24 +45,39 @@ pub enum Subset { Even, } -/// Resolve one range token: a page label, or a number (1-based). +/// Resolve one range token: a page label, a number in parentheses for the page at that position, +/// or a number (1-based). fn page_of(tok: &str, count: usize, labels: &Labels) -> Result { let t = tok.trim(); // Page labels win over numbers (Acrobat's "use logical page numbers"). if let Some(i) = labels.get(t) { return Ok(i); } + let number = parenthesized(t).unwrap_or(t); // `usize::from_str` accepts a leading '+', which is not part of a page number. - if !t.bytes().all(|b| b.is_ascii_digit()) { + if !number.bytes().all(|b| b.is_ascii_digit()) { return Err(PrintError::NotAPage(t.to_string())); } - match t.parse::() { + match number.parse::() { Ok(n) if n >= 1 && n <= count => Ok(n - 1), Ok(n) => Err(PrintError::OutOfRange(n, count)), Err(_) => Err(PrintError::NotAPage(t.to_string())), } } +/// What a token holds between one pair of parentheses, trimmed: "( 3 )" holds "3". +fn parenthesized(t: &str) -> Option<&str> { + t.trim().strip_prefix('(')?.strip_suffix(')').map(str::trim) +} + +/// The page position (1-based) a token names in parentheses. A page's label and its position +/// need not coincide (ISO 32000-2 §12.4.2): "(n)" always names the nth page, so a page can be +/// named by position where a label such as "1" belongs to another page. `None` for anything +/// else. Callers look labels up first, so a label spelled "(1)" keeps naming its own page. +pub fn position(token: &str) -> Option { + parenthesized(token).filter(|n| n.bytes().all(|b| b.is_ascii_digit()))?.parse().ok() +} + /// Split a range whose endpoint is a logical page label containing a hyphen, such /// as "A-1-A-3" or "A-2-". The plain range parser must not mistake the first /// hyphen inside the label for the delimiter between endpoints. @@ -83,8 +99,8 @@ fn hyphenated_label_range(part: &str, count: usize, labels: &Labels) -> Option<( } /// The pages to print (0-based, in print order). `range` is `None` for all pages; it may list -/// numbers and labels with `-` ranges (open-ended allowed: `5-`, `-3`). The subset keeps the odd or -/// even pages of those chosen, by page number (see [`narrow`]). +/// numbers, labels and `(n)` positions with `-` ranges (open-ended allowed: `5-`, `-3`). The subset +/// keeps the odd or even pages of those chosen, by page number (see [`narrow`]). pub fn select_pages(count: usize, range: Option<&str>, labels: &[String], subset: Subset, reverse: bool) -> Result, PrintError> { let mut pages = Vec::new(); match range.map(str::trim).filter(|r| !r.is_empty()) { diff --git a/crates/print/src/tests.rs b/crates/print/src/tests.rs index 5b8954fee9..a1026308ea 100644 --- a/crates/print/src/tests.rs +++ b/crates/print/src/tests.rs @@ -126,6 +126,54 @@ fn page_selection_handles_ranges_between_hyphenated_labels() { assert_eq!(select_pages(3, Some("A-1-B-C"), &ambiguous, Subset::All, false).unwrap(), [1, 2]); } +/// A page's label and its position need not coincide (ISO 32000-2 §12.4.2). With front matter +/// labelled i–iii and the body 1–7, "1" is the page labelled 1 (the fourth), while "(1)" is the +/// first page, whatever it is labelled; positions combine with labels and open ends in ranges. +#[test] +fn a_number_in_parentheses_names_the_page_at_that_position() { + let labels: Vec = ["i", "ii", "iii", "1", "2", "3", "4", "5", "6", "7"].iter().map(|s| s.to_string()).collect(); + let pages = |range| select_pages(10, Some(range), &labels, Subset::All, false); + assert_eq!(pages("1").unwrap(), [3], "a bare number is the label"); + assert_eq!(pages("(1)").unwrap(), [0], "in parentheses it is the position"); + assert_eq!(pages("( 2 )").unwrap(), [1]); + assert_eq!(pages("(1)-(3)").unwrap(), [0, 1, 2]); + assert_eq!(pages("(9)-").unwrap(), [8, 9]); + assert_eq!(pages("-(2)").unwrap(), [0, 1]); + assert_eq!(pages("(2)-1").unwrap(), [1, 2, 3], "a position and a label"); + assert_eq!(pages("ii-(5)").unwrap(), [1, 2, 3, 4]); + assert_eq!(pages("(4)-(2), 7").unwrap(), [3, 2, 1, 9]); + assert_eq!(select_pages(10, Some("(1)-(4)"), &labels, Subset::Even, false).unwrap(), [1, 3], "odd and even stay page numbers"); + assert_eq!(pages("(0)"), Err(PrintError::OutOfRange(0, 10))); + assert_eq!(pages("(11)"), Err(PrintError::OutOfRange(11, 10))); + for bad in ["(x)", "()", "(1", "1)", "((1))", "(+3)"] { + assert_eq!(pages(bad), Err(PrintError::NotAPage(bad.into())), "{bad}"); + } + // The page box takes positions through `position`, with the same digits-only rule. + assert_eq!(crate::position("( 3 )"), Some(3)); + assert_eq!(crate::position("(+3)"), None, "a sign is not part of a page number"); + assert!(pages("(-1)").is_err(), "a dash inside parentheses is still a range, of nothing"); + // A label that is itself "(1)" still names its own page, as every label does. + let odd: Vec = ["a", "b", "(1)"].iter().map(|s| s.to_string()).collect(); + assert_eq!(select_pages(3, Some("(1)"), &odd, Subset::All, false).unwrap(), [2]); + // Without labels a position is the same page as its number. + assert_eq!(select_pages(5, Some("(2)-4"), &[], Subset::All, false).unwrap(), [1, 2, 3]); +} + +/// Odd and even count page positions, never the numbers inside labels: page indices are fixed +/// and consecutive, while labels can restart, repeat, use letters or carry no number (ISO 32000-2 +/// §12.4.2). With front matter labelled i–iii the page labelled 1 is the fourth, so it is even, and +/// a range typed in labels keeps the same pages as the same range typed as positions. +#[test] +fn odd_and_even_count_positions_on_a_labelled_document() { + let labels: Vec = ["i", "ii", "iii", "1", "2", "3", "4", "5", "6", "7"].iter().map(|s| s.to_string()).collect(); + let pick = |range: Option<&str>, subset| select_pages(10, range, &labels, subset, false).unwrap(); + assert_eq!(pick(Some("1-4"), Subset::Even), [3, 5], "labels 1 and 3, the fourth and sixth pages"); + assert_eq!(pick(Some("1-4"), Subset::Odd), [4, 6], "labels 2 and 4, the fifth and seventh pages"); + assert_eq!(pick(Some("1-4"), Subset::Even), pick(Some("(4)-(7)"), Subset::Even), "labels or positions, the same pages"); + assert_eq!(pick(None, Subset::Odd), [0, 2, 4, 6, 8], "i, iii, 2, 4, 6"); + assert_eq!(pick(None, Subset::Even), [1, 3, 5, 7, 9]); +} + #[test] fn size_modes() { let sizes = [(200.0, 300.0), (1000.0, 1500.0)]; diff --git a/crates/ui-egui/src/canvas/navigation.rs b/crates/ui-egui/src/canvas/navigation.rs index 49dc3336c6..4e491d514c 100644 --- a/crates/ui-egui/src/canvas/navigation.rs +++ b/crates/ui-egui/src/canvas/navigation.rs @@ -240,7 +240,8 @@ impl DocView { /// Retain the current page when `typed` exactly matches its displayed label, even if /// another page has that label. Otherwise resolve a trimmed label (case-insensitive), - /// then a physical page number. `false` when it names no page. + /// then a position in parentheses ("(3)", as print ranges take it), then a physical page + /// number. `false` when it names no page. pub fn go_to_typed(&mut self, typed: &str, labels: &[String]) -> bool { // Compare the displayed value before trimming: a label may itself contain spaces. // Repeated labels occur in restarted sections and prefix-only ranges. @@ -251,7 +252,7 @@ impl DocView { let page = labels .iter() .position(|l| !l.is_empty() && l.eq_ignore_ascii_case(t)) - .or_else(|| t.parse::().ok().filter(|n| *n >= 1).map(|n| n - 1)); + .or_else(|| pdfcraft_engine::print::position(t).or_else(|| t.parse::().ok()).filter(|n| *n >= 1).map(|n| n - 1)); match page { Some(p) if p < self.page_count => { self.go_to_page(p); diff --git a/crates/ui-egui/tests/it/viewer.rs b/crates/ui-egui/tests/it/viewer.rs index 464b63a17c..fb94cc2c5b 100644 --- a/crates/ui-egui/tests/it/viewer.rs +++ b/crates/ui-egui/tests/it/viewer.rs @@ -655,6 +655,24 @@ fn page_label_input_resolves_labels_numbers_and_restores_invalid_input() { assert_eq!(h.state().views[0].current, 0, "the current fallback number must not jump to another page labelled 1"); } +/// A page's label and its position need not coincide (ISO 32000-2 §12.4.2). Typed in the page +/// box, "(n)" goes to the nth page whatever its label, as print ranges take it, while a bare +/// number is still a label first. On these pages (i, ii, 1, 2, 3) "1" is the third page and +/// "(1)" the first; a position past the end, or anything else in parentheses, leaves the page. +#[test] +fn page_label_input_takes_a_position_in_parentheses() { + let mut h = harness(); + restart_page_labels(&mut h); + h.run_steps(3); + for (typed, page, displayed) in [("(1)", 0, "i"), ("1", 2, "1"), ("(4)", 3, "2"), ("( 2 )", 1, "ii"), ("(9)", 1, "ii"), ("(x)", 1, "ii")] { + type_page_input(&mut h, typed); + h.key_press(egui::Key::Enter); + h.run_steps(3); + assert_eq!(h.state().views[0].current, page, "{typed}"); + assert_eq!(h.state().views[0].page_input, displayed, "{typed}"); + } +} + #[test] fn page_label_input_preserves_literal_whitespace_before_trimmed_lookup() { use pdfcraft_render::{DocInfo, PageInfo}; diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index 0991c7cf1d..927e32df83 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -8225,8 +8225,8 @@ milestone = "M10" status = "shipped" commands = ["print.dialog"] tools = ["doc_print"] -evidence = ["crates/print/src/tests.rs::page_selection", "crates/automation/tests/it/automation.rs::printing_through_tools"] -notes = "all, current, ranges with labels and open ends" +evidence = ["crates/print/src/tests.rs::page_selection", "crates/print/src/tests.rs::a_number_in_parentheses_names_the_page_at_that_position", "crates/automation/tests/it/automation.rs::printing_through_tools"] +notes = "all, current, ranges with labels and open ends; (n) names the nth page whatever its label (ISO 32000-2 §12.4.2: labels and page indices need not coincide), alone or as a range end; labels are looked up first, so a page labelled exactly \"(n)\" keeps that name. The same parser serves Combine files ranges. Organize Pages range selection still takes positions only, without labels or (n)." [[feature]] id = "print.odd-even-reverse" @@ -8237,8 +8237,8 @@ milestone = "M10" status = "shipped" commands = ["print.dialog"] tools = ["doc_print"] -evidence = ["crates/print/src/tests.rs::page_selection", "crates/print/src/tests.rs::odd_and_even_pages_are_page_numbers_not_places_in_the_range", "crates/automation/tests/it/automation.rs::cut_stack_printing_through_tools"] -notes = "Odd and even are page numbers, as Acrobat counts them (Adobe Acrobat DC Help, 11/28/2019, Options in the Print dialog box, p. 515: a range of 2, 7–10 with Even Pages Only prints pages 2, 8 and 10), for a typed range, picked thumbnails and the current page; Reverse turns the chosen pages round (#3984). Not yet: with page labels, odd and even still count page numbers (a document whose front matter is numbered i, ii, iii… keeps label 2 among the odd pages when it is page 5); the Acrobat help consulted doesn't say how Acrobat counts there, and the other Print-dialog behaviours #3984 mentions are open." +evidence = ["crates/print/src/tests.rs::page_selection", "crates/print/src/tests.rs::odd_and_even_pages_are_page_numbers_not_places_in_the_range", "crates/print/src/tests.rs::odd_and_even_count_positions_on_a_labelled_document", "crates/automation/tests/it/automation.rs::cut_stack_printing_through_tools"] +notes = "Odd and even are page numbers, as Acrobat counts them (Adobe Acrobat DC Help, 11/28/2019, Options in the Print dialog box, p. 515: a range of 2, 7–10 with Even Pages Only prints pages 2, 8 and 10), for a typed range, picked thumbnails and the current page; Reverse turns the chosen pages round (#3984). With page labels, odd and even count page positions, not the numbers in the labels: page indices are fixed and consecutive while labels can restart, repeat, use letters or carry no number at all (ISO 32000-2 §12.4.2), so with front matter labelled i–iii the page labelled 1 (the fourth) is even. The other Print-dialog behaviours #3984 mentions are open." [[feature]] id = "print.scaling" From ba2846cba800926c63a64996f089c3fc4f5255ec Mon Sep 17 00:00:00 2001 From: Livio Oswald <125505497+Lcoding15@users.noreply.github.com> Date: Mon, 9 Nov 2026 11:33:33 +0100 Subject: [PATCH 51/64] M4: a recovered document's first Save asks where instead of writing the sidecar's path (#3923) (#4327) --- ROADMAP.md | 1 + crates/ui-egui/src/app.rs | 1 + crates/ui-egui/src/editing/save.rs | 13 ++- crates/ui-egui/src/lib.rs | 3 + crates/ui-egui/src/persist/recovery.rs | 153 +++++++++++++++++++++++-- crates/ui-egui/tests/it/recovery.rs | 2 +- parity/acrobat-features.toml | 4 +- 7 files changed, 161 insertions(+), 16 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 85f06fcd1e..1fb7459f49 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -313,6 +313,7 @@ Ranked; detail and the full milestone table in [docs/roadmap.md](docs/roadmap.md - **2026-10-23 (M11, JBIG2):** The PDF Optimizer can store black-and-white images as lossless JBIG2 (a generic region, written from the JBIG2 draft standard and checked against its arithmetic-coder test sequence), about a third smaller than Group 4 on 300 dpi text, and Export ▸ Images reads JBIG2 images. JBIG2 symbol coding, where its largest gains on text are, is next. - **2026-10-23 (M11, monochrome compression):** The PDF Optimizer and Reduce File Size store black-and-white images (scans, image masks) losslessly as CCITT Group 4 when that is smaller, with a Monochrome Images setting in the Optimizer and `doc_optimize` `mono`. JBIG2 and monochrome downsampling are next. - **2026-10-23 (M6, calculation checks):** The calculation order now flags a formula that reads a field the form doesn't have (a field deleted from under a sum, a typo in notation, a script's `getField`) and a script that builds the names it reads at run time. Prepare a form ▸ Calculation order marks both on the row and in a card that names the broken formulas, in place of its all-clear. `form_calculation_order` gives each field's formula, and `form_fields` shows a custom Calculate script. +- **2026-10-23 (M4, #3923):** A recovered document's original path is only a suggestion: the first Save after recovering opens Save As at that path instead of writing it, since anyone who can write the recovery folder (a shared portable `PdfCraftData`, for example) can name any file in a sidecar. The file chosen is the document's file from then on. Sidecars are read through a 64 KiB cap; a longer one is not read, and its snapshot recovers without metadata. Estimates unchanged. - **2026-10-23 (M7, #4280):** Edit text names each standard stand-in font after its face (`PCEdTimesBold`, ...) instead of one shared `PCEdHelv`. Rewriting a bold paragraph whose subset lacks an accent no longer repoints the paragraphs set earlier in a regular stand-in at Times-Bold, and the editor's frame stays on the paragraph being edited. Pages saved by older builds keep their shared name and are not repaired. Estimates unchanged. - **2026-10-23 (M8, #3205 follow-up):** Phone-pattern redaction matches complete labelled Italian geographic numbers such as `Tel. +39 0426/123456`, with synthetic matcher and mark/apply/save/reopen coverage; unlabelled international slash numbers remain unsupported. - **2026-10-23 (M7, #2636 partial):** A new `PasteContent` edit pastes an added text box or image copied in the same document or another, keeping its style, picture, turns, flips and crop, as one undo step; `content_copy` drives it headlessly. A paste lands beside earlier copies (18 pt steps) and stays on the page. A pasted image reuses its picture object in its own document and after the first paste into another; there a colour profile is dropped and CMYK colours stored losslessly are turned to RGB. The desktop keys and menus follow in separate PRs. Estimates unchanged. diff --git a/crates/ui-egui/src/app.rs b/crates/ui-egui/src/app.rs index 010b7f7bd7..fd6d2dcbc6 100644 --- a/crates/ui-egui/src/app.rs +++ b/crates/ui-egui/src/app.rs @@ -279,6 +279,7 @@ impl PdfCraftApp { recovery: None, recoverable: Vec::new(), recovery_keys: Default::default(), + recovered_paths: Default::default(), last_autosave: None, allow_quit: false, deferred_commands: Vec::new(), diff --git a/crates/ui-egui/src/editing/save.rs b/crates/ui-egui/src/editing/save.rs index ffb533b243..35867559f6 100644 --- a/crates/ui-egui/src/editing/save.rs +++ b/crates/ui-egui/src/editing/save.rs @@ -57,7 +57,9 @@ impl PdfCraftApp { (_, _, Some(p)) => p.clone(), // An email attachment's temporary copy is no place to keep work: ask, as for a new // document (`is_mail_temp_copy`). - (SaveTarget::InPlace, Some(p), _) if !is_mail_temp_copy(&p) => p, + // Neither is a path read back from a recovery sidecar until a save confirms it: + // anyone who can write the recovery folder can name any file there (#3923). + (SaveTarget::InPlace, Some(p), _) if !is_mail_temp_copy(&p) && !self.recovered_paths.contains(&id) => p, _ => { let name = if name.to_ascii_lowercase().ends_with(".pdf") { name } else { format!("{name}.pdf") }; let dialog = rfd::AsyncFileDialog::new().add_filter_any_case("PDF", &["pdf"]).set_file_name(name); @@ -164,11 +166,12 @@ impl PdfCraftApp { if !self.commit_typing_in(id) || !self.flatten_fill_sign_for_save(id) { return false; } + let recovered = self.recovered_paths.contains(&id); let Some((name, full)) = self.session.get(id).map(|d| { - // Save As to another file is a clean copy, without the earlier revisions that still - // hold deleted pages or replaced text (#2165). A signed document stays incremental so - // its signatures keep covering the file. - let full = target == SaveTarget::As + // Save As to another file and a recovered document are clean copies, without earlier + // revisions that may retain deleted pages or replaced text (#2165, #3923). A signed + // document stays incremental so its signatures keep covering the file. + let full = (target == SaveTarget::As || recovered) && !d.path.as_deref().is_some_and(|p| crate::same_file(p, dest)) && d.read_only_reason.is_none() && !d.is_signed(); diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 0d527d5ef5..0e47d4baff 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -680,6 +680,9 @@ pub struct PdfCraftApp { /// Entries left by a previous session, offered in the Recovery dialog. pub recoverable: Vec, recovery_keys: std::collections::HashMap, + /// Recovered documents whose path came from a recovery sidecar and no save has confirmed + /// yet: their first Save asks where, starting at that path, instead of writing it (#3923). + recovered_paths: std::collections::HashSet, last_autosave: Option, allow_quit: bool, /// Shortcuts pressed while a text field had the keyboard, run on the next frame (see diff --git a/crates/ui-egui/src/persist/recovery.rs b/crates/ui-egui/src/persist/recovery.rs index 34b6bdf76a..aa10cceb19 100644 --- a/crates/ui-egui/src/persist/recovery.rs +++ b/crates/ui-egui/src/persist/recovery.rs @@ -5,6 +5,9 @@ //! `.pdf` plus `.json` (name, original path, time). Saving, discarding or closing a //! document removes its entry. At startup the app offers snapshots even when their metadata is //! missing or damaged; those recover without an original path, so saving asks for a destination. +//! A recovered document's original path is only a suggestion: the folder is the user's, but +//! anyone who can write it can name any file in a sidecar, so the first Save after recovering +//! asks where, starting at that path, and later saves write where the user chose (#3923). //! //! Encrypted documents are stored encrypted (the working file is), so recovery never writes //! plaintext of a protected document to disk. The web build has no recovery store yet. @@ -32,7 +35,8 @@ pub const AUTOSAVE_SECS: f64 = 60.0; pub struct RecoveryMeta { pub key: String, pub name: String, - /// Where the document was saved before (Save writes back there after recovery). + /// Where the document was saved before. After recovery the first Save offers it in the + /// Save As dialog rather than writing it unasked (#3923). pub path: Option, /// Seconds since the Unix epoch when the snapshot was taken. pub saved_at: u64, @@ -230,8 +234,7 @@ impl RecoveryStore { continue; } let Some(key) = path.file_stem().and_then(std::ffi::OsStr::to_str) else { continue }; - let meta = std::fs::read(path.with_extension("json")) - .and_then(|b| serde_json::from_slice::(&b).map_err(std::io::Error::other)) + let meta = read_meta(&path.with_extension("json")) .and_then(|m| if m.key == key { Ok(m) } else { Err(std::io::Error::new(std::io::ErrorKind::InvalidData, "snapshot key mismatch")) }); out.push(meta.unwrap_or_else(|e| { log::warn!("recovering snapshot without metadata: {}: {e}", path.display()); @@ -261,7 +264,7 @@ impl RecoveryStore { /// recovered without metadata), so damaged metadata doesn't stay behind. The PDF is untouched. pub fn repair_meta(&self, meta: &RecoveryMeta) { let path = self.dir.join(format!("{}.json", meta.key)); - let readable = std::fs::read(&path).ok().and_then(|b| serde_json::from_slice::(&b).ok()).is_some_and(|m| m.key == meta.key); + let readable = read_meta(&path).is_ok_and(|m| m.key == meta.key); if readable { return; } @@ -273,8 +276,25 @@ impl RecoveryStore { } } + /// + /// Only a regular file is read, and no more than it held when it was opened: anyone who can + /// write the recovery folder can put a FIFO there, which would hold the read forever, or a + /// link to a device such as `/dev/zero`, which never ends (#3923). Snapshots have no size + /// limit of their own, as autosave writes whatever the document holds. pub fn read(&self, key: &str) -> std::io::Result> { - std::fs::read(self.dir.join(format!("{key}.pdf"))) + use std::io::Read; + let path = self.dir.join(format!("{key}.pdf")); + let meta = std::fs::metadata(&path)?; + if !meta.is_file() { + return Err(std::io::Error::new(std::io::ErrorKind::InvalidInput, "the recovery snapshot is not a regular file")); + } + let len = meta.len(); + let mut bytes = Vec::new(); + File::open(&path)?.take(len.saturating_add(1)).read_to_end(&mut bytes)?; + if bytes.len() as u64 > len { + return Err(std::io::Error::new(std::io::ErrorKind::InvalidData, "the recovery snapshot grew while it was read")); + } + Ok(bytes) } /// Remove every entry (clean quit, or "Discard all"), and metadata left without a snapshot. @@ -296,6 +316,22 @@ impl RecoveryStore { } } +/// The most a sidecar may hold. One PdfCraft writes is a name, a path and a few fields, well under +/// this; anything longer is not one of ours (#3923). +const MAX_META_BYTES: u64 = 64 * 1024; + +/// A sidecar's metadata, read through [`MAX_META_BYTES`] so a planted file can't make the start-up +/// listing read gigabytes. A longer file is refused, and its snapshot recovers without metadata. +fn read_meta(path: &Path) -> std::io::Result { + use std::io::Read; + let mut bytes = Vec::new(); + File::open(path)?.take(MAX_META_BYTES.saturating_add(1)).read_to_end(&mut bytes)?; + if bytes.len() as u64 > MAX_META_BYTES { + return Err(std::io::Error::new(std::io::ErrorKind::InvalidData, "recovery metadata is too large")); + } + serde_json::from_slice(&bytes).map_err(std::io::Error::other) +} + fn now_secs() -> u64 { std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map(|d| d.as_secs()).unwrap_or(0) } @@ -329,6 +365,9 @@ impl PdfCraftApp { /// Drop a document's recovery entry (it was saved, discarded or closed). pub(crate) fn forget_recovery(&mut self, doc: pdfcraft_engine::DocId) { + // Saved (to a destination the user chose), closed or reloaded from its file: the path is + // no longer only what the sidecar said. + self.recovered_paths.remove(&doc); if let (Some(store), Some(key)) = (&self.recovery, self.recovery_keys.remove(&doc)) { store.remove(&key); } @@ -422,6 +461,9 @@ impl PdfCraftApp { pub(crate) fn finish_recovery(&mut self, meta: &RecoveryMeta) { let Some(id) = self.views.last().map(|v| v.id) else { return }; self.session.mark_recovered(id, meta.path.clone()); + if meta.path.is_some() { + self.recovered_paths.insert(id); + } self.recovery_keys.insert(id, meta.key.clone()); // A snapshot recovered without readable metadata gets a fresh sidecar now, so the // damaged one doesn't linger until the next autosave (or forever, if none comes). @@ -462,13 +504,13 @@ mod idle_tests { use super::*; use std::time::Duration; - struct TestDir(PathBuf); + pub(super) struct TestDir(PathBuf); impl TestDir { /// A new folder of this test's own. The tests run on parallel threads of one process, and /// two can read the same clock time (it moves in microseconds on macOS), so a counter /// tells their folders apart; the time keeps runs of the process apart. - fn new() -> Self { + pub(super) fn new() -> Self { static NEXT: AtomicU64 = AtomicU64::new(0); let n = NEXT.fetch_add(1, Ordering::Relaxed); let nonce = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_nanos(); @@ -478,7 +520,7 @@ mod idle_tests { } /// The folder. - fn path(&self) -> &Path { + pub(super) fn path(&self) -> &Path { &self.0 } } @@ -519,6 +561,31 @@ mod idle_tests { out.viewport_output[&egui::ViewportId::ROOT].repaint_delay } + /// #3923 (review on #4327): a snapshot that is not a regular file is refused before it is + /// opened. A FIFO in the recovery folder would otherwise hold the read, and the recovery with + /// it, until something wrote to it. + #[cfg(unix)] + #[test] + fn a_snapshot_that_is_not_a_regular_file_is_refused_at_once() { + let dir = TestDir::new(); + let store = RecoveryStore::new(dir.path()); + let fifo = dir.path().join("planted.pdf"); + match std::process::Command::new("mkfifo").arg(&fifo).status() { + Ok(s) if s.success() => {} + _ => return, // No mkfifo here: nothing to plant. + } + let (tx, rx) = std::sync::mpsc::channel(); + let reader = store.clone(); + std::thread::spawn(move || { + let _ = tx.send(reader.read("planted").map(|b| b.len())); + }); + let read = rx.recv_timeout(Duration::from_secs(5)).expect("the read returned instead of waiting on the FIFO"); + assert_eq!(read.map_err(|e| e.kind()), Err(std::io::ErrorKind::InvalidInput)); + // A directory under the name is refused the same way. + std::fs::create_dir(dir.path().join("folder.pdf")).unwrap(); + assert_eq!(store.read("folder").map_err(|e| e.kind()), Err(std::io::ErrorKind::InvalidInput)); + } + /// Check that an idle dirty document schedules a bounded write and autosaves without input, then stops /// waking the window. #[test] @@ -624,3 +691,73 @@ mod idle_tests { } } } + +#[cfg(test)] +mod sidecar_tests { + use super::idle_tests::TestDir; + use super::*; + use crate::SaveTarget; + + /// #3923: a sidecar names a file the user can write (here, one that is not the document's). + /// After Recover, Save asks where instead of writing that file; the file the user picks is + /// the document's file from then on. + #[test] + fn the_first_save_after_recovering_asks_instead_of_writing_the_sidecars_path() { + let dir = TestDir::new(); + let store = RecoveryStore::new(dir.path().join("recovery")); + let named = dir.path().join("named.pdf"); + std::fs::write(&named, b"someone else's file").unwrap(); + { + let mut crashed = PdfCraftApp::new(); + crashed.enable_recovery(store.clone()); + let bytes = crashed.session.create_blank(200.0, 300.0, 1).unwrap(); + crashed.open_bytes("work.pdf", Some(named.to_string_lossy().into_owned()), bytes.to_vec()).unwrap(); + assert!(crashed.apply_edit(pdfcraft_engine::Edit::RotatePages { pages: vec![0], degrees: 90 })); + crashed.autosave_now(); + } + + let mut app = PdfCraftApp::new(); + app.set_option("language", "en").unwrap(); + app.enable_recovery(store.clone()); + let keys: Vec = app.recoverable.iter().map(|m| m.key.clone()).collect(); + assert_eq!(keys.len(), 1); + app.recover(&keys); + assert_eq!(app.views.len(), 1); + let chosen = dir.path().join("chosen.pdf"); + app.pick_override = Some(vec![chosen.to_string_lossy().into_owned()]); + + assert!(!app.save_active(SaveTarget::InPlace), "Save asks instead of writing the sidecar's path"); + app.process_picked(); + assert_eq!(std::fs::read(&named).unwrap(), b"someone else's file", "the named file is untouched"); + assert!(std::fs::read(&chosen).unwrap().starts_with(b"%PDF"), "the document went where the user chose"); + + // The chosen file is the document's file now: the next Save writes it without asking. + app.pick_override = None; + assert!(app.apply_edit(pdfcraft_engine::Edit::RotatePages { pages: vec![0], degrees: 90 })); + assert!(app.save_active(SaveTarget::InPlace)); + } + + /// #3923: a sidecar longer than any PdfCraft writes is not read whole; its snapshot is + /// offered without metadata, so it has no path to save to. + #[test] + fn an_oversized_sidecar_is_not_read() { + let dir = TestDir::new(); + let store = RecoveryStore::new(dir.path()); + let meta = RecoveryMeta { + key: "planted".into(), + // Over 64 KiB, and still valid metadata that names a file. + name: "x".repeat(70_000), + path: Some(dir.path().join("named.pdf").to_string_lossy().into_owned()), + saved_at: 1, + encrypted: false, + instance: None, + }; + store.write(&meta, b"%PDF-1.7\n").unwrap(); + let listed = store.list(); + assert_eq!(listed.len(), 1); + assert_eq!((listed[0].name.as_str(), listed[0].path.as_deref()), ("planted.pdf", None)); + // A sidecar of ordinary size is still read. + store.write(&RecoveryMeta { name: "work.pdf".into(), ..meta.clone() }, b"%PDF-1.7\n").unwrap(); + assert_eq!(store.list()[0].path, meta.path); + } +} diff --git a/crates/ui-egui/tests/it/recovery.rs b/crates/ui-egui/tests/it/recovery.rs index 93ea045b6e..57500b310c 100644 --- a/crates/ui-egui/tests/it/recovery.rs +++ b/crates/ui-egui/tests/it/recovery.rs @@ -90,7 +90,7 @@ fn a_crashed_session_is_offered_and_recovered_unsaved() { assert_eq!(app.views.len(), 1); let doc = app.session.get(app.views[0].id).unwrap(); assert!(doc.dirty, "recovered changes are unsaved"); - assert_eq!(doc.path.as_deref(), Some("/docs/work.pdf"), "Save goes back to the original file"); + assert_eq!(doc.path.as_deref(), Some("/docs/work.pdf"), "the original path is kept, as what Save offers (#3923)"); assert_eq!(doc.info.pages.len(), 2, "the edit survived the crash"); assert_eq!(s.list().len(), 1, "the entry stays until the document is saved or closed"); } diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index 927e32df83..3c17a3ed70 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -550,8 +550,8 @@ milestone = "M4" status = "shipped" commands = [] tools = [] -evidence = ["crates/ui-egui/tests/it/recovery.rs::a_crashed_session_is_offered_and_recovered_unsaved", "crates/ui-egui/tests/it/recovery.rs::discarding_removes_the_autosaves", "crates/ui-egui/tests/it/recovery.rs::a_running_instances_autosaves_are_not_offered_to_a_second_instance", "crates/engine/src/tests.rs::recovered_documents_reopen_unsaved_at_their_original_path"] -notes = "A running instance holds an exclusively locked instance file in the recovery folder and names it in its snapshots, so a second instance neither offers nor discards what the first is autosaving (#2166); the OS releases the lock when the process dies. Where the folder can't be written or the platform has no file locks, snapshots are offered as before." +evidence = ["crates/ui-egui/tests/it/recovery.rs::a_crashed_session_is_offered_and_recovered_unsaved", "crates/ui-egui/tests/it/recovery.rs::discarding_removes_the_autosaves", "crates/ui-egui/tests/it/recovery.rs::a_running_instances_autosaves_are_not_offered_to_a_second_instance", "crates/engine/src/tests.rs::recovered_documents_reopen_unsaved_at_their_original_path", "crates/ui-egui/src/persist/recovery.rs::the_first_save_after_recovering_asks_instead_of_writing_the_sidecars_path", "crates/ui-egui/src/persist/recovery.rs::an_oversized_sidecar_is_not_read", "crates/ui-egui/src/persist/recovery.rs::a_snapshot_that_is_not_a_regular_file_is_refused_at_once"] +notes = "A recovered document keeps its original path only as a suggestion: the first Save asks where, starting there, and the file chosen is its file from then on, since anyone who can write the recovery folder can name any file in a sidecar (#3923); sidecars longer than 64 KiB are not read, and their snapshot is offered without metadata. A running instance holds an exclusively locked instance file in the recovery folder and names it in its snapshots, so a second instance neither offers nor discards what the first is autosaving (#2166); the OS releases the lock when the process dies. Where the folder can't be written or the platform has no file locks, snapshots are offered as before." [[feature]] id = "core.encrypted-autosave" From e78102c90711d31b0e3e666697e67cbda0f5e18f Mon Sep 17 00:00:00 2001 From: ".CooCoo" Date: Mon, 9 Nov 2026 02:35:01 -0800 Subject: [PATCH 52/64] M11: PDF Optimizer can discard form submission, import and reset actions (optimize.discard-form-actions) (#4416) --- crates/automation/src/documents.rs | 3 + crates/automation/src/tools.rs | 3 +- crates/automation/tests/it/automation.rs | 26 ++ crates/optimize/README.md | 5 +- crates/optimize/src/form_actions.rs | 468 ++++++++++++++++++++ crates/optimize/src/lib.rs | 13 +- crates/optimize/tests/optimize.rs | 341 ++++++++++++++ crates/ui-egui/src/workflows/optimize_ui.rs | 1 + parity/acrobat-features.toml | 10 +- 9 files changed, 862 insertions(+), 8 deletions(-) create mode 100644 crates/optimize/src/form_actions.rs diff --git a/crates/automation/src/documents.rs b/crates/automation/src/documents.rs index 8741b2829f..823bc078c0 100644 --- a/crates/automation/src/documents.rs +++ b/crates/automation/src/documents.rs @@ -361,6 +361,7 @@ impl Automation { ("discard_alternate_images", &mut settings.discard_alternate_images), ("discard_tags", &mut settings.discard_tags), ("discard_print_settings", &mut settings.discard_print_settings), + ("discard_form_actions", &mut settings.discard_form_actions), ("flate_unencoded", &mut settings.flate_unencoded), ("object_streams", &mut settings.object_streams), ("lzw_to_flate", &mut settings.lzw_to_flate), @@ -471,6 +472,8 @@ impl Automation { "thumbnails": o.thumbnails, "alternate_images": o.alternate_images, "tags_removed": o.tags_removed, + "print_settings": o.print_settings, + "form_actions": o.form_actions, "streams_compressed": o.streams_compressed, "lzw_streams": o.lzw_streams, "invalid_links": o.invalid_links, diff --git a/crates/automation/src/tools.rs b/crates/automation/src/tools.rs index c2eed3651c..27ce2d8b7c 100644 --- a/crates/automation/src/tools.rs +++ b/crates/automation/src/tools.rs @@ -1678,7 +1678,7 @@ pub fn tools() -> Vec { .with(schema(json!({ "doc": doc() }), &["doc"])), t("doc_open_revision", "Open a revision", "Open saved revision `revision` (1 = the oldest) of a document as a new, unsaved document, to see the file as it was then.") .with(schema(json!({ "doc": doc(), "revision": { "type": "integer", "minimum": 1 } }), &["doc", "revision"])), - t("doc_optimize", "Optimize PDF", "Write an optimized copy to `path` (Acrobat's PDF Optimizer). preset: standard (default; Reduce File Size's choices) or mobile (images to 96 ppi above 144, JPEG high quality); the options below change it. compatibility: retain (default; keeps the document's version, or 1.5 when object streams are written), 1.4, 1.5, 1.6, 1.7 or 2.0. merge_fragments (default true) first puts a page picture drawn as many stacked image strips (as some PDF printers write it) back together into one image, so it can be resampled (merged_images, fragments_merged). color / gray: { downsample, ppi, above_ppi, compression: jpeg|zip|retain, quality 1–100 } (defaults: downsample to 150 ppi above 225, JPEG 60). mono: { downsample, ppi, above_ppi, compression: ccitt_g4|jbig2|retain } for black-and-white images and image masks (default: averaged down to 300 ppi above 450 ppi, counted in images_resampled; stored losslessly as CCITT Group 4 or JBIG2 (or 1-bit Flate, once resampled or packed from more bits) when that is smaller, up to 16 JBIG2 images sharing one symbol dictionary (/JBIG2Globals) where that is smaller still (image masks and DeviceGray images named directly, of at most 64 Mi pixels, which redaction can store again without it), Group 3 fax images, JBIG2 images, black-and-white RGB and indexed images using only palette entries 0 and 1 among them, counted in images_recompressed; retain resamples only, a CCITT image staying CCITT, a JBIG2 image JBIG2 and any other becoming Flate). Images past what one run may decode stay as they are, counted in images_refused; streams it only stores with Flate past that stay LZW, uncounted. Images are measured where pages draw them and replaced only when smaller. discard_*: thumbnails (default true), alternate_images (true), tags, print_settings; flate_unencoded (true); object_streams (default true; always off for 1.4); lzw_to_flate (true) decodes LZW streams and stores them with Flate instead, leaving any it cannot decode unchanged (lzw_streams counts the streams converted); remove_invalid_links and remove_unreferenced_dests (true); images and forms the pages list but never draw are always dropped (unused_xobjects). discard: Remove Hidden Information categories (metadata, attachments, comments, form-fields, hidden-text, hidden-layers, bookmarks, links-actions-scripts, private-data). Signed documents are refused. target_bytes (optional): if the copy is larger, retry with up to 5 progressively stronger image settings (downsample to at most 110, 96, 72, 54, 36 ppi, applied to colour and grey images; images set to JPEG also get JPEG quality at most 45, 35, 28, 20, 12, while zip and retain images keep their encoding and only get the lower ppi) and stop at the first copy that fits; reports target_reached, attempts and target_settings, and writes the smallest copy with target_reached false when none fits. Without it nothing changes. The open document is unchanged.") + t("doc_optimize", "Optimize PDF", "Write an optimized copy to `path` (Acrobat's PDF Optimizer). preset: standard (default; Reduce File Size's choices) or mobile (images to 96 ppi above 144, JPEG high quality); the options below change it. compatibility: retain (default; keeps the document's version, or 1.5 when object streams are written), 1.4, 1.5, 1.6, 1.7 or 2.0. merge_fragments (default true) first puts a page picture drawn as many stacked image strips (as some PDF printers write it) back together into one image, so it can be resampled (merged_images, fragments_merged). color / gray: { downsample, ppi, above_ppi, compression: jpeg|zip|retain, quality 1–100 } (defaults: downsample to 150 ppi above 225, JPEG 60). mono: { downsample, ppi, above_ppi, compression: ccitt_g4|jbig2|retain } for black-and-white images and image masks (default: averaged down to 300 ppi above 450 ppi, counted in images_resampled; stored losslessly as CCITT Group 4 or JBIG2 (or 1-bit Flate, once resampled or packed from more bits) when that is smaller, up to 16 JBIG2 images sharing one symbol dictionary (/JBIG2Globals) where that is smaller still (image masks and DeviceGray images named directly, of at most 64 Mi pixels, which redaction can store again without it), Group 3 fax images, JBIG2 images, black-and-white RGB and indexed images using only palette entries 0 and 1 among them, counted in images_recompressed; retain resamples only, a CCITT image staying CCITT, a JBIG2 image JBIG2 and any other becoming Flate). Images past what one run may decode stay as they are, counted in images_refused; streams it only stores with Flate past that stay LZW, uncounted. Images are measured where pages draw them and replaced only when smaller. discard_*: thumbnails (default true), alternate_images (true), tags, print_settings (entries removed: print_settings), form_actions (submit, import and reset actions removed: form_actions; the fields and links stay, and the actions chained after one still run); flate_unencoded (true); object_streams (default true; always off for 1.4); lzw_to_flate (true) decodes LZW streams and stores them with Flate instead, leaving any it cannot decode unchanged (lzw_streams counts the streams converted); remove_invalid_links and remove_unreferenced_dests (true); images and forms the pages list but never draw are always dropped (unused_xobjects). discard: Remove Hidden Information categories (metadata, attachments, comments, form-fields, hidden-text, hidden-layers, bookmarks, links-actions-scripts, private-data). Signed documents are refused. target_bytes (optional): if the copy is larger, retry with up to 5 progressively stronger image settings (downsample to at most 110, 96, 72, 54, 36 ppi, applied to colour and grey images; images set to JPEG also get JPEG quality at most 45, 35, 28, 20, 12, while zip and retain images keep their encoding and only get the lower ppi) and stop at the first copy that fits; reports target_reached, attempts and target_settings, and writes the smallest copy with target_reached false when none fits. Without it nothing changes. The open document is unchanged.") .cmd("optimize.advanced") .with(schema( json!({ @@ -1695,6 +1695,7 @@ pub fn tools() -> Vec { "discard_alternate_images": { "type": "boolean" }, "discard_tags": { "type": "boolean" }, "discard_print_settings": { "type": "boolean" }, + "discard_form_actions": { "type": "boolean" }, "flate_unencoded": { "type": "boolean" }, "object_streams": { "type": "boolean" }, "lzw_to_flate": { "type": "boolean" }, diff --git a/crates/automation/tests/it/automation.rs b/crates/automation/tests/it/automation.rs index ac6b2374e5..e209d3102a 100644 --- a/crates/automation/tests/it/automation.rs +++ b/crates/automation/tests/it/automation.rs @@ -7710,6 +7710,32 @@ fn optimizing_through_tools() { assert!(matches!(a.call("doc_optimize", &json!({ "doc": doc, "path": "x.pdf", "discard": ["everything"] })), Err(ToolError::InvalidArgs(_)))); } +#[test] +fn optimizing_discards_form_actions_through_tools() { + let dir = workdir("optimize-form-actions"); + let mut a = auto(&dir); + let doc = ok(&mut a, "doc_open", json!({ "path": "a.pdf" }))["doc"].as_u64().unwrap(); + ok(&mut a, "form_add_field", json!({ "doc": doc, "page": 1, "type": "button", "rect": [20, 20, 120, 42], "name": "Send" })); + ok( + &mut a, + "form_set_actions", + json!({ "doc": doc, "field": "Send", "actions": [ + { "trigger": "mouse_up", "submit": "https://example.com/submit" }, + { "trigger": "mouse_down", "reset": [] }, + { "trigger": "mouse_enter", "javascript": "app.alert(1);" } + ] }), + ); + let kept = ok(&mut a, "doc_optimize", json!({ "doc": doc, "path": "kept.pdf" })); + assert_eq!(kept["form_actions"], 0, "{kept}"); + let r = ok(&mut a, "doc_optimize", json!({ "doc": doc, "path": "small.pdf", "discard_form_actions": true })); + assert_eq!(r["form_actions"], 2, "{r}"); + let small = ok(&mut a, "doc_open", json!({ "path": "small.pdf" }))["doc"].as_u64().unwrap(); + let fields = ok(&mut a, "form_fields", json!({ "doc": small })); + assert!(fields.to_string().contains("Send"), "the button stays: {fields}"); + let actions = ok(&mut a, "form_actions", json!({ "doc": small, "field": "Send" })); + assert_eq!(actions["actions"], json!([{ "trigger": "mouse_enter", "javascript": "app.alert(1);" }]), "{actions}"); +} + /// #3227 review: retaining the version, a catalog `/Version` below the 1.5 an object stream /// needs is not what viewers read: the header is raised, and the later of the two is reported. #[test] diff --git a/crates/optimize/README.md b/crates/optimize/README.md index 4c0549b1c8..1a571452ec 100644 --- a/crates/optimize/README.md +++ b/crates/optimize/README.md @@ -127,7 +127,10 @@ let report = optimize_with_progress(&mut doc, &settings, &mut |stage| keep_going its priority. The document only changes in order on one thread, and what a picture costs is paid in that order, so the file written, and the pictures the run's decoding allowance refuses, are the same with any number. -- **Discard objects:** thumbnails, alternate images, document tags, print settings. +- **Discard objects:** thumbnails, alternate images, document tags, print settings, and form + submission, import and reset actions (`/SubmitForm`, `/ImportData`, `/ResetForm`, off by + default). The fields, links, bookmarks and pages that ran a removed action keep their other + actions, and the actions chained after it through `/Next` still run in order. - **Clean up:** Flate for streams with no filter; for streams with an outer ASCII85 or ASCIIHex layer, that layer is removed when the result is smaller (the inner filters keep their encoded bytes; streams with `/DecodeParms`, images and object streams keep their ASCII layer. LZW-compressed diff --git a/crates/optimize/src/form_actions.rs b/crates/optimize/src/form_actions.rs new file mode 100644 index 0000000000..fbcb24a2fe --- /dev/null +++ b/crates/optimize/src/form_actions.rs @@ -0,0 +1,468 @@ +//! Discard Objects: form submission, import and reset actions (`/SubmitForm`, `/ImportData`, +//! `/ResetForm`). The fields, buttons, links, bookmarks and pages that ran them stay; a removed +//! action's `/Next` actions take its place, so the rest of a chain still runs in order. + +use std::collections::{HashMap, HashSet}; + +use pdfcraft_cos::{Dict, Document, ObjRef, Object}; + +use crate::OptimizeError; + +const FORM_ACTIONS: [&[u8]; 3] = [b"SubmitForm", b"ImportData", b"ResetForm"]; + +/// Keys whose value is one action (or, for `/OpenAction`, an action or a destination). +const ACTION_KEYS: [&[u8]; 2] = [b"A", b"OpenAction"]; + +/// How deep direct objects and action chains are followed. +const MAX_DEPTH: usize = 64; + +/// How many action values one object may make the walk look at. +const MAX_STEPS: usize = 100_000; + +/// How many action values the walk may look at in the whole document. +const MAX_TOTAL_STEPS: usize = 1_000_000; + +/// Remove every form submission, import and reset action. Returns how many the document held in +/// action entries, each counted once: an indirect action however many entries refer to it, and a +/// direct one in the object that holds it. Fails with [`OptimizeError::ActionLimit`] when an +/// object holds a form action more than [`MAX_DEPTH`] direct objects deep, chains form actions +/// more than [`MAX_DEPTH`] deep, or makes the walk look at more than [`MAX_STEPS`] action values, +/// or when the walk has looked at more than [`MAX_TOTAL_STEPS`] action values in the document. +pub(crate) fn discard(doc: &mut Document) -> Result { + let numbers = doc.object_numbers(); + let mut count = Count::default(); + for &num in &numbers { + let r = ObjRef { num, generation: doc.generation(num) }; + if let o @ (Object::Dict(_) | Object::Array(_)) = &*doc.get(r) { + count.object(doc, o, 0); + } + } + let mut total = 0usize; + for num in numbers { + let r = ObjRef { num, generation: doc.generation(num) }; + let mut obj = match &*doc.get(r) { + o @ (Object::Dict(_) | Object::Array(_)) => o.clone(), + _ => continue, + }; + let limit = MAX_STEPS.min(MAX_TOTAL_STEPS - total); + let mut walk = + Walk { this: r, changed: false, steps: 0, limit, exhausted: false, chain: HashSet::new(), looped: false, writes: HashMap::new() }; + walk.clean(doc, &mut obj, 0); + total += walk.steps.min(limit); + if walk.exhausted { + return Err(OptimizeError::ActionLimit(r)); + } + if walk.changed { + doc.set(r, obj); + } + for (shared, cleaned) in walk.writes { + doc.set(shared, cleaned); + } + } + Ok(count.direct + count.indirect.len()) +} + +/// Whether `d` is a form action; its `/S` may be stored as an indirect name. +fn is_form_action(doc: &Document, d: &Dict) -> bool { + d.get(b"S").is_some_and(|s| doc.resolve(s).as_name().is_some_and(|s| FORM_ACTIONS.contains(&s))) +} + +/// The form actions in action entries, counted before they are removed. Each object's pass looks +/// only at its own direct objects, so a direct action is counted once, in the object that holds +/// it; references are counted by identity. +#[derive(Default)] +struct Count { + direct: usize, + indirect: HashSet, + /// Indirect `/AA` dictionaries and `/Next` arrays already counted. + shared: HashSet, +} + +impl Count { + fn object(&mut self, doc: &Document, o: &Object, depth: usize) { + if depth > MAX_DEPTH { + return; + } + match o { + Object::Dict(d) => { + for key in [b"A".as_slice(), b"OpenAction", b"Next"] { + if let Some(v) = d.get(key) { + self.entry(doc, v, depth); + } + } + match d.get(b"AA") { + Some(Object::Ref(r)) if self.shared.insert(*r) => { + if let Object::Dict(t) = &*doc.get(*r) { + for (_, v) in t.iter() { + self.entry(doc, v, depth); + } + } + } + Some(Object::Dict(t)) => { + for (_, v) in t.iter() { + self.entry(doc, v, depth); + } + } + _ => {} + } + for (_, v) in d.iter() { + self.object(doc, v, depth + 1); + } + } + Object::Array(items) => { + for item in items { + self.object(doc, item, depth + 1); + } + } + _ => {} + } + } + + /// One action entry's value: an action, or (in `/Next`) a list of them. + fn entry(&mut self, doc: &Document, v: &Object, depth: usize) { + if depth > MAX_DEPTH { + return; + } + match v { + Object::Dict(a) if is_form_action(doc, a) => self.direct += 1, + Object::Array(items) => { + for item in items { + self.entry(doc, item, depth + 1); + } + } + Object::Ref(r) => match &*doc.get(*r) { + Object::Dict(a) if is_form_action(doc, a) => { + self.indirect.insert(*r); + } + // An indirect `/Next` array: its direct actions are counted from the first entry + // that refers to it, and their own entries by the array's own pass. + Object::Array(items) if self.shared.insert(*r) => { + for item in items { + self.entry(doc, item, depth + 1); + } + } + _ => {} + }, + _ => {} + } + } +} + +struct Walk { + /// The object being walked. + this: ObjRef, + /// Whether the object changed. + changed: bool, + steps: usize, + /// How many action values this object's walk may look at: [`MAX_STEPS`], or what is left of + /// [`MAX_TOTAL_STEPS`] when that is less. + limit: usize, + /// Whether a limit stopped the walk before it looked at everything. + exhausted: bool, + /// Indirect form actions and indirect arrays on the path of the chain being followed. + chain: HashSet, + /// Whether the entry being cleaned holds an indirect array that lists itself. + looped: bool, + /// Cleaned replacements for indirect actions, `/Next` arrays and `/AA` dictionaries that + /// action entries of this object refer to, written over them after the walk. + writes: HashMap, +} + +impl Walk { + /// Take form actions out of the action entries of `o` and of the direct objects inside it. + fn clean(&mut self, doc: &Document, o: &mut Object, depth: usize) { + if depth > MAX_DEPTH { + if holds_form_action(doc, o) { + self.exhausted = true; + } + return; + } + match o { + Object::Dict(d) => self.clean_dict(doc, d, depth), + Object::Array(items) => { + for item in items { + self.clean(doc, item, depth + 1); + } + } + _ => {} + } + } + + fn clean_dict(&mut self, doc: &Document, d: &mut Dict, depth: usize) { + for key in ACTION_KEYS { + if let Some(v) = d.get(key) + && let shared = v.as_ref() + && let Some(rest) = self.take(doc, v) + { + let a = chain(doc, rest); + self.put(doc, d, key, shared, a, depth); + } + } + if let Some(v) = d.get(b"Next") + && let shared = v.as_ref() + && let Some(mut rest) = self.take(doc, v) + { + let next = match rest.len() { + 0 => None, + 1 => Some(rest.remove(0)), + // An action that other entries may run as their `/A` stays a dictionary. + _ if shared.is_some_and(|r| doc.get(r).as_dict().is_some()) => chain(doc, rest), + _ => Some(Object::Array(rest)), + }; + self.put(doc, d, b"Next", shared, next, depth); + } + // Additional actions: each trigger holds one action. + if let Some(aa) = d.get(b"AA") + && let shared = aa.as_ref() + && let Object::Dict(t) = &*doc.resolve(aa) + { + let mut triggers = t.clone(); + let mut changed = false; + let keys: Vec> = triggers.iter().map(|(k, _)| k.to_vec()).collect(); + for key in keys { + if let Some(v) = triggers.get(&key) + && let Some(rest) = self.take(doc, v) + { + changed = true; + match chain(doc, rest) { + Some(a) => triggers.set(key, a), + None => { + triggers.remove(&key); + } + } + } + } + if changed { + let aa = (!triggers.is_empty()).then_some(Object::Dict(triggers)); + self.put(doc, d, b"AA", shared, aa, depth); + } + } + let keys: Vec> = d.iter().map(|(k, _)| k.to_vec()).collect(); + for key in keys { + if let Some(v) = d.get_mut(&key) { + self.clean(doc, v, depth + 1); + } + } + } + + /// Set entry `key` of `d` to its cleaned value, or remove it when nothing is left. When the + /// entry referred to indirect object `shared` and the cleaned value is a direct object, the + /// value (with the form actions inside it taken out too) replaces `shared` and the entry keeps + /// its reference, so the objects that share it share one cleaned copy. An entry that refers to + /// an object already replaced, or being cleaned, keeps its reference. + fn put(&mut self, doc: &Document, d: &mut Dict, key: &[u8], shared: Option, value: Option, depth: usize) { + match (shared, value) { + (_, None) => { + d.remove(key); + } + (Some(r), Some(_)) if self.writes.contains_key(&r) => {} + (Some(r), Some(mut v)) if r != self.this && !matches!(v, Object::Ref(_)) => { + self.writes.insert(r, Object::Null); + self.clean(doc, &mut v, depth + 1); + self.writes.insert(r, v); + } + (_, Some(v)) => d.set(key.to_vec(), v), + } + } + + /// [`Walk::without`] for one action entry; marks the object changed when it changes. An entry + /// that changes while it holds an array that lists itself stops the walk when what is left + /// still reaches a form action through that array. + fn take(&mut self, doc: &Document, v: &Object) -> Option> { + self.chain.clear(); + self.looped = false; + let rest = self.without(doc, v, 0)?; + if self.looped && self.reaches_form_action(doc, &rest) { + self.exhausted = true; + return None; + } + self.changed = true; + Some(rest) + } + + /// The actions an action value stands for once the form actions at the head of its chain are + /// taken out: a form action gives way to the actions of its `/Next`. `None` when nothing + /// changes (the value is kept as it is, references included). + fn without(&mut self, doc: &Document, v: &Object, depth: usize) -> Option> { + self.steps += 1; + if depth > MAX_DEPTH || self.steps > self.limit { + self.exhausted = true; + return None; + } + let resolved = doc.resolve(v); + match &*resolved { + Object::Dict(a) if is_form_action(doc, a) => { + let r = v.as_ref(); + // A chain that comes back to an action it is still inside ends there. + if let Some(r) = r + && !self.chain.insert(r) + { + return Some(Vec::new()); + } + let rest = match a.get(b"Next") { + None => Vec::new(), + Some(next) => match self.without(doc, next, depth + 1) { + Some(rest) => rest, + None => match &*doc.resolve(next) { + Object::Array(items) => items.clone(), + _ => vec![next.clone()], + }, + }, + }; + if let Some(r) = r { + self.chain.remove(&r); + } + Some(rest) + } + Object::Array(items) => { + let r = v.as_ref(); + // An indirect array that lists itself, directly or through other arrays, is left + // as it is where it comes back. + if let Some(r) = r + && !self.chain.insert(r) + { + self.looped = true; + return None; + } + let mut out = Vec::new(); + let mut changed = false; + for item in items { + match self.without(doc, item, depth + 1) { + Some(rest) => { + changed = true; + out.extend(rest); + } + None => out.push(item.clone()), + } + } + if let Some(r) = r { + self.chain.remove(&r); + } + changed.then_some(out) + } + _ => None, + } + } + + /// Whether `items`, or an indirect array they list, lists a form action. Each value looked at + /// counts as a step. + fn reaches_form_action(&mut self, doc: &Document, items: &[Object]) -> bool { + let mut seen = HashSet::new(); + let mut pending = Vec::new(); + if self.lists_form_action(doc, items, &mut seen, &mut pending) { + return true; + } + while let Some(r) = pending.pop() { + if let Object::Array(items) = &*doc.get(r) + && self.lists_form_action(doc, items, &mut seen, &mut pending) + { + return true; + } + } + false + } + + /// Whether `items`, or a direct array inside them, lists a form action; indirect arrays not + /// yet seen go to `pending`. + fn lists_form_action(&mut self, doc: &Document, items: &[Object], seen: &mut HashSet, pending: &mut Vec) -> bool { + let mut stack: Vec<&Object> = items.iter().collect(); + while let Some(item) = stack.pop() { + self.steps += 1; + if self.steps > self.limit { + self.exhausted = true; + return false; + } + match item { + Object::Dict(a) if is_form_action(doc, a) => return true, + Object::Array(inner) => stack.extend(inner.iter()), + Object::Ref(r) => match &*doc.get(*r) { + Object::Dict(a) if is_form_action(doc, a) => return true, + Object::Array(_) if seen.insert(*r) => pending.push(*r), + _ => {} + }, + _ => {} + } + } + false + } +} + +/// Whether the walk would take a form action out of `o` or the direct objects inside it: a form +/// action, or an action entry (or a `/Next` array or `/AA` dictionary, direct or indirect) that +/// refers to one. Looks through `o` without recursion. +fn holds_form_action(doc: &Document, o: &Object) -> bool { + let mut stack = vec![o]; + while let Some(o) = stack.pop() { + match o { + Object::Dict(d) => { + if is_form_action(doc, d) { + return true; + } + for key in [b"A".as_slice(), b"OpenAction", b"Next"] { + let refers = match d.get(key) { + Some(Object::Array(items)) => items.iter().any(|item| refers_to_form_action(doc, item)), + Some(v) => refers_to_form_action(doc, v), + None => false, + }; + if refers { + return true; + } + } + if let Some(aa) = d.get(b"AA") + && let Object::Dict(t) = &*doc.resolve(aa) + && t.iter().any(|(_, v)| is_or_refers_to_form_action(doc, v)) + { + return true; + } + stack.extend(d.iter().map(|(_, v)| v)); + } + Object::Array(items) => stack.extend(items.iter()), + _ => {} + } + } + false +} + +/// Whether `v` is a direct form action or a reference to one. +fn is_or_refers_to_form_action(doc: &Document, v: &Object) -> bool { + match v { + Object::Dict(a) => is_form_action(doc, a), + Object::Ref(r) => matches!(&*doc.get(*r), Object::Dict(a) if is_form_action(doc, a)), + _ => false, + } +} + +/// Whether `v` refers to a form action, or to an indirect array that lists one. +fn refers_to_form_action(doc: &Document, v: &Object) -> bool { + let Object::Ref(r) = v else { return false }; + match &*doc.get(*r) { + Object::Dict(a) => is_form_action(doc, a), + Object::Array(items) => items.iter().any(|item| is_or_refers_to_form_action(doc, item)), + _ => false, + } +} + +/// One action value for a list of actions run in order: the first action, with the others +/// after its own `/Next` actions. `None` when no action remains. +fn chain(doc: &Document, actions: Vec) -> Option { + let mut actions = actions.into_iter(); + loop { + let first = actions.next()?; + let rest: Vec = actions.as_slice().to_vec(); + if rest.is_empty() { + return Some(first); + } + // A list needs a dictionary at its head to carry the rest; anything else is dropped. + let Some(mut head) = doc.resolve(&first).as_dict().cloned() else { continue }; + let mut next = match head.get(b"Next") { + None => Vec::new(), + Some(n) => match &*doc.resolve(n) { + Object::Array(items) => items.clone(), + _ => vec![n.clone()], + }, + }; + next.extend(rest); + head.set(b"Next".to_vec(), Object::Array(next)); + return Some(Object::Dict(head)); + } +} diff --git a/crates/optimize/src/lib.rs b/crates/optimize/src/lib.rs index 275c6f32a6..fc29a45d4e 100644 --- a/crates/optimize/src/lib.rs +++ b/crates/optimize/src/lib.rs @@ -17,7 +17,7 @@ //! are stored as CCITT Group 4 or lossless JBIG2, or 1-bit //! Flate once resampled or packed, when that is smaller. //! - **Discard objects:** page thumbnails, alternate images, document tags (structure tree), -//! print settings. +//! print settings, form submission, import and reset actions. //! - **Clean up:** drop images and forms that pages list but never draw (such as a deleted //! image); Flate-compress streams that have no filter; remove a redundant outer ASCII85 or //! ASCIIHex layer from streams that have one; store LZW-compressed streams with Flate instead. @@ -29,6 +29,7 @@ #![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] mod audit; +mod form_actions; pub mod fragments; mod images; mod links; @@ -49,6 +50,8 @@ pub use samples::{NotDecoded, Samples, decode_samples, encode_samples}; pub enum OptimizeError { #[error("the document has no page tree")] NoPages, + #[error("object {0} nests or chains its form actions too deeply, or the document holds too many actions, to discard them")] + ActionLimit(ObjRef), #[error("the document's encryption requires PDF {required} or later (chosen: PDF {chosen})")] IncompatibleEncryption { required: &'static str, chosen: &'static str }, #[error(transparent)] @@ -197,6 +200,8 @@ pub struct Settings { pub discard_alternate_images: bool, pub discard_tags: bool, pub discard_print_settings: bool, + /// Remove form submission, import and reset actions; the fields and links that ran them stay. + pub discard_form_actions: bool, /// Flate-compress streams stored without a filter, and strip a redundant outer ASCII85 or /// ASCIIHex layer from streams that have one. pub flate_unencoded: bool, @@ -233,6 +238,7 @@ impl Default for Settings { discard_alternate_images: true, discard_tags: false, discard_print_settings: false, + discard_form_actions: false, flate_unencoded: true, object_streams: true, lzw_to_flate: true, @@ -297,6 +303,8 @@ pub struct Report { pub alternate_images: usize, pub tags_removed: bool, pub print_settings: usize, + /// Form submission, import and reset actions removed. + pub form_actions: usize, pub streams_compressed: usize, /// Streams whose LZW compression was replaced with Flate (Clean Up). pub lzw_streams: usize, @@ -426,6 +434,9 @@ pub fn optimize_with_progress(doc: &mut Document, settings: &Settings, progress: _ => {} } } + if settings.discard_form_actions { + report.form_actions = form_actions::discard(doc)?; + } if settings.remove_invalid_links { let (l, b) = links::remove_invalid(doc, &pages)?; report.invalid_links = l; diff --git a/crates/optimize/tests/optimize.rs b/crates/optimize/tests/optimize.rs index b6fe624082..92203852c7 100644 --- a/crates/optimize/tests/optimize.rs +++ b/crates/optimize/tests/optimize.rs @@ -2450,6 +2450,347 @@ fn discards_and_clean_up() { assert!(vp.contains(b"HideToolbar") && !vp.contains(b"Duplex"), "other preferences stay"); } +fn action(kind: &str) -> Dict { + let mut a = Dict::new(); + a.set(b"S".to_vec(), Object::name(kind)); + a +} + +fn with(mut d: Dict, key: &str, value: impl Into) -> Dict { + d.set(key.as_bytes().to_vec(), value); + d +} + +/// The kinds of the actions `o` runs, in order: each action, then its `/Next` actions. +fn kinds(doc: &Document, o: Option<&Object>) -> Vec { + let mut out = Vec::new(); + let Some(o) = o else { return out }; + match &*doc.resolve(o) { + Object::Array(items) => { + for item in items { + out.extend(kinds(doc, Some(item))); + } + } + Object::Dict(a) => { + out.push(String::from_utf8_lossy(a.name(b"S").unwrap_or_default()).into_owned()); + out.extend(kinds(doc, a.get(b"Next"))); + } + _ => {} + } + out +} + +fn dict(doc: &Document, r: ObjRef) -> Dict { + doc.get(r).as_dict().cloned().unwrap() +} + +#[test] +fn form_submit_import_and_reset_actions_are_discarded_and_the_fields_stay() { + let mut doc = Document::new_empty(); + let p = page(&mut doc, &[], ""); + let root = doc.root().unwrap(); + let triggers = with(Dict::new(), "U", action("ResetForm")); + let triggers = with(triggers, "E", with(action("JavaScript"), "JS", Object::String(pdfcraft_cos::PdfString::literal("app.alert(1)")))); + let button = with(with(with(Dict::new(), "Subtype", Object::name("Widget")), "FT", Object::name("Btn")), "A", action("SubmitForm")); + let button = doc.add(Object::Dict(with(button, "AA", triggers))); + let import = doc.add(Object::Dict(action("ImportData"))); + let field = with(with(Dict::new(), "Subtype", Object::name("Widget")), "FT", Object::name("Tx")); + let field = doc.add(Object::Dict(with(field, "AA", with(Dict::new(), "K", Object::Ref(import))))); + let link = with(with(Dict::new(), "Subtype", Object::name("Link")), "A", with(action("GoTo"), "Next", action("SubmitForm"))); + let link = doc.add(Object::Dict(with(link, "D", Object::Array(vec![Object::Ref(p), Object::name("Fit")])))); + let reset = doc.add(Object::Dict(action("ResetForm"))); + doc.update_dict(p, |d| d.set(b"Annots".to_vec(), Object::Array(vec![Object::Ref(button), Object::Ref(field), Object::Ref(link)]))).unwrap(); + doc.update_dict(root, |c| c.set(b"OpenAction".to_vec(), Object::Ref(reset))).unwrap(); + + let mut kept = doc.clone(); + assert_eq!(optimize(&mut kept, &Settings::default()).unwrap().form_actions, 0, "the option is off by default"); + assert_eq!(kinds(&kept, dict(&kept, button).get(b"A")), ["SubmitForm"]); + + let r = optimize(&mut doc, &Settings { discard_form_actions: true, ..Settings::default() }).unwrap(); + assert_eq!(r.form_actions, 5, "{r:?}"); + let annots = dict(&doc, p).get(b"Annots").and_then(|a| a.as_array().cloned()).unwrap(); + assert_eq!(annots.len(), 3, "the fields and the link stay"); + let b = dict(&doc, button); + assert_eq!(b.name(b"FT"), Some(&b"Btn"[..])); + assert!(!b.contains(b"A"), "{b:?}"); + let aa = b.get(b"AA").and_then(|a| a.as_dict().cloned()).unwrap(); + assert_eq!((aa.contains(b"U"), kinds(&doc, aa.get(b"E"))), (false, vec!["JavaScript".to_string()]), "other triggers stay"); + assert!(!dict(&doc, field).contains(b"AA"), "an emptied /AA goes"); + assert_eq!(kinds(&doc, dict(&doc, link).get(b"A")), ["GoTo"]); + assert!(!dict(&doc, root).contains(b"OpenAction")); +} + +#[test] +fn a_discarded_form_action_hands_its_place_to_the_actions_after_it() { + let mut doc = Document::new_empty(); + let p = page(&mut doc, &[], ""); + let root = doc.root().unwrap(); + // A reset whose /S is an indirect name. + let name = doc.add(Object::name("ResetForm")); + let reset = doc.add(Object::Dict(with(with(Dict::new(), "S", Object::Ref(name)), "Next", action("URI")))); + let submit = with( + action("SubmitForm"), + "Next", + Object::Array(vec![Object::Dict(action("JavaScript")), Object::Ref(reset), Object::Dict(action("Named"))]), + ); + let link = doc.add(Object::Dict(with(with(Dict::new(), "Subtype", Object::name("Link")), "A", submit))); + // A reset whose chain comes back to itself. + let looped = doc.add(Object::Dict(action("ResetForm"))); + doc.update_dict(looped, |a| a.set(b"Next".to_vec(), Object::Ref(looped))).unwrap(); + doc.update_dict(p, |d| d.set(b"Annots".to_vec(), Object::Array(vec![Object::Ref(link)]))).unwrap(); + doc.update_dict(root, |c| c.set(b"OpenAction".to_vec(), Object::Ref(looped))).unwrap(); + + let r = optimize(&mut doc, &Settings { discard_form_actions: true, ..Settings::default() }).unwrap(); + assert_eq!(r.form_actions, 3, "{r:?}"); + assert_eq!(kinds(&doc, dict(&doc, link).get(b"A")), ["JavaScript", "URI", "Named"]); + assert!(!dict(&doc, root).contains(b"OpenAction")); +} + +#[test] +fn a_form_action_listed_twice_in_a_chain_gives_way_both_times() { + let mut doc = Document::new_empty(); + let p = page(&mut doc, &[], ""); + let reset = doc.add(Object::Dict(with(action("ResetForm"), "Next", action("JavaScript")))); + let submit = with(action("SubmitForm"), "Next", Object::Array(vec![Object::Ref(reset), Object::Ref(reset)])); + let link = doc.add(Object::Dict(with(with(Dict::new(), "Subtype", Object::name("Link")), "A", submit))); + doc.update_dict(p, |d| d.set(b"Annots".to_vec(), Object::Array(vec![Object::Ref(link)]))).unwrap(); + // An indirect submit that holds a direct reset in its /Next. + let root = doc.root().unwrap(); + let opening = doc.add(Object::Dict(with(action("SubmitForm"), "Next", action("ResetForm")))); + doc.update_dict(root, |c| c.set(b"OpenAction".to_vec(), Object::Ref(opening))).unwrap(); + + let r = optimize(&mut doc, &Settings { discard_form_actions: true, ..Settings::default() }).unwrap(); + assert_eq!(r.form_actions, 4, "each action counts once: {r:?}"); + assert_eq!(kinds(&doc, dict(&doc, link).get(b"A")), ["JavaScript", "JavaScript"]); + assert!(!dict(&doc, root).contains(b"OpenAction")); +} + +#[test] +fn form_actions_chained_past_the_limit_stop_the_optimization() { + let mut doc = Document::new_empty(); + page(&mut doc, &[], ""); + let root = doc.root().unwrap(); + let mut chain = action("ResetForm"); + for _ in 0..80 { + chain = with(action("SubmitForm"), "Next", chain); + } + doc.update_dict(root, |c| c.set(b"OpenAction".to_vec(), chain)).unwrap(); + let r = optimize(&mut doc, &Settings { discard_form_actions: true, ..Settings::default() }); + assert!(matches!(r, Err(pdfcraft_optimize::OptimizeError::ActionLimit(at)) if at == root), "{r:?}"); +} + +#[test] +fn a_next_array_that_lists_itself_is_left_as_it_is() { + let mut doc = Document::new_empty(); + let p = page(&mut doc, &[], ""); + let looped = doc.add(Object::Null); + doc.set(looped, Object::Array(vec![Object::Ref(looped)])); + let (first, second) = (doc.add(Object::Null), doc.add(Object::Null)); + doc.set(first, Object::Array(vec![Object::Ref(second)])); + doc.set(second, Object::Array(vec![Object::Ref(first)])); + let link = |doc: &mut Document, next: ObjRef| { + let a = with(action("GoTo"), "Next", Object::Ref(next)); + doc.add(Object::Dict(with(with(Dict::new(), "Subtype", Object::name("Link")), "A", a))) + }; + let (own, pair) = (link(&mut doc, looped), link(&mut doc, first)); + let submit = with(action("SubmitForm"), "Next", Object::Ref(looped)); + let button = doc.add(Object::Dict(with(with(Dict::new(), "Subtype", Object::name("Widget")), "A", submit))); + doc.update_dict(p, |d| d.set(b"Annots".to_vec(), Object::Array(vec![Object::Ref(own), Object::Ref(pair), Object::Ref(button)]))).unwrap(); + let settings = Settings { discard_form_actions: true, ..Settings::default() }; + + let mut cleaned = doc.clone(); + let r = optimize(&mut cleaned, &settings).unwrap(); + assert_eq!(r.form_actions, 1, "{r:?}"); + for (l, next) in [(own, looped), (pair, first)] { + let a = dict(&cleaned, l).get(b"A").and_then(|a| a.as_dict().cloned()).unwrap(); + assert_eq!(a.get(b"Next"), Some(&Object::Ref(next)), "{l:?}"); + } + assert_eq!(dict(&cleaned, button).get(b"A"), Some(&Object::Ref(looped)), "the submit's /Next takes its place"); + + // An array that lists itself and a submit, directly or in an array inside it: a cleaned copy + // would still reach the submit. + for submit in [Object::Dict(action("SubmitForm")), Object::Array(vec![Object::Dict(action("SubmitForm"))])] { + let mut mixed = doc.clone(); + mixed.set(first, Object::Array(vec![Object::Ref(second), submit])); + let r = optimize(&mut mixed, &settings); + assert!(matches!(r, Err(pdfcraft_optimize::OptimizeError::ActionLimit(_))), "{r:?}"); + } +} + +/// `referrers` actions whose `/Next` is one shared array of 2,000 actions, a submit among them. +fn shared_next_array(referrers: usize) -> Document { + let mut doc = Document::new_empty(); + page(&mut doc, &[], ""); + let script = doc.add(Object::Dict(action("JavaScript"))); + let mut items = vec![Object::Ref(script); 1_999]; + items.push(Object::Dict(action("SubmitForm"))); + let shared = doc.add(Object::Array(items)); + for _ in 0..referrers { + doc.add(Object::Dict(with(action("GoTo"), "Next", Object::Ref(shared)))); + } + doc +} + +#[test] +fn a_shared_next_array_cleaned_for_too_many_actions_stops_the_optimization() { + let settings = Settings { discard_form_actions: true, ..Settings::default() }; + let mut doc = shared_next_array(400); + assert_eq!(optimize(&mut doc, &settings).unwrap().form_actions, 1); + // Each referrer is under the limit for one object; together they pass the document's. + let mut doc = shared_next_array(600); + let r = optimize(&mut doc, &settings); + assert!(matches!(r, Err(pdfcraft_optimize::OptimizeError::ActionLimit(_))), "{r:?}"); +} + +/// 80 direct dictionaries nested in each other under `/Deep`, the innermost one being `inner`. +fn nested(inner: Dict) -> Object { + let mut o = Object::Dict(inner); + for _ in 0..80 { + o = Object::Dict(with(Dict::new(), "Deep", o)); + } + o +} + +#[test] +fn deep_nesting_stops_the_optimization_only_when_a_form_action_is_past_the_limit() { + let mut doc = Document::new_empty(); + let p = page(&mut doc, &[], ""); + let plain = doc.add(nested(with(action("GoTo"), "Next", action("URI")))); + let link = doc.add(Object::Dict(with(with(Dict::new(), "Subtype", Object::name("Link")), "A", action("SubmitForm")))); + doc.update_dict(p, |d| { + d.set(b"Annots".to_vec(), Object::Array(vec![Object::Ref(link)])); + d.set(b"PieceInfo".to_vec(), Object::Ref(plain)); + }) + .unwrap(); + let settings = Settings { discard_form_actions: true, ..Settings::default() }; + let mut ok = doc.clone(); + let r = optimize(&mut ok, &settings).unwrap(); + assert_eq!(r.form_actions, 1, "deep nesting with no form action in it is left as it is: {r:?}"); + + let reset = doc.add(Object::Dict(action("ResetForm"))); + for (n, inner) in [ + action("SubmitForm"), + with(Dict::new(), "A", Object::Ref(reset)), + with(Dict::new(), "Next", Object::Ref(doc.add(Object::Array(vec![Object::Ref(reset)])))), + with(Dict::new(), "AA", Object::Ref(doc.add(Object::Dict(with(Dict::new(), "U", action("ImportData")))))), + with(Dict::new(), "AA", with(Dict::new(), "U", Object::Ref(reset))), + with(Dict::new(), "Next", Object::Array(vec![Object::Ref(reset)])), + ] + .into_iter() + .enumerate() + { + let mut deep = doc.clone(); + let held = deep.add(nested(inner)); + deep.update_dict(p, |d| d.set(b"PieceInfo".to_vec(), Object::Ref(held))).unwrap(); + let r = optimize(&mut deep, &settings); + assert!(matches!(r, Err(pdfcraft_optimize::OptimizeError::ActionLimit(at)) if at == held), "case {n}: {r:?}"); + } +} + +#[test] +fn a_shared_additional_actions_dictionary_is_cleaned_for_each_field_that_uses_it() { + let mut doc = Document::new_empty(); + let p = page(&mut doc, &[], ""); + let triggers = with(with(Dict::new(), "U", action("SubmitForm")), "E", action("JavaScript")); + let shared = doc.add(Object::Dict(triggers)); + let widget = |doc: &mut Document| doc.add(Object::Dict(with(with(Dict::new(), "Subtype", Object::name("Widget")), "AA", Object::Ref(shared)))); + let (first, second) = (widget(&mut doc), widget(&mut doc)); + doc.update_dict(p, |d| d.set(b"Annots".to_vec(), Object::Array(vec![Object::Ref(first), Object::Ref(second)]))).unwrap(); + + let r = optimize(&mut doc, &Settings { discard_form_actions: true, ..Settings::default() }).unwrap(); + assert_eq!(r.form_actions, 1, "{r:?}"); + for w in [first, second] { + assert_eq!(dict(&doc, w).get(b"AA"), Some(&Object::Ref(shared)), "the widgets still share one dictionary: {w:?}"); + } + let aa = dict(&doc, shared); + assert_eq!((aa.contains(b"U"), kinds(&doc, aa.get(b"E"))), (false, vec!["JavaScript".to_string()])); +} + +#[test] +fn shared_indirect_actions_and_next_arrays_are_cleaned_once_and_stay_shared() { + let mut doc = Document::new_empty(); + let p = page(&mut doc, &[], ""); + // A shared submit whose /Next is a script that resets the form after it runs. + let script = with(action("JavaScript"), "Next", action("ResetForm")); + let submit = doc.add(Object::Dict(with(action("SubmitForm"), "Next", script))); + let next = doc.add(Object::Array(vec![Object::Dict(action("ImportData")), Object::Dict(action("URI")), Object::Dict(action("Named"))])); + let link = |doc: &mut Document| { + let a = with(action("GoTo"), "Next", Object::Ref(next)); + doc.add(Object::Dict(with(with(Dict::new(), "Subtype", Object::name("Link")), "A", a))) + }; + let widget = |doc: &mut Document| doc.add(Object::Dict(with(with(Dict::new(), "Subtype", Object::name("Widget")), "A", Object::Ref(submit)))); + let (w1, w2, l1, l2) = (widget(&mut doc), widget(&mut doc), link(&mut doc), link(&mut doc)); + doc.update_dict(p, |d| d.set(b"Annots".to_vec(), Object::Array([w1, w2, l1, l2].map(Object::Ref).to_vec()))).unwrap(); + + let r = optimize(&mut doc, &Settings { discard_form_actions: true, ..Settings::default() }).unwrap(); + assert_eq!(r.form_actions, 3, "{r:?}"); + for w in [w1, w2] { + assert_eq!(dict(&doc, w).get(b"A"), Some(&Object::Ref(submit)), "{w:?}"); + assert_eq!(kinds(&doc, dict(&doc, w).get(b"A")), ["JavaScript"], "{w:?}"); + } + for l in [l1, l2] { + let a = dict(&doc, l).get(b"A").and_then(|a| a.as_dict().cloned()).unwrap(); + assert_eq!(a.get(b"Next"), Some(&Object::Ref(next)), "{l:?}"); + assert_eq!(kinds(&doc, dict(&doc, l).get(b"A")), ["GoTo", "URI", "Named"], "{l:?}"); + } +} + +#[test] +fn a_form_action_in_an_array_inside_an_indirect_next_array_is_counted() { + let mut doc = Document::new_empty(); + let p = page(&mut doc, &[], ""); + let next = doc.add(Object::Array(vec![Object::Array(vec![Object::Dict(action("ResetForm"))]), Object::Dict(action("URI"))])); + let a = with(action("GoTo"), "Next", Object::Ref(next)); + let link = doc.add(Object::Dict(with(with(Dict::new(), "Subtype", Object::name("Link")), "A", a))); + doc.update_dict(p, |d| d.set(b"Annots".to_vec(), Object::Array(vec![Object::Ref(link)]))).unwrap(); + + let r = optimize(&mut doc, &Settings { discard_form_actions: true, ..Settings::default() }).unwrap(); + assert_eq!(r.form_actions, 1, "{r:?}"); + assert_eq!(kinds(&doc, dict(&doc, link).get(b"A")), ["GoTo", "URI"]); +} + +#[test] +fn a_shared_action_run_as_a_next_action_and_as_an_a_entry_stays_a_dictionary() { + let mut doc = Document::new_empty(); + let p = page(&mut doc, &[], ""); + // A reset before two actions, run by one link after a GoTo and by another as its /A. + let reset = doc.add(Object::Dict(with( + action("ResetForm"), + "Next", + Object::Array(vec![Object::Dict(action("JavaScript")), Object::Dict(action("URI"))]), + ))); + let link = |doc: &mut Document, a: Object| doc.add(Object::Dict(with(with(Dict::new(), "Subtype", Object::name("Link")), "A", a))); + let after = link(&mut doc, Object::Dict(with(action("GoTo"), "Next", Object::Ref(reset)))); + let runs = link(&mut doc, Object::Ref(reset)); + doc.update_dict(p, |d| d.set(b"Annots".to_vec(), Object::Array([after, runs].map(Object::Ref).to_vec()))).unwrap(); + + let r = optimize(&mut doc, &Settings { discard_form_actions: true, ..Settings::default() }).unwrap(); + assert_eq!(r.form_actions, 1, "{r:?}"); + assert_eq!(dict(&doc, reset).name(b"S"), Some(&b"JavaScript"[..]), "the reset's place is taken by a dictionary"); + assert_eq!(dict(&doc, runs).get(b"A"), Some(&Object::Ref(reset))); + assert_eq!(kinds(&doc, dict(&doc, runs).get(b"A")), ["JavaScript", "URI"]); + let a = dict(&doc, after).get(b"A").and_then(|a| a.as_dict().cloned()).unwrap(); + assert_eq!(a.get(b"Next"), Some(&Object::Ref(reset))); + assert_eq!(kinds(&doc, dict(&doc, after).get(b"A")), ["GoTo", "JavaScript", "URI"]); +} + +#[test] +fn a_shared_submit_whose_script_runs_it_again_gives_way_to_the_script() { + let mut doc = Document::new_empty(); + let p = page(&mut doc, &[], ""); + // The widget comes first, so the walk reaches the submit through it. + let widget = doc.add(Object::Null); + let submit = doc.add(Object::Null); + doc.set(widget, Object::Dict(with(with(Dict::new(), "Subtype", Object::name("Widget")), "A", Object::Ref(submit)))); + doc.set(submit, Object::Dict(with(action("SubmitForm"), "Next", with(action("JavaScript"), "Next", Object::Ref(submit))))); + doc.update_dict(p, |d| d.set(b"Annots".to_vec(), Object::Array(vec![Object::Ref(widget)]))).unwrap(); + let r = optimize(&mut doc, &Settings { discard_form_actions: true, ..Settings::default() }).unwrap(); + assert_eq!(r.form_actions, 1, "{r:?}"); + assert_eq!(dict(&doc, widget).get(b"A"), Some(&Object::Ref(submit))); + let script = dict(&doc, submit); + assert_eq!((script.name(b"S"), script.get(b"Next")), (Some(&b"JavaScript"[..]), Some(&Object::Ref(submit))), "the script runs itself again"); +} + #[test] fn space_audit_shares_the_file_out_by_kind() { let mut doc = Document::new_empty(); diff --git a/crates/ui-egui/src/workflows/optimize_ui.rs b/crates/ui-egui/src/workflows/optimize_ui.rs index f54fc177f0..6d46e5dfca 100644 --- a/crates/ui-egui/src/workflows/optimize_ui.rs +++ b/crates/ui-egui/src/workflows/optimize_ui.rs @@ -251,6 +251,7 @@ pub(crate) fn body(ui: &mut egui::Ui, d: &mut OptimizeDraft, t: &Tokens) -> (boo ui.add(widgets::checkbox(&mut s.discard_thumbnails, tl!("Discard embedded page thumbnails"))); ui.add(widgets::checkbox(&mut s.discard_tags, tl!("Discard document tags"))); ui.add(widgets::checkbox(&mut s.discard_print_settings, tl!("Discard embedded print settings"))); + ui.add(widgets::checkbox(&mut s.discard_form_actions, tl!("Discard all form submission, import and reset actions"))); discard_box(ui, &mut d.discard, Hidden::Bookmarks, "Discard bookmarks"); discard_box(ui, &mut d.discard, Hidden::FormFields, "Flatten form fields"); discard_box(ui, &mut d.discard, Hidden::HiddenLayers, "Discard hidden layer content"); diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index 3c17a3ed70..6557c2b1f8 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -7678,11 +7678,11 @@ title = "Discard form submit, import and reset actions" area = "K" tier = "P2" milestone = "M11" -status = "planned" -commands = [] -tools = [] -evidence = [] -notes = "" +status = "shipped" +commands = ["optimize.advanced"] +tools = ["doc_optimize"] +evidence = ["crates/optimize/tests/optimize.rs::form_submit_import_and_reset_actions_are_discarded_and_the_fields_stay", "crates/optimize/tests/optimize.rs::a_discarded_form_action_hands_its_place_to_the_actions_after_it", "crates/optimize/tests/optimize.rs::a_form_action_listed_twice_in_a_chain_gives_way_both_times", "crates/optimize/tests/optimize.rs::a_shared_additional_actions_dictionary_is_cleaned_for_each_field_that_uses_it", "crates/optimize/tests/optimize.rs::shared_indirect_actions_and_next_arrays_are_cleaned_once_and_stay_shared", "crates/optimize/tests/optimize.rs::deep_nesting_stops_the_optimization_only_when_a_form_action_is_past_the_limit", "crates/automation/tests/it/automation.rs::optimizing_discards_form_actions_through_tools"] +notes = "PDF Optimizer ▸ Discard Objects, off by default. Removes /SubmitForm, /ImportData and /ResetForm actions from link, widget and bookmark /A, catalog /OpenAction, every /AA trigger and /Next chains; the actions after a removed one still run in order. An object that holds a form action more than 64 direct objects deep, chains form actions more than 64 deep, or makes the walk look at more than 100,000 action values, or a document that makes it look at more than 1,000,000 in all, stops the optimization with an error. Not changed: JavaScript that calls submitForm or resetForm (Discard all links, actions and JavaScript removes the JavaScript in links, widgets, /AA triggers, /OpenAction and the document's scripts), and XFA form submissions." [[feature]] id = "optimize.flatten-form-fields" From 743a772ee946621af869b67cd331730701fa4a82 Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Mon, 9 Nov 2026 05:36:04 -0500 Subject: [PATCH 53/64] fix(content): an inline image only the renderer reads counts as ambiguous (#5874) --- crates/content/src/lib.rs | 16 ++++++++++------ crates/content/src/tests.rs | 10 ++++++++++ crates/redact/src/tests/inline_images.rs | 9 +++++++++ 3 files changed, 29 insertions(+), 6 deletions(-) diff --git a/crates/content/src/lib.rs b/crates/content/src/lib.rs index 4957f86861..fb17a903d1 100644 --- a/crates/content/src/lib.rs +++ b/crates/content/src/lib.rs @@ -67,8 +67,9 @@ pub struct Parsed { /// Bytes that could not be read as operands or operators. pub skipped: usize, /// Inline images the renderer's tokenizer reads no image for (it stops, or finds no `EI` it - /// accepts). Their end here is a guess, so the operators after them may not be the ones the - /// page draws. + /// accepts), so their end here is a guess and the operators after them may not be the ones + /// the page draws; and images it reads whose parameters this parser can't, which are missing + /// from `ops` although the page draws them. pub ambiguous_images: usize, /// For [`Pieces::parse`]: per piece, whether a comment runs into it from the piece before /// and whether one runs out of it into the next. Only boundaries are recorded, so the size @@ -139,8 +140,8 @@ impl<'a> Ops<'a> { self.skipped } - /// Inline images read so far whose end is this parser's guess (as in - /// [`Parsed::ambiguous_images`]). + /// Inline images read so far that are ambiguous (as in [`Parsed::ambiguous_images`]): either + /// their end is this parser's guess, or the renderer reads them and this parser cannot. pub fn ambiguous_images(&self) -> usize { self.ambiguous_images } @@ -291,7 +292,8 @@ struct Inline { image: Option<(Dict, Vec)>, /// The position after its `EI` (or the end of the stream). end: usize, - /// The renderer's tokenizer reads no image here, so `end` is this parser's guess. + /// The renderer's tokenizer reads no image here, so `end` is this parser's guess; or it reads + /// one whose parameters this parser can't, so `image` is `None` for an image the page draws. ambiguous: bool, } @@ -339,7 +341,9 @@ fn inline_image(data: &[u8], bi: usize, pos: usize) -> Inline { { samples = rest; } - return Inline { image: readable.then(|| (dict, samples.to_vec())), end, ambiguous: false }; + // The renderer draws an image whose parameters this parser can't read: it is not + // represented here, so a redaction can't inspect or remove it and must not trust the page. + return Inline { image: readable.then(|| (dict, samples.to_vec())), end, ambiguous: !readable }; } if !readable { // Without an `EI` after it, the image runs to the end of the stream. diff --git a/crates/content/src/tests.rs b/crates/content/src/tests.rs index 9ac9297cd2..9ca12c4536 100644 --- a/crates/content/src/tests.rs +++ b/crates/content/src/tests.rs @@ -209,6 +209,16 @@ fn filtered_unknown_and_damaged_inline_images_end_where_the_renderer_ends_them() } } +/// The renderer reads an image whose parameters this parser can't (a stray operand before `ID`): +/// the page draws an image `ops` doesn't hold, so it is counted as ambiguous rather than dropped +/// silently. +#[test] +fn an_inline_image_read_by_the_renderer_but_not_here_is_ambiguous() { + let parsed = parse(b"q BI /W 1 /H 1 /BPC 8 /CS /G 7 ID \x80 EI Q"); + assert_eq!(parsed.ops.iter().map(|op| op.op.as_slice()).collect::>(), [b"q".as_slice(), b"Q"]); + assert_eq!(parsed.ambiguous_images, 1); +} + /// A zlib stream (one stored block) for 64 DeviceGray samples whose bytes hold " EI\n" at offset 41, /// the way a stored deflate block copies incompressible samples verbatim (#3579). const FLATE_WITH_EI: &str = "789c014000bfffed746159d50788f7ea604984cd08e00b4c8983207f56fe82c77ef8dfb8b9a3a586382045490a3120\ diff --git a/crates/redact/src/tests/inline_images.rs b/crates/redact/src/tests/inline_images.rs index 77fd4dc2d7..f1c61b71a5 100644 --- a/crates/redact/src/tests/inline_images.rs +++ b/crates/redact/src/tests/inline_images.rs @@ -32,3 +32,12 @@ fn a_page_whose_inline_image_the_renderer_cannot_end_is_refused() { mark(&mut doc, 0, &[[5.0, 95.0, 45.0, 110.0]], ""); assert_eq!(apply(&mut doc, None), Err(RedactError::Unreadable(1))); } + +/// The renderer draws an image whose parameters the content parser can't read (a stray operand +/// before `ID`): it would stay under the mark unseen, so the page is refused. +#[test] +fn a_page_whose_inline_image_only_the_renderer_reads_is_refused() { + let mut doc = one_page(b"q 10 0 0 10 50 50 cm BI /W 1 /H 1 /CS /G /BPC 8 7 ID \x80 EI Q", "", vec![]); + mark(&mut doc, 0, &[[45.0, 45.0, 65.0, 65.0]], ""); + assert_eq!(apply(&mut doc, None), Err(RedactError::Unreadable(1))); +} From 44a26f4b930ba05fb64bc1835bedac19b83640eb Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Mon, 9 Nov 2026 05:36:19 -0500 Subject: [PATCH 54/64] fix(model,oracle): read a page box only when it has four entries; skip empty images (#5941) --- crates/model/src/lib.rs | 8 ++++++-- crates/oracle/src/pixels.rs | 12 ++++++++++++ 2 files changed, 18 insertions(+), 2 deletions(-) diff --git a/crates/model/src/lib.rs b/crates/model/src/lib.rs index 94da24ea1c..23eede9b45 100644 --- a/crates/model/src/lib.rs +++ b/crates/model/src/lib.rs @@ -24,8 +24,12 @@ const MIN_BOX_SIDE: f64 = 1.0 / 4096.0; fn rect(doc: &Document, o: Option<&Object>) -> Option<[f64; 4]> { let o = doc.resolve(o?); - let v: Vec = o.as_array()?.iter().filter_map(|x| doc.resolve(x).as_f64()).collect(); - (v.len() == 4 && v.iter().all(|x| x.is_finite())).then(|| [v[0].min(v[2]), v[1].min(v[3]), v[0].max(v[2]), v[1].max(v[3])]) + // Four entries, or not a rectangle: a longer array (one shared by many pages, say) is not + // resolved entry by entry only to be discarded. + let [a, b, c, d] = o.as_array()?.as_slice() else { return None }; + let v = [a, b, c, d].map(|x| doc.resolve(x).as_f64()); + let [Some(x0), Some(y0), Some(x1), Some(y1)] = v else { return None }; + [x0, y0, x1, y1].iter().all(|x| x.is_finite()).then(|| [x0.min(x1), y0.min(y1), x0.max(x1), y0.max(y1)]) } impl Page { diff --git a/crates/oracle/src/pixels.rs b/crates/oracle/src/pixels.rs index 4ff0ebaf13..6681aaa5df 100644 --- a/crates/oracle/src/pixels.rs +++ b/crates/oracle/src/pixels.rs @@ -105,6 +105,10 @@ pub fn compare(a: Gray<'_>, b: Gray<'_>, tolerance: u8) -> Result, b: Gray<'_>, tolerance: u8) -> usize { + // No pixels, nothing to match: a zero-width image of enormous height is not walked row by row. + if a.pixels.is_empty() { + return 0; + } let mut count = 0usize; for y in 0..a.height { for x in 0..a.width { @@ -210,6 +214,14 @@ fn find(haystack: &[u8], needle: &[u8]) -> Option { #[cfg(test)] mod tests { + /// A zero-width image of enormous height has no pixels to walk. + #[test] + fn an_image_without_pixels_compares_at_once() { + let a = Gray { width: 0, height: usize::MAX, pixels: &[] }; + let d = compare(a, a, 0).unwrap(); + assert_eq!((d.differing, d.total), (0, 0)); + } + use super::*; fn gray(w: usize, h: usize, fill: u8) -> Vec { From ca56433f5fb17332a744dc1511cb283cfa66331f Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Mon, 9 Nov 2026 05:36:34 -0500 Subject: [PATCH 55/64] fix(redact): read text-state operands from the end, as the renderer does (#5951) --- crates/redact/src/interp.rs | 27 +++++++++++++++++---------- crates/redact/src/tests/text_state.rs | 14 ++++++++++++++ 2 files changed, 31 insertions(+), 10 deletions(-) diff --git a/crates/redact/src/interp.rs b/crates/redact/src/interp.rs index 1ac7cb6714..326adff769 100644 --- a/crates/redact/src/interp.rs +++ b/crates/redact/src/interp.rs @@ -591,8 +591,10 @@ impl Walk { } fn select_font(&mut self, doc: &Document, scope: &mut Scope<'_>, op: &Op) { - self.gs.size = op.num(1).unwrap_or(self.gs.size); - let Some(name) = op.name(0) else { return }; + // `/F1 12 Tf` takes its last two operands, as the renderer does: a surplus before them + // is ignored, not read in their place. + self.gs.size = op.nums::<1>().map_or(self.gs.size, |[size]| size); + let Some(name) = op.operands.len().checked_sub(2).and_then(|i| op.name(i)) else { return }; let font_dict = self.fonts_res.get(name).and_then(|f| doc.resolve(f).as_dict().cloned()); self.gs.vertical = font_dict.as_ref().is_some_and(|d| is_vertical(doc, d)); self.gs.type3_inherits = font_dict.as_ref().is_some_and(|d| type3_without_resources(doc, d)); @@ -609,17 +611,20 @@ impl Walk { /// Text state and text-positioning operators. fn text_state(&mut self, op: &Op) { let gs = &mut self.gs; + // The operand immediately before the operator, as the renderer reads it: surplus operands + // before it must not move where glyphs are placed (`1 50 Ts` raises text by 50, not 1). + let last = op.nums::<1>().map(|[v]| v); match op.op.as_slice() { b"BT" => { self.tm = Matrix::IDENTITY; self.tlm = Matrix::IDENTITY; } - b"Tc" => gs.char_spacing = op.num(0).unwrap_or(0.0), - b"Tw" => gs.word_spacing = op.num(0).unwrap_or(0.0), - b"Tz" => gs.scale = op.num(0).unwrap_or(100.0) / 100.0, - b"TL" => gs.leading = op.num(0).unwrap_or(0.0), - b"Ts" => gs.rise = op.num(0).unwrap_or(0.0), - b"Tr" => gs.render_mode = op.num(0).unwrap_or(0.0) as i64, + b"Tc" => gs.char_spacing = last.unwrap_or(0.0), + b"Tw" => gs.word_spacing = last.unwrap_or(0.0), + b"Tz" => gs.scale = last.unwrap_or(100.0) / 100.0, + b"TL" => gs.leading = last.unwrap_or(0.0), + b"Ts" => gs.rise = last.unwrap_or(0.0), + b"Tr" => gs.render_mode = last.unwrap_or(0.0) as i64, b"Td" | b"TD" => { if let Some([x, y]) = op.nums::<2>() { if op.is("TD") { @@ -656,8 +661,10 @@ impl Walk { } let o = op.op.as_slice(); if o == b"\"" { - self.gs.word_spacing = op.num(0).unwrap_or(self.gs.word_spacing); - self.gs.char_spacing = op.num(1).unwrap_or(self.gs.char_spacing); + // `aw ac string "`: the two numbers before the string, whatever surplus precedes them. + let at = |back: usize| op.operands.len().checked_sub(back).and_then(|i| op.num(i)); + self.gs.word_spacing = at(3).unwrap_or(self.gs.word_spacing); + self.gs.char_spacing = at(2).unwrap_or(self.gs.char_spacing); } if matches!(o, b"'" | b"\"") { self.tlm = Matrix::translate(0.0, -self.gs.leading).then(&self.tlm); diff --git a/crates/redact/src/tests/text_state.rs b/crates/redact/src/tests/text_state.rs index 4e888f262c..2df6b53fad 100644 --- a/crates/redact/src/tests/text_state.rs +++ b/crates/redact/src/tests/text_state.rs @@ -25,3 +25,17 @@ fn a_form_inherits_the_text_state_of_the_page_that_draws_it() { let r = apply(&mut doc, None).unwrap(); assert_eq!((r.glyphs, r.forms_rewritten), (6, 1), "{r:?}"); } + +/// `1 50 Ts` raises text by 50, the operand before the operator, as the renderer reads it; the +/// surplus `1` before it is ignored. Reading the first operand put the glyphs 49 pt too low, so a +/// mark over the text the page shows missed it. +#[test] +fn surplus_text_state_operands_do_not_move_the_glyphs() { + let mut doc = one_page(b"BT /F1 10 Tf 1 50 Ts 10 100 Td (SECRET) Tj ET", "", vec![]); + assert_eq!(under(&mut doc, 0, &[SECRET_AT_10_100]), 0, "nothing is drawn at y 100"); + let raised = [5.0, 145.0, 45.0, 160.0]; + assert_eq!(under(&mut doc, 0, &[raised]), 6); + mark(&mut doc, 0, &[raised], ""); + assert_eq!(apply(&mut doc, None).unwrap().glyphs, 6); + assert!(!content(&doc, 0).contains("SECRET")); +} From 0d4074755d8382e758f6e9ffc9a4afe7b8e6bcc4 Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Mon, 9 Nov 2026 05:36:50 -0500 Subject: [PATCH 56/64] fix(automation): sign_trust deduplicates certificates with a set (#5965) --- crates/automation/src/signing.rs | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/crates/automation/src/signing.rs b/crates/automation/src/signing.rs index df0fd643fb..c78baf25e9 100644 --- a/crates/automation/src/signing.rs +++ b/crates/automation/src/signing.rs @@ -310,6 +310,9 @@ impl Automation { pub(crate) fn sign_trust(&mut self, a: &Args) -> Result { let mut certs: Vec = if a.opt_bool("clear")?.unwrap_or(false) { Vec::new() } else { self.session.trusted_certificates().to_vec() }; + // Which certificates are already in, by their bytes: a set, so a bundle of many distinct + // certificates isn't compared against every one before it. + let mut known: std::collections::HashSet> = certs.iter().map(|c| c.raw.clone()).collect(); let paths: Vec = match a.get("paths") { None => Vec::new(), Some(v) => v @@ -330,7 +333,7 @@ impl Automation { sign::x509::load_certificates(&bytes).map_err(failed)? }; for c in found { - if !certs.iter().any(|t| t.raw == c.raw) { + if known.insert(c.raw.clone()) { certs.push(c); } } From 3fe86a118e6c02d07030cf8759f9c567d16265d3 Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Mon, 9 Nov 2026 05:37:07 -0500 Subject: [PATCH 57/64] fix(print,a11y): read summary scans one operator at a time and borrow form resources (#5968) --- crates/a11y/src/content.rs | 3 ++- crates/print/src/sheets.rs | 12 ++++++++---- 2 files changed, 10 insertions(+), 5 deletions(-) diff --git a/crates/a11y/src/content.rs b/crates/a11y/src/content.rs index d548c57f9d..118b7af4e5 100644 --- a/crates/a11y/src/content.rs +++ b/crates/a11y/src/content.rs @@ -73,7 +73,8 @@ impl Walk<'_> { // Marked-content stack: whether each level is tagged or an artifact. let mut stack: Vec = Vec::new(); let mut untagged = 0; - for op in pdfcraft_content::parse(data).ops { + // One operator at a time: the walk keeps counts, never the stream's operators. + for op in pdfcraft_content::ops(data) { let inside = covered || stack.last().copied().unwrap_or(false); match op.op.as_slice() { b"BMC" => stack.push(inside || op.name(0) == Some(b"Artifact")), diff --git a/crates/print/src/sheets.rs b/crates/print/src/sheets.rs index fa34f5c6f8..b4ee125fd3 100644 --- a/crates/print/src/sheets.rs +++ b/crates/print/src/sheets.rs @@ -20,7 +20,8 @@ pub fn text_reads_up(doc: &Document, index: usize) -> bool { pub(crate) fn text_direction(doc: &Document, data: &[u8], res: &Dict, base: Matrix, depth: usize, score: &mut i64, budget: &mut DecodeBudget) { let (mut ctm, mut tm, mut stack) = (base, Matrix::IDENTITY, Vec::new()); - for op in pdfcraft_content::parse(data).ops { + // One operator at a time: only the running score is kept, never the stream's operators. + for op in pdfcraft_content::ops(data) { match op.op.as_slice() { // Bounded like the renderer's graphics-state stack: deeper saves are ignored. b"q" if stack.len() < 256 => stack.push(ctm), @@ -39,13 +40,16 @@ pub(crate) fn text_direction(doc: &Document, data: &[u8], res: &Dict, base: Matr if form.dict.get(b"Subtype").and_then(|s| doc.resolve(s).as_name().map(<[u8]>::to_vec)).as_deref() != Some(b"Form".as_slice()) { continue; } - let m = form.dict.get(b"Matrix").and_then(|m| doc.resolve(m).as_array().and_then(|a| Matrix::from_operands(a))).unwrap_or_default(); - let inner = form.dict.get(b"Resources").and_then(|r| doc.resolve(r).as_dict().cloned()).unwrap_or_else(|| res.clone()); + // The visit is paid for before anything else is read, and the form's resources + // (or the inherited ones) are borrowed, not copied for each invocation. if !budget.enter_form() { continue; } + let m = form.dict.get(b"Matrix").and_then(|m| doc.resolve(m).as_array().and_then(|a| Matrix::from_operands(a))).unwrap_or_default(); + let own = form.dict.get(b"Resources").map(|r| doc.resolve(r)); + let inner = own.as_deref().and_then(Object::as_dict).unwrap_or(res); if let Ok(body) = budget.decode(form) { - text_direction(doc, &body, &inner, m.then(&ctm), depth + 1, score, budget); + text_direction(doc, &body, inner, m.then(&ctm), depth + 1, score, budget); } } _ => {} From 8f29ed66f93280b14c7e373f5fd19239ff197ac5 Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Mon, 9 Nov 2026 05:37:30 -0500 Subject: [PATCH 58/64] fix(engine): open linked documents without blocking and check the handle is a regular file (#5982) --- crates/engine/src/links.rs | 20 +++++++++++++++++++- 1 file changed, 19 insertions(+), 1 deletion(-) diff --git a/crates/engine/src/links.rs b/crates/engine/src/links.rs index e1d54d56ce..da3f0fc904 100644 --- a/crates/engine/src/links.rs +++ b/crates/engine/src/links.rs @@ -227,7 +227,25 @@ pub fn read_remote_document(path: &Path) -> Result, RemoteDocumentPathEr if meta.len() > MAX_REMOTE_DOCUMENT_BYTES { return Err(RemoteDocumentPathError::TooLarge); } - let file = std::fs::File::open(path).map_err(|e| RemoteDocumentPathError::Read(e.to_string()))?; + // The path can change between that check and the open (a file replaced by a FIFO): opened + // without blocking, and checked again on the handle, which is what is read. + let mut options = std::fs::OpenOptions::new(); + options.read(true); + #[cfg(unix)] + { + use std::os::unix::fs::OpenOptionsExt as _; + // O_NONBLOCK: Linux 0o4000, macOS and the BSDs 0x4. + options.custom_flags(if cfg!(target_os = "linux") { 0o4000 } else { 0x4 }); + } + let file = options.open(path).map_err(|e| RemoteDocumentPathError::Read(e.to_string()))?; + let meta = file.metadata().map_err(|e| RemoteDocumentPathError::Read(e.to_string()))?; + if !meta.is_file() { + return Err(RemoteDocumentPathError::NotAFile); + } + // the opened handle's own length: a file replaced after the path check must not size the buffer + if meta.len() > MAX_REMOTE_DOCUMENT_BYTES { + return Err(RemoteDocumentPathError::TooLarge); + } let mut bytes = Vec::with_capacity(usize::try_from(meta.len()).unwrap_or(0)); file.take(MAX_REMOTE_DOCUMENT_BYTES.saturating_add(1)).read_to_end(&mut bytes).map_err(|e| RemoteDocumentPathError::Read(e.to_string()))?; if u64::try_from(bytes.len()).map_or(true, |n| n > MAX_REMOTE_DOCUMENT_BYTES) { From 4cdc3d15dde53f4cd423c54b23c840152a4824e6 Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Mon, 9 Nov 2026 05:37:46 -0500 Subject: [PATCH 59/64] fix(redact): read the page tree once per sanitize pass, not per page (#5983) --- crates/redact/src/sanitize.rs | 8 ++++++-- crates/redact/src/tests/limits.rs | 20 ++++++++++++++++++++ 2 files changed, 26 insertions(+), 2 deletions(-) diff --git a/crates/redact/src/sanitize.rs b/crates/redact/src/sanitize.rs index 35eac975f8..5a4ea3bfcd 100644 --- a/crates/redact/src/sanitize.rs +++ b/crates/redact/src/sanitize.rs @@ -587,9 +587,12 @@ fn content_pass(doc: &mut Document, text: bool, layers: bool, write: bool) -> Re // The original image and form XObjects the rewrite drops or replaces, on any page. let mut doomed = Vec::new(); let mut retired_properties = Vec::new(); - let n = pdfcraft_model::pages(doc).len(); + // The page tree is read once, and again only after a page's rewrite (pinning its inherited + // resources can change its ancestors), not once per page: a scan of many pages stays linear. + let mut pages = pdfcraft_model::pages(doc); + let n = pages.len(); for pi in 0..n { - let page = pdfcraft_model::pages(doc).swap_remove(pi); + let Some(page) = pages.get(pi).cloned() else { break }; let mut budget = pdfcraft_cos::DecodeBudget::page(); let Ok((list, data)) = page_streams_within(doc, &page.dict, pi, &mut budget) else { if write { @@ -672,6 +675,7 @@ fn content_pass(doc: &mut Document, text: bool, layers: bool, write: bool) -> Re pdfcraft_content::Pieces::join(&page_content).data(), ); crate::tags::clean(doc, page.obj, None, &changed_ids, &empty_ids)?; + pages = pdfcraft_model::pages(doc); } for (old, new, changed, empty) in out.tag_changes { crate::tags::clean(doc, page.obj, Some((old, new)), &changed, &empty)?; diff --git a/crates/redact/src/tests/limits.rs b/crates/redact/src/tests/limits.rs index 8519e08608..b241dc4cdc 100644 --- a/crates/redact/src/tests/limits.rs +++ b/crates/redact/src/tests/limits.rs @@ -53,3 +53,23 @@ fn a_change_inside_nested_marked_content_touches_every_enclosing_mcid() { assert_eq!(changed, HashSet::from([0, 1])); assert_eq!(empty, HashSet::from([1])); } + +/// The hidden-information scan read the whole page tree again for every page: 20 000 pages cost +/// 400 million page visits. It reads the tree once per pass now. +#[test] +fn a_scan_of_many_pages_reads_the_page_tree_once() { + use crate::sanitize::{Hidden, scan}; + const N: usize = 20_000; + let kids: String = (0..N).map(|i| format!("{} 0 R ", 4 + i)).collect(); + let mut objs = vec![ + b"<< /Type /Catalog /Pages 2 0 R >>".to_vec(), + format!("<< /Type /Pages /Kids [{kids}] /Count {N} /MediaBox [0 0 300 300] >>").into_bytes(), + stream("", b"BT 3 Tr (x) Tj ET"), + ]; + objs.extend((0..N).map(|_| b"<< /Type /Page /Parent 2 0 R /Contents 3 0 R >>".to_vec())); + let doc = pdf(objs); + let started = std::time::Instant::now(); + let counts: HashMap = scan(&doc).into_iter().collect(); + assert_eq!(counts[&Hidden::HiddenText], N, "one invisible glyph on every page"); + assert!(started.elapsed() < std::time::Duration::from_secs(60), "{:?}", started.elapsed()); +} From f800c12a7eb23bc613edd79a1489055fbbc99e6e Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Mon, 9 Nov 2026 05:38:01 -0500 Subject: [PATCH 60/64] fix(xtask): render-oracle writes page pixels into a private temporary folder (#5988) --- xtask/src/gates.rs | 54 +++++++++++++++++++++++++++++++++++++++++++--- 1 file changed, 51 insertions(+), 3 deletions(-) diff --git a/xtask/src/gates.rs b/xtask/src/gates.rs index 58efe66777..c6f1e06d83 100644 --- a/xtask/src/gates.rs +++ b/xtask/src/gates.rs @@ -323,6 +323,35 @@ pub fn text_oracle(args: &[String]) -> anyhow::Result<()> { Ok(()) } +/// A new folder in the temporary directory that only this user can open (0700 on Unix), removed +/// when dropped, on success and on error alike. +struct PrivateDir(PathBuf); + +impl PrivateDir { + fn new(prefix: &str) -> anyhow::Result { + let nanos = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map_or(0, |d| d.subsec_nanos()); + for attempt in 0..100u32 { + let path = std::env::temp_dir().join(format!("{prefix}-{}-{nanos}-{attempt}", std::process::id())); + let mut builder = std::fs::DirBuilder::new(); + #[cfg(unix)] + std::os::unix::fs::DirBuilderExt::mode(&mut builder, 0o700); + // `create`, not `create_all`: a name that is already there is never used. + match builder.create(&path) { + Ok(()) => return Ok(Self(path)), + Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => continue, + Err(e) => return Err(e).with_context(|| format!("create {}", path.display())), + } + } + bail!("no unused temporary folder name for {prefix}") + } +} + +impl Drop for PrivateDir { + fn drop(&mut self) { + let _ = std::fs::remove_dir_all(&self.0); + } +} + /// Compare our rendering against Poppler's, page 1 of every PDF in a directory. /// /// This is the measured half of the fidelity gap (`docs/gaps.md` gap 12): until it exists, every @@ -368,7 +397,10 @@ pub fn render_oracle(args: &[String]) -> anyhow::Result<()> { .collect(); files.sort(); - let out_pam = std::env::temp_dir().join(format!("pdfcraft-render-oracle-{}.pam", std::process::id())); + // The page's pixels go into a folder only this user can open, made for this run (never an + // existing one, which another user could have put a link in), and removed with it. + let private = PrivateDir::new("pdfcraft-render-oracle")?; + let out_pam = private.0.join("page.pam"); let (mut scores, mut sizes_differed, mut ours_failed, mut theirs_failed) = (Vec::new(), 0usize, 0usize, 0usize); let mut worst: Vec<(f64, String)> = Vec::new(); for file in files.iter().take(limit) { @@ -410,7 +442,7 @@ pub fn render_oracle(args: &[String]) -> anyhow::Result<()> { Err(_) => sizes_differed = sizes_differed.saturating_add(1), } } - let _ = std::fs::remove_file(&out_pam); + drop(private); if scores.is_empty() { bail!( @@ -551,7 +583,23 @@ fn f1(a: &BTreeMap, b: &BTreeMap) -> f64 { #[cfg(test)] mod tests { - use super::{f1, order_score, root, word_sequence, words}; + use super::{PrivateDir, f1, order_score, root, word_sequence, words}; + + /// Each run gets a new folder of its own, private to the user, gone when the run ends. + #[test] + fn the_oracle_folder_is_new_private_and_removed() { + let (a, b) = (PrivateDir::new("pdfcraft-private-test").unwrap(), PrivateDir::new("pdfcraft-private-test").unwrap()); + assert_ne!(a.0, b.0); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + assert_eq!(std::fs::metadata(&a.0).unwrap().permissions().mode() & 0o777, 0o700); + } + let path = a.0.clone(); + std::fs::write(path.join("page.pam"), b"x").unwrap(); + drop(a); + assert!(!path.exists()); + } /// The two scores answer different questions, and the point of adding the second one is the /// first row: F1 cannot tell a page from the same page read backwards. From ba47f7bbc5d8e6e8df77b41bbac9ff4ccb2d0ec5 Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Mon, 9 Nov 2026 05:38:17 -0500 Subject: [PATCH 61/64] fix(cli): render needs the copying permission, as text does (#5990) --- apps/pdfcraft-cli/src/commands.rs | 8 ++++++++ apps/pdfcraft-cli/tests/it/owner_password.rs | 6 ++++++ 2 files changed, 14 insertions(+) diff --git a/apps/pdfcraft-cli/src/commands.rs b/apps/pdfcraft-cli/src/commands.rs index 86b31bafd3..1df3cf8789 100644 --- a/apps/pdfcraft-cli/src/commands.rs +++ b/apps/pdfcraft-cli/src/commands.rs @@ -387,6 +387,14 @@ pub(crate) fn render(args: &[String]) -> Result<(), CliError> { // The renderer takes only the user password of R2–R4 files; `Session::open` and the display // copy take either. let render_password = pdfcraft_engine::render_password_for(bytes.clone(), password); + // A page image is an image taken out of the document, as a snapshot or Export is in the app: + // like `text`, it needs the document to allow copying (or the permissions password). + let mut session = pdfcraft_engine::Session::new(); + if let Some(doc) = session.open(path, None, bytes.clone(), password).ok().and_then(|id| session.get(id)) { + doc.check_copying().map_err( + |_| "render: the document's permissions don't allow copying its pages as images; pass the owner password with --password to render it", + )?; + } let display = if inspect(bytes.clone(), render_password.as_deref()).is_ok_and(|info| info.xfa == Some(pdfcraft_render::Xfa::Dynamic)) { let mut session = pdfcraft_engine::Session::new(); let id = session.open(path, None, bytes, password).map_err(|e| e.to_string())?; diff --git a/apps/pdfcraft-cli/tests/it/owner_password.rs b/apps/pdfcraft-cli/tests/it/owner_password.rs index 6796980982..5a3e061728 100644 --- a/apps/pdfcraft-cli/tests/it/owner_password.rs +++ b/apps/pdfcraft-cli/tests/it/owner_password.rs @@ -130,6 +130,12 @@ fn render_accepts_the_owner_password() { .output() .unwrap(); let stderr = String::from_utf8_lossy(&out.stderr); + if pw == "user" { + // The user password opens it, but a page image is a copy the permissions forbid. + assert!(!out.status.success() && stderr.contains("don't allow copying"), "{alg:?} with {pw:?}: {stderr}"); + assert!(!png.exists(), "{alg:?}: no image is written"); + continue; + } assert!(out.status.success(), "{alg:?} with {pw:?}: {stderr}"); assert_rendered_page(&std::fs::read(&png).unwrap(), DPI, &format!("{alg:?} with {pw:?}")); } From b4679deafd9105d050dd4b3bc0d83f320857c833 Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Mon, 9 Nov 2026 05:38:34 -0500 Subject: [PATCH 62/64] fix(cli): cap input reads, budget check's edit pass and escape its stderr records (#5997) --- apps/pdfcraft-cli/src/check.rs | 37 +++++++++++++++++++++------ apps/pdfcraft-cli/src/commands.rs | 13 +++++++++- apps/pdfcraft-cli/tests/it/check.rs | 15 +++++++++++ apps/pdfcraft-cli/tests/it/options.rs | 11 ++++++++ 4 files changed, 67 insertions(+), 9 deletions(-) diff --git a/apps/pdfcraft-cli/src/check.rs b/apps/pdfcraft-cli/src/check.rs index 62993ad6dc..4521181b6f 100644 --- a/apps/pdfcraft-cli/src/check.rs +++ b/apps/pdfcraft-cli/src/check.rs @@ -43,7 +43,18 @@ pub(crate) fn check_one(args: &[String]) -> Result<(), CliError> { let path = *positional(args, CHECK_ONE_OPTIONS).first().ok_or("check-one: missing file")?; let dpi = parse_dpi(flag(args, "--dpi").unwrap_or("36"))?; let start = Instant::now(); - let bytes = read(path)?; + // A file that can't be read (past the input cap, say) is this file's record, not a crash of the child. + let bytes = match read(path) { + Ok(bytes) => bytes, + Err(e) => { + let status = if e.contains("larger than") { "input-too-large" } else { "read-error" }; + let line = serde_json::json!({ "file": path, "status": status, "pages": 0, "failed_pages": 0, "warnings": 0, + "ms": start.elapsed().as_millis() as u64, "detail": e.chars().take(400).collect::() }); + stdout_line(format_args!("{line}"))?; + // (the record is the answer `check` reads; the error and exit status are for a direct run) + return Err(e.into()); + } + }; let (status, pages, failed, detail, warnings) = match inspect(bytes.clone(), None) { Err(e) => (format!("open-error:{}", short(&e.to_string())), 0, 0, e.to_string(), 0), Ok(info) => { @@ -65,7 +76,7 @@ pub(crate) fn check_one(args: &[String]) -> Result<(), CliError> { // `--edit` (fuzzing): also run our own object layer end to end, with no panic safety net, // so a panic in cos shows up as a crash of this process. if args.iter().any(|a| a == "--edit") { - edit_round_trip(&std::fs::read(path).map_err(|e| e.to_string())?); + edit_round_trip(&read(path)?); } let line = serde_json::json!({ "file": path, "status": status, "pages": pages, "failed_pages": failed, "warnings": warnings, "ms": start.elapsed().as_millis() as u64, "detail": detail.chars().take(400).collect::() }); @@ -76,13 +87,19 @@ pub(crate) fn check_one(args: &[String]) -> Result<(), CliError> { /// Open with cos, touch every object, edit the catalog, save incrementally and in full (both /// output styles), and reopen each result. Errors are fine; panics, hangs and aborts are bugs. fn edit_round_trip(bytes: &[u8]) { - use pdfcraft_cos::{Document, Object, SaveOptions, write_full, write_incremental}; + use pdfcraft_cos::{DecodeBudget, Document, Object, SaveOptions, write_full, write_incremental}; let Ok(mut doc) = Document::open(Arc::new(bytes.to_vec())) else { return }; + // One allowance for every stream, charged for what each decode did (failed ones too), so + // many small bombs can't each inflate to the per-stream limit. + let mut budget = DecodeBudget::page(); for num in doc.object_numbers().into_iter().take(20_000) { + if budget.exhausted() { + break; + } if let Ok(o) = doc.try_get(num) && let Object::Stream(s) = &*o { - let _ = s.decoded(); + let _ = budget.decode(s); } } if let Some(root) = doc.root() { @@ -113,7 +130,7 @@ fn collect(paths: &[&str]) -> Vec { let identity = match p.canonicalize() { Ok(identity) => identity, Err(error) => { - eprintln!("{}: could not resolve directory: {error}", p.display()); + eprintln!("{}: could not resolve directory: {error}", p.display().to_string().escape_debug()); continue; } }; @@ -158,12 +175,16 @@ pub(crate) fn check(args: &[String]) -> Result<(), CliError> { let status = r["status"].as_str().unwrap_or("?").to_string(); *counts.entry(status.split(':').next().unwrap_or("?").to_string()).or_default() += 1; if status != "ok" { + // One record per line: a file name or detail with a line break or a terminal control in + // it is written escaped, so it can't forge another record (the JSON report keeps both). + let detail: String = r["detail"].as_str().unwrap_or("").chars().take(160).collect(); eprintln!( - "[{}/{}] {status:<28} {} {}", + "[{}/{}] {:<28} {} {}", n + 1, files.len(), - f.display(), - r["detail"].as_str().unwrap_or("").chars().take(160).collect::() + status.escape_debug().to_string(), + f.display().to_string().escape_debug(), + detail.escape_debug() ); } results.push(r); diff --git a/apps/pdfcraft-cli/src/commands.rs b/apps/pdfcraft-cli/src/commands.rs index 1df3cf8789..5a74f19f56 100644 --- a/apps/pdfcraft-cli/src/commands.rs +++ b/apps/pdfcraft-cli/src/commands.rs @@ -4,8 +4,19 @@ use super::options::{ }; use super::{Arc, CliError, ImageFormat, PageRenderer, Path, PathBuf, RenderConfig, RenderRequest, RequestKind, Write, inspect, stdout_line}; +/// Largest input file read into memory (512 MiB, as automation's `doc_open` reads). +pub(crate) const MAX_INPUT: u64 = 512 * 1024 * 1024; + +/// The file at `path`, read no further than [`MAX_INPUT`]: a name that leads to something +/// endless (a device, a pipe nobody closes) fails at the cap instead of filling memory. pub(crate) fn read(path: &str) -> Result>, String> { - std::fs::read(path).map(Arc::new).map_err(|e| format!("{path}: {e}")) + use std::io::Read as _; + let mut bytes = Vec::new(); + std::fs::File::open(path).and_then(|f| f.take(MAX_INPUT + 1).read_to_end(&mut bytes)).map_err(|e| format!("{path}: {e}"))?; + if bytes.len() as u64 > MAX_INPUT { + return Err(format!("{path}: the file is larger than 512 MiB")); + } + Ok(Arc::new(bytes)) } /// `info FILE`: a JSON summary of the document as the app opens it (pages, fields, metadata, the diff --git a/apps/pdfcraft-cli/tests/it/check.rs b/apps/pdfcraft-cli/tests/it/check.rs index 7a6236419c..3f32459089 100644 --- a/apps/pdfcraft-cli/tests/it/check.rs +++ b/apps/pdfcraft-cli/tests/it/check.rs @@ -129,3 +129,18 @@ fn a_bad_timeout_or_dpi_is_refused_before_scanning() { ); } } + +/// A failing file's name goes to stderr escaped: a line break in it can't start a second, +/// forged record. The JSON report keeps the name as it is. +#[cfg(unix)] +#[test] +fn a_file_name_cannot_forge_a_stderr_record() { + let dir = Workdir::new("escaped-name"); + let name = "bad\n[9] ok forged.pdf"; + std::fs::write(dir.0.join("input").join(name), b"not a pdf").unwrap(); + let out = Command::new(BIN).arg("check").arg(dir.0.join("input")).args(["--dpi", "36", "--timeout", "5"]).output().unwrap(); + let stderr = String::from_utf8_lossy(&out.stderr); + let records: Vec<&str> = stderr.lines().filter(|l| l.starts_with('[')).collect(); + assert_eq!(records.len(), 1, "{stderr}"); + assert!(records[0].contains("bad\\n[9] ok forged.pdf"), "{stderr}"); +} diff --git a/apps/pdfcraft-cli/tests/it/options.rs b/apps/pdfcraft-cli/tests/it/options.rs index 7955157e28..d414922998 100644 --- a/apps/pdfcraft-cli/tests/it/options.rs +++ b/apps/pdfcraft-cli/tests/it/options.rs @@ -113,3 +113,14 @@ fn an_argument_that_is_not_utf8_is_an_error_not_a_panic() { assert!(!stderr.contains("panicked"), "{stderr}"); } } + +/// A file `check-one` cannot read is a record on stdout with its own status and the reason, which +/// `check` relays, not an empty stdout that reads as a crash. +#[test] +fn check_one_reports_an_unreadable_file_as_a_record() { + let out = Command::new(BIN).args(["check-one", "missing.pdf"]).output().expect("run cli"); + let stdout = String::from_utf8_lossy(&out.stdout); + let record: serde_json::Value = serde_json::from_str(stdout.trim()).unwrap_or_else(|e| panic!("{e}: {stdout:?}")); + assert_eq!(record["status"], "read-error"); + assert!(record["detail"].as_str().is_some_and(|d| d.contains("missing.pdf")), "{record}"); +} From dafb3be3de1373465018912c0157bc0df12d0b83 Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Mon, 9 Nov 2026 05:38:52 -0500 Subject: [PATCH 63/64] fix(xfa): one decoding allowance for all XFA packets (#6000) --- crates/xfa/src/packets.rs | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/crates/xfa/src/packets.rs b/crates/xfa/src/packets.rs index f79b071c0e..4202ffbb55 100644 --- a/crates/xfa/src/packets.rs +++ b/crates/xfa/src/packets.rs @@ -7,6 +7,8 @@ use crate::XfaError; /// Most decoded packet bytes read in total. const MAX_XDP: usize = 64 << 20; +/// Most bytes all filter stages of all packets may decode together. +const MAX_DECODE_WORK: usize = 4 * MAX_XDP; /// What was found in the XFA entry. #[derive(Clone, Debug, Default, PartialEq)] @@ -116,13 +118,17 @@ pub fn read_packets(doc: &Document) -> Result, XfaError> { // A stream listed again adds nothing but another decode, which may cost far more than the // bytes it yields (a Flate stream of hex whitespace). let mut seen = std::collections::HashSet::new(); + // One decoding allowance for every packet, charged for what each filter stage produced (a + // stage a later one shrinks included): a fresh MAX_XDP per stream let many packets of + // compressed bulk that decode to little cost far more than the packets kept. + let mut budget = pdfcraft_cos::DecodeBudget::new(MAX_DECODE_WORK, 0); let mut read = |o: &Object| -> Result, XfaError> { if o.as_ref().is_some_and(|r| !seen.insert(r)) { return Ok(None); } let s = doc.resolve(o); let Object::Stream(s) = &*s else { return Ok(None) }; - let bytes = s.decoded_within(MAX_XDP)?; + let bytes = budget.decode_within(s, MAX_XDP)?; total = total.saturating_add(bytes.len()); if total > MAX_XDP { return Err(XfaError::TooLarge("the XFA packets are larger than 64 MB".into())); From 9ee213eff725e39f4450f0160cfe6ea5a9120f20 Mon Sep 17 00:00:00 2001 From: BFlat Date: Mon, 9 Nov 2026 19:12:07 +0800 Subject: [PATCH 64/64] Fix main's build and tests after the latest merges (#6706) --- crates/automation/src/printing.rs | 35 +-- crates/automation/src/tools.rs | 2 +- crates/automation/tests/it/automation.rs | 26 -- crates/edit/src/tests.rs | 169 ------------- crates/edit/src/text.rs | 8 +- crates/edit/src/text/fallback.rs | 43 +--- crates/edit/src/text/interp.rs | 6 - crates/edit/src/text/lines.rs | 10 +- crates/edit/src/text/rewrite.rs | 107 +-------- crates/engine/src/text_links.rs | 2 +- crates/forms/src/tests.rs | 2 +- crates/ui-egui/src/app/prefs.rs | 24 +- crates/ui-egui/src/lib.rs | 1 - crates/ui-egui/src/tools/prepare.rs | 2 +- crates/ui-egui/src/workflows.rs | 1 - crates/ui-egui/src/workflows/print_paper.rs | 224 ------------------ crates/ui-egui/src/workflows/print_ui.rs | 10 +- crates/ui-egui/src/workflows/print_ui/body.rs | 75 +----- .../ui-egui/src/workflows/print_ui/draft.rs | 17 +- crates/ui-egui/tests/it/main.rs | 2 - crates/ui-egui/tests/it/print.rs | 92 ------- .../tests/it/print_custom_paper_names.rs | 62 ----- .../tests/it/print_late_printer_list.rs | 66 ------ parity/acrobat-features.toml | 14 +- 24 files changed, 41 insertions(+), 959 deletions(-) delete mode 100644 crates/ui-egui/src/workflows/print_paper.rs delete mode 100644 crates/ui-egui/tests/it/print_custom_paper_names.rs delete mode 100644 crates/ui-egui/tests/it/print_late_printer_list.rs diff --git a/crates/automation/src/printing.rs b/crates/automation/src/printing.rs index 8a6f825e6a..2f1cf5e136 100644 --- a/crates/automation/src/printing.rs +++ b/crates/automation/src/printing.rs @@ -38,14 +38,8 @@ fn bad(m: impl Into) -> ToolError { } /// `doc_print`'s `paper`, by its name or a short form ("Letter", "ARCH D"), as its `PAPERS` -/// index and size (or a size such as "8.5x14in", whose index is past the list); US Letter when -/// none is given. +/// index and size; US Letter when none is given. fn paper_named(name: Option<&str>) -> Result<(usize, (f64, f64))> { - // A size of its own: "8.5x14in", "210x297mm", "10x15cm", "612x792pt". It has no place in - // `PAPERS` (index `PAPERS.len()`), so no `media` option is sent for it. - if let Some(size) = name.and_then(paper_size) { - return Ok((PAPERS.len(), size)); - } let mut papers = PAPERS.iter().enumerate(); let found = match name { None => papers.next(), @@ -57,7 +51,7 @@ fn paper_named(name: Option<&str>) -> Result<(usize, (f64, f64))> { }; found.map(|(index, (_, size))| (index, *size)).ok_or_else(|| { bad(format!( - "unknown paper {:?}; choose one of {}, or a size such as 8.5x14in or 210x297mm (1 to 200 inches a side)", + "unknown paper {:?}; choose one of {}", name.unwrap_or_default(), PAPERS.iter().map(|(name, _)| *name).collect::>().join(", ") )) @@ -243,19 +237,6 @@ impl Automation { } } -/// "WxH" with a unit (in, mm, cm or pt) as a portrait paper size in points; each side 1 to 200 -/// inches (PDF's own page limit). -fn paper_size(spec: &str) -> Option<(f64, f64)> { - let s = spec.trim().to_ascii_lowercase(); - let (body, per_point) = [("in", 1.0 / 72.0), ("mm", 25.4 / 72.0), ("cm", 2.54 / 72.0), ("pt", 1.0)] - .into_iter() - .find_map(|(unit, per_point)| s.strip_suffix(unit).map(|b| (b, per_point)))?; - let (w, h) = body.split_once('x')?; - let side = |t: &str| t.trim().parse::().ok().map(|v| v / per_point).filter(|v| (72.0..=14_400.0).contains(v)); - let (w, h) = (side(w)?, side(h)?); - Some((w.min(h), w.max(h))) -} - #[cfg(test)] mod tests { use super::*; @@ -308,19 +289,7 @@ mod tests { assert_eq!(job(json!({}), Some("letter")).options, [media("Letter")]); assert_eq!(job(json!({}), Some("ARCH D")).options, [media("na_arch-d_24x36in")]); assert!(matches!(paper_named(Some("Quarto")), Err(ToolError::InvalidArgs(m)) if m.contains("US Letter"))); - // A size of its own is laid out as asked and names no media. - assert_eq!(paper_named(Some("8.5x14in")).ok(), Some((PAPERS.len(), (612.0, 1008.0)))); - assert!(job(json!({}), Some("8.5x14in")).options.is_empty()); assert_eq!(spool_job(&Args(&json!({})), "default", String::new(), None, 0).ok().map(|j| j.printer), Some(None)); assert!(spool_job(&Args(&json!({ "options": { "InputSlot": 3 } })), "Office", String::new(), None, 0).is_err()); } - - #[test] - fn a_paper_size_reads_its_numbers_and_needs_a_unit() { - assert_eq!(paper_size("8.5 x 14 in"), Some((612.0, 1008.0))); - assert_eq!(paper_size("1e3x1e3pt"), Some((1000.0, 1000.0))); - for spec in ["0x0mm", "-5x10cm", "5x-10cm", "10x15"] { - assert_eq!(paper_size(spec), None, "{spec}"); - } - } } diff --git a/crates/automation/src/tools.rs b/crates/automation/src/tools.rs index 27ce2d8b7c..c51dbdfbee 100644 --- a/crates/automation/src/tools.rs +++ b/crates/automation/src/tools.rs @@ -741,7 +741,7 @@ pub fn tools() -> Vec { "cut_marks": { "type": "boolean" }, "orientation": { "type": "string", "enum": ["auto", "portrait", "landscape"] }, "comments_forms": { "type": "string", "enum": ["document", "document-and-markups", "document-and-stamps", "form-fields-only"] }, - "paper": { "type": "string", "description": "A paper name (US Letter, A4…) or a size of its own such as 8.5x14in, 210x297mm, 10x15cm or 612x792pt (1 to 200 inches a side)." }, + "paper": { "type": "string" }, "path": { "type": "string" }, "printer": { "type": "string" }, "copies": { "type": "integer", "minimum": 1, "maximum": 999 }, diff --git a/crates/automation/tests/it/automation.rs b/crates/automation/tests/it/automation.rs index e209d3102a..19c26bf87a 100644 --- a/crates/automation/tests/it/automation.rs +++ b/crates/automation/tests/it/automation.rs @@ -11161,32 +11161,6 @@ fn a_missing_path_under_the_root_names_where_it_was_looked_for() { let _ = std::fs::remove_dir_all(base); } -/// `doc_print` takes a paper size of its own besides the named ones, in inches, millimetres, -/// centimetres or points, either way round; a size it can't read or outside 1 to 200 inches is -/// refused. -#[test] -fn doc_print_takes_a_paper_size_of_its_own() { - let dir = workdir("print-paper-size"); - let mut a = auto(&dir); - let doc = ok(&mut a, "doc_open", json!({ "path": "a.pdf" }))["doc"].as_u64().unwrap(); - for (paper, want) in - [("8.5x14in", (612.0, 1008.0)), ("297x210mm", (595.28, 841.89)), ("10 x 15 cm", (283.46, 425.2)), ("288x432PT", (288.0, 432.0))] - { - let file = format!("{}.pdf", want.0); - ok(&mut a, "doc_print", json!({ "doc": doc, "pages": "1", "paper": paper, "orientation": "portrait", "path": file })); - let bytes = std::fs::read(dir.join(&file)).unwrap(); - let out = pdfcraft_cos::Document::open(std::sync::Arc::new(bytes)).unwrap(); - let page = pdfcraft_model::pages(&out)[0].display_size(&out); - assert!((page.0 - want.0).abs() < 0.5 && (page.1 - want.1).abs() < 0.5, "{paper}: {page:?}"); - } - for paper in ["8.5xbroad", "8.5x14", "0.5x11in", "8.5x300in", "-8.5x11in", "NaNx11in"] { - match a.call("doc_print", &json!({ "doc": doc, "paper": paper, "path": "x.pdf" })) { - Err(ToolError::InvalidArgs(m)) => assert!(m.contains("unknown paper"), "{paper}: {m}"), - other => panic!("{paper}: {other:?}"), - } - } -} - /// `doc_print` prints as image: pictures at `dpi`, grey with `grayscale`; a resolution outside /// 72–600 is refused. #[test] diff --git a/crates/edit/src/tests.rs b/crates/edit/src/tests.rs index 242659f531..3c1100d312 100644 --- a/crates/edit/src/tests.rs +++ b/crates/edit/src/tests.rs @@ -3240,175 +3240,6 @@ fn win_ansi_high_glyphs_use_helvetica_not_the_fallback_font() { assert_eq!(lines.iter().map(|l| l.text.as_str()).collect::>().join(" "), text); } -/// #2378: a word the line's subset can't show goes to Helvetica, and only that word. The words -/// it can show keep the source font (here `F2`, a subset of `a` and `b` without a space glyph), -/// one font per word, and the line reads back whole and in place: one line, in its own font, so -/// editing it again replaces all of it (review on #2955), also when it starts with a substitute -/// word. -#[test] -fn only_the_words_a_subset_cannot_show_are_substituted() { - let mut doc = text_page("BT /F2 10 Tf 72 600 Td (ab) Tj ET"); - let r = text::replace_line(&mut doc, 0, 0, "ab ba cab ba").unwrap(); - assert_eq!(r.substituted.as_deref(), Some("Helvetica")); - let mut doc = reopen(&doc); - let content = String::from_utf8_lossy(&page_content_bytes(&doc, 0)).into_owned(); - assert!(content.contains("/F2") && content.contains("/PCEdHelvetica"), "both fonts are used: {content}"); - let lines = text::text_lines(&doc, 0).unwrap(); - let read: Vec<(&str, &str)> = lines.iter().map(|l| (l.text.trim(), l.font.as_str())).collect(); - assert_eq!(read, [("ab ba cab ba", "F2")]); - // Each word where its font puts it, the substituted `cab` included: no gap or overlap opens - // where the font changes (review on #2955). - let starts: Vec = lines[0].ink.iter().map(|b| b[0]).collect(); - assert_eq!(starts.len(), 4, "{:?}", lines[0].ink); - for (got, want) in starts.iter().zip([72.0, 85.5, 98.48, 117.9]) { - assert!((got - want).abs() < 0.01, "word starts {starts:?}, want {want} among them"); - } - assert!(lines[0].ink.windows(2).all(|w| w[0][2] < w[1][0]), "no word box overlaps the next: {:?}", lines[0].ink); - for (text, first) in [("cab ab", "PCEdHelvetica"), ("ba cab", "F2")] { - text::replace_line(&mut doc, 0, 0, text).unwrap(); - doc = reopen(&doc); - let lines = text::text_lines(&doc, 0).unwrap(); - let read: Vec<(&str, &str)> = lines.iter().map(|l| (l.text.trim(), l.font.as_str())).collect(); - assert_eq!(read, [(text, "F2")], "after replacing with {text:?} (starting with {first})"); - } -} - -/// Review on #2955: replacing a line written in two fonts again replaces its `/PCEdLine` -/// sequence too. Kept, each edit nested one more, and its `EMC`s outnumbered what the reader -/// counts once 256 deep, closing the sequence around the line (here a `/Span`). The content -/// doesn't grow from one edit to the next. -#[test] -fn replacing_a_mixed_font_line_again_replaces_its_sequence() { - let mut doc = text_page("/Span BMC BT /F2 10 Tf 72 600 Td (ab) Tj ET EMC BT /F2 10 Tf 72 500 Td (ba) Tj ET"); - let count = |content: &str, op: &str| content.split_whitespace().filter(|w| *w == op).count(); - let mut sizes = Vec::new(); - for text in ["ab ba cab ba", "cab ab", "ab cab", "ab cab", "ab ba", "ba ab"] { - text::replace_line(&mut doc, 0, 0, text).unwrap(); - doc = reopen(&doc); - let content = String::from_utf8_lossy(&page_content_bytes(&doc, 0)).into_owned(); - let mixed = text.contains('c'); - assert_eq!(content.matches("/PCEdLine").count(), usize::from(mixed), "after {text:?}: {content}"); - assert_eq!((count(&content, "BMC"), count(&content, "EMC")), (1 + usize::from(mixed), 1 + usize::from(mixed)), "after {text:?}: {content}"); - assert_eq!(count(&content, "Tf"), 2 + if mixed { 4 } else { 1 }, "after {text:?}: {content}"); - let lines = text::text_lines(&doc, 0).unwrap(); - let read: Vec<&str> = lines.iter().map(|l| l.text.trim()).collect(); - assert_eq!(read, [text, "ba"], "after {text:?}"); - assert_eq!(lines[0].baseline(), [72.0, 600.0], "after {text:?}: in place"); - sizes.push(content.split_whitespace().count()); - } - assert_eq!(sizes[2], sizes[3], "the same text again, the same content"); - // A sequence holding marked content of its own goes whole, and what follows it keeps its - // font and size unless the replacement sets the same again. - for (after, font, size) in [("/F1 12 Tf", "F1", 12.0), ("/F2 12 Tf", "F2", 12.0), ("/F1 10 Tf", "F1", 10.0)] { - let mut doc = text_page(&format!( - "BT /F2 10 Tf 72 600 Td /PCEdLine BMC /F2 10 Tf /Span <<>> BDC (ab) Tj EMC /F1 10 Tf ( cab) Tj EMC {after} 0 -20 Td (ba) Tj ET" - )); - text::replace_line(&mut doc, 0, 0, "ab ab").unwrap(); - let doc = reopen(&doc); - let content = String::from_utf8_lossy(&page_content_bytes(&doc, 0)).into_owned(); - assert_eq!((count(&content, "BMC"), count(&content, "BDC"), count(&content, "EMC")), (0, 0, 0), "{after}: {content}"); - let lines = text::text_lines(&doc, 0).unwrap(); - let read: Vec<(&str, &str, f64)> = lines.iter().map(|l| (l.text.trim(), l.font.as_str(), l.size)).collect(); - assert_eq!(read, [("ab ab", "F2", 10.0), ("ba", font, size)], "{after}: {content}"); - } -} - -/// Review on #2955: a leading space is not a word the line's font shows. " cab" (no word the -/// subset can show) was split into an empty source-font run and a substitute run, so it was -/// written as a two-font `/PCEdLine` with an empty `Tj` in the line's font; read back, the line -/// was the substitute's alone, and the next edit took Helvetica for the line's own font. It is -/// substituted as a whole instead, as a line with no word in the source font always was. -#[test] -fn a_leading_space_does_not_make_a_source_font_run() { - let mut doc = text_page("BT /F2 10 Tf 72 600 Td (ab) Tj ET"); - text::replace_line(&mut doc, 0, 0, " cab").unwrap(); - let doc = reopen(&doc); - let content = String::from_utf8_lossy(&page_content_bytes(&doc, 0)).into_owned(); - assert!(!content.contains("/PCEdLine") && !content.contains("() Tj"), "{content}"); - // With a word in the source font after it, the line is still written in both. - let mut doc = text_page("BT /F2 10 Tf 72 600 Td (ab) Tj ET"); - text::replace_line(&mut doc, 0, 0, " ab cab").unwrap(); - let doc = reopen(&doc); - let content = String::from_utf8_lossy(&page_content_bytes(&doc, 0)).into_owned(); - assert!(content.contains("/PCEdLine") && !content.contains("() Tj"), "{content}"); - let lines = text::text_lines(&doc, 0).unwrap(); - assert_eq!(lines.iter().map(|l| (l.text.trim(), l.font.as_str())).collect::>(), [("ab cab", "F2")]); - // The spaces the subset has no glyph for are moves, each one kept: the leading one before - // the first word, two between words as two. - let flat = |doc: &Document| String::from_utf8_lossy(&page_content_bytes(doc, 0)).split_whitespace().collect::>().join(" "); - assert!(flat(&doc).contains("/F2 10 Tf [-330] TJ (ab) Tj"), "{}", flat(&doc)); - let mut doc = text_page("BT /F2 10 Tf 72 600 Td (ab) Tj ET"); - text::replace_line(&mut doc, 0, 0, "ab ba cab").unwrap(); - let doc = reopen(&doc); - assert!(flat(&doc).contains("(ab) Tj [-330] TJ [-330] TJ (ba) Tj"), "{}", flat(&doc)); - // Before a substitute word, the leading spaces are the substitute's to show, all of them. - let mut doc = text_page("BT /F2 10 Tf 72 600 Td (ab) Tj ET"); - text::replace_line(&mut doc, 0, 0, " cab ab").unwrap(); - let doc = reopen(&doc); - let content = String::from_utf8_lossy(&page_content_bytes(&doc, 0)).into_owned(); - assert!(content.contains("( cab) Tj"), "{content}"); -} - -/// Review on #2955: a paragraph rewrite keeps the `/PCEdLine` delimiters of a line an earlier -/// replacement wrote in two fonts, so a rewritten paragraph's rows can end up inside one. A -/// later replacement of one of those rows must not take the sequence's other rows with it. -#[test] -fn replacing_one_row_of_a_sequence_keeps_the_other_rows() { - let content = "BT /F2 10 Tf 72 600 Td /PCEdLine BMC /F2 10 Tf (ab) Tj /F1 10 Tf ( cab) Tj 0 -20 Td /F2 10 Tf (ba) Tj EMC ET"; - let rows = text::text_lines(&text_page(content), 0).unwrap(); - assert_eq!(rows.iter().map(|l| l.text.trim()).collect::>(), ["ab cab", "ba"]); - for (row, text, other) in [(0, "ab ab", "ba"), (1, "ab", "ab cab")] { - let mut doc = text_page(content); - text::replace_line(&mut doc, 0, row, text).unwrap(); - let doc = reopen(&doc); - let after: Vec = text::text_lines(&doc, 0).unwrap().iter().map(|l| l.text.trim().to_string()).collect(); - assert!(after.iter().any(|t| t == text) && after.iter().any(|t| t == other), "replacing row {row}: {after:?}"); - } -} - -/// Review on #2955: a row inside a shared `/PCEdLine` sequence that starts with a substitute run -/// has the substitute's font in effect at its first operator. Replaced by text the row's own font -/// shows, it must set that font again, or the new text is drawn in the substitute. -#[test] -fn a_row_replaced_inside_a_shared_sequence_starts_in_its_own_font() { - // A real two-font line, starting with the substitute run, then a second row put inside its - // sequence, as a paragraph rewrite that keeps the delimiters can leave it. - let mut doc = text_page("BT /F2 10 Tf 72 600 Td (ab) Tj ET"); - text::replace_line(&mut doc, 0, 0, " cab ab").unwrap(); - let doc = reopen(&doc); - let content = String::from_utf8_lossy(&page_content_bytes(&doc, 0)).into_owned(); - assert!(content.contains("/PCEdLine") && content.contains("( cab) Tj"), "{content}"); - let mut doc = doc; - let shared = content.replacen("EMC", "0 -20 Td (ba) Tj EMC", 1); - let stream = Object::Ref(doc.add(Object::Stream(Stream::from_raw(Dict::new(), shared.into_bytes())))); - set_page_contents(&mut doc, stream, None); - let rows = text::text_lines(&doc, 0).unwrap(); - assert_eq!(rows.iter().map(|l| (l.text.trim(), l.font.as_str())).collect::>(), [("cab ab", "F2"), ("ba", "F2")]); - text::replace_line(&mut doc, 0, 0, "ab").unwrap(); - let doc = reopen(&doc); - let after = text::text_lines(&doc, 0).unwrap(); - assert_eq!(after.iter().map(|l| (l.text.trim(), l.font.as_str())).collect::>(), [("ab", "F2"), ("ba", "F2")]); -} - -/// Review on #2955: a sequence holding one row can also hold that row's positioning and text -/// state, as a paragraph rewrite writes it inside kept delimiters. Replacing the row drops the -/// delimiters, fonts and text, not the `Tm` that places it. -#[test] -fn replacing_the_only_row_of_a_sequence_keeps_its_position() { - let content = "BT /F2 10 Tf /PCEdLine BMC 1 0 0 1 72 600 Tm /F2 10 Tf (ab) Tj /F1 10 Tf ( cab) Tj EMC ET"; - let mut doc = text_page(content); - let before = text::text_lines(&doc, 0).unwrap(); - assert_eq!(before.iter().map(|l| l.text.trim()).collect::>(), ["ab cab"]); - text::replace_line(&mut doc, 0, 0, "ab").unwrap(); - let doc = reopen(&doc); - let content = String::from_utf8_lossy(&page_content_bytes(&doc, 0)).into_owned(); - assert!(!content.contains("PCEdLine") && !content.contains("cab"), "{content}"); - let after = text::text_lines(&doc, 0).unwrap(); - assert_eq!(after.len(), 1, "{content}"); - let (was, now) = (before[0].rect, after[0].rect); - assert!((was[0] - now[0]).abs() < 0.01 && (was[1] - now[1]).abs() < 0.01, "moved from {was:?} to {now:?}: {content}"); -} - #[test] fn missing_glyphs_substitute_helvetica_and_impossible_text_is_refused() { let mut doc = text_page("BT /F2 10 Tf 72 600 Td (ab) Tj ET"); diff --git a/crates/edit/src/text.rs b/crates/edit/src/text.rs index c08edb1e86..2dbf302685 100644 --- a/crates/edit/src/text.rs +++ b/crates/edit/src/text.rs @@ -6,9 +6,8 @@ //! dropped, and every positioning operator stays, so the lines after it keep their places //! (`Td`, `T*` and friends move from the line matrix, which showing text doesn't change). //! -//! The new text uses the line's own font when every character has a code and a glyph in it. -//! Otherwise the words the font can show keep it and only the others are set in Helvetica -//! (WinAnsi), run by run, and the result says the font was substituted. +//! The new text uses the line's own font when every character has a code and a glyph in it; +//! otherwise it is set in Helvetica (WinAnsi), and the result says the font was substituted. //! Reflowing a paragraph to a new width is not done here (see `edit.text-reflow`). use std::collections::HashMap; @@ -133,9 +132,6 @@ pub struct TextLine { /// Chinese/Japanese word split), from `/PCSoft`'s `/N`; 1 when the marker carried none /// (#2529). Meaningless otherwise. soft_gap: usize, - /// The `/PCEdLine` marked-content sequence a replacement mixing fonts wrote the line in - /// (see [`fallback::continues_font`]). - edited: Option, } impl TextLine { diff --git a/crates/edit/src/text/fallback.rs b/crates/edit/src/text/fallback.rs index 91e5258b31..f7c795ca65 100644 --- a/crates/edit/src/text/fallback.rs +++ b/crates/edit/src/text/fallback.rs @@ -1,4 +1,4 @@ -use super::interp::{Shown, Ts}; +use super::interp::Ts; use super::lines::page_dict; use super::{ CraftFont, Dict, Document, EditError, GlyphError, HashMap, Metrics, Object, Op, PdfString, Stream, TextLine, japanese_glyph_from, parse, @@ -148,23 +148,6 @@ fn fonts_shown(doc: &Document, page: &Dict, resources: &Dict) -> Option bool { - same_font(line, &s.font, &s.base_font, s.face.as_deref()) || (s.edited.is_some() && line.edited == s.edited) -} - -/// A line that began with a substitute run takes the source font of a later run, so the next -/// replacement starts from the line's own font again. -pub(super) fn adopt_source_font(line: &mut TextLine, s: &Shown) { - if line.edited.is_some() && line.font.starts_with("PCEd") && !s.font.starts_with(b"PCEd") { - line.font = String::from_utf8_lossy(&s.font).into_owned(); - line.base_font = s.base_font.clone(); - line.face = s.face.clone(); - } -} - /// Whether a run shown in `font` (a resource name), with `/BaseFont` `base_font` and the Japanese /// or Hebrew fallback face it subsets, if any, continues `line`: the same font resource, another /// resource for the same font (a writer may split one font into several subsets, #3661), or @@ -747,30 +730,6 @@ pub(crate) fn source_show(metrics: &Metrics, text: &str, size: f64, space: f64, Ok(out) } -/// `text` split into runs of words `metrics` can show (`true`) and words it can't (`false`), in -/// order. A word is shown in one font, so a word with any character the font lacks goes to the -/// substitute whole rather than switching fonts letter by letter. Spaces join the run they are -/// in, since [`source_show`] draws spaces the font lacks with a displacement. -pub(super) fn source_runs(metrics: &Metrics, text: &str) -> Vec<(bool, String)> { - let mut runs: Vec<(bool, String)> = Vec::new(); - // Spaces before the first word go with it: on their own they would make a run of the line's - // font with nothing to show (review on #2955). - let mut lead = String::new(); - for (i, word) in text.split(' ').enumerate() { - let piece = if i == 0 { word.to_string() } else { format!(" {word}") }; - if runs.is_empty() && word.is_empty() { - lead.push_str(&piece); - continue; - } - let own = word.is_empty() || metrics.encode(word).is_some(); - match runs.last_mut() { - Some((last, run)) if *last == own || word.is_empty() => run.push_str(&piece), - _ => runs.push((own, std::mem::take(&mut lead) + &piece)), - } - } - runs -} - #[cfg(test)] mod tests { use super::*; diff --git a/crates/edit/src/text/interp.rs b/crates/edit/src/text/interp.rs index bc0bce0c7b..6fb50138ab 100644 --- a/crates/edit/src/text/interp.rs +++ b/crates/edit/src/text/interp.rs @@ -359,7 +359,6 @@ pub(super) struct Shown { /// `/PCSoft` with no `/N` (a file saved before this was tracked, or by another writer), /// matching the single space a rejoin always used before #2529. pub(super) soft_gap: usize, - pub(super) edited: Option, } impl Shown { @@ -541,8 +540,6 @@ type Invocation = (usize, Vec, Ts); struct MarkedContent { regions: Vec>, layers: Vec>>, - // The `/PCEdLine` sequences around the text (a replacement mixing fonts), innermost last. - edited: Vec>, actual_text: Vec>, } @@ -556,7 +553,6 @@ impl MarkedContent { let layer = op.is("BDC") && op.name(0) == Some(b"OC".as_slice()); self.layers .push(layer.then(|| op.operands.get(1).and_then(Object::as_name).map_or_else(|| format!("@{index}").into_bytes(), <[u8]>::to_vec))); - self.edited.push(if op.name(0) == Some(b"PCEdLine") { Some(index) } else { self.edited.last().copied().flatten() }); // The property list is inline or named in the resources' /Properties (§14.6.2). let list = logical_text.and_then(|properties| match op.operands.get(1) { Some(Object::Name(name)) => properties.get(name).and_then(|object| doc.dict(object)), @@ -572,7 +568,6 @@ impl MarkedContent { fn end(&mut self, index: usize, shown: &mut Vec) { self.regions.pop(); self.layers.pop(); - self.edited.pop(); if let Some(Some((start, open, logical))) = self.actual_text.pop() { let tail = shown.split_off(start.min(shown.len())); if tail.iter().any(|s| s.glyph_count > 0) { @@ -831,7 +826,6 @@ fn show_text(op: &Op, i: usize, state: &mut TextRunState<'_>) -> Result<(), Edit soft_break: state.soft_break, hard_break: state.hard_break, soft_gap: state.soft_gap, - edited: marked.edited.last().copied().flatten(), }); carry.gap_space = gap_space; Ok(()) diff --git a/crates/edit/src/text/lines.rs b/crates/edit/src/text/lines.rs index 8d0ad80cba..1e899bc352 100644 --- a/crates/edit/src/text/lines.rs +++ b/crates/edit/src/text/lines.rs @@ -1,6 +1,6 @@ use std::sync::Arc; -use super::fallback::{adopt_source_font, continues_font, same_paint}; +use super::fallback::{same_font, same_paint}; use super::interp::{Carry, Origin, Pen, Shown, TextState, Ts, direction, interpret, to_frame, union}; use super::{DecodeBudget, Dict, Document, EditError, HashMap, Matrix, Object, Op, Pieces, Stream, TextLine, parse, serialize_ops}; @@ -340,7 +340,7 @@ fn text_lines_in(doc: &Document, p: &pdfcraft_model::Page, actual_text: bool, in piece == si && lines.last().is_some_and(|l| l.scope == s.scope && l.paint == s.paint && l.logical_order == s.logical_order) && (bt == s.bt || lines.last().is_some_and(|l| compatible_word(l, &s))) - && (s.text.trim().is_empty() || lines.last().is_some_and(|l| continues_font(l, &s))) + && (s.text.trim().is_empty() || lines.last().is_some_and(|l| same_font(l, &s.font, &s.base_font, s.face.as_deref()))) && (s.text.trim().is_empty() || lines.last().is_some_and(|l| { (s.size - l.size).abs() < 0.01 @@ -374,7 +374,6 @@ fn text_lines_in(doc: &Document, p: &pdfcraft_model::Page, actual_text: bool, in if l.unreadable_mode.is_none() { l.unreadable_mode = s.state.unreadable.as_deref().map(str::to_owned); } - adopt_source_font(l, &s); // Spaces may use a separate subset font; they do not change the visible style. } else { lines.push(TextLine { @@ -402,7 +401,6 @@ fn text_lines_in(doc: &Document, p: &pdfcraft_model::Page, actual_text: bool, in soft_break: s.soft_break, hard_break: s.hard_break, soft_gap: s.soft_gap, - edited: s.edited, ops: std::iter::once(s.op).chain(s.extra_ops.iter().copied()).collect(), origin: Origin { tm: s.tm.0, @@ -463,7 +461,7 @@ fn add_shown(lines: &mut Vec, last: &mut Option, s: Shown, stream let bt = pen.bt; lines.last().is_some_and(|l| l.scope == s.scope && l.paint == s.paint && l.logical_order == s.logical_order) && (bt == s.bt || lines.last().is_some_and(|l| compatible_word(l, &s))) - && (s.text.trim().is_empty() || lines.last().is_some_and(|l| continues_font(l, &s))) + && (s.text.trim().is_empty() || lines.last().is_some_and(|l| same_font(l, &s.font, &s.base_font, s.face.as_deref()))) && (s.text.trim().is_empty() || lines.last().is_some_and(|l| { (s.size - l.size).abs() < 0.01 @@ -494,7 +492,6 @@ fn add_shown(lines: &mut Vec, last: &mut Option, s: Shown, stream if l.unreadable_mode.is_none() { l.unreadable_mode = s.state.unreadable.as_deref().map(str::to_owned); } - adopt_source_font(l, &s); } else { lines.push(TextLine { text: s.text.clone(), @@ -521,7 +518,6 @@ fn add_shown(lines: &mut Vec, last: &mut Option, s: Shown, stream soft_break: s.soft_break, hard_break: s.hard_break, soft_gap: s.soft_gap, - edited: s.edited, ops: std::iter::once(s.op).chain(s.extra_ops.iter().copied()).collect(), origin: Origin { tm: s.tm.0, diff --git a/crates/edit/src/text/rewrite.rs b/crates/edit/src/text/rewrite.rs index 1bf27d2b9e..c146ffcbc0 100644 --- a/crates/edit/src/text/rewrite.rs +++ b/crates/edit/src/text/rewrite.rs @@ -1,7 +1,7 @@ use super::blocks::{BlockStyle, TextBlock, paragraph_separator, row_break, wrap}; use super::fallback::{ Encoder, MAX_TYPE3_GLYPHS, Type3Fallback, Type3Run, drop_unused_fallback_fonts, is_variation_selector, needs_type3, no_japanese_font, - source_encodes, source_family, source_program_completes, source_runs, source_show, source_space, source_width, type3_encode, type3_font, + source_encodes, source_family, source_program_completes, source_show, source_space, source_width, type3_encode, type3_font, }; use super::interp::{Carry, Origin, TextState, interpret}; use super::lines::{content_streams, joined, page_dict, rewritten_contents, splice, text_lines}; @@ -110,24 +110,10 @@ pub(crate) fn replace_line_with_limit(doc: &mut Document, page: usize, line: usi replacement.push(Op::new("Tf", vec![Object::name(&target.font), pdfcraft_content::num(size)])); substituted = Some(format!("{} Type3", fallback.family)); } else { - // The words the line's font can show keep it; only the others are set in the - // substitute (#2378): a subset without `V` or `ü` no longer turns the whole line - // into Helvetica. Reordered text (right to left, Indic) keeps one run as before. - let mixed = font - .as_ref() - .filter(|_| !visual_indic && fallback_shown == text) - .map(|m| source_runs(m, &text)) - .filter(|runs| runs.iter().any(|(own, _)| *own)); - // What the substitute has to show: only the runs the line's font can't. - let for_substitute: String = match &mixed { - Some(runs) => runs.iter().filter(|(own, _)| !*own).map(|(_, run)| run.as_str()).collect(), - None => fallback_shown.clone(), - }; let win = pdfcraft_fonts::win_ansi(&fallback_shown); // WinAnsi turns what it can't show into '?'; refuse rather than print the wrong thing. - let checked = pdfcraft_fonts::win_ansi(&for_substitute); - let back: String = checked.iter().map(|b| char::from_u32(u32::from(*b)).unwrap_or('?')).collect(); - if for_substitute.chars().zip(back.chars()).any(|(a, b)| b == '?' && a != '?') { + let back: String = win.iter().map(|b| char::from_u32(u32::from(*b)).unwrap_or('?')).collect(); + if fallback_shown.chars().zip(back.chars()).any(|(a, b)| b == '?' && a != '?') { return Err(EditError::Invalid(format!("\"{text}\" has characters neither {} nor Helvetica can show", target.base_font))); } // The size in text space: the current Tf's size. @@ -135,36 +121,8 @@ pub(crate) fn replace_line_with_limit(doc: &mut Document, page: usize, line: usi let family = source_family(&target.base_font); let base = family.base_font(target.bold, target.italic); let substitute_name = format!("PCEd{}", base.replace('-', "")); - match mixed { - Some(runs) => { - let space = font.as_ref().map_or(0.33, |m| source_space(doc, &fonts_res, m)); - // Marked as one line, so it reads back as one (see `continues_font`). - replacement.push(Op::new("BMC", vec![Object::name("PCEdLine")])); - for (own, run) in runs { - if own { - replacement.push(Op::new("Tf", vec![Object::name(&target.font), pdfcraft_content::num(size)])); - if let Some(m) = font.as_ref() { - replacement.extend(source_show( - m, - &run, - target.origin.state.font.as_ref().map_or(target.size, |(_, size)| *size), - space, - target.origin.state.char_spacing, - target.origin.state.word_spacing, - )?); - } - } else { - replacement.push(Op::new("Tf", vec![Object::name(&substitute_name), pdfcraft_content::num(size)])); - replacement.push(Op::new("Tj", vec![Object::String(PdfString::literal(pdfcraft_fonts::win_ansi(&run)))])); - } - } - replacement.push(Op::new("EMC", Vec::new())); - } - None => { - replacement.push(Op::new("Tf", vec![Object::name(&substitute_name), pdfcraft_content::num(size)])); - replacement.push(Op::new("Tj", vec![Object::String(PdfString::literal(win))])); - } - } + replacement.push(Op::new("Tf", vec![Object::name(&substitute_name), pdfcraft_content::num(size)])); + replacement.push(Op::new("Tj", vec![Object::String(PdfString::literal(win))])); replacement.push(Op::new("Tf", vec![Object::name(&target.font), pdfcraft_content::num(size)])); substituted = Some(base.to_string()); let mut f = Dict::new(); @@ -177,43 +135,6 @@ 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. - // A line an earlier replacement wrote in two fonts is in a `/PCEdLine` sequence of its own, - // with its fonts and the moves standing in for spaces the source font lacks: those go - // with the line, or each edit would nest one more (review on #2955). The line then starts in - // its own font, set again where the sequence was. - if let Some((open, close)) = target.edited.and_then(|open| Some((open, matching_emc(&ops, open)?))) { - // Only when the sequence holds nothing but this line: a paragraph rewrite keeps the - // delimiters, so other rows can be inside it (review on #2955). Then the sequence stays - // as it is, since it is also what reads its own two-font line back as one, and only - // this line's operators go. - let others_inside = lines.iter().any(|l| l.ops.iter().any(|o| (open..=close).contains(o) && !drops.contains(o))); - // Either way the replacement starts in the line's own font: inside the sequence the font - // in effect at the line's first operator can be the substitute's, which a replacement - // the line's font shows by itself would otherwise inherit (review on #2955). - let size = font_size_before(&ops, first).unwrap_or(target.size); - replacement.insert(0, Op::new("Tf", vec![Object::name(&target.font), pdfcraft_content::num(size)])); - if !others_inside { - // What goes is what the replacement stands in for: the marked-content delimiters - // (nested ones too, which come in pairs), the fonts and the text-showing operators - // (the line's runs and the moves between them). A paragraph rewrite can also write a - // row's positioning and text state (`Tm`, `Td`, `Tw`, ...), a `q`/`Q` or a `BT`/`ET` - // inside kept delimiters; those stay, or the replacement loses its place and the - // content its balance (review on #2955). - drops.extend( - (open..=close) - .filter(|&i| ops.get(i).is_some_and(|o| [b"BMC".as_slice(), b"BDC", b"EMC", b"Tf", b"Tj", b"TJ"].contains(&o.op.as_slice()))), - ); - let own_font = |i: usize| ops.get(i).is_some_and(|o| o.is("Tf") && o.name(0) == Some(target.font.as_bytes()) && o.num(1) == Some(size)); - // The same font set just before the sequence (by the last edit) is set again here. - if let Some(i) = open.checked_sub(1).filter(|&i| own_font(i)) { - drops.insert(i); - } - // And set back after it: the replacement ends in that font too. - if let Some(i) = close.checked_add(1).filter(|&i| own_font(i)) { - drops.insert(i); - } - } - } 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; @@ -229,24 +150,6 @@ pub(crate) fn replace_line_with_limit(doc: &mut Document, page: usize, line: usi Ok(LineEdit { substituted }) } -/// The `EMC` that closes the marked-content sequence `BMC`/`BDC` operator `open` begins. -fn matching_emc(ops: &[Op], open: usize) -> Option { - let mut depth = 0usize; - for (i, op) in ops.iter().enumerate().skip(open) { - match op.op.as_slice() { - b"BMC" | b"BDC" => depth = depth.saturating_add(1), - b"EMC" => { - depth = depth.checked_sub(1)?; - if depth == 0 { - return Some(i); - } - } - _ => {} - } - } - None -} - /// The size of the last `Tf` before operator `at`. fn font_size_before(ops: &[Op], at: usize) -> Option { ops[..at].iter().rev().find(|o| o.is("Tf")).and_then(|o| o.num(1)) diff --git a/crates/engine/src/text_links.rs b/crates/engine/src/text_links.rs index 90c587898e..0ed8098ad3 100644 --- a/crates/engine/src/text_links.rs +++ b/crates/engine/src/text_links.rs @@ -138,7 +138,7 @@ mod tests { space_before.push(gap); (x, gap) = (x + 6.0, false); } - PageText { glyphs, line_of, space_before } + PageText { glyphs, line_of, space_before, truncated: false } } fn uris(s: &str) -> Vec { diff --git a/crates/forms/src/tests.rs b/crates/forms/src/tests.rs index f00d1cad9a..0f3189cd2c 100644 --- a/crates/forms/src/tests.rs +++ b/crates/forms/src/tests.rs @@ -1714,7 +1714,7 @@ fn unsupported_first_validation_call_preserves_custom_script_dispatch() { assert_eq!(script, JS); assert_eq!(target.name, "amount"); self.calls += 1; - ScriptResult { rc: false, value: value.into(), changes: Vec::new(), message: Some("custom validation ran".into()) } + ScriptResult { rc: false, value: value.into(), value_set: false, changes: Vec::new(), message: Some("custom validation ran".into()) } } } let mut doc = one_page(); diff --git a/crates/ui-egui/src/app/prefs.rs b/crates/ui-egui/src/app/prefs.rs index 6a6452d463..37a11e210e 100644 --- a/crates/ui-egui/src/app/prefs.rs +++ b/crates/ui-egui/src/app/prefs.rs @@ -161,9 +161,6 @@ impl PdfCraftApp { v["pinned_tools"] = serde_json::json!(self.pinned_tools); // Set outside json! above, which is at serde_json's macro recursion limit. v["hide_empty_highlight_hover"] = serde_json::Value::Bool(self.comment_prefs.hide_empty_highlight_hover); - // The Print dialog's paper list and each printer's paper (print.page-setup). - v["print_custom_papers"] = serde_json::json!(self.print_draft.custom_papers); - v["print_paper_by_printer"] = serde_json::json!(self.print_draft.remembered_paper); v["show_page_edge_shadows"] = self.show_page_edge_shadows.into(); // Not `check_updates_at_start`: that key was an opt-in of its own, off unless chosen, and // settings written then would turn this off unasked. @@ -173,10 +170,8 @@ impl PdfCraftApp { v["show_secured_notice"] = serde_json::Value::Bool(self.show_secured_notice); v["text_links"] = serde_json::Value::Bool(self.text_links); v["organize_zoom"] = serde_json::json!(self.organize_zoom); - // Canonical names survive changes to the order of PAPERS and do not depend on UI language; - // a custom paper is stored by the name it was given. - let d = &self.print_draft; - v["print_paper"] = serde_json::json!((d.paper < d.paper_count()).then(|| d.paper_name())); + // Canonical names survive changes to the order of PAPERS and do not depend on UI language. + v["print_paper"] = serde_json::json!(pdfcraft_engine::print::PAPERS.get(self.print_draft.paper).map(|(name, _)| *name)); v.to_string() } @@ -297,16 +292,11 @@ impl PdfCraftApp { if let Some(on) = v["always_default_view"].as_bool() { self.always_default_view = on; } - // The Print dialog's paper list and each printer's paper; settings are untrusted, so each - // custom paper is checked as if it were typed. - if v.get("print_custom_papers").is_some() { - self.print_draft.custom_papers = print_paper::read_custom(&v["print_custom_papers"]); - } - if v.get("print_paper_by_printer").is_some() { - self.print_draft.remembered_paper = print_paper::read_remembered(&v["print_paper_by_printer"]); - } - // After the custom papers, so a custom paper chosen last time is found by its name. - if let Some(paper) = v.get("print_paper").and_then(serde_json::Value::as_str).and_then(|name| self.print_draft.paper_index(name)) { + if let Some(paper) = v + .get("print_paper") + .and_then(serde_json::Value::as_str) + .and_then(|name| pdfcraft_engine::print::PAPERS.iter().position(|(canonical, _)| *canonical == name)) + { self.print_draft.paper = paper; } if let Some(on) = v["highlight_fields"].as_bool() { diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 0e47d4baff..227136c003 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -197,7 +197,6 @@ use workflows::ocr_verify; #[cfg(target_arch = "wasm32")] use workflows::ocr_web; use workflows::optimize_ui; -use workflows::print_paper; use workflows::print_ui; use workflows::scan_clean_ui; use workflows::web_print; diff --git a/crates/ui-egui/src/tools/prepare.rs b/crates/ui-egui/src/tools/prepare.rs index 1ab23a7fd6..8297eda94d 100644 --- a/crates/ui-egui/src/tools/prepare.rs +++ b/crates/ui-egui/src/tools/prepare.rs @@ -920,7 +920,7 @@ mod tests { let widget = Rect::from_min_size(pos2(30.0, 40.0), vec2(20.0, 15.0)); let size = vec2(24.0, 14.0); let original = tab_badge_rect(widget, page, size).unwrap(); - let moved = dragged(widget, Grab::Move, vec2(12.0, 8.0), false); + let moved = dragged(widget, Grab::Move(None), vec2(12.0, 8.0), false); assert_eq!(tab_badge_rect(moved, page, size).unwrap().min, original.min + vec2(12.0, 8.0)); let near_edge = Rect::from_min_size(pos2(100.0, 95.0), vec2(8.0, 8.0)); diff --git a/crates/ui-egui/src/workflows.rs b/crates/ui-egui/src/workflows.rs index f20be70394..7b1bbeb9f3 100644 --- a/crates/ui-egui/src/workflows.rs +++ b/crates/ui-egui/src/workflows.rs @@ -15,7 +15,6 @@ pub(super) mod ocr_verify; #[cfg(target_arch = "wasm32")] pub(super) mod ocr_web; pub(super) mod optimize_ui; -pub(super) mod print_paper; pub(super) mod print_ui; pub(super) mod scan_clean_ui; pub(super) mod web_print; diff --git a/crates/ui-egui/src/workflows/print_paper.rs b/crates/ui-egui/src/workflows/print_paper.rs deleted file mode 100644 index 30edb4dc91..0000000000 --- a/crates/ui-egui/src/workflows/print_paper.rs +++ /dev/null @@ -1,224 +0,0 @@ -//! The Print dialog's paper list and the paper each printer is remembered with (print.page-setup): -//! paper sizes of a person's own, added with Custom… beside Paper and kept with the settings, and -//! for each printer the paper chosen when "Remember this paper for …" was ticked at Print time. -//! Two-sided, grayscale, collate, sizing and comments & forms are left to the dialog itself. - -use std::collections::BTreeMap; - -use pdfcraft_engine::print::PAPERS; -use serde::{Deserialize, Serialize}; - -use crate::print_ui::PrintDraft; - -/// The largest side a custom paper can have, in points: PDF's own page limit (200 inches). -pub const MAX_SIDE: f64 = 14_400.0; -/// The smallest side, in points (one inch). -pub const MIN_SIDE: f64 = 72.0; -/// How many custom papers the settings keep. -pub const MAX_CUSTOM: usize = 100; -/// The longest name a custom paper keeps, in characters. -const MAX_NAME: usize = 64; - -/// A paper size added in the Print dialog (points, portrait). -#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] -pub struct CustomPaper { - pub name: String, - pub width: f64, - pub height: f64, -} - -impl CustomPaper { - /// A custom paper from a name and two sides in points, portrait; None when the name is empty - /// or a side is outside [`MIN_SIDE`]..=[`MAX_SIDE`], or a standard paper already has the name - /// (any case), which would otherwise be found first when the paper is looked up by name. - pub fn new(name: &str, a: f64, b: f64) -> Option { - let name: String = name.trim().chars().filter(|c| !c.is_control()).take(MAX_NAME).collect(); - let fits = |v: f64| v.is_finite() && (MIN_SIDE..=MAX_SIDE).contains(&v); - let standard = PAPERS.iter().any(|(n, _)| n.eq_ignore_ascii_case(&name)); - (!name.is_empty() && !standard && fits(a) && fits(b)).then(|| Self { name, width: a.min(b), height: a.max(b) }) - } -} - -/// The paper remembered for each printer, by the printer's name ("" is Save as PDF). -pub type RememberedPaper = BTreeMap; - -/// Custom papers read back from the settings, which are untrusted: each one is checked as if it -/// were typed, a name already taken keeps its first size, and at most [`MAX_CUSTOM`] are kept. -pub fn read_custom(value: &serde_json::Value) -> Vec { - let Some(list) = value.as_array() else { return Vec::new() }; - let mut out: Vec = Vec::new(); - for item in list { - let (Some(name), Some(w), Some(h)) = (item["name"].as_str(), item["width"].as_f64(), item["height"].as_f64()) else { continue }; - if let Some(paper) = CustomPaper::new(name, w, h) - && !out.iter().any(|p| p.name == paper.name) - && out.len() < MAX_CUSTOM - { - out.push(paper); - } - } - out -} - -/// The remembered papers read back from the settings: printer name to paper name, strings only. -pub fn read_remembered(value: &serde_json::Value) -> RememberedPaper { - let Some(map) = value.as_object() else { return RememberedPaper::new() }; - map.iter().filter_map(|(printer, paper)| paper.as_str().map(|p| (printer.clone(), p.to_string()))).collect() -} - -impl PrintDraft { - /// The paper list: the standard sizes, then the custom ones. - pub fn paper_count(&self) -> usize { - PAPERS.len() + self.custom_papers.len() - } - - /// The chosen paper's size (points, portrait). - pub fn paper_size(&self) -> (f64, f64) { - match self.paper.checked_sub(PAPERS.len()) { - None => PAPERS[self.paper.min(PAPERS.len() - 1)].1, - Some(c) => self.custom_papers.get(c).map_or(PAPERS[0].1, |p| (p.width, p.height)), - } - } - - /// The chosen paper's name (a standard name, or the custom paper's). - pub fn paper_name(&self) -> String { - match self.paper.checked_sub(PAPERS.len()) { - None => PAPERS[self.paper.min(PAPERS.len() - 1)].0.to_string(), - Some(c) => self.custom_papers.get(c).map_or_else(|| PAPERS[0].0.to_string(), |p| p.name.clone()), - } - } - - /// The index of the paper called `name`, standard or custom. - pub fn paper_index(&self, name: &str) -> Option { - PAPERS.iter().position(|(n, _)| *n == name).or_else(|| self.custom_papers.iter().position(|p| p.name == name).map(|i| PAPERS.len() + i)) - } - - /// True when the chosen paper is a custom one (which can be removed). - pub fn paper_is_custom(&self) -> bool { - self.paper >= PAPERS.len() - } - - /// Add (or replace, by name) a custom paper and choose it. - pub fn add_custom_paper(&mut self, paper: CustomPaper) { - self.custom_papers.retain(|p| p.name != paper.name); - if self.custom_papers.len() >= MAX_CUSTOM { - self.custom_papers.remove(0); - } - self.custom_papers.push(paper); - self.paper = PAPERS.len() + self.custom_papers.len() - 1; - } - - /// Take the chosen custom paper off the list; the dialog goes back to the first size. - pub fn remove_custom_paper(&mut self) { - if let Some(i) = self.paper.checked_sub(PAPERS.len()) - && i < self.custom_papers.len() - { - self.custom_papers.remove(i); - self.paper = 0; - } - } - - /// The key the printer's paper is remembered under ("" is Save as PDF). - fn printer_key(&self) -> String { - self.printer.clone().unwrap_or_default() - } - - /// Start from the paper remembered for the printer chosen, if one was and it is still on the - /// list; the Remember box says whether one was. - pub fn apply_remembered(&mut self) { - let remembered = self.remembered_paper.get(&self.printer_key()).cloned(); - self.remember_paper = remembered.is_some(); - if let Some(i) = remembered.and_then(|name| self.paper_index(&name)) { - self.paper = i; - } - } - - /// At Print time: keep the paper for the printer when the box is ticked, forget it when not. - pub fn remember_now(&mut self) { - let key = self.printer_key(); - if self.remember_paper { - let name = self.paper_name(); - self.remembered_paper.insert(key, name); - } else { - self.remembered_paper.remove(&key); - } - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn a_custom_paper_is_portrait_named_and_within_pdfs_limits() { - let p = CustomPaper::new(" Legal, tray 2 ", 1008.0, 612.0).unwrap(); - assert_eq!((p.name.as_str(), p.width, p.height), ("Legal, tray 2", 612.0, 1008.0)); - assert!(CustomPaper::new("", 612.0, 792.0).is_none()); - assert!(CustomPaper::new("\u{1}", 612.0, 792.0).is_none()); - assert!(CustomPaper::new("Tiny", 10.0, 792.0).is_none()); - assert!(CustomPaper::new("Huge", 612.0, 20_000.0).is_none()); - assert!(CustomPaper::new("NaN", f64::NAN, 792.0).is_none()); - assert_eq!(CustomPaper::new(&"x".repeat(500), 612.0, 792.0).unwrap().name.chars().count(), MAX_NAME); - } - - #[test] - fn settings_are_read_as_untrusted() { - let v = serde_json::json!([ - { "name": "Label", "width": 288.0, "height": 432.0 }, - { "name": "Label", "width": 300.0, "height": 400.0 }, - { "name": "Bad", "width": -1.0, "height": 432.0 }, - { "name": 5, "width": 288.0, "height": 432.0 }, - "nonsense", - ]); - assert_eq!(read_custom(&v), vec![CustomPaper { name: "Label".into(), width: 288.0, height: 432.0 }]); - assert!(read_custom(&serde_json::json!(5)).is_empty()); - let many: Vec<_> = (0..300).map(|i| serde_json::json!({ "name": format!("P{i}"), "width": 300.0, "height": 400.0 })).collect(); - assert_eq!(read_custom(&serde_json::Value::Array(many)).len(), MAX_CUSTOM); - let r = read_remembered(&serde_json::json!({ "Office": "A4", "": "Label", "Odd": 3 })); - assert_eq!(r.len(), 2); - assert_eq!(r.get("").map(String::as_str), Some("Label")); - assert!(read_remembered(&serde_json::json!([1])).is_empty()); - } - - #[test] - fn the_paper_list_is_the_standard_sizes_then_the_custom_ones() { - let mut d = PrintDraft::default(); - assert_eq!(d.paper_count(), PAPERS.len()); - d.add_custom_paper(CustomPaper::new("Label", 288.0, 432.0).unwrap()); - assert_eq!((d.paper, d.paper_name().as_str(), d.paper_size()), (PAPERS.len(), "Label", (288.0, 432.0))); - assert_eq!(d.paper_index("A4"), PAPERS.iter().position(|(n, _)| *n == "A4")); - assert_eq!(d.paper_index("Label"), Some(PAPERS.len())); - // The same name replaces the size; removing goes back to the first standard size. - d.add_custom_paper(CustomPaper::new("Label", 300.0, 400.0).unwrap()); - assert_eq!((d.paper_count(), d.paper_size()), (PAPERS.len() + 1, (300.0, 400.0))); - d.remove_custom_paper(); - assert_eq!((d.paper_count(), d.paper), (PAPERS.len(), 0)); - // A stale index reads as the first size, never out of bounds. - d.paper = PAPERS.len() + 7; - assert_eq!(d.paper_size(), PAPERS[0].1); - } - - #[test] - fn the_paper_is_remembered_per_printer_only_when_asked() { - let mut d = PrintDraft::default(); - let a4 = d.paper_index("A4").unwrap(); - d.printer = Some("Office".into()); - d.paper = a4; - d.remember_paper = true; - d.remember_now(); - d.printer = Some("Plotter".into()); - d.paper = 0; - d.apply_remembered(); - assert_eq!((d.paper, d.remember_paper), (0, false), "another printer keeps the dialog's paper"); - d.printer = Some("Office".into()); - d.apply_remembered(); - assert_eq!((d.paper, d.remember_paper), (a4, true)); - d.remember_paper = false; - d.remember_now(); - assert!(d.remembered_paper.is_empty()); - // A remembered custom paper that was removed since leaves the paper alone. - d.remembered_paper.insert("Office".into(), "Gone".into()); - d.paper = 0; - d.apply_remembered(); - assert_eq!((d.paper, d.remember_paper), (0, true)); - } -} diff --git a/crates/ui-egui/src/workflows/print_ui.rs b/crates/ui-egui/src/workflows/print_ui.rs index 880dccbd00..7ea7a18602 100644 --- a/crates/ui-egui/src/workflows/print_ui.rs +++ b/crates/ui-egui/src/workflows/print_ui.rs @@ -171,10 +171,8 @@ impl PdfCraftApp { sheet: 0, selected, which, - custom_new: None, ..keep }; - self.print_draft.apply_remembered(); self.dialog = Some(crate::Dialog::Print); } @@ -316,13 +314,7 @@ impl PdfCraftApp { // A picked printer that is no longer listed falls back to the default too. if !d.printer_picked || !offered(d.printer.as_deref(), &printers) { d.printer_picked = false; - // Only a different printer starts over with its remembered paper; the - // same one keeps the paper and the Remember box as they were left. - let default = default_printer(&printers); - if default != d.printer { - d.printer = default; - d.apply_remembered(); - } + d.printer = default_printer(&printers); } d.printers = printers; d.printers_loading = false; diff --git a/crates/ui-egui/src/workflows/print_ui/body.rs b/crates/ui-egui/src/workflows/print_ui/body.rs index d38272a001..690dc3142e 100644 --- a/crates/ui-egui/src/workflows/print_ui/body.rs +++ b/crates/ui-egui/src/workflows/print_ui/body.rs @@ -158,7 +158,6 @@ pub(crate) fn body( d.printer_picked = true; d.show_driver_options = false; d.driver_error = None; - d.apply_remembered(); } ui.end_row(); // The spooler answered with no printer at all (#160): say so, rather than @@ -203,35 +202,15 @@ pub(crate) fn body( ui.end_row(); } let paper_label = ui.label(tl!("Paper:")); - d.paper = d.paper.min(d.paper_count().saturating_sub(1)); + let paper_name = PAPERS.get(d.paper).map_or("US Letter", |p| p.0); let before = d.paper; - ui.horizontal(|ui| { - // A standard size's name is translated; a custom paper keeps the name it was given. - let shown = if d.paper_is_custom() { d.paper_name() } else { tl!(d.paper_name().as_str()).to_string() }; - craft_ui::ComboBox::from_id_salt("paper").selected_text(shown).width(260.0).show_ui(ui, |ui| { - for (i, (name, _)) in PAPERS.iter().enumerate() { - ui.selectable_value(&mut d.paper, i, tl!(name)); - } - if !d.custom_papers.is_empty() { - ui.separator(); - } - for (i, p) in d.custom_papers.iter().enumerate() { - let label = format!("{} ({})", p.name, d.units.size(p.width, p.height)); - ui.selectable_value(&mut d.paper, PAPERS.len() + i, label); - } - }) - .response - .labelled_by(paper_label.id); - if ui.button(tl!("Custom…")).on_hover_text(tl!("Add a paper size of your own to the list")).clicked() { - d.custom_new = match d.custom_new { - Some(_) => None, - None => Some((String::new(), String::new(), String::new())), - }; + craft_ui::ComboBox::from_id_salt("paper").selected_text(tl!(paper_name)).width(260.0).show_ui(ui, |ui| { + for (i, (name, _)) in PAPERS.iter().enumerate() { + ui.selectable_value(&mut d.paper, i, tl!(name)); } - if d.paper_is_custom() && ui.button(tl!("Remove")).on_hover_text(tl!("Take this paper size off the list")).clicked() { - d.remove_custom_paper(); - } - }); + }) + .response + .labelled_by(paper_label.id); // Label stock gets the label layout (cut, turned upright, fitted). if d.paper != before && d.paper == print::LABEL_PAPER { d.paper_before_label = Some(before); @@ -239,11 +218,6 @@ pub(crate) fn body( d.sheet = 0; } ui.end_row(); - if d.custom_new.is_some() { - ui.label(""); - custom_paper_row(ui, d, t); - ui.end_row(); - } }); // Windows says which drivers can't print two-sided as they're asked; they keep their // own setting, even when Two-sided is Off. @@ -585,11 +559,6 @@ pub(crate) fn body( }); ui.add_space(12.0); let (mut go, mut cancel) = (false, false); - // The paper is kept for this printer (or Save as PDF) when this is ticked at Print time. On its - // own row above the buttons: sharing their row moved them while the dialog settled. - let for_whom = d.printer.as_deref().map_or_else(|| tl!("Save as PDF").to_string(), printer_label); - ui.add(widgets::checkbox(&mut d.remember_paper, crate::i18n::fmt(tl!("Remember this paper for {printer}"), &[("printer", &for_whom)]))); - ui.add_space(8.0); ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { let looking = still_looking(d); let label = if d.printer.is_some() || looking { tl!("Print") } else { tl!("Save as PDF") }; @@ -600,35 +569,5 @@ pub(crate) fn body( cancel = true; } }); - if go { - d.remember_now(); - } (go, cancel) } - -/// The row that adds a custom paper: name, width and height in the preferences' unit. -fn custom_paper_row(ui: &mut egui::Ui, d: &mut PrintDraft, t: &Tokens) { - let unit = d.units; - let Some((name, width, height)) = d.custom_new.as_mut() else { return }; - let side = |s: &str| s.trim().replace(',', ".").parse::().ok().map(|v| v / unit.per_point()); - let paper = match (side(width), side(height)) { - (Some(w), Some(h)) => crate::print_paper::CustomPaper::new(name, w, h), - _ => None, - }; - let (mut add, mut cancel) = (false, false); - ui.horizontal(|ui| { - ui.add(egui::TextEdit::singleline(name).hint_text(tl!("Name")).desired_width(110.0)).accessible_name(tl!("Name")); - ui.add(egui::TextEdit::singleline(width).hint_text(tl!("Width")).desired_width(52.0)).accessible_name(tl!("Width")); - ui.label("×"); - ui.add(egui::TextEdit::singleline(height).hint_text(tl!("Height")).desired_width(52.0)).accessible_name(tl!("Height")); - ui.label(egui::RichText::new(unit.label()).color(t.text_muted)); - add = ui.add_enabled(paper.is_some(), egui::Button::new(tl!("Add"))).clicked(); - cancel = ui.button(tl!("Cancel")).clicked(); - }); - if add && let Some(paper) = paper { - d.add_custom_paper(paper); - d.custom_new = None; - } else if cancel { - d.custom_new = None; - } -} diff --git a/crates/ui-egui/src/workflows/print_ui/draft.rs b/crates/ui-egui/src/workflows/print_ui/draft.rs index 988880a550..27080a7329 100644 --- a/crates/ui-egui/src/workflows/print_ui/draft.rs +++ b/crates/ui-egui/src/workflows/print_ui/draft.rs @@ -1,4 +1,4 @@ -use super::{Binding, BookletSubset, Content, Layout, Orientation, PageOrder, SizeMode, Subset, print, spool}; +use super::{Binding, BookletSubset, Content, Layout, Orientation, PAPERS, PageOrder, SizeMode, Subset, print, spool}; #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum Which { @@ -62,16 +62,7 @@ pub struct PrintDraft { /// Print as image (Acrobat's Advanced Print Setup) at `dpi`. pub as_image: bool, pub dpi: f64, - /// Index into the paper list: the standard sizes, then `custom_papers`. pub paper: usize, - /// Paper sizes added with Custom…, kept with the settings. - pub custom_papers: Vec, - /// The paper each printer is remembered with, kept with the settings. - pub remembered_paper: crate::print_paper::RememberedPaper, - /// "Remember this paper for …", applied at Print time. - pub remember_paper: bool, - /// The custom paper being added: name, width and height as typed (the preferences' unit). - pub custom_new: Option<(String, String, String)>, /// The paper chosen before Label put label stock in, so leaving Label gives it back (#3564). pub paper_before_label: Option, /// The sheet shown in the preview (0-based). @@ -135,10 +126,6 @@ impl Default for PrintDraft { as_image: false, dpi: 300.0, paper: 0, - custom_papers: Vec::new(), - remembered_paper: Default::default(), - remember_paper: false, - custom_new: None, paper_before_label: None, sheet: 0, current_page: 0, @@ -219,7 +206,7 @@ impl PrintDraft { }; Ok(print::Settings { pages, - paper: self.paper_size(), + paper: PAPERS[self.paper.min(PAPERS.len() - 1)].1, orientation: self.orientation, layout, content: self.content, diff --git a/crates/ui-egui/tests/it/main.rs b/crates/ui-egui/tests/it/main.rs index f9ca6a2977..1d848fc8e6 100644 --- a/crates/ui-egui/tests/it/main.rs +++ b/crates/ui-egui/tests/it/main.rs @@ -66,8 +66,6 @@ mod pointer; mod preferences_layout; mod prepare; mod print; -mod print_custom_paper_names; -mod print_late_printer_list; mod protect; mod reading_colors; mod recent_files; diff --git a/crates/ui-egui/tests/it/print.rs b/crates/ui-egui/tests/it/print.rs index a6346ca1e2..3e0fec7a6e 100644 --- a/crates/ui-egui/tests/it/print.rs +++ b/crates/ui-egui/tests/it/print.rs @@ -918,98 +918,6 @@ fn restored_paper_keeps_preview_and_saved_sheet_dimensions() { std::fs::remove_file(out).unwrap(); } -/// After the Print dialog opens: frames until its printer list has loaded (its arrival changes -/// the dialog's rows, even an empty list, which adds the "no printers" line), then a few more so -/// the dialog has settled where it stays. A click before then can land where a control was -/// (windows-latest CI). -fn settle(h: &mut Harness<'static, PdfCraftApp>) { - for _ in 0..500 { - h.run_steps(1); - if !h.state().print_draft.printers_loading { - break; - } - std::thread::sleep(std::time::Duration::from_millis(2)); - } - assert!(!h.state().print_draft.printers_loading, "the printer list loaded"); - h.run_steps(8); -} - -/// Page setup: a paper size of one's own joins the Paper list (Custom…), the paper is kept for -/// the printer when "Remember this paper for …" is ticked at Print time, starts the dialog next -/// time, survives a restart with the settings, and is forgotten when unticked; Remove takes a -/// custom size off the list. -#[test] -fn custom_papers_and_the_paper_remembered_for_a_printer() { - let mut h = harness(); - // No printers here: Save as PDF is the dialog's printer, now and when it opens again. - h.state_mut().printer_source = Some(std::sync::Arc::new(Vec::new)); - assert!(h.state_mut().execute("print.dialog")); - settle(&mut h); - assert_eq!(h.state().print_draft.printer, None); - h.get_by_label("Custom…").click(); - h.run_steps(2); - // Typed in the preferences' unit: 4 × 6 inches. - let per_point = h.state().print_draft.units.per_point(); - h.state_mut().print_draft.custom_new = Some(("Label 4×6".into(), format!("{}", 432.0 * per_point), format!("{}", 288.0 * per_point))); - h.run_steps(2); - h.get_by_label("Add").click(); - h.run_steps(2); - { - let d = &h.state().print_draft; - assert!(d.paper_is_custom() && d.paper_name() == "Label 4×6", "{}", d.paper_name()); - let (w, l) = d.paper_size(); - assert!((w - 288.0).abs() < 0.01 && (l - 432.0).abs() < 0.01, "portrait: {w}×{l}"); - } - // The custom row has closed: let the dialog settle at its new height before clicking. - h.run_steps(8); - h.get_by_label("Remember this paper for Save as PDF").click(); - h.run_steps(1); - assert!(h.state().print_draft.remember_paper, "ticked"); - let out = std::env::temp_dir().join(format!("pdfcraft-print-paper-test-{}.pdf", std::process::id())); - h.state_mut().save_override = Some(out.to_string_lossy().into_owned()); - h.run_steps(8); - h.get_by_label("Save as PDF").click(); - h.run_steps(3); - assert_eq!(h.state().dialog, None); - let bytes = std::fs::read(&out).expect("saved"); - let _ = std::fs::remove_file(&out); - let saved = pdfcraft_cos::Document::open(std::sync::Arc::new(bytes)).unwrap(); - let sheet = pdfcraft_model::pages(&saved)[0].display_size(&saved); - assert!((sheet.0.min(sheet.1) - 288.0).abs() < 0.5 && (sheet.0.max(sheet.1) - 432.0).abs() < 0.5, "the label paper: {sheet:?}"); - assert_eq!(h.state().print_draft.remembered_paper.get("").map(String::as_str), Some("Label 4×6")); - // Next time the dialog starts with it. - h.state_mut().print_draft.paper = 0; - assert!(h.state_mut().execute("print.dialog")); - settle(&mut h); - assert_eq!((h.state().print_draft.paper_name().as_str(), h.state().print_draft.remember_paper), ("Label 4×6", true)); - // And after a restart. - let settings = h.state().persist(); - let mut fresh = PdfCraftApp::new(); - fresh.restore(&settings); - assert_eq!(fresh.print_draft.paper_count(), h.state().print_draft.paper_count()); - assert_eq!(fresh.print_draft.remembered_paper, h.state().print_draft.remembered_paper); - // Unticked at Print time, the paper is forgotten for Save as PDF. - h.get_by_label("Remember this paper for Save as PDF").click(); - h.run_steps(1); - assert!(!h.state().print_draft.remember_paper, "unticked"); - h.state_mut().save_override = Some(out.to_string_lossy().into_owned()); - h.run_steps(8); - h.get_by_label("Save as PDF").click(); - h.run_steps(3); - assert_eq!(h.state().dialog, None, "saved"); - let _ = std::fs::remove_file(&out); - assert!(h.state().print_draft.remembered_paper.is_empty()); - // Remove takes the custom size off the list. - assert!(h.state_mut().execute("print.dialog")); - settle(&mut h); - let custom = h.state().print_draft.paper_count() - 1; - h.state_mut().print_draft.paper = custom; - h.run_steps(2); - h.get_by_label("Remove").click(); - h.run_steps(2); - assert_eq!((h.state().print_draft.paper_count(), h.state().print_draft.paper), (custom, 0)); -} - /// #2707: a booklet decides two-sided itself, as Acrobat does (its Print dialog hides Two-sided /// while Booklet is chosen): the job flips on the short edge for both sides and prints /// one-sided for the front or back only, and the Two-sided choice is kept for other layouts. diff --git a/crates/ui-egui/tests/it/print_custom_paper_names.rs b/crates/ui-egui/tests/it/print_custom_paper_names.rs deleted file mode 100644 index ba083b3c0b..0000000000 --- a/crates/ui-egui/tests/it/print_custom_paper_names.rs +++ /dev/null @@ -1,62 +0,0 @@ -//! A custom paper cannot take the name of a standard one: papers are looked up by name, so the -//! standard size would be found first and a remembered custom paper would come back as it. - -use egui::accesskit::Role; -use egui_kittest::Harness; -use egui_kittest::kittest::{NodeT, Queryable}; -use pdfcraft_ui_egui::PdfCraftApp; - -fn harness() -> Harness<'static, PdfCraftApp> { - let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).build_eframe(|_cc| { - let mut app = PdfCraftApp::new(); - app.set_option("language", "en").unwrap(); - app.open_bytes("form.pdf", None, include_bytes!("../data/form.pdf").to_vec()).unwrap(); - app - }); - h.run_steps(4); - h -} - -#[test] -fn a_custom_paper_cannot_take_a_standard_name() { - let mut h = harness(); - // No printers here: the list is empty as soon as it is asked for. - h.state_mut().printer_source = Some(std::sync::Arc::new(Vec::new)); - assert!(h.state_mut().execute("print.dialog")); - for _ in 0..500 { - h.run_steps(1); - if !h.state().print_draft.printers_loading { - break; - } - std::thread::sleep(std::time::Duration::from_millis(2)); - } - assert!(!h.state().print_draft.printers_loading, "the printer list loaded"); - h.run_steps(8); - h.get_by_label("Custom…").click(); - h.run_steps(2); - let before = h.state().print_draft.paper_count(); - let per_point = h.state().print_draft.units.per_point(); - let (w, l) = (format!("{}", 288.0 * per_point), format!("{}", 432.0 * per_point)); - // The name of a standard paper, in either case, is refused: Add stays disabled. - for name in ["A4", " a4 "] { - h.state_mut().print_draft.custom_new = Some((name.into(), w.clone(), l.clone())); - h.run_steps(3); - assert!(h.get_by_role_and_label(Role::Button, "Add").accesskit_node().is_disabled(), "{name:?} is a standard paper's name"); - assert_eq!(h.state().print_draft.paper_count(), before); - } - // Any other name is accepted by the same row. - h.state_mut().print_draft.custom_new = Some(("A4 label".into(), w.clone(), l.clone())); - h.run_steps(3); - assert!(!h.get_by_role_and_label(Role::Button, "Add").accesskit_node().is_disabled()); - - // Settings read back are checked the same way: the custom "A4" is dropped, the rest kept. - let paper = |name: &str| serde_json::json!({ "name": name, "width": 288.0, "height": 432.0 }); - let settings = serde_json::json!({ - "print_custom_papers": [paper("A4"), paper("Label"), paper("us letter")], - "print_paper_by_printer": { "": "A4" }, - }); - let mut fresh = PdfCraftApp::new(); - fresh.restore(&settings.to_string()); - let names: Vec<&str> = fresh.print_draft.custom_papers.iter().map(|p| p.name.as_str()).collect(); - assert_eq!(names, ["Label"]); -} diff --git a/crates/ui-egui/tests/it/print_late_printer_list.rs b/crates/ui-egui/tests/it/print_late_printer_list.rs deleted file mode 100644 index b32772df20..0000000000 --- a/crates/ui-egui/tests/it/print_late_printer_list.rs +++ /dev/null @@ -1,66 +0,0 @@ -//! A printer list that arrives late must not undo what was picked while it was being read, when -//! the printer it names is the one the dialog already has. - -use egui_kittest::Harness; -use pdfcraft_engine::print::spool::Printer; -use pdfcraft_ui_egui::PdfCraftApp; -use std::sync::{Arc, Mutex}; - -fn harness() -> Harness<'static, PdfCraftApp> { - let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).build_eframe(|_cc| { - let mut app = PdfCraftApp::new(); - app.set_option("language", "en").unwrap(); - app.open_bytes("form.pdf", None, include_bytes!("../data/form.pdf").to_vec()).unwrap(); - app - }); - h.run_steps(4); - h -} - -fn wait_for_list(h: &mut Harness<'static, PdfCraftApp>) { - let deadline = std::time::Instant::now() + std::time::Duration::from_secs(10); - while h.state().print_draft.printers_loading && std::time::Instant::now() < deadline { - h.run_steps(1); - std::thread::sleep(std::time::Duration::from_millis(5)); - } - assert!(!h.state().print_draft.printers_loading, "the printer list loaded"); -} - -#[test] -fn a_late_list_keeps_the_paper_picked_meanwhile() { - let mut h = harness(); - // The source waits for the test to let it answer, once the first list is in. - let gate = Arc::new(Mutex::new(())); - let wait = gate.clone(); - h.state_mut().printer_source = Some(Arc::new(move || { - let _open = wait.lock(); - vec![Printer { name: "Office".into(), default: true, two_sided: None, description: String::new() }] - })); - assert!(h.state_mut().execute("print.dialog")); - wait_for_list(&mut h); - h.run_steps(4); - assert_eq!(h.state().print_draft.printer.as_deref(), Some("Office")); - - // "Office" is remembered with Tabloid (the third size). - h.state_mut().print_draft.remembered_paper.insert("Office".into(), "Tabloid".into()); - h.state_mut().dialog = None; - let hold = gate.lock().unwrap(); - assert!(h.state_mut().execute("print.dialog")); - h.run_steps(2); - assert!(h.state().print_draft.printers_loading, "the fresh list is still being read"); - assert_eq!(h.state().print_draft.printer.as_deref(), Some("Office"), "last time's list shows at once"); - assert_eq!((h.state().print_draft.paper_name().as_str(), h.state().print_draft.remember_paper), ("Tabloid", true)); - - // Meanwhile the person picks another paper and unticks Remember. - { - let d = &mut h.state_mut().print_draft; - d.paper = 0; - d.remember_paper = false; - } - drop(hold); - wait_for_list(&mut h); - h.run_steps(2); - let d = &h.state().print_draft; - assert_eq!(d.printer.as_deref(), Some("Office"), "the same printer"); - assert_eq!((d.paper, d.remember_paper), (0, false), "the paper and the Remember box are as they were left"); -} diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index 6557c2b1f8..44e56fbf01 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -1907,8 +1907,8 @@ milestone = "M7" status = "partial" commands = ["edit.edit_text"] tools = ["text_prepare", "text_lines", "text_paragraphs", "text_edit", "text_edit_layout", "text_font_files", "text_font_import", "text_fonts"] -evidence = ["crates/edit/src/tests.rs::editing_legacy_thai_keeps_the_embedded_face_weight_size_spacing_and_paint_after_reopen", "crates/edit/src/tests.rs::editing_legacy_thai_shapes_a_run_across_subsets_of_the_same_face", "crates/edit/src/tests.rs::grouped_text_nested_past_the_level_limit_is_reported", "crates/edit/src/tests.rs::grouped_image_artwork_is_not_prepared", "crates/edit/src/tests.rs::grouped_text_in_an_rgb_group_needs_a_page_that_blends_in_rgb", "crates/fonts/src/pdf/thai.rs::recovered_thai_map_checks_every_kept_glyph_against_the_font_program", "crates/automation/tests/it/automation.rs::text_prepare_refuses_pages_without_grouped_text", "crates/fonts/src/pdf/tests.rs::composite_subset_without_cmap_uses_pdf_glyph_ids_in_editor_and_shaper_without_replacing_the_program", "crates/automation/tests/it/automation.rs::grouped_existing_thai_body_edits_saves_reopens_and_undo_restores_original_glyphs", "crates/edit/src/tests.rs::grouped_text_preparation_keeps_unsafe_compositing_paths_and_malformed_forms_intact", "crates/automation/tests/it/automation.rs::explicit_bundled_thai_font_choice_layout_edit_undo_and_reopen", "crates/edit/src/tests.rs::existing_thai_subset_uses_shaped_matching_face_without_embedding_variable_font", "crates/edit/src/tests.rs::paragraphs_are_found_and_rewrapped", "crates/edit/src/tests.rs::text_lines_are_found_and_replaced_in_place", "crates/ui-egui/tests/it/editing.rs::editing_existing_text_in_place", "crates/automation/tests/it/automation.rs::editing_existing_text_through_tools", "crates/edit/src/tests.rs::a_line_whose_operator_starts_the_next_stream_can_be_edited", "crates/automation/tests/it/automation.rs::a_line_split_across_content_streams_is_listed_and_edited", "crates/ui-egui/tests/it/editing.rs::selected_text_formatting_keeps_the_rest_of_the_paragraph_and_reopens", "crates/edit/src/tests.rs::marked_paragraph_boundaries_and_cross_stream_transforms_survive_rewrite", "crates/automation/tests/it/automation.rs::explicit_font_and_disjoint_ranges_save_reopen_through_tools", "crates/edit/src/tests.rs::disjoint_style_ranges_preserve_unselected_runs_and_validate_utf8_atomically", "crates/engine/tests/it/document_fonts.rs::opentype_cff_document_font_preserves_program_codes_and_rendering_after_reopen", "crates/ui-egui/tests/it/editing.rs::cropped_images_do_not_intercept_text_and_stay_inside_each_page", "crates/automation/tests/it/automation.rs::clipped_image_bounds_and_text_edit_save_reopen_through_tools", "crates/edit/src/tests.rs::mixed_styles_and_matrix_sizes_form_visual_regions_without_merging_columns", "crates/edit/src/tests.rs::rich_region_edits_preserve_styles_positions_and_delete_all_runs_after_reopen", "crates/ui-egui/tests/it/editing.rs::one_editor_contains_mixed_styles_and_visual_rows", "crates/ui-egui/tests/it/editing.rs::inline_editor_preserves_source_font_and_previews_live_bounds", "crates/fonts/src/pdf/tests.rs::mac_subset_cmap_uses_pdf_unicode_without_changing_source_program", "crates/automation/tests/it/automation.rs::mixed_visual_region_edit_preview_save_reopen_and_undo_through_tools", "crates/ui-egui/tests/it/ui.rs::the_edit_tab_turns_on_edit_text_and_images", "crates/ui-egui/tests/it/editing.rs::the_caret_on_a_small_row_is_as_tall_as_that_row", "crates/edit/src/tests.rs::uzbek_and_kazakh_cyrillic_use_the_bundled_latin_face", "crates/automation/tests/it/automation.rs::editing_uzbek_and_kazakh_cyrillic_through_tools", "crates/edit/src/tests.rs::styled_paragraph_batches_cyrillic_without_changing_source_fonts", "crates/edit/src/tests.rs::styled_paragraph_over_240_fallback_glyphs_splits_and_refuses_atomically", "crates/edit/src/tests.rs::styled_fallback_batches_keep_face_choices_and_range_styles", "crates/edit/src/tests.rs::styled_fallback_shards_keep_text_advances_and_alignment", "crates/edit/src/tests.rs::styled_fallback_budget_is_enforced_before_objects_are_written", "crates/edit/src/tests.rs::styled_fallback_same_base_font_and_character_keep_both_weights", "crates/automation/tests/it/automation.rs::styled_cyrillic_fallback_is_batched_through_tools_and_reopen", "crates/ui-egui/tests/it/editing.rs::escape_in_an_open_paragraph_selects_its_box_for_delete", "crates/edit/src/tests.rs::ungrouping_form_artwork_makes_its_text_editable", "crates/automation/tests/it/automation.rs::ungrouping_a_table_drawn_in_a_form_makes_its_text_editable", "crates/ui-egui/tests/it/editing.rs::ungroup_in_the_artwork_menu_makes_a_forms_text_editable", "crates/edit/src/tests.rs::ungroup_carries_the_placement_into_the_patterns_it_selects", "crates/automation/tests/it/automation.rs::ungrouping_keeps_patterns_where_the_forms_placement_put_them", "crates/edit/src/tests.rs::ungroup_refuses_a_form_with_its_own_default_colour_space", "crates/edit/src/tests.rs::ungroup_renames_the_name_operand_the_renderer_reads", "crates/ui-egui/tests/it/editing.rs::one_click_on_grouped_text_opens_it_for_editing", "crates/edit/src/tests.rs::grouped_text_at_says_which_artwork_preparation_would_open", "crates/edit/src/tests.rs::legacy_thai_map_repair_needs_no_grouped_text", "crates/edit/src/tests.rs::ungroup_refuses_a_form_drawn_inside_text_or_a_path", "crates/edit/src/tests.rs::ungroup_reads_what_a_group_draws_within_one_allowance", "crates/edit/src/tests.rs::unicode_row_breaks_break_the_row_instead_of_needing_a_glyph", "crates/edit/src/tests.rs::only_the_words_a_subset_cannot_show_are_substituted"] -notes = "In Edit text, one click on text in a supported grouped Form prepares its page (one undoable step) and opens the paragraph clicked, so its existing source glyphs are edited directly; nothing is prepared while pages are only shown, a drag still moves the selected group, and a click on grouped artwork that preparation wouldn't open (a logo, a refused group) selects it as before. text_prepare does the same headlessly, with the same code as Ungroup. Unsafe groups remain intact and are reported by text_prepare and in the repair log when the page is prepared, as is text grouped more than eight levels deep; image-only forms are left grouped. Narrow legacy THSarabunPSK character-map recovery, checked glyph by glyph against the subset's own advances, is recorded, also for text that was never grouped (text_prepare). Legacy Thai source editing reuses original font programs, positional glyphs and same-face subsets (a Thai run split across subsets is shaped cluster by cluster; what no subset can show in its codes is shaped whole by a complete program of the face, such as one from Load font), preserving Regular/Bold, point size, spacing and paint. A different Thai family requires explicit selection; the Noto replacement is Regular only; mixed contextual style runs remain limited. The Edit tab turns Edit text & images on at once, as in Acrobat (leaving the tab puts the tool down and applies an edit in progress); Esc in an open paragraph leaves its text and selects its box, so Delete removes it (the unapplied draft is discarded); click a paragraph box and retype it in place (font reused or Helvetica substituted, colour and spacing kept, rewrapped; replacing one line keeps the source font for every word it can show and sets only the other words in Helvetica (#2378), while a paragraph rewrite still substitutes the whole paragraph for a Latin character its subset lacks); a page's /Contents pieces are parsed joined, so a line whose operands and operator sit in different pieces is listed and edited (lines and paragraphs still don't join across pieces; Edit image still parses each piece alone); drag a box to move it or its right-edge handle to rewrap it (edit.text-move-resize); per-character formatting changes are next Since #1116: mixed-style visual regions retain source rows, subset encodings, colour and baseline anchors in one textless-background editor. Explicit paragraph/heading scopes constrain grouping; compatible graphics planes permit cross-stream regions. Full matching document fonts complete absent subset glyphs; installed/file selection, TrueType/OpenType-CFF embedding and disjoint UTF-8 range styles have save/reopen coverage. Single-face OpenType shaping/bidi retains logical text through ToUnicode/ActualText. Replacement text the paragraph's font can't show takes the craft-fonts Japanese faces when they draw all of it (Uzbek қ, Kazakh ә are in none of them), else the bundled Inter face, so those letters write in every build (#2593). Legacy non-SFNT preview, mixed-font/style contextual runs, full structure-tree preservation and author intent in untagged PDFs remain limited; Styled-path outline fallback batches by actual face with 240 glyphs per font and preserves source styles; adjacent equal-font/style characters form runs. At most 15360 distinct fallback glyphs per styled paragraph; missing glyphs are refused before fallback objects are written. The Unicode row breaks (U+2028, U+2029, U+0085, vertical tab, form feed and the information separators) break the row instead of being looked up as glyphs (#6272). Glyph coverage, mixed contextual shaping, single-style fallback and stale-font cleanup are unchanged; see docs/editing-existing-text.md. Text drawn inside a Form XObject (HTML-to-PDF output, stamped or imposed pages) is grouped artwork until Ungroup (artwork right-click menu; image_edit action ungroup) draws that placement in place with its resources merged; knockout groups and transparency groups that blend inside, are drawn translucent or blend in a colour space other than the page's are refused, as are forms with resources of their own whose /DefaultGray, /DefaultRGB or /DefaultCMYK differs from the page's, either side lacking it included (ISO 32000-2 §8.6.5.6), and forms drawn inside a text object or an unfinished path, each resource name is read from the operand the renderer uses (a surplus operand before it is ignored; an operator whose operands can't be read is refused), and a pattern the form fills with is drawn from a copy whose /Matrix carries the placement (ISO 32000-2 §8.7.2), so ungrouping never changes the pixels." +evidence = ["crates/edit/src/tests.rs::editing_legacy_thai_keeps_the_embedded_face_weight_size_spacing_and_paint_after_reopen", "crates/edit/src/tests.rs::editing_legacy_thai_shapes_a_run_across_subsets_of_the_same_face", "crates/edit/src/tests.rs::grouped_text_nested_past_the_level_limit_is_reported", "crates/edit/src/tests.rs::grouped_image_artwork_is_not_prepared", "crates/edit/src/tests.rs::grouped_text_in_an_rgb_group_needs_a_page_that_blends_in_rgb", "crates/fonts/src/pdf/thai.rs::recovered_thai_map_checks_every_kept_glyph_against_the_font_program", "crates/automation/tests/it/automation.rs::text_prepare_refuses_pages_without_grouped_text", "crates/fonts/src/pdf/tests.rs::composite_subset_without_cmap_uses_pdf_glyph_ids_in_editor_and_shaper_without_replacing_the_program", "crates/automation/tests/it/automation.rs::grouped_existing_thai_body_edits_saves_reopens_and_undo_restores_original_glyphs", "crates/edit/src/tests.rs::grouped_text_preparation_keeps_unsafe_compositing_paths_and_malformed_forms_intact", "crates/automation/tests/it/automation.rs::explicit_bundled_thai_font_choice_layout_edit_undo_and_reopen", "crates/edit/src/tests.rs::existing_thai_subset_uses_shaped_matching_face_without_embedding_variable_font", "crates/edit/src/tests.rs::paragraphs_are_found_and_rewrapped", "crates/edit/src/tests.rs::text_lines_are_found_and_replaced_in_place", "crates/ui-egui/tests/it/editing.rs::editing_existing_text_in_place", "crates/automation/tests/it/automation.rs::editing_existing_text_through_tools", "crates/edit/src/tests.rs::a_line_whose_operator_starts_the_next_stream_can_be_edited", "crates/automation/tests/it/automation.rs::a_line_split_across_content_streams_is_listed_and_edited", "crates/ui-egui/tests/it/editing.rs::selected_text_formatting_keeps_the_rest_of_the_paragraph_and_reopens", "crates/edit/src/tests.rs::marked_paragraph_boundaries_and_cross_stream_transforms_survive_rewrite", "crates/automation/tests/it/automation.rs::explicit_font_and_disjoint_ranges_save_reopen_through_tools", "crates/edit/src/tests.rs::disjoint_style_ranges_preserve_unselected_runs_and_validate_utf8_atomically", "crates/engine/tests/it/document_fonts.rs::opentype_cff_document_font_preserves_program_codes_and_rendering_after_reopen", "crates/ui-egui/tests/it/editing.rs::cropped_images_do_not_intercept_text_and_stay_inside_each_page", "crates/automation/tests/it/automation.rs::clipped_image_bounds_and_text_edit_save_reopen_through_tools", "crates/edit/src/tests.rs::mixed_styles_and_matrix_sizes_form_visual_regions_without_merging_columns", "crates/edit/src/tests.rs::rich_region_edits_preserve_styles_positions_and_delete_all_runs_after_reopen", "crates/ui-egui/tests/it/editing.rs::one_editor_contains_mixed_styles_and_visual_rows", "crates/ui-egui/tests/it/editing.rs::inline_editor_preserves_source_font_and_previews_live_bounds", "crates/fonts/src/pdf/tests.rs::mac_subset_cmap_uses_pdf_unicode_without_changing_source_program", "crates/automation/tests/it/automation.rs::mixed_visual_region_edit_preview_save_reopen_and_undo_through_tools", "crates/ui-egui/tests/it/ui.rs::the_edit_tab_turns_on_edit_text_and_images", "crates/ui-egui/tests/it/editing.rs::the_caret_on_a_small_row_is_as_tall_as_that_row", "crates/edit/src/tests.rs::uzbek_and_kazakh_cyrillic_use_the_bundled_latin_face", "crates/automation/tests/it/automation.rs::editing_uzbek_and_kazakh_cyrillic_through_tools", "crates/edit/src/tests.rs::styled_paragraph_batches_cyrillic_without_changing_source_fonts", "crates/edit/src/tests.rs::styled_paragraph_over_240_fallback_glyphs_splits_and_refuses_atomically", "crates/edit/src/tests.rs::styled_fallback_batches_keep_face_choices_and_range_styles", "crates/edit/src/tests.rs::styled_fallback_shards_keep_text_advances_and_alignment", "crates/edit/src/tests.rs::styled_fallback_budget_is_enforced_before_objects_are_written", "crates/edit/src/tests.rs::styled_fallback_same_base_font_and_character_keep_both_weights", "crates/automation/tests/it/automation.rs::styled_cyrillic_fallback_is_batched_through_tools_and_reopen", "crates/ui-egui/tests/it/editing.rs::escape_in_an_open_paragraph_selects_its_box_for_delete", "crates/edit/src/tests.rs::ungrouping_form_artwork_makes_its_text_editable", "crates/automation/tests/it/automation.rs::ungrouping_a_table_drawn_in_a_form_makes_its_text_editable", "crates/ui-egui/tests/it/editing.rs::ungroup_in_the_artwork_menu_makes_a_forms_text_editable", "crates/edit/src/tests.rs::ungroup_carries_the_placement_into_the_patterns_it_selects", "crates/automation/tests/it/automation.rs::ungrouping_keeps_patterns_where_the_forms_placement_put_them", "crates/edit/src/tests.rs::ungroup_refuses_a_form_with_its_own_default_colour_space", "crates/edit/src/tests.rs::ungroup_renames_the_name_operand_the_renderer_reads", "crates/ui-egui/tests/it/editing.rs::one_click_on_grouped_text_opens_it_for_editing", "crates/edit/src/tests.rs::grouped_text_at_says_which_artwork_preparation_would_open", "crates/edit/src/tests.rs::legacy_thai_map_repair_needs_no_grouped_text", "crates/edit/src/tests.rs::ungroup_refuses_a_form_drawn_inside_text_or_a_path", "crates/edit/src/tests.rs::ungroup_reads_what_a_group_draws_within_one_allowance", "crates/edit/src/tests.rs::unicode_row_breaks_break_the_row_instead_of_needing_a_glyph"] +notes = "In Edit text, one click on text in a supported grouped Form prepares its page (one undoable step) and opens the paragraph clicked, so its existing source glyphs are edited directly; nothing is prepared while pages are only shown, a drag still moves the selected group, and a click on grouped artwork that preparation wouldn't open (a logo, a refused group) selects it as before. text_prepare does the same headlessly, with the same code as Ungroup. Unsafe groups remain intact and are reported by text_prepare and in the repair log when the page is prepared, as is text grouped more than eight levels deep; image-only forms are left grouped. Narrow legacy THSarabunPSK character-map recovery, checked glyph by glyph against the subset's own advances, is recorded, also for text that was never grouped (text_prepare). Legacy Thai source editing reuses original font programs, positional glyphs and same-face subsets (a Thai run split across subsets is shaped cluster by cluster; what no subset can show in its codes is shaped whole by a complete program of the face, such as one from Load font), preserving Regular/Bold, point size, spacing and paint. A different Thai family requires explicit selection; the Noto replacement is Regular only; mixed contextual style runs remain limited. The Edit tab turns Edit text & images on at once, as in Acrobat (leaving the tab puts the tool down and applies an edit in progress); Esc in an open paragraph leaves its text and selects its box, so Delete removes it (the unapplied draft is discarded); click a paragraph box and retype it in place (font reused or Helvetica substituted, colour and spacing kept, rewrapped); a page's /Contents pieces are parsed joined, so a line whose operands and operator sit in different pieces is listed and edited (lines and paragraphs still don't join across pieces; Edit image still parses each piece alone); drag a box to move it or its right-edge handle to rewrap it (edit.text-move-resize); per-character formatting changes are next Since #1116: mixed-style visual regions retain source rows, subset encodings, colour and baseline anchors in one textless-background editor. Explicit paragraph/heading scopes constrain grouping; compatible graphics planes permit cross-stream regions. Full matching document fonts complete absent subset glyphs; installed/file selection, TrueType/OpenType-CFF embedding and disjoint UTF-8 range styles have save/reopen coverage. Single-face OpenType shaping/bidi retains logical text through ToUnicode/ActualText. Replacement text the paragraph's font can't show takes the craft-fonts Japanese faces when they draw all of it (Uzbek қ, Kazakh ә are in none of them), else the bundled Inter face, so those letters write in every build (#2593). Legacy non-SFNT preview, mixed-font/style contextual runs, full structure-tree preservation and author intent in untagged PDFs remain limited; Styled-path outline fallback batches by actual face with 240 glyphs per font and preserves source styles; adjacent equal-font/style characters form runs. At most 15360 distinct fallback glyphs per styled paragraph; missing glyphs are refused before fallback objects are written. The Unicode row breaks (U+2028, U+2029, U+0085, vertical tab, form feed and the information separators) break the row instead of being looked up as glyphs (#6272). Glyph coverage, mixed contextual shaping, single-style fallback and stale-font cleanup are unchanged; see docs/editing-existing-text.md. Text drawn inside a Form XObject (HTML-to-PDF output, stamped or imposed pages) is grouped artwork until Ungroup (artwork right-click menu; image_edit action ungroup) draws that placement in place with its resources merged; knockout groups and transparency groups that blend inside, are drawn translucent or blend in a colour space other than the page's are refused, as are forms with resources of their own whose /DefaultGray, /DefaultRGB or /DefaultCMYK differs from the page's, either side lacking it included (ISO 32000-2 §8.6.5.6), and forms drawn inside a text object or an unfinished path, each resource name is read from the operand the renderer uses (a surplus operand before it is ignored; an operator whose operands can't be read is refused), and a pattern the form fills with is drawn from a copy whose /Matrix carries the placement (ISO 32000-2 §8.7.2), so ungrouping never changes the pixels." [[feature]] id = "edit.text-reflow" @@ -8390,11 +8390,11 @@ title = "Page setup" area = "L" tier = "P1" milestone = "M10" -status = "partial" -commands = ["print.dialog"] -tools = ["doc_print"] -evidence = ["crates/ui-egui/tests/it/print.rs::custom_papers_and_the_paper_remembered_for_a_printer", "crates/ui-egui/src/workflows/print_paper.rs::the_paper_is_remembered_per_printer_only_when_asked", "crates/ui-egui/src/workflows/print_paper.rs::settings_are_read_as_untrusted", "crates/automation/tests/it/automation.rs::doc_print_takes_a_paper_size_of_its_own"] -notes = "In the Print dialog, as the rest of printing is (no separate Page Setup window): Custom… beside Paper adds a named size in the preferences' unit (1 to 200 inches a side), kept with the settings and removable; 'Remember this paper for ' at Print time keeps the paper for that printer (or Save as PDF) and the dialog starts with it. doc_print takes sizes such as 8.5x14in or 210x297mm. Missing: margins, and the dialog's other choices (orientation, two-sided, sizing and so on) remembered per printer." +status = "planned" +commands = [] +tools = [] +evidence = [] +notes = "" [[feature]] id = "print.print-ready-pdf"
NotesNotesNotesNotesNotes