From 7d4d851343226a5c1479101f75d83ca955f4d7bd Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sun, 8 Nov 2026 19:47:24 -0800 Subject: [PATCH 01/83] Updates: take craft-update from craft-libs main at 1b34eba41 --- Cargo.lock | 15 +-------------- Cargo.toml | 2 +- 2 files changed, 2 insertions(+), 15 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index fd3935a659..4a534dbedf 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1289,7 +1289,7 @@ dependencies = [ [[package]] name = "craft-update" version = "0.1.0" -source = "git+https://github.com/storytold/craft-libs?rev=787a7a0f972fd1d7a7618fdbeca3a5267a0407ff#787a7a0f972fd1d7a7618fdbeca3a5267a0407ff" +source = "git+https://github.com/storytold/craft-libs?rev=1b34eba41e62138fc2438d2734523b302840b45d#1b34eba41e62138fc2438d2734523b302840b45d" dependencies = [ "chrono", "dunce", @@ -8763,7 +8763,6 @@ dependencies = [ "nix", "typed-path", "windows-sys 0.61.2", - "zopfli", ] [[package]] @@ -8778,18 +8777,6 @@ version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" -[[package]] -name = "zopfli" -version = "0.8.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "aaf7fc5d30c28483d93805c4a5e12b05bbb52407fa67c5f8bd552374cd01fb11" -dependencies = [ - "bumpalo", - "crc32fast", - "log", - "simd-adler32", -] - [[package]] name = "zune-core" version = "0.5.3" diff --git a/Cargo.toml b/Cargo.toml index d919a90383..b0f3411624 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -28,7 +28,7 @@ craft-print = { git = "https://github.com/storytold/craft-libs", version = "0.1. # Whether a path stored in a document may be opened (GoToR link targets). craft-docpath = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "787a7a0f972fd1d7a7618fdbeca3a5267a0407ff" } # craft-update: app-specific pretend versions apply to startup and manual checks. -craft-update = { git = "https://github.com/storytold/craft-libs", version = "=0.1.0", rev = "787a7a0f972fd1d7a7618fdbeca3a5267a0407ff", features = ["egui"] } +craft-update = { git = "https://github.com/storytold/craft-libs", version = "=0.1.0", rev = "1b34eba41e62138fc2438d2734523b302840b45d", features = ["egui"] } # internal pdfcraft-geom = { path = "crates/geom", version = "0.18.1" } pdfcraft-filters = { path = "crates/filters", version = "0.18.1" } From fac631cd023269fa9f560351634d245bb5a54e0b Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Sun, 8 Nov 2026 23:00:08 -0500 Subject: [PATCH 02/83] Updates: handle RestartToUpdate response --- crates/ui-egui/src/platform/updates.rs | 3 +++ 1 file changed, 3 insertions(+) diff --git a/crates/ui-egui/src/platform/updates.rs b/crates/ui-egui/src/platform/updates.rs index bcf6783cdb..4a5df450a8 100644 --- a/crates/ui-egui/src/platform/updates.rs +++ b/crates/ui-egui/src/platform/updates.rs @@ -113,6 +113,9 @@ pub(crate) fn dialog(app: &mut PdfCraftApp, ctx: &egui::Context) { app.updates.updater.dismiss_status(); } CheckResultResponse::CheckAgain => app.updates.check_requested = true, + CheckResultResponse::RestartToUpdate => { + app.updates.updater.restart_to_update(); + } } } } From 930b44131264cdfc5ac770b8de6d353265117a8c Mon Sep 17 00:00:00 2001 From: ljkx <177671075+ljkx@users.noreply.github.com> Date: Mon, 9 Nov 2026 11:01:36 +0100 Subject: [PATCH 03/83] fix(redact): Remove Hidden Information reads only what the chosen categories need (part of #5873) (#6021) --- crates/automation/src/redact.rs | 14 +- crates/automation/tests/it/automation.rs | 34 +++++ crates/engine/src/document.rs | 6 + crates/redact/src/sanitize.rs | 166 ++++++++++++++++------- crates/redact/src/tests.rs | 1 + crates/redact/src/tests/hidden_scope.rs | 111 +++++++++++++++ 6 files changed, 277 insertions(+), 55 deletions(-) create mode 100644 crates/redact/src/tests/hidden_scope.rs diff --git a/crates/automation/src/redact.rs b/crates/automation/src/redact.rs index 6ec66ce680..a793487d9f 100644 --- a/crates/automation/src/redact.rs +++ b/crates/automation/src/redact.rs @@ -2,7 +2,7 @@ //! (removing what they cover for good) or clear them. use pdfcraft_engine::{ - Edit, Hidden, NewAnnotation, REDACT_MAX_WORDS, REDACT_VALIDATORS, REDACTION_CODE_SETS, RedactPattern, RedactRegex, RedactValidator, + Edit, HIDDEN, Hidden, NewAnnotation, REDACT_MAX_WORDS, REDACT_VALIDATORS, REDACTION_CODE_SETS, RedactPattern, RedactRegex, RedactValidator, RedactionCodeSet, Shape, Style, find_pattern, rect_quad, redact_word_list, }; use serde_json::{Value, json}; @@ -235,6 +235,9 @@ impl Automation { Ok(json!({ "findings": items, "count": items.len() })) } + /// `doc_remove_hidden`: remove the listed categories of hidden information, or all of them + /// (Sanitize Document) when none are listed, and report how many items the listed + /// categories lost as `removed`. pub(crate) fn doc_remove_hidden(&mut self, a: &Args) -> Result { let which = match a.get("categories") { None => None, @@ -253,8 +256,11 @@ impl Automation { // One scan, for this check and for what was removed. The removal refuses when it counts // every listed category as 0 (an AcroForm with no fields counts 1), with an error about // redaction marks; this reads the same scan, so it refuses exactly then. A document that - // can't be edited has no scan: `apply` says why. - let info = self.doc(a)?.hidden_info(); + // can't be edited has no scan: `apply` says why. Only the listed categories are counted, + // here and after: the others would read every page's content and every object for + // nothing (#5873). + let listed = which.clone().unwrap_or_else(|| HIDDEN.to_vec()); + let info = self.doc(a)?.hidden_info_of(&listed); let edit = match which { None => Edit::Sanitize, Some(which) => { @@ -266,7 +272,7 @@ impl Automation { }; let before: usize = info.iter().map(|c| c.1).sum(); let mut out = self.apply(a, edit)?; - let after: usize = self.doc(a)?.hidden_info().iter().map(|c| c.1).sum(); + let after: usize = self.doc(a)?.hidden_info_of(&listed).iter().map(|c| c.1).sum(); out["removed"] = json!(before.saturating_sub(after)); Ok(out) } diff --git a/crates/automation/tests/it/automation.rs b/crates/automation/tests/it/automation.rs index 9a042320ee..e7ca9c12a3 100644 --- a/crates/automation/tests/it/automation.rs +++ b/crates/automation/tests/it/automation.rs @@ -5059,6 +5059,40 @@ fn removing_hidden_information_through_tools() { assert!(matches!(&refused, Err(ToolError::Failed(m)) if m.contains("can't be edited")), "{refused:?}"); } +/// `removed` counts what went from the listed categories. Flattening a field whose appearance +/// draws invisible text puts that text on the page, where hidden text (not listed) counts it; +/// a count over every category took it off what was removed (#5873). +#[test] +fn removed_hidden_information_counts_the_listed_categories() { + let appearance = "BT /Helv 12 Tf 2 4 Td (Ada) Tj 3 Tr (Secret) Tj ET"; + let appearance = format!( + "<< /Type /XObject /Subtype /Form /BBox [0 0 140 18] /Resources << /Font << /Helv 7 0 R >> >> /Length {} >>\nstream\n{appearance}\nendstream", + appearance.len() + ); + let objs = [ + "<< /Type /Catalog /Pages 2 0 R /AcroForm 4 0 R >>", + "<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 400 300] >>", + "<< /Type /Page /Parent 2 0 R /Annots [5 0 R] /Resources << /Font << /Helv 7 0 R >> >> >>", + "<< /Fields [5 0 R] /DA (/Helv 12 Tf 0 g) /DR << /Font << /Helv 7 0 R >> >> >>", + "<< /Type /Annot /Subtype /Widget /FT /Tx /T (name) /V (Ada) /Rect [10 250 150 268] /P 3 0 R /AP << /N 6 0 R >> >>", + &appearance, + "<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding /WinAnsiEncoding >>", + ]; + let dir = workdir("hidden-listed"); + std::fs::write(dir.join("form.pdf"), pdf_of(&objs)).unwrap(); + let mut a = auto(&dir); + let doc = ok(&mut a, "doc_open", json!({ "path": "form.pdf" }))["doc"].as_u64().unwrap(); + let count = |a: &mut Automation, c: &str| { + let info = ok(a, "doc_hidden_info", json!({ "doc": doc })); + info["categories"].as_array().unwrap().iter().find(|x| x["category"] == c).unwrap()["count"].as_u64().unwrap() + }; + assert_eq!((count(&mut a, "form-fields"), count(&mut a, "hidden-text")), (1, 0)); + let r = ok(&mut a, "doc_remove_hidden", json!({ "doc": doc, "categories": ["form-fields"] })); + assert_eq!(r["removed"], 1, "{r}"); + assert_eq!(count(&mut a, "form-fields"), 0); + assert_eq!(count(&mut a, "hidden-text"), 6, "the flattened appearance's invisible word is page content now"); +} + /// A one-page PDF whose text "TOP SECRET" sits under an opaque black rectangle. fn faux_redacted_fixture() -> Vec { helvetica_page("BT /F1 24 Tf 100 700 Td (TOP SECRET) Tj ET\n0 g\n90 688 220 32 re f") diff --git a/crates/engine/src/document.rs b/crates/engine/src/document.rs index 9514d9f443..0527d710d0 100644 --- a/crates/engine/src/document.rs +++ b/crates/engine/src/document.rs @@ -606,6 +606,12 @@ impl Document { self.editor.as_ref().map(|e| pdfcraft_redact::sanitize::scan(&e.cos)).unwrap_or_default() } + /// [`Self::hidden_info`] for the categories in `which` alone, without the reading the others + /// need (see [`pdfcraft_redact::sanitize::scan_only`]). + pub fn hidden_info_of(&self, which: &[Hidden]) -> Vec<(Hidden, usize)> { + self.editor.as_ref().map(|e| pdfcraft_redact::sanitize::scan_only(&e.cos, which)).unwrap_or_default() + } + /// Verification: faux redactions (opaque covers over extractable text) and redaction /// marks that were never applied. Fails closed: any read error is returned, never an /// empty list. diff --git a/crates/redact/src/sanitize.rs b/crates/redact/src/sanitize.rs index 1ef9a1f6de..35eac975f8 100644 --- a/crates/redact/src/sanitize.rs +++ b/crates/redact/src/sanitize.rs @@ -79,6 +79,21 @@ impl Hidden { } } +#[cfg(test)] +thread_local! { + /// Passes over every page's content ([`content_pass`]) made on this thread. + static CONTENT_PASSES: std::cell::Cell = const { std::cell::Cell::new(0) }; + /// Walks of the whole object graph for owned entries ([`owners`]) made by [`scan_only`] on + /// this thread. + static OWNER_WALKS: std::cell::Cell = const { std::cell::Cell::new(0) }; +} + +/// How many content passes and owner walks this thread has made, for tests to count the work. +#[cfg(test)] +pub(crate) fn passes() -> (usize, usize) { + (CONTENT_PASSES.with(std::cell::Cell::get), OWNER_WALKS.with(std::cell::Cell::get)) +} + fn catalog(doc: &Document) -> Option<(ObjRef, Dict)> { let r = doc.root()?; Some((r, doc.get(r).as_dict()?.clone())) @@ -565,6 +580,8 @@ fn content_pass(doc: &mut Document, text: bool, layers: bool, write: bool) -> Re if !text && !layers { return Ok((0, std::collections::HashSet::new())); } + #[cfg(test)] + CONTENT_PASSES.with(|n| n.set(n.get() + 1)); let mut total = 0; let mut retained_off_layers = std::collections::HashSet::new(); // The original image and form XObjects the rewrite drops or replaces, on any page. @@ -672,19 +689,40 @@ fn content_pass(doc: &mut Document, text: bool, layers: bool, write: bool) -> Re /// How many items each category would remove (categories with nothing are included as 0). pub fn scan(doc: &Document) -> Vec<(Hidden, usize)> { + scan_only(doc, &HIDDEN) +} + +/// [`scan`] for the categories in `which` alone, in [`HIDDEN`] order, each once. A category's +/// count costs what it reads: hidden text and hidden layers read every page's content, and +/// metadata, attachments, links and private data walk every object the catalog reaches, so +/// counting bookmarks alone does neither. +pub fn scan_only(doc: &Document, which: &[Hidden]) -> Vec<(Hidden, usize)> { let cat = catalog(doc).map(|c| c.1).unwrap_or_default(); - let pages = pdfcraft_model::pages(doc); - let annots: Vec = pages.iter().flat_map(|p| annots_of(doc, &p.dict)).filter_map(|a| doc.resolve(&a).as_dict().cloned()).collect(); - let count_sub = |s: &[u8]| annots.iter().filter(|a| a.name(b"Subtype") == Some(s)).count(); - let names = sub(doc, &cat, b"Names").unwrap_or_default(); - let owned_keys: Vec<&[u8]> = OWNED.iter().map(|o| o.1).collect(); - // A graph nested too deeply to walk counts as holding everything, so the removal runs and - // refuses it. - let held = owners(doc, &owned_keys).map_or_else(|_| vec![1; OWNED.len()], |o| o.0); - let owned = |h: Hidden| OWNED.iter().zip(&held).filter(|(o, _)| o.0 == h).map(|(_, n)| n).sum::(); + let annots_read = std::cell::OnceCell::>::new(); + let annots = || { + annots_read.get_or_init(|| { + pdfcraft_model::pages(doc).iter().flat_map(|p| annots_of(doc, &p.dict)).filter_map(|a| doc.resolve(&a).as_dict().cloned()).collect() + }) + }; + let count_sub = |s: &[u8]| annots().iter().filter(|a| a.name(b"Subtype") == Some(s)).count(); + let names_read = std::cell::OnceCell::::new(); + let names = || names_read.get_or_init(|| sub(doc, &cat, b"Names").unwrap_or_default()); + let held = std::cell::OnceCell::>::new(); + let owned = |h: Hidden| { + let held = held.get_or_init(|| { + #[cfg(test)] + OWNER_WALKS.with(|n| n.set(n.get() + 1)); + let owned_keys: Vec<&[u8]> = OWNED.iter().map(|o| o.1).collect(); + // A graph nested too deeply to walk counts as holding everything, so the removal + // runs and refuses it. + owners(doc, &owned_keys).map_or_else(|_| vec![1; OWNED.len()], |o| o.0) + }); + OWNED.iter().zip(held).filter(|(o, _)| o.0 == h).map(|(_, n)| n).sum::() + }; let mut doc2 = doc.clone(); HIDDEN .into_iter() + .filter(|h| which.contains(h)) .map(|h| { let n = match h { Hidden::Metadata => { @@ -692,11 +730,11 @@ pub fn scan(doc: &Document) -> Vec<(Hidden, usize)> { info + owned(Hidden::Metadata) } Hidden::Attachments => { - sub(doc, &names, b"EmbeddedFiles").map_or(0, |t| name_tree_len(doc, &t)) + sub(doc, names(), b"EmbeddedFiles").map_or(0, |t| name_tree_len(doc, &t)) + count_sub(b"FileAttachment") + owned(Hidden::Attachments) } - Hidden::Comments => annots.iter().filter(|a| is_comment(a.name(b"Subtype").unwrap_or(b""))).count(), + Hidden::Comments => annots().iter().filter(|a| is_comment(a.name(b"Subtype").unwrap_or(b""))).count(), Hidden::FormFields => { let n = pdfcraft_forms::fields(doc).len(); if n > 0 { @@ -717,8 +755,8 @@ pub fn scan(doc: &Document) -> Vec<(Hidden, usize)> { count_sub(b"Link") + usize::from(cat.get(b"OpenAction").is_some_and(|a| doc.resolve(a).as_dict().is_some())) + owned(Hidden::LinksActionsScripts) - + annots.iter().filter(|a| !a.contains(b"AA") && a.name(b"Subtype") == Some(b"Widget") && a.contains(b"A")).count() - + sub(doc, &names, b"JavaScript").map_or(0, |t| name_tree_len(doc, &t)) + + annots().iter().filter(|a| !a.contains(b"AA") && a.name(b"Subtype") == Some(b"Widget") && a.contains(b"A")).count() + + sub(doc, names(), b"JavaScript").map_or(0, |t| name_tree_len(doc, &t)) + outline_action_count(doc, &cat) } Hidden::PrivateData => owned(Hidden::PrivateData), @@ -764,36 +802,15 @@ fn strip_outline_actions(doc: &mut Document, cat: &mut Dict) -> Result<(), Redac /// sensitive content still present. Call this on a working clone and replace the original only /// on success. On any error, discard the working clone; do not save or reuse it. pub fn remove_hidden(doc: &mut Document, which: &[Hidden]) -> Result, RedactError> { - let counts = scan(doc); - let count = |h: Hidden| counts.iter().find(|c| c.0 == h).map_or(0, |c| c.1); - let (root, _) = catalog(doc).ok_or(RedactError::NothingToApply)?; - let mut done = Vec::new(); - // Every annotation the pages list now: whatever is no longer listed at the end (flattened - // widgets included) leaves the structure tree too. - let listed_before = listed_annotations(doc); - let has_acroform = doc.get(root).as_dict().is_some_and(|d| d.contains(b"AcroForm")); - let fields = which.contains(&Hidden::FormFields) && (count(Hidden::FormFields) > 0 || has_acroform); - if fields { - // Fields without appearances get one first, so their values stay visible. - for f in pdfcraft_forms::fields(doc) { - if f.widgets.iter().any(|w| !doc.get(w.obj).as_dict().is_some_and(|d| d.contains(b"AP"))) { - pdfcraft_forms::redraw_field(doc, &f.name)?; - } - } - let n = pdfcraft_model::pages(doc).len(); - pdfcraft_edit::flatten(doc, &(0..n).collect::>(), false, true)?; - doc.update_dict(root, |d| { - d.remove(b"AcroForm"); - })?; - } - // Content next (it needs the layers still in place). Flattened appearances are page content - // by now, so hidden text or layers inside them are removed with the rest. - let (text, layers) = (which.contains(&Hidden::HiddenText), which.contains(&Hidden::HiddenLayers)); - let retained_off_layers = if (text && (fields || count(Hidden::HiddenText) > 0)) || (layers && (fields || count(Hidden::HiddenLayers) > 0)) { - content_pass(doc, text, layers, true)?.1 - } else { - std::collections::HashSet::new() - }; + // Only what the chosen categories need is counted: a full scan reads every page's content + // twice and walks the whole object graph, which removing bookmarks alone doesn't need. + let counts = scan_only(doc, which); + remove_counted(doc, which, &counts, false) +} + +/// Take off every page the annotations the chosen categories remove (with `fields`, the widgets +/// flattening kept), and the actions of those that stay when links, actions and scripts go. +fn drop_page_annotations(doc: &mut Document, which: &[Hidden], fields: bool) -> Result<(), RedactError> { // Annotations: comments, attachments, links, actions, and the widgets flattening kept (hidden // ones, and ones with nothing to draw), which still hold their field's value and parent. let drop_annot = |a: &Dict| -> bool { @@ -805,13 +822,14 @@ pub fn remove_hidden(doc: &mut Document, which: &[Hidden]) -> Result = pdfcraft_model::pages(doc) + let pages = pdfcraft_model::pages(doc); + let removed: std::collections::HashSet = pages .iter() .flat_map(|p| annots_of(doc, &p.dict)) .filter_map(|a| a.as_ref()) .filter(|&r| doc.get(r).as_dict().is_some_and(drop_annot)) .collect(); - for p in pdfcraft_model::pages(doc) { + for p in pages { let list = annots_of(doc, &p.dict); let mut kept = Vec::new(); for a in list { @@ -842,12 +860,58 @@ pub fn remove_hidden(doc: &mut Document, which: &[Hidden]) -> Result = listed_before.difference(&still).copied().collect(); - crate::tags::drop_object_refs(doc, &gone)?; + Ok(()) +} + +/// [`remove_hidden`] with `counts` from a scan of at least the categories in `which`. With +/// `every_page`, every page's annotation list is rewritten even when no chosen category changes +/// it, as removal did before it skipped that work; tests compare the two. +pub(crate) fn remove_counted( + doc: &mut Document, + which: &[Hidden], + counts: &[(Hidden, usize)], + every_page: bool, +) -> Result, RedactError> { + let count = |h: Hidden| counts.iter().find(|c| c.0 == h).map_or(0, |c| c.1); + let (root, _) = catalog(doc).ok_or(RedactError::NothingToApply)?; + let mut done = Vec::new(); + let has_acroform = doc.get(root).as_dict().is_some_and(|d| d.contains(b"AcroForm")); + let fields = which.contains(&Hidden::FormFields) && (count(Hidden::FormFields) > 0 || has_acroform); + // Pages' annotation lists change only for these; otherwise every page is left as it is. + let annotations = every_page || fields || which.iter().any(|h| matches!(h, Hidden::Comments | Hidden::Attachments | Hidden::LinksActionsScripts)); + // Every annotation the pages list now: whatever is no longer listed at the end (flattened + // widgets included) leaves the structure tree too. + let listed_before = if annotations { listed_annotations(doc) } else { std::collections::HashSet::new() }; + if fields { + // Fields without appearances get one first, so their values stay visible. + for f in pdfcraft_forms::fields(doc) { + if f.widgets.iter().any(|w| !doc.get(w.obj).as_dict().is_some_and(|d| d.contains(b"AP"))) { + pdfcraft_forms::redraw_field(doc, &f.name)?; + } + } + let n = pdfcraft_model::pages(doc).len(); + pdfcraft_edit::flatten(doc, &(0..n).collect::>(), false, true)?; + doc.update_dict(root, |d| { + d.remove(b"AcroForm"); + })?; + } + // Content next (it needs the layers still in place). Flattened appearances are page content + // by now, so hidden text or layers inside them are removed with the rest. + let (text, layers) = (which.contains(&Hidden::HiddenText), which.contains(&Hidden::HiddenLayers)); + let retained_off_layers = if (text && (fields || count(Hidden::HiddenText) > 0)) || (layers && (fields || count(Hidden::HiddenLayers) > 0)) { + content_pass(doc, text, layers, true)?.1 + } else { + std::collections::HashSet::new() + }; + if annotations { + drop_page_annotations(doc, which, fields)?; + // A tagged PDF's structure tree still points at the removed links, comments and flattened + // widgets (#2447): drop those references, or the full rewrite keeps the annotations, URLs, + // text and field values included. + let still = listed_annotations(doc); + let gone: std::collections::HashSet = listed_before.difference(&still).copied().collect(); + crate::tags::drop_object_refs(doc, &gone)?; + } let off = hidden_layers(doc); let mut cat = doc.get(root).as_dict().cloned().unwrap_or_default(); let mut names = sub(doc, &cat, b"Names"); diff --git a/crates/redact/src/tests.rs b/crates/redact/src/tests.rs index 6518817899..d4d16920ef 100644 --- a/crates/redact/src/tests.rs +++ b/crates/redact/src/tests.rs @@ -4113,6 +4113,7 @@ fn unused_descriptions_in_shared_or_inherited_resources_are_retired() { } mod descriptions; +mod hidden_scope; mod inline_images; mod limits; mod property_retirement; diff --git a/crates/redact/src/tests/hidden_scope.rs b/crates/redact/src/tests/hidden_scope.rs new file mode 100644 index 0000000000..a44546b31f --- /dev/null +++ b/crates/redact/src/tests/hidden_scope.rs @@ -0,0 +1,111 @@ +//! Remove Hidden Information does the work of the categories it is asked for, and no more: a +//! textbook of 1,412 pages took over 15 minutes to lose its bookmarks while every page's content +//! was read for hidden text and layers it was not removing (#5873). + +use super::*; +use crate::sanitize::{HIDDEN, Hidden, passes, remove_hidden, scan, scan_only}; + +/// Content passes and owner walks `work` makes on this thread. +fn passes_of(work: impl FnOnce() -> T) -> (T, (usize, usize)) { + let before = passes(); + let out = work(); + let after = passes(); + (out, (after.0 - before.0, after.1 - before.1)) +} + +/// Every category counted alone gives what the full scan gives it; lists come back in scan +/// order, once each. +#[test] +fn each_category_counts_alone_what_the_full_scan_counts() { + let doc = hidden_fixture(); + let full = scan(&doc); + assert_eq!(full.iter().map(|c| c.0).collect::>(), HIDDEN, "the full scan lists every category in order"); + assert!(full.iter().all(|c| c.1 > 0), "the fixture holds something in every category: {full:?}"); + for &(h, n) in &full { + assert_eq!(scan_only(&doc, &[h]), [(h, n)], "{h:?}"); + } + // Listed out of order and twice, the categories come back in scan order, once each. + let mut every: Vec = HIDDEN.into_iter().rev().collect(); + every.push(Hidden::Bookmarks); + assert_eq!(scan_only(&doc, &every), full); + assert!(scan_only(&doc, &[]).is_empty()); +} + +/// What each category costs: content passes for hidden text and layers, one shared object walk +/// for the owned entries, neither for the rest. +#[test] +fn a_category_reads_page_content_or_walks_every_object_only_when_it_needs_to() { + let doc = hidden_fixture(); + assert_eq!(passes_of(|| scan(&doc)).1, (2, 1), "hidden text and hidden layers read the pages; four categories share one walk"); + assert_eq!(passes_of(|| scan_only(&doc, &[Hidden::Bookmarks, Hidden::Comments, Hidden::FormFields])).1, (0, 0)); + assert_eq!(passes_of(|| scan_only(&doc, &[Hidden::HiddenText])).1, (1, 0)); + assert_eq!(passes_of(|| scan_only(&doc, &[Hidden::Metadata, Hidden::PrivateData])).1, (0, 1), "one walk counts both"); +} + +/// Bookmarks alone: no content pass or walk, only the catalog edited, the rest still counted. +#[test] +fn removing_bookmarks_changes_the_catalog_alone() { + let mut doc = hidden_fixture(); + let before = scan(&doc); + // The fixture adds its /Info dictionary as an edit. + let edited = doc.modified_objects(); + let (done, work) = passes_of(|| remove_hidden(&mut doc, &[Hidden::Bookmarks])); + assert_eq!(done.unwrap(), [(Hidden::Bookmarks, 2)]); + assert_eq!(work, (0, 0), "no page content read, no object walk"); + let root = doc.root().unwrap(); + let mut expected = [edited, vec![root.num]].concat(); + expected.sort_unstable(); + assert_eq!(doc.modified_objects(), expected, "the pages, their annotations and everything else stay as they were"); + let cat = doc.get(root).as_dict().cloned().unwrap(); + assert!(!cat.contains(b"Outlines") && !cat.contains(b"PageMode")); + // Every other category still holds what it held. + let after: Vec<(Hidden, usize)> = scan(&doc).into_iter().filter(|c| c.0 != Hidden::Bookmarks).collect(); + assert_eq!(after, before.into_iter().filter(|c| c.0 != Hidden::Bookmarks).collect::>()); +} + +/// Hidden text alone: one pass to count it and one to remove it, no object walk. +#[test] +fn removing_hidden_text_reads_the_pages_for_it_alone() { + let mut doc = hidden_fixture(); + let (done, work) = passes_of(|| remove_hidden(&mut doc, &[Hidden::HiddenText])); + assert_eq!(done.unwrap(), [(Hidden::HiddenText, 13)]); + assert_eq!(work, (2, 0), "one pass to count the hidden text, one to remove it"); + let c = content(&doc, 0); + assert!(c.contains("(Visible)") && !c.contains("Hidden") && c.contains("(Layer)"), "{c}"); +} + +/// Comments alone still rewrite the pages' annotation lists, without reading their content. +#[test] +fn removing_comments_still_takes_them_off_every_page() { + let mut doc = hidden_fixture(); + let (done, work) = passes_of(|| remove_hidden(&mut doc, &[Hidden::Comments])); + assert_eq!(done.unwrap(), [(Hidden::Comments, 1)]); + assert_eq!(work, (0, 0)); + let p = pdfcraft_model::pages(&doc).swap_remove(0); + let left: Vec = annots_of(&doc, &p.dict).iter().filter_map(Object::as_ref).collect(); + // The square and its pop-up go; the widget, the link and the attachment stay. + assert_eq!(left, [ObjRef::new(17, 0), ObjRef::new(18, 0), ObjRef::new(19, 0)]); +} + +/// Each category alone, every pair and all of them remove what the full scan and a rewrite of +/// every page's annotation list removed: the same report and the same bytes in a full save. +#[test] +fn removal_writes_what_it_wrote_with_the_full_scan() { + let mut cases: Vec> = HIDDEN.iter().map(|&h| vec![h]).collect(); + for (i, &a) in HIDDEN.iter().enumerate() { + cases.extend(HIDDEN[i + 1..].iter().map(|&b| vec![a, b])); + } + cases.push(HIDDEN.to_vec()); + assert_eq!(cases.len(), 9 + 36 + 1); + for which in cases { + let mut before = hidden_fixture(); + let counts = scan(&before); + let old = crate::sanitize::remove_counted(&mut before, &which, &counts, true); + let mut after = hidden_fixture(); + let new = remove_hidden(&mut after, &which); + assert_eq!(new, old, "{which:?}"); + // Removal gives the file a new identifier, salted at random unless the caller pins it. + let save = SaveOptions { id_entropy: Some([7; 16]), ..SaveOptions::default() }; + assert!(write_full(&after, &save).unwrap() == write_full(&before, &save).unwrap(), "{which:?}: the saved files differ"); + } +} From 9e29af78492cdd8e796f4250722a197f647cf9f9 Mon Sep 17 00:00:00 2001 From: mtlcl410 <2368091+mtlcl410@users.noreply.github.com> Date: Mon, 9 Nov 2026 18:03:41 +0800 Subject: [PATCH 04/83] fix(render): a stream ending in endstrea is not read into a later object's stream (hayro-syntax patch 36) (#6669) --- crates/cos/src/document/objects.rs | 52 ++++--- crates/cos/src/document/state.rs | 52 +++++++ crates/cos/src/document/tests.rs | 61 ++++++++ crates/cos/src/parser.rs | 180 ++++++++++++++++++++--- crates/render/src/raster/tests.rs | 120 +++++++++++++-- vendor/README.md | 2 +- vendor/hayro-syntax/src/object/stream.rs | 96 +++++++++++- 7 files changed, 502 insertions(+), 61 deletions(-) diff --git a/crates/cos/src/document/objects.rs b/crates/cos/src/document/objects.rs index bef0d4f543..8f9d3a8a5b 100644 --- a/crates/cos/src/document/objects.rs +++ b/crates/cos/src/document/objects.rs @@ -2,7 +2,8 @@ use std::collections::HashSet; use super::reader::find; use super::state::{ - LENGTHS, LoadGuard, ObjStm, ObjectReader, ReadState, Slot, SourceIdentity, loaded_bytes, note_overlay_read, objstm_member, overlay_reads, + LENGTHS, LoadGuard, ObjStm, ObjectReader, ReadState, ScanLengths, Slot, SourceIdentity, loaded_bytes, loading, note_overlay_read, objstm_member, + overlay_reads, }; use super::xref::{Revision, XrefEntry, XrefIndex}; use super::{ @@ -705,23 +706,7 @@ impl Document { match self.entries.lookup(num, &self.data) { None | Some(XrefEntry::Free { .. }) => Ok(Object::Null), Some(XrefEntry::InFile { offset, .. }) => { - let resolve = |r: ObjRef| { - if self.overlay.contains_key(&r.num) { - note_overlay_read(); - } - // A length resolved through this document's edits counts as an edit read - // on every reuse. - if let Some((known, edits)) = LENGTHS.with(|m| m.borrow().get(&r).copied()) { - if edits { - note_overlay_read(); - } - return known; - } - let edits_read = overlay_reads(); - let length = self.try_get_reference(r).ok().and_then(|o| o.as_int()); - LENGTHS.with(|m| m.borrow_mut().insert(r, (length, overlay_reads() != edits_read))); - length - }; + let resolve = |r: ObjRef| self.stream_length(r); let off = offset as usize; let at = |off: usize| match parse_indirect_shared(&self.data, off, &resolve, &self.endstreams) { Ok((id, o)) if id.num == num => Some(self.decrypted(id, o)), @@ -795,6 +780,29 @@ impl Document { Ok(stm) } + /// An indirect stream `/Length`, read through the loader's guard against cycles. Kept in + /// [`LENGTHS`] only during a load, which clears it when it ends: a scan of the file from + /// [`Self::objstm`] can come here without one. + fn stream_length(&self, r: ObjRef) -> Option { + if self.overlay.contains_key(&r.num) { + note_overlay_read(); + } + let cached = loading(); + // A length resolved through this document's edits counts as an edit read on every reuse. + if cached && let Some((known, edits)) = LENGTHS.with(|m| m.borrow().get(&r).copied()) { + if edits { + note_overlay_read(); + } + return known; + } + let edits_read = overlay_reads(); + let length = self.try_get_reference(r).ok().and_then(|o| o.as_int()); + if cached { + LENGTHS.with(|m| m.borrow_mut().insert(r, (length, overlay_reads() != edits_read))); + } + length + } + /// Last-resort lookup: scan the file for `num G obj`. What isn't there is remembered: each miss /// reads the whole file, and a file can refer to such an object from everywhere. fn scan_for(&self, num: u32) -> Option<(ObjRef, Object)> { @@ -805,6 +813,8 @@ impl Document { super::tests::FILE_SCANS.with(|scans| scans.set(scans.get() + 1)); let needle = format!("{num} "); let data = &self.data; + let budget = ScanLengths::enter(); + let resolve = |r: ObjRef| if budget.take() { self.stream_length(r) } else { None }; let mut found = None; let mut i = 0; while let Some(p) = find(data, needle.as_bytes(), i, data.len()) { @@ -812,7 +822,11 @@ impl Document { if p > 0 && !is_whitespace(data[p - 1]) { continue; } - if let Ok((id, o)) = parse_indirect_shared(data, p, &|_| None, &self.endstreams) + // An indirect `/Length` is resolved, as at the object's offset, or a stream that ends + // in a misspelt `endstream` is searched on into a later object's. A header that parses + // as far as the `/Length` starts with `num` itself, after white space. A budget shared + // with the scans this leads to bounds how often the file is read again ([`ScanLengths`]). + if let Ok((id, o)) = parse_indirect_shared(data, p, &resolve, &self.endstreams) && id.num == num { found = Some((id, o)); // keep the last (newest) definition diff --git a/crates/cos/src/document/state.rs b/crates/cos/src/document/state.rs index af71f95359..c5bd85c065 100644 --- a/crates/cos/src/document/state.rs +++ b/crates/cos/src/document/state.rs @@ -11,6 +11,52 @@ thread_local! { pub(super) static LENGTHS: std::cell::RefCell, bool)>> = std::cell::RefCell::new(HashMap::new()); /// Unsaved edits read on this thread (`Document::try_get`, and a `/Length` in `Document::load`). static OVERLAY_READS: std::cell::Cell = const { std::cell::Cell::new(0) }; + /// How deep `Document::scan_for` is nested on this thread, and how many indirect `/Length` + /// values the outermost scan and those inside it have resolved ([`ScanLengths`]). + static SCAN_LENGTHS: std::cell::Cell<(u32, u32)> = const { std::cell::Cell::new((0, 0)) }; +} + +/// The indirect `/Length` values a scan of the file for an object may resolve, shared with the +/// scans it leads to. Each header in the file that starts with the object's number is parsed, and +/// a `/Length` that names an object the cross-reference table cannot place scans the file for that +/// one: a damaged file with many such headers, each naming another missing object, would read the +/// whole file once per header. Past the budget a `/Length` is left unresolved, and the stream is +/// read as before resolution, by its `endstream`. +pub(super) struct ScanLengths; + +impl ScanLengths { + /// A real object is defined once per revision, and its `/Length` once per definition. + pub(super) const MAX: u32 = 32; + + /// Marks a scan as running; the outermost one starts the budget afresh. + pub(super) fn enter() -> Self { + SCAN_LENGTHS.with(|c| { + let (depth, used) = c.get(); + c.set((depth.saturating_add(1), if depth == 0 { 0 } else { used })); + }); + ScanLengths + } + + /// Whether one more `/Length` may be resolved, counting it if so. + pub(super) fn take(&self) -> bool { + SCAN_LENGTHS.with(|c| { + let (depth, used) = c.get(); + if used >= Self::MAX { + return false; + } + c.set((depth, used.saturating_add(1))); + true + }) + } +} + +impl Drop for ScanLengths { + fn drop(&mut self) { + SCAN_LENGTHS.with(|c| { + let (depth, used) = c.get(); + c.set((depth.saturating_sub(1), used)); + }); + } } /// Unsaved edits read so far on this thread: a load during which this changes depended on one. @@ -42,6 +88,12 @@ impl LoadGuard { } } +/// Whether an object is being loaded on this thread: [`LENGTHS`] holds only while one is, since +/// it is cleared when the outermost load ends. +pub(super) fn loading() -> bool { + LOADING.with(|l| !l.borrow().is_empty()) +} + /// What loading a file object gave, kept in the document's cache either way. pub(super) type Loaded = Result, CosError>; diff --git a/crates/cos/src/document/tests.rs b/crates/cos/src/document/tests.rs index a6116762ea..274672bc7c 100644 --- a/crates/cos/src/document/tests.rs +++ b/crates/cos/src/document/tests.rs @@ -30,6 +30,67 @@ fn what_a_scan_of_the_file_did_not_find_is_remembered() { assert_eq!(FILE_SCANS.with(std::cell::Cell::get), 1, "a clone has the same file"); } +/// An object found by a scan of the file, because its cross-reference offset is wrong, has its +/// indirect `/Length` resolved as at the right offset. Unresolved, a stream ending in a misspelt +/// `endstream` (`endstrea`) was searched on into a later object's `endstream`, and took that +/// object's header and data as its own. +#[test] +fn a_scanned_stream_resolves_its_indirect_length() { + let mut out = build( + &[ + "<< /Type /Catalog /Pages 2 0 R >>", + "<< /Type /Pages /Kids [] /Count 0 >>", + "<< /Length 4 0 R >>\nstream\nHELLO\nendstrea", + "5", + "<< /Length 5 >>\nstream\nWORLD\nendstream", + ], + "/Root 1 0 R", + ); + // Point object 3's entry at the catalog, so only the scan finds it. + let at = |needle: &[u8], out: &[u8]| out.windows(needle.len()).position(|w| w == needle).unwrap(); + let catalog = format!("{:010} 00000 n ", at(b"1 0 obj", &out)); + let entry = at(format!("{:010} 00000 n ", at(b"3 0 obj", &out)).as_bytes(), &out); + out.splice(entry..entry + catalog.len(), catalog.bytes()); + let doc = Document::open(Arc::new(out)).unwrap(); + let stream = doc.try_get(3).unwrap(); + let Object::Stream(s) = &*stream else { panic!("stream expected: {stream:?}") }; + assert_eq!(&s.raw[..], b"HELLO"); +} + +/// A scan of the file resolves only so many indirect `/Length` values. Each header that starts +/// with the sought number is parsed, and a `/Length` naming an object the table cannot place scans +/// the file for that one: with a hundred such headers, each naming another misplaced object, one +/// lookup read the whole file a hundred and one times. +#[test] +fn a_scan_of_the_file_resolves_a_bounded_number_of_lengths() { + const HEADERS: u32 = 100; + let mut out = b"%PDF-1.7\n".to_vec(); + let catalog = out.len(); + out.extend_from_slice(b"1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj\n"); + let pages = out.len(); + out.extend_from_slice(b"2 0 obj << /Type /Pages /Kids [] /Count 0 >> endobj\n"); + // Object 3 is defined only by headers inside this string, each with its own misplaced /Length. + out.extend_from_slice(b"4 0 obj (\n"); + for k in 0..HEADERS { + out.extend_from_slice(format!("3 0 obj << /Length {} 0 R >> stream\nX\nendstream endobj\n", 10 + k).as_bytes()); + } + out.extend_from_slice(b") endobj\n"); + let size = 10 + HEADERS; + let xref = out.len(); + out.extend_from_slice(format!("xref\n0 {size}\n0000000000 65535 f \n").as_bytes()); + for num in 1..size { + let at = if num == 2 { pages } else { catalog }; + out.extend_from_slice(format!("{at:010} 00000 n \n").as_bytes()); + } + out.extend_from_slice(format!("trailer << /Size {size} /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n").as_bytes()); + let doc = Document::open(Arc::new(out)).unwrap(); + FILE_SCANS.with(|scans| scans.set(0)); + let three = doc.try_get(3).unwrap(); + assert!(matches!(&*three, Object::Stream(_)), "{three:?}"); + let scans = FILE_SCANS.with(std::cell::Cell::get); + assert!(scans <= 1 + super::state::ScanLengths::MAX as usize, "{scans} scans of the file"); +} + /// An object can be nothing but a reference to another. A chain of them is followed, and one that /// goes round ends in what isn't a dictionary or a name, without hanging. #[test] diff --git a/crates/cos/src/parser.rs b/crates/cos/src/parser.rs index d2cb904871..49793d950c 100644 --- a/crates/cos/src/parser.rs +++ b/crates/cos/src/parser.rs @@ -437,7 +437,7 @@ pub(crate) type EndstreamIndex = std::sync::OnceLock>; fn endstream_offsets(data: &[u8]) -> Vec { #[cfg(test)] ENDSTREAM_SCAN.with(|c| c.set(c.get() + data.len())); - data.windows(9).enumerate().filter(|(_, w)| *w == b"endstream").map(|(i, _)| i).collect() + data.windows(9).enumerate().filter(|(i, w)| *w == b"endstream" && endstream_at(data, *i)).map(|(i, _)| i).collect() } fn parse_indirect_in( @@ -491,26 +491,24 @@ fn parse_indirect_in( Some(Object::Ref(r)) => resolve_length(*r), _ => None, }; - let valid = |len: i64| -> bool { - if len < 0 { - return false; - } - let end = start.saturating_add(len as usize); - if end > data.len() { - return false; - } - let mut after = Lexer::new(data, end); - after.eat_keyword(b"endstream") - }; - // A /Length that fits in the file, for a stream whose `endstream` is missing or misspelt. + // A /Length that fits in the file (not negative, and no wrap on a 32-bit target). let fits = declared.and_then(|l| usize::try_from(l).ok()).filter(|l| start.checked_add(*l).is_some_and(|end| end <= data.len())); - let len = match declared { - Some(l) if valid(l) => l as usize, + // What follows the /Length bytes, white space aside. + let after = |len: usize| { + let mut after = Lexer::new(data, start.saturating_add(len)); + after.skip_ws(); + data.get(after.pos..).unwrap_or_default() + }; + let len = match fits { + // The /Length ends at `endstream`, or where it would be: misspelt (`endstrea`), or + // left out before `endobj` or the end of the file. The stream is the bytes its /Length + // gives, "how many bytes of the PDF file are used for the stream's data" (ISO 32000-2 + // §7.3.8.2), as other readers take it. A producer that wrote `endstrea` after every + // stream lost every font and every page's content, and searching on would run into a + // later object's `endstream`. + Some(l) if at_stream_end(after(l)) => l, // Wrong or missing /Length: search for "endstream" (§7.3.8.1 recovery). With none - // left in the file, a /Length that fits is the stream's extent, "how many bytes of the - // PDF file are used for the stream's data" (ISO 32000-2 §7.3.8.2), as other readers - // take it: a producer that wrote `endstrea` after every stream lost every font and - // every page's content. + // left in the file, a /Length that fits is still the stream's extent. _ => match find_endstream(data, start, endstreams) { Some(end) => end - start, None => fits.ok_or_else(|| CosError::Syntax { offset: start, detail: "stream without endstream".into() })?, @@ -529,6 +527,45 @@ fn parse_indirect_in( Ok((id, obj)) } +/// Whether `rest`, what follows a stream's /Length bytes with white space skipped, is where the +/// stream ends. A whole `endstream` token is: a token runs on through regular characters (ISO +/// 32000-2 §7.2.3), so payload such as `endstreamer` is not one. A misspelt `endstream` +/// (`endstrea`), an `endobj` with the `endstream` left out, or both, end it only where the object +/// ends as well, before the end of the data, another object's `N G obj` header, or `xref`, +/// `trailer` or `startxref`: payload such as `endobj more` or `endobject`, where a wrong /Length +/// stops, is not, and the search for `endstream` runs instead. So does anything else but the end +/// of the data. +fn at_stream_end(rest: &[u8]) -> bool { + let mut lx = Lexer::new(rest, 0); + if lx.eat_keyword(b"endstream") { + return true; + } + let misspelt = lx.eat_keyword(b"endstrea"); + let endobj = lx.eat_keyword(b"endobj"); + lx.skip_ws(); + if lx.pos >= rest.len() { + return true; + } + if !(misspelt || endobj) { + return false; + } + if [&b"xref"[..], b"trailer", b"startxref"].iter().any(|kw| Lexer::new(rest, lx.pos).eat_keyword(kw)) { + return true; + } + let number = |lx: &mut Lexer| { + lx.skip_ws(); + let t = lx.token(); + !t.is_empty() && t.iter().all(u8::is_ascii_digit) + }; + number(&mut lx) && number(&mut lx) && lx.eat_keyword(b"obj") +} + +/// Whether a whole `endstream` token starts at `at` in `data`: one followed by a regular +/// character (`endstreamer`) is payload (ISO 32000-2 §7.2.3). +fn endstream_at(data: &[u8], at: usize) -> bool { + data.get(at..).is_some_and(|d| d.starts_with(b"endstream")) && !data.get(at.saturating_add(9)).copied().is_some_and(is_regular) +} + /// Offset where stream data ends (before the EOL preceding "endstream"). fn find_endstream(data: &[u8], from: usize, endstreams: Option<&EndstreamIndex>) -> Option { let at = match endstreams { @@ -538,7 +575,7 @@ fn find_endstream(data: &[u8], from: usize, endstreams: Option<&EndstreamIndex>) } None => { let hay = data.get(from..)?; - let found = hay.windows(9).position(|w| w == b"endstream"); + let found = hay.windows(9).enumerate().position(|(i, w)| w == b"endstream" && endstream_at(hay, i)); #[cfg(test)] ENDSTREAM_SCAN.with(|c| c.set(c.get() + found.map_or(hay.len(), |p| p + 9))); found? + from @@ -683,6 +720,109 @@ mod tests { assert!(parse_indirect(b"7 0 obj\n<< >>\nstream\nHELLO\nendstrea\nendobj\n", 0, &|_| None).is_err(), "no /Length"); } + /// A stream ending in `endstrea` is its /Length even when a later object does have an + /// `endstream`: the search for one would otherwise run on into that object and take its + /// header and data as this stream's. + #[test] + fn a_stream_without_endstream_is_not_read_into_a_later_one() { + let data = b"7 0 obj\n<< /Length 5 >>\nstream\r\nHELLO\r\nendstrea\rendobj\r\n8 0 obj\n<< /Length 5 >>\nstream\nWORLD\nendstream\nendobj\n"; + let second = data.windows(7).position(|w| w == b"8 0 obj").unwrap(); + for (offset, want) in [(0, &b"HELLO"[..]), (second, b"WORLD")] { + let (_, o) = parse_indirect(data, offset, &|_| None).unwrap(); + let Object::Stream(s) = o else { panic!("stream expected at {offset}") }; + assert_eq!(&s.raw[..], want, "at {offset}"); + } + // Through the loader's shared index of `endstream` offsets as well. + let index = EndstreamIndex::new(); + let (_, o) = parse_indirect_in(data, None, 0, &|_| None, Some(&index)).unwrap(); + let Object::Stream(s) = o else { panic!("stream expected") }; + assert_eq!(&s.raw[..], b"HELLO"); + } + + /// The same for a stream whose `endstream` is left out before `endobj`: the later object's + /// `endstream` is not this stream's end. + #[test] + fn a_stream_ending_at_endobj_is_not_read_into_a_later_one() { + let data = b"7 0 obj\n<< /Length 5 >>\nstream\nHELLO\nendobj\n8 0 obj\n<< /Length 5 >>\nstream\nWORLD\nendstream\nendobj\n"; + let (_, o) = parse_indirect(data, 0, &|_| None).unwrap(); + let Object::Stream(s) = o else { panic!("stream expected") }; + assert_eq!(&s.raw[..], b"HELLO"); + } + + /// A wrong /Length that stops before payload bytes beginning like an end marker (`endobject`, + /// `endstreak`, `endstreamer`) is not taken for the stream's end: a token runs on through + /// regular characters (ISO 32000-2 §7.2.3), so the search for `endstream` finds the whole + /// payload, and skips `endstreamer` too, by itself and through the loader's shared index. + #[test] + fn a_length_ending_inside_a_longer_word_is_not_an_end_marker() { + for word in ["endobject", "endstreak", "endstreamer"] { + let pdf = format!("7 0 obj\n<< /Length 5 >>\nstream\nHELLO {word} more\nendstream\nendobj\n"); + let (_, o) = parse_indirect(pdf.as_bytes(), 0, &|_| None).unwrap(); + let Object::Stream(s) = o else { panic!("stream expected") }; + assert_eq!(s.raw[..], *format!("HELLO {word} more").as_bytes(), "{word}"); + let index = EndstreamIndex::new(); + let (_, o) = parse_indirect_in(pdf.as_bytes(), None, 0, &|_| None, Some(&index)).unwrap(); + let Object::Stream(s) = o else { panic!("stream expected") }; + assert_eq!(s.raw[..], *format!("HELLO {word} more").as_bytes(), "{word}, indexed"); + } + // Whole markers still end it, followed by white space, a delimiter or the end of the data. + for tail in ["endobj\n", "endstrea%", "endobj"] { + let pdf = format!("7 0 obj\n<< /Length 5 >>\nstream\nHELLO\n{tail}"); + let (_, o) = parse_indirect(pdf.as_bytes(), 0, &|_| None).unwrap(); + let Object::Stream(s) = o else { panic!("stream expected") }; + assert_eq!(&s.raw[..], b"HELLO", "{tail:?}"); + } + } + + /// A whole `endobj` or `endstrea` token where a wrong /Length stops is payload, not the + /// stream's end, unless the object ends there too: what follows is the end of the data, + /// another object's header, or `xref`, `trailer` or `startxref`. Otherwise the search for + /// `endstream` finds the whole payload. + #[test] + fn an_end_marker_inside_the_payload_does_not_end_the_stream() { + for payload in ["endobj more", "endstrea more", "endstrea endobj more", "endobj 12 more"] { + let pdf = format!("7 0 obj\n<< /Length 5 >>\nstream\nHELLO {payload}\nendstream\nendobj\n"); + let (_, o) = parse_indirect(pdf.as_bytes(), 0, &|_| None).unwrap(); + let Object::Stream(s) = o else { panic!("stream expected") }; + assert_eq!(s.raw[..], *format!("HELLO {payload}").as_bytes(), "{payload}"); + } + // Where the object does end, the /Length is the stream, also with an `endstream` later. + for next in ["8 0 obj\n<< >>", "%c\n12 0 obj", "xref", "trailer", "startxref"] { + for marker in ["endobj", "endstrea\nendobj", "endstrea"] { + let pdf = format!("7 0 obj\n<< /Length 5 >>\nstream\nHELLO\n{marker}\n{next}\nstream\nWORLD\nendstream\nendobj\n"); + let (_, o) = parse_indirect(pdf.as_bytes(), 0, &|_| None).unwrap(); + let Object::Stream(s) = o else { panic!("stream expected") }; + assert_eq!(&s.raw[..], b"HELLO", "{marker:?} then {next:?}"); + } + } + } + /// Without an `endstream`, the /Length is trusted as written when it fits, also when it is + /// wrong, and an indirect one is resolved first; one that doesn't fit, a negative one and one + /// too large for any file are errors rather than wrapped into a short read. + #[test] + fn a_stream_without_endstream_takes_its_length_only_when_it_fits() { + let stream = |length: &str, tail: &str| format!("7 0 obj\n<< /Length {length} >>\nstream\nHELLO{tail}"); + let raw = |pdf: &str, lookup: &dyn Fn(ObjRef) -> Option| match parse_indirect(pdf.as_bytes(), 0, lookup) { + Ok((_, Object::Stream(s))) => Ok(s.raw.to_vec()), + Ok((_, other)) => panic!("{pdf:?} is {other:?}"), + Err(e) => Err(e), + }; + let none = |_: ObjRef| None; + // Wrong but fitting: the bytes it gives, not the bytes up to `endstrea`. + assert_eq!(raw(&stream("3", "\nendstrea\nendobj\n"), &none).unwrap(), b"HEL"); + // Exactly to the end of the file, and nothing at all. + assert_eq!(raw(&stream("5", ""), &none).unwrap(), b"HELLO"); + assert_eq!(raw(&stream("0", "\nendstrea\nendobj\n"), &none).unwrap(), b""); + // Indirect, resolved through the lookup. + let lookup = |r: ObjRef| (r == ObjRef::new(9, 0)).then_some(4); + assert_eq!(raw(&stream("9 0 R", "\nendstrea\nendobj\n"), &lookup).unwrap(), b"HELL"); + assert!(raw(&stream("9 0 R", "\nendstrea\nendobj\n"), &none).is_err(), "an unresolved /Length"); + // One past the end, negative, the largest integer and one beyond it. + for length in ["6", "-1", "-9223372036854775808", "9223372036854775807", "18446744073709551621"] { + assert!(raw(&stream(length, ""), &none).is_err(), "/Length {length}"); + } + } + #[test] fn indirect_stream_with_a_space_before_the_line_break() { // `stream \r\n` as Microsoft Reporting Services writes it: the data starts after the diff --git a/crates/render/src/raster/tests.rs b/crates/render/src/raster/tests.rs index 679e42cd5e..fbe6dcede8 100644 --- a/crates/render/src/raster/tests.rs +++ b/crates/render/src/raster/tests.rs @@ -10872,19 +10872,27 @@ trailer << /Root 1 0 R >> /// A stream with no `endstream` after it, as a producer that writes `endstrea` after every stream /// leaves it, is the bytes its `/Length` gives: "how many bytes of the PDF file are used for the /// stream's data" (ISO 32000-2 7.3.8.2). Nothing found its end, so the page's content was lost and -/// the page drew blank, while other readers draw it. Vendored hayro-syntax patch 36. +/// the page drew blank, while other readers draw it. The objects after it are still read: a second +/// content stream paints the right half. A `/Length` past the end of the file, or none, leaves the +/// stream unreadable and its half blank, without a panic. Vendored hayro-syntax patch 36. #[test] fn a_content_stream_without_endstream_is_drawn_to_its_length() { use super::*; - let content = "1 0 0 rg 0 0 200 200 re f"; - let centre = |end: &str| -> [u8; 4] { + const RED: [u8; 4] = [255, 0, 0, 255]; + const BLUE: [u8; 4] = [0, 0, 255, 255]; + const WHITE: [u8; 4] = [255, 255, 255, 255]; + let left = "1 0 0 rg 0 0 100 200 re f"; + let right = "0 0 1 rg 100 0 100 200 re f"; + // The left and right pixels of a page whose content is objects 4 and 5, with `stream_4` the + // whole of object 4 and `rest` written after the trailer. + let pixels = |stream_4: &str, rest: &str| -> [[u8; 4]; 2] { let page = format!( "%PDF-1.4\n1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj\n\ 2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 >> endobj\n\ - 3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 200 200] /Contents 4 0 R >> endobj\n\ - 4 0 obj << /Length {} >> stream\n{content}\n{end}\nendobj\n\ - trailer << /Root 1 0 R >>\n%%EOF", - content.len() + 3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 200 200] /Contents [4 0 R 5 0 R] >> endobj\n\ + 5 0 obj << /Length {} >> stream\n{right}\nendstream\nendobj\n\ + {stream_4}trailer << /Root 1 0 R >>\n{rest}", + right.len() ); let page = PageRenderer::new(Arc::new(page.into_bytes()), RenderConfig::default()).render(RenderRequest { page: 0, @@ -10894,15 +10902,101 @@ fn a_content_stream_without_endstream_is_drawn_to_its_length() { tag: 0, preview: false, }); - let start = (100 * page.width as usize + 100) * 4; - let mut pixel = [0; 4]; - pixel.copy_from_slice(page.rgba.get(start..start + 4).expect("centre pixel")); - pixel + [50, 150].map(|x| { + let start = (100 * page.width as usize + x) * 4; + let mut pixel = [0; 4]; + pixel.copy_from_slice(page.rgba.get(start..start + 4).expect("pixel")); + pixel + }) }; - assert_eq!(centre("endstream"), [255, 0, 0, 255]); + let object_4 = |length: &str, end: &str| format!("4 0 obj << {length} >> stream\n{left}\n{end}\nendobj\n"); + let length = format!("/Length {}", left.len()); + assert_eq!(pixels(&object_4(&length, "endstream"), "%%EOF"), [RED, BLUE]); for end in ["endstrea", "", "end"] { - assert_eq!(centre(end), [255, 0, 0, 255], "{end:?}"); + assert_eq!(pixels(&object_4(&length, end), "%%EOF"), [RED, BLUE], "{end:?}"); } + // The last object in the file, which ends with its data. + let last = format!("4 0 obj << {length} >> stream\n{left}"); + assert_eq!(pixels("", &last), [RED, BLUE], "at the end of the file"); + // A /Length past the end of the file, and none: object 4 can't be read and its half stays + // blank (as does the rest of the page: a `/Contents` array with an unreadable member isn't + // drawn, as before this change). + let past = format!("4 0 obj << /Length {} >> stream\n{left}", left.len() + 1); + assert_eq!(pixels("", &past)[0], WHITE, "past the end of the file"); + assert_eq!(pixels(&object_4("", "endstrea"), "%%EOF")[0], WHITE, "no /Length"); +} + +/// The raw bytes hayro-syntax reads for object 4 of a file whose objects are `objects`. +fn hayro_stream_4(objects: &str) -> Vec { + use hayro_syntax::object::{ObjectIdentifier, Stream}; + let pdf = format!( + "%PDF-1.4\n1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj\n\ + 2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 >> endobj\n\ + 3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 20 20] >> endobj\n\ + {objects}trailer << /Root 1 0 R >>\n%%EOF" + ); + let parsed = hayro_syntax::Pdf::new(pdf.into_bytes()).unwrap(); + let stream = parsed.xref().get::>(ObjectIdentifier::new(4, 0)).expect("object 4 is a stream"); + stream.raw_data().to_vec() +} + +/// A stream whose `endstream` is misspelt (`endstrea`) or left out before `endobj` is its +/// `/Length` in hayro-syntax too, not the bytes up to a later object's `endstream` (vendored +/// hayro-syntax patch 36). +#[test] +fn hayro_reads_a_stream_without_endstream_to_its_length_before_a_later_one() { + for end in ["endstrea\nendobj", "endobj"] { + let objects = format!("4 0 obj << /Length 5 >> stream\nHELLO\n{end}\n5 0 obj << /Length 5 >> stream\nWORLD\nendstream\nendobj\n"); + assert_eq!(hayro_stream_4(&objects), b"HELLO", "{end:?}"); + } +} + +/// A wrong `/Length` that stops before payload bytes beginning like an end marker (`endobject`, +/// `endstreak`, `endstreamer`) does not end the stream there: a token runs on through regular +/// characters (ISO 32000-2 7.2.3), and the search for `endstream` finds the whole payload, past +/// `endstreamer` too. Neither does a whole `endobj` or `endstrea` that the object does not end +/// after (`endobj more`); one followed by another object's header, by `trailer` or by the end of +/// the data still does. +#[test] +fn hayro_does_not_end_a_stream_inside_a_longer_word() { + for word in ["endobject", "endstreak", "endstreamer", "endobj more", "endstrea more", "endstrea endobj more"] { + let objects = format!("4 0 obj << /Length 5 >> stream\nHELLO {word} more\nendstream\nendobj\n"); + assert_eq!(hayro_stream_4(&objects), format!("HELLO {word} more").as_bytes(), "{word}"); + } + for end in ["endobj\n", "endstrea\n", "endstrea\nendobj\n%c\n"] { + assert_eq!(hayro_stream_4(&format!("4 0 obj << /Length 5 >> stream\nHELLO\n{end}")), b"HELLO", "{end:?} then trailer"); + } +} + +/// A stream ending in `endstrea` is its `/Length` even when a later object has an `endstream`: +/// the search for one ran on into that object. Here the content is ASCIIHex-encoded without its +/// `>` end marker, so reading on took the next object's header as hex data, which it isn't, and +/// the page drew blank instead of red. +#[test] +fn a_content_stream_without_endstream_is_not_read_into_a_later_stream() { + use super::*; + let content: String = "1 0 0 rg 0 0 200 200 re f".bytes().map(|b| format!("{b:02X}")).collect(); + let unused = "0 1 0 rg 0 0 200 200 re f"; + let page = format!( + "%PDF-1.4\n1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj\n\ + 2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 >> endobj\n\ + 3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 200 200] /Contents 4 0 R >> endobj\n\ + 4 0 obj << /Length {} /Filter /ASCIIHexDecode >> stream\n{content}\nendstrea\nendobj\n\ + 5 0 obj << /Length {} >> stream\n{unused}\nendstream\nendobj\n\ + trailer << /Root 1 0 R >>\n%%EOF", + content.len(), + unused.len() + ); + let page = PageRenderer::new(Arc::new(page.into_bytes()), RenderConfig::default()).render(RenderRequest { + page: 0, + kind: RequestKind::Pixels, + tile: None, + scale: 1.0, + tag: 0, + preview: false, + }); + let start = (100 * page.width as usize + 100) * 4; + assert_eq!(page.rgba.get(start..start + 4), Some(&[255, 0, 0, 255][..])); } /// A ToUnicode CMap with a token the scanner can't read before its mappings (a stray `>` after diff --git a/vendor/README.md b/vendor/README.md index cd29f5e128..0c31d0802d 100644 --- a/vendor/README.md +++ b/vendor/README.md @@ -43,7 +43,7 @@ Other resource categories retain their existing parent-chain lookup. | 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` | -| hayro-syntax | 0.7.2 | Apache-2.0 OR MIT | (1) Page-tree cycle guard in `resolve_pages` (visited set + depth cap): a `/Kids` loop among compressed objects overflowed the stack and aborted the process; (2) `ccitt_size_ok`: CCITT images wider than 2^20 columns or over 2^28 pixels are not decoded (a fuzzed `/Columns 4294967295` allocated 4 GiB); (3) Flate/LZW predictor row widths are saturating `usize`, a zero width is refused and a row longer than the data is not allocated (a fuzzed `/Columns 9223372036854775807` wrapped to a 2^61-byte allocation and aborted); (4) the inline-image end-of-data check (`EI`) doesn't nest inside its own look-ahead and examines at most 256 candidates and steps (50 images with "EI" in their data ran for over ten minutes); an EI followed by a delimiter (EI%..., EI[...) ends the image like one followed by white space; only a regular character after EI skips the candidate; (5) a stream whose `/Length` is wrong is recovered only when `stream` is the next token — scanning ahead to a later object's `stream` made an indirect appearance-state dictionary (Quartz checkboxes: `/AP /N` → `<< /Yes … /Off … >>`) render as a non-form, so the widget was skipped; (6) JPX rescaling uses an exact integer sample range for 1–32-bit components (32 shifted past u32, and floating-point maxima at 25–31 bits wrapped to zero), and refuses zero-sized rows; (7) `decode_limit`: Flate (through flate2 and the fallback inflater), LZW, RunLength and ASCII85 never write past `MAX_DECODED_STREAM` (256 MiB), or, for the last filter of an image of known size, past twice its pixel bytes (a predictor tag byte per byte), at most 1 GiB; `Stream::decoded_within` for budgeted callers; a page's `/Contents` array shares one `MAX_DECODED_STREAM` (a 2 MB page whose content stream or 8 × 8 image inflated to 2 GB took the renderer past 3 GB in under a second; naming the stream twenty times in `/Contents`, 5 GB); (8) high-precision numbers (jsPDF MediaBox coordinates with over 19 digits) parse without overflowing the u64 mantissa: fractional digits past it are dropped, integer digits past it scale the value (a real, kept finite); (9) large dictionary indexes are built on first use and shared across repeated reads within each PDF (#307); the validated-byte cache is bounded, reader contexts remain separate, repairs and inline/content dictionaries bypass sharing, and small dictionaries stay eager; (10) decoded object-stream slots publish once under contention, cold loading rejects recursive dependencies, and xref repair serializes without holding recursive read guards; this permits one parser per native render pool with private worker caches; (11) `sync.rs`: a poisoned lock is recovered instead of unwrapped (a worker panic no longer cascades into every reader of the same PDF), and xref repair takes a blocking write lock instead of asserting a non-blocking one; (12) `SegmentList` (`util.rs`) returns `None` for an out-of-range index instead of panicking; (13) a page's `/UserUnit` scales `render_dimensions` and `initial_transform`, so the page and everything on it are sized in points, as Acrobat shows them; it is read as Acrobat reads it (the page's own entry, not inherited; a number from 1 up, at most 75,000; anything else counts as 1), and a page without it keeps exactly the transform it had; (14) revision 2–4 passwords are tried as given and then in PDFDocEncoding (`legacy_passwords` in `crypto/mod.rs`, which resolves the upstream `TODO: Convert to PDFDocEncoding`): passwords arrive as UTF-8 text, so a password with characters outside ASCII (`Çok güzel`, `ılık€`) written in PDFDocEncoding, as ISO 32000-2 §7.6.4.3.2 asks and qpdf and Acrobat write it, was always wrong; (15) page content is read a piece at a time (`page::ContentReader`, `Page::content_reader`): plain streams and Flate streams without a predictor are decoded as they are read, with a budget of 64 times their compressed size and never less than `MAX_DECODED_STREAM` (a stream that expands past it is cut and reported); other streams are decoded whole, as before, and share `MAX_DECODED_STREAM`. The content tokenizer reads a window at a time (`UntypedIter::new_window`, `TypedIter::needs_more`, `TypedIter::committed`): an instruction that runs past the window is not decided until more data follows, which `Reader` records by flagging reads and `at_end` past the end of the data, including through the clones the parser makes. The Flate decoder keeps its own 32 KiB history, so it pauses at any output size. `ContentReader::new` reuses raw/decrypted stream bytes for budget accounting and piece creation, and `decoded_raw_within` applies the existing Whole-stream filters and limits to those bytes without decrypting twice; (16) revision 5–6 (AES-256) passwords are tried in their SASLprep form (RFC 4013, then UTF-8, at most 127 bytes, as ISO 32000-2 §7.6.4.3.3 Algorithm 2.A asks and pdfcraft-crypt writes them) and then, if that differs, as given (`aes256_passwords` in `crypto/mod.rs`, which resolves the upstream `TODO: Do the above.`; `stringprep`, already in the tree through pdfcraft-crypt, is an optional dependency enabled by `std`, and without `std` passwords are used as given): the renderer used the password as typed, so one that SASLprep changes (a decomposed `café`, a no-break space, the fullwidth `ab` a Japanese IME types in full-width mode) was wrong, although pdfcraft-cos accepted it: protecting a document with such an open password was rolled back ("the password is incorrect"), and a file with such a password did not open with it.; (17) CCITT `/Rows 0` means the height is not predetermined (ISO 32000-2 Table 11), not zero rows: scanned Group 4 pages written that way rendered blank; (18) The streaming Flate decoder (`lzw_flate::flate::fallback`) copies a back-reference in runs of up to its distance (`extend_from_within`), not byte by byte. The copy reads only bytes already in the output, and a copy whose source does not exist still writes nothing, so the bytes are those a byte-by-byte copy writes. Inflating the A1 sample's content stream (278.6 MB) takes about 14% less time (0.66 s to 0.56 s); the output is identical; (19) CCITT `/Rows` below the image's `/Height` decodes to the height: a plotter driver wrote `/Rows` equal to `/Columns`, so only the first rows were decoded and the rest drawn black (#3308) (20) `stream` followed by spaces or tabs and then the line break (`stream \r\n`, as Microsoft Reporting Services writes it) starts the data after the line break; the content stream was taken to start at the space and the page rendered blank.; (21) a JP2 colour specification naming an enumerated colour space the decoder refuses (CIEJab in a veraPDF fixture, YCbCr, PhotoYCC, YPbPr, bilevel…) no longer fails the image: it is decoded again from a copy whose `colr` names the space the decoder assumes without one, greyscale, sRGB or CMYK by component count, as MuPDF and Poppler draw it (`without_refused_enumerated_colour`; the page drew blank, #3306); (22) an object stream's index of `objnum offset` pairs is parsed once per stream and kept beside its decoded data (`ObjectStreamIndexes`, `XRef::with_object_stream_indexes`), and an offset past `usize` is malformed instead of wrapping: `XRef::get_with` parsed the whole index again for every object it fetched from the stream, so reading all of a stream of N objects took N² steps (a Firefox page whose 65,542 objects share one stream took 37 s to draw and 40 s to extract its text); (23) the inline-image end-of-data look-ahead no longer rejects a candidate `EI` because a byte above 0x7F lies between it and the next one: when the candidate is the end, those bytes are the content stream that follows the image, and its strings carry such bytes (WinAnsi `(Grüße) Tj`, the two-byte codes of any Type 0 font), so the operator check that follows decides alone, bounded as in (4) (a scanned page whose OCR text held an accented word and a later capital "EI" — EINGANG, BEI, KLEIN — drew and extracted nothing after the image: 40 of 275 pages across five real documents); (24) `xref::prev_offset`: a trailer `/Prev` written as a reference `N G R` is read as the offset that object holds when the section's own entries place it (iText's `indirectPrev.pdf`; each section's entries are read before its `Prev` is followed), and otherwise as offset N (tiff2pdf; JHOVE's PDF-HUL-157 files, #2366), as `pdfcraft-cos` and other readers take it, so the earlier sections' pages are seen instead of one; (25) direct arrays and dictionaries share a reader-local nesting cap of 128, counted while skipping and while parsing a dictionary (its lenient repair reads, rather than skips, an object found where a key belongs, so `<< << …` recursed through that read); deeper objects return `None` instead of overflowing the stack (including unused trailer values, trailer entries, content operands and marked-content properties); a chain of 65 direct stitching functions now stops at this cap, before the 64-function guard; (26) an xref stream subsection must be backed by its decoded data: all-zero `/W` widths (an entry reads no bytes) and a declared count longer than the data are refused before any entry is inserted, where a tiny stream with a large `/Size` or `/Index` count inserted that many entries (from storytold/craft-libs#239); a subsection running past object number `i32::MAX` is refused instead of overflowing or turning negative; (27) an xref stream's type field is read big-endian at its full `/W[0]` width, where only its first byte was read and wider fields shifted every later entry, and `/W[0]` over 8 bytes is refused like `/W[1]` (from storytold/craft-libs#241); (28) a content instruction keeps up to 256 operands (`MAX_OPERANDS`) instead of 10, and past that drops the oldest instead of ending the content stream: the eleventh operand of a twelve-ink DeviceN `scn` (ISO 32000-2 §8.6.6.5 allows any number of components) blanked the rest of the page (#4260); (29) a `/JBIG2Globals` stream that is itself JBIG2 isn't decoded: globals are raw segments, and a chain of 16,000 such streams overflowed the stack, which aborts the process; (30) a stream's own crypt filter: `/Crypt` in its chain read as the filter it names, `/Identity` without one, instead of the document's filter and then a filter that can't be decoded, which left such a stream unreadable in any spelling (ISO 32000-2 §7.4.10); (31) `set_page_jbig2_work`: the JBIG2 images decoded on a thread spend one allowance, set per page, 2^30 steps (`MAX_PAGE_JBIG2_WORK`), so a page of many images made to reach their own limit stops at one, and what they cost to read counts, whether they decode or not; unbounded until a page sets it; (32) a group of array items read together (a tuple: an image's `/Decode`, `/W`, `/W2`, and a function's `/Domain`, `/Range`, `/Encode` and `/Decode`) can start with a reference: it was taken for the whole group and lost, which ended the array, so an image with `/Decode [7 0 R 0]` didn't draw and CID widths fell back to `/DW`; (33) an object that holds only a reference stands for what that names, stored on its own or in an object stream, up to 32 references: read as a number it was the reference's own object number, so a `/Decode` or `/W` entry naming such an object took a wrong value, and as anything else it was lost. Past 32, as any cycle goes, it reads as a missing object. An object stream isn't reached that way, as its checks are of the object named; reading its index counts against the 64 nested loads and reads its dictionary alone, as a chain of them, each with a member of the next for its `/N`, `/First` or `/Length`, ran out of stack; and a comment inside a reference counts as white space; (34) a table rebuilt by scanning the file (`fallback_xref_map`) finds an object by its number when its header names another generation, including one no generation may be (`3 18446744073709551616 obj` in `poppler-85140-0.pdf`, #5580): the header is read leniently only in that scan (`read_object_header`), the object is also filed under generation 0 unless a real `N 0 obj` holds that key (a later header for the number moves the stand-in), `XRef::get_with` matches by object number only in a repaired table, and `XRef::objects` yields each location once; (35) PNG predictors (`lzw_flate::unfilter_png`) are undone a byte row at a time on slices, as the PNG filters are defined, instead of through the bit-level readers TIFF predictors need: a 993 × 936 RGB image took about 63 ms to unfilter whatever its row filters, and now takes about 3 ms (Up) to 16 ms (Paeth) in a release build (#5430); a row ending part-way through a pixel (three 4-bit samples, an odd width) keeps its last byte, where the bit-level readers dropped it and each later row started a byte early, shearing the picture; (36) a stream with no `endstream` left after it is the bytes its `/Length` gives, "how many bytes of the PDF file are used for the stream's data" (ISO 32000-2 7.3.8.2), when the file holds that many (`parse_declared`, tried after the search for `endstream` finds none): a producer that writes `endstrea` after every stream lost every font and page content, and its pages drew blank while other readers draw them; (37) a document that encrypts only its embedded files, authorised when one is opened (`/AuthEvent /EFOpen`, ISO 32000-2 §7.6.6, Table 25: every encrypting crypt filter is EFOpen, `/EFF` names one, `/StmF` and `/StrF` don't), opens without a password with the pass-through decryptor: its strings and streams are in the clear and nothing drawn reads an embedded file; (38) a page box that encloses no area is read as a missing one: a `/MediaBox` without area (`[0 0 0 0]`) as missing (an inherited one with area, else the default page, US Letter since patch 44), and a `/CropBox` without area, or with none in common with the media box, as missing (an inherited one, else the media box; ISO 32000-2 §14.11.2, Table 33); kept, the visible region had no area, so nothing was drawn and no text extracted (pdf.js `boundingBox_invalid`); (40) the last Flate filter of an image whose PNG predictor (`/Predictor` 10–15) its writer declared but never applied gives the image's samples as they are: when the inflated data is exactly `/Height` rows of the predictor's row length with no filter-type byte before each (where predicted rows would need one more byte each, ISO 32000-2 §7.4.4.4), `untagged_png_rows` takes it for unpredicted samples and reports it (`warn!`), as PDFium, MuPDF and Poppler read it; read with the predictor, each row's first sample was taken for a filter type and the image came out a row short and shifted, drawn in fragments (PDFBOX-3932). `Filter::apply` is given the image's rows for its last filter only; any other length is predicted as before; (41) CCITT `/Rows` above the image's `/Height` decodes to the height too, so the decoder is never asked for more rows than the image has (ISO 32000-2 Table 87): a scan with `/Rows 1084975104` on 2,592 columns had the size check refuse every page, and all 17 drew blank (PDFBOX-771); `/Rows` still counts for a stream decoded without an image's height; (42) TIFF predictor 2 on 8- and 16-bit samples (`lzw_flate::undo_tiff_bytes`) is undone a byte row at a time on slices, each sample added to the same component of the pixel to its left, wrapping at the sample's width (16-bit samples big-endian), instead of through the bit-level readers 1-, 2- and 4-bit samples still need; the bytes are those the bit-level path wrote, and a partial last row is still dropped. A page of 45 full-page 2445 × 4724 8-bit grey images so predicted (PDFBOX-1350) took about 23 s to render at 96 dpi and now takes about 4 s, with the same pixels; (43) a last update cut off before its trailer (the last `startxref` more than 4 KiB from the end of the file, as `pdfcraft-cos` judges the footer, with `N G obj` headers after it: `cut_off_update_follows`) is read by rebuilding the table from object headers (`Pdf::new_with_password` calls `fallback` first, after the table the last `startxref` names has refused a wrong password, and takes the rebuilt table only when it is encrypted exactly when that one is), the latest definition of each object winning, as `pdfcraft-cos` reads such a file; the older table the last `startxref` names is read only when the rebuilt table has no page (a file cut short inside the update, whose new page tree names pages that are not there). The renderer had shown the older revision while the document model read the newer one (PDFBOX-3934: 12 pages against 13; ISO 32000-2 §7.5.6). A `startxref` near the end, or one followed only by data that holds no object header, is read as before; (44) a page with no `/MediaBox`, its own or inherited, or one without area (`[0 0 0 0]`, which now counts as missing, so the page is placed and clipped by the region it is sized by and its content shows), is US Letter (`LETTER`, 612 × 792 pt), not A4, and so is the `base_dimensions` fallback for a visible region without area: ISO 32000-2 Table 31 requires the box and gives no default, other readers use Letter, and PdfCraft's page model and inspector already did (PDFBOX-4001); (45) `root_xref`: when the newest cross-reference section names no earlier one (no `/Prev`, ISO 32000-2 §7.5.6, §7.5.8.2; a hybrid section's `/XRefStm` stream belongs to the same update and doesn't count, §7.5.8.4), the file holds another `startxref`, and the catalog's `/Pages` can't be read, the table is rebuilt by scanning the file (`XRef::repair`) before the page tree is read, so the cut-off page tree is found instead of the page dictionaries `Pages::new_brute_force` turns up (the signed files of PDFBOX-2720 and PDFBOX-2942 showed 2 blank pages for 1, 10, 13 and 1754); `Pdf::rebuilt_unlinked_update` says so, and `pdfcraft-cos` rebuilds under the same conditions | (1) `cos/tests/writer.rs::page_tree_loops_in_object_streams_do_not_crash_readers`; (2) `raster::tests::absurd_mask_and_fax_sizes_are_refused_before_decoding`; (3) `raster::tests::absurd_predictor_columns_and_line_widths_render`; (4) `raster::tests::inline_images_with_ei_in_their_data_parse_in_linear_time`, `content::tests::inline_image_ei_may_be_followed_by_a_delimiter`; (5) `raster::tests::indirect_appearance_state_dict_is_not_another_stream`; (6) `raster::tests::jpx_stream_bit_depth_boundaries_do_not_panic`, `raster::tests::jpx_32_bit_image_does_not_prevent_other_page_content_from_rendering`, `raster::tests::jpx_gray_rgb_and_alpha_keep_eight_bit_samples`, `raster::tests::jpx_streams_reject_out_of_range_bit_depths`; (7) `raster::tests::decompression_bombs_stop_at_the_stream_limit`, `raster::tests::page_contents_and_nested_forms_share_one_budget`; (8) `inspect::tests::jspdf_high_precision_mediabox_does_not_overflow_dimensions`, `object::number::tests::high_precision_float_does_not_overflow_mantissa` (hayro-syntax); (9) `object::dict::lazy_index_tests`, `raster::tests::large_resource_indexes_preserve_pixels_and_text`; (10) `data::tests`, `xref::tests::concurrent_cold_object_stream_reads_all_succeed`, `raster::tests::shared_parser_is_lazy_and_initializes_once_under_contention`; (11) `data::tests::panicking_initializer_wakes_waiters_and_prevents_retries`, `xref::tests::concurrent_cold_object_stream_reads_all_succeed`; (12) `util::tests::segment_list_rejects_out_of_range_indices`; (13) `raster::tests::user_unit_scales_the_page_and_everything_on_it`; (14) `crypto::pdfcraft_password_tests`, `pdfcraft-engine` `tests::legacy_passwords_with_turkish_characters_open`; (15) `content::tests::windows_keep_every_instruction_at_every_split`, `content::tests::windows_keep_every_instruction_in_pieces_of_any_size`, `content::tests::windows_match_the_slice_on_random_content`, `content::tests::windows_drop_blanks_and_keep_a_comment_that_may_go_on`, `page::content_reader_tests::content_past_the_cap_is_read_to_the_end`, `page::content_reader_tests::a_stream_that_expands_past_the_budget_is_cut_there`, `page::content_reader_tests::a_small_expansion_under_the_cap_is_not_cut`, `raster::tests::page_content_past_the_old_cap_renders`, `raster::tests::content_reader_returns_the_same_bytes_in_any_pieces`, `raster::tests::content_bombs_stop_at_the_budget_and_say_so`, `page::content_reader_tests::plain_and_whole_pieces_keep_their_bytes`, `page::content_reader_tests::repeated_content_reference_counts_compressed_bytes_once`, `page::content_reader_tests::whole_cap_still_counts_later_unique_streams`; (16) `crypto::pdfcraft_password_tests::aes256_passwords_are_tried_in_their_saslprep_form_then_as_given`, `crypto::pdfcraft_password_tests::aes256_passwords_open_as_typed_and_in_their_saslprep_form`, `pdfcraft-engine` `tests::aes256_open_passwords_that_saslprep_changes_protect_and_reopen_as_typed`, `tests::aes256_files_whose_passwords_saslprep_changes_open_as_typed`; (17) `raster::tests::group4_fax_with_rows_zero_decodes_to_the_image_height`; (18) `filter::lzw_flate::tests::back_reference_longer_than_its_distance_repeats_its_source`, `back_reference_cut_by_the_limit_resumes_in_the_next_call` and `back_reference_reads_bytes_written_by_an_earlier_call`; each fails when the copy returns the wrong remaining count; (19) `raster::tests::group4_fax_with_rows_below_its_height_decodes_to_the_image_height`; (20) `raster::tests::space_before_the_line_break_after_stream_still_renders`; (21) `raster::tests::a_jp2_image_in_a_refused_enumerated_colour_space_is_drawn`, `filter::jpx::tests::refused_enumerated_colour_spaces_become_the_component_counts_default`, `filter::jpx::tests::what_the_decoder_handles_or_cant_be_read_is_left_alone` (`cargo test --manifest-path vendor/hayro-syntax/Cargo.toml`); (22) `raster::tests::objects_in_one_large_object_stream_are_read_in_linear_time`; (23) `text::tests::text_after_an_inline_image_survives_a_non_ascii_byte_before_a_later_ei`, `content::tests::an_inline_image_ends_at_its_own_ei_when_later_text_holds_a_non_ascii_byte` (hayro-syntax); (24) `xref::tests::prev_as_reference_reaches_the_earlier_section`, `xref::tests::prev_as_reference_to_an_object_holding_the_offset_reads_that_offset`, `pdfcraft-cos` `document::tests::a_prev_written_as_a_reference_still_reaches_the_earlier_sections`, `document::tests::a_prev_reference_to_an_object_holding_the_offset_reads_that_offset`; (25) `raster::tests::deeply_nested_direct_objects_do_not_overflow_the_stack`, `raster::tests::dictionaries_nested_without_keys_in_content_do_not_overflow_the_stack`, `raster::tests::dictionaries_nested_without_keys_in_the_trailer_do_not_overflow_the_stack`, `raster::tests::dictionaries_nested_without_keys_as_marked_content_properties_do_not_overflow_the_stack`, `raster::tests::dictionaries_nested_without_keys_are_read_up_to_the_nesting_cap`, `raster::tests::nesting_at_the_cap_fits_a_512_kib_stack` (native only), `raster::tests::function_limits_depth_guard_refuses_a_chain_of_references`; (26) `xref::tests::xref_stream_entries_need_backing_bytes`; (27) `xref::tests::xref_stream_type_field_uses_the_full_w0_width`, `xref::tests::xref_stream_loader_rejects_oversized_w0` (`cargo test --manifest-path vendor/hayro-syntax/Cargo.toml`); (28) `content::ops::tests::scn_with_twelve_components_keeps_them_all`, `content::ops::tests::operands_past_the_cap_keep_the_latest_in_order` (hayro-syntax), `raster::tests::devicen_inks::a_twelve_ink_devicen_colour_paints`, `raster::tests::devicen_inks::a_four_ink_devicen_colour_paints`, `raster::tests::devicen_inks::an_operator_with_hundreds_of_operands_does_not_end_the_content`; (29) `raster::tests::jbig2_globals_that_are_jbig2_themselves_are_not_decoded`, `raster::tests::jbig2_globals_are_read`, `raster::tests::the_jbig2_images_of_a_page_share_one_allowance`, `raster::tests::an_image_that_fails_spends_the_pages_allowance_too`; (30) `raster::tests::a_stream_with_a_crypt_filter_of_its_own_is_drawn`; (32) `raster::tests::a_decode_array_with_references_in_it_is_read`, `raster::tests::a_functions_arrays_can_start_with_a_reference` (pdfcraft-render), `font::cid::width_tests::a_width_group_can_start_with_a_reference` (hayro-interpret); (33) `raster::tests::a_reference_to_a_reference_is_what_it_names`, `raster::tests::a_decode_array_with_references_in_it_is_read` (pdfcraft-render), `font::cid::width_tests::a_width_group_can_start_with_a_reference` (hayro-interpret), `xref::tests::an_object_stream_isnt_reached_through_a_reference`, `xref::tests::a_chain_of_object_streams_ends_at_the_limit_of_nested_loads`, `xref::tests::a_chain_of_object_streams_takes_no_more_work_for_two_ways_on`, `xref::tests::a_reference_held_in_either_storage_is_followed`, `xref::tests::a_page_a_reference_is_held_to_is_found_once` (hayro-syntax); (34) `raster::tests::a_rebuilt_table_finds_an_object_whose_header_names_another_generation`, `raster::tests::a_rebuilt_tables_generation_0_stand_in_is_not_a_second_object` (pdfcraft-render), `xref::tests::an_object_stream_and_its_first_member_are_both_enumerated` (hayro-syntax); (35) `filter::lzw_flate::pdfcraft_png_predictor_tests` (hayro-syntax: every filter type at 1–16 bits and 1–4 colours against the PNG specification's byte-by-byte definition, a row ending part-way through a pixel, Average and Paeth over 6-byte pixels), `raster::tests::png_predicted_images::a_four_bit_rgb_image_of_odd_width_with_up_predicted_rows_is_not_sheared`; (36) `raster::tests::a_content_stream_without_endstream_is_drawn_to_its_length` (pdfcraft-render); (37) `inspect::tests::a_document_encrypting_only_its_embedded_files_opens_without_a_password`; (38) `text::tests::text_on_a_page_whose_box_has_no_area_is_extracted` (pdfcraft-render); (40) `filter::lzw_flate::pdfcraft_png_predictor_tests::an_image_whose_rows_carry_no_png_tags_is_read_as_its_samples`, `an_image_whose_rows_carry_png_tags_still_has_its_predictor_undone`, `data_of_neither_length_decodes_as_it_did`, `rows_of_no_bytes_or_of_more_than_memory_holds_do_not_panic` (hayro-syntax), `raster::tests::png_predicted_images::an_image_and_soft_mask_whose_rows_carry_no_png_tags_draw_their_samples`, `an_image_one_byte_short_of_untagged_rows_still_goes_through_the_predictor`; (41) `raster::tests::group4_fax_with_rows_past_its_height_decodes_to_the_image_height`; (42) `filter::lzw_flate::pdfcraft_tiff_predictor_tests` (hayro-syntax: random 8- and 16-bit rows at 1–7 colours and 1–50 columns against the bit-level path, partial and empty rows, and a row counter showing whole-byte samples skip the bit-level path), `raster::tests::tiff_predicted_images`; (43) `raster::tests::an_update_cut_off_before_its_trailer_shows_its_pages_as_the_model_reads_them`, `raster::tests::a_complete_update_reads_as_before`, `raster::tests::trailing_data_without_objects_reads_as_before`, `raster::tests::a_short_cut_off_update_reads_as_before`, `raster::tests::a_cut_off_update_with_obj_followed_by_a_delimiter_is_rebuilt`, `raster::tests::a_cut_off_update_with_obj_followed_by_a_comment_is_rebuilt`, `raster::tests::trailing_data_with_an_object_header_reads_as_the_model_reads_it`, `raster::tests::an_update_cut_off_before_its_new_pages_keeps_the_older_table`, `raster::tests::an_encrypted_file_with_a_cut_off_update_still_needs_its_password`, `raster::tests::a_rebuild_whose_trailer_lacks_the_encryption_dictionary_is_not_taken` (pdfcraft-render); (44) `raster::tests::a_page_without_a_media_box_is_letter`, `raster::tests::raw_crop_unit_floor_preserves_nearly_zero_fallback` (pdfcraft-render), `pdfcraft-model::tests::{a_page_without_a_media_box_is_letter, a_page_whose_media_box_has_no_area_is_letter}`; (45) `inspect::core::unlinked_update_tests::an_update_without_prev_shows_the_page_tree_and_says_it_was_rebuilt`, `inspect::core::unlinked_update_tests::a_hybrid_update_without_prev_is_rebuilt_as_the_document_model_rebuilds_it` (pdfcraft-render) | +| hayro-syntax | 0.7.2 | Apache-2.0 OR MIT | (1) Page-tree cycle guard in `resolve_pages` (visited set + depth cap): a `/Kids` loop among compressed objects overflowed the stack and aborted the process; (2) `ccitt_size_ok`: CCITT images wider than 2^20 columns or over 2^28 pixels are not decoded (a fuzzed `/Columns 4294967295` allocated 4 GiB); (3) Flate/LZW predictor row widths are saturating `usize`, a zero width is refused and a row longer than the data is not allocated (a fuzzed `/Columns 9223372036854775807` wrapped to a 2^61-byte allocation and aborted); (4) the inline-image end-of-data check (`EI`) doesn't nest inside its own look-ahead and examines at most 256 candidates and steps (50 images with "EI" in their data ran for over ten minutes); an EI followed by a delimiter (EI%..., EI[...) ends the image like one followed by white space; only a regular character after EI skips the candidate; (5) a stream whose `/Length` is wrong is recovered only when `stream` is the next token — scanning ahead to a later object's `stream` made an indirect appearance-state dictionary (Quartz checkboxes: `/AP /N` → `<< /Yes … /Off … >>`) render as a non-form, so the widget was skipped; (6) JPX rescaling uses an exact integer sample range for 1–32-bit components (32 shifted past u32, and floating-point maxima at 25–31 bits wrapped to zero), and refuses zero-sized rows; (7) `decode_limit`: Flate (through flate2 and the fallback inflater), LZW, RunLength and ASCII85 never write past `MAX_DECODED_STREAM` (256 MiB), or, for the last filter of an image of known size, past twice its pixel bytes (a predictor tag byte per byte), at most 1 GiB; `Stream::decoded_within` for budgeted callers; a page's `/Contents` array shares one `MAX_DECODED_STREAM` (a 2 MB page whose content stream or 8 × 8 image inflated to 2 GB took the renderer past 3 GB in under a second; naming the stream twenty times in `/Contents`, 5 GB); (8) high-precision numbers (jsPDF MediaBox coordinates with over 19 digits) parse without overflowing the u64 mantissa: fractional digits past it are dropped, integer digits past it scale the value (a real, kept finite); (9) large dictionary indexes are built on first use and shared across repeated reads within each PDF (#307); the validated-byte cache is bounded, reader contexts remain separate, repairs and inline/content dictionaries bypass sharing, and small dictionaries stay eager; (10) decoded object-stream slots publish once under contention, cold loading rejects recursive dependencies, and xref repair serializes without holding recursive read guards; this permits one parser per native render pool with private worker caches; (11) `sync.rs`: a poisoned lock is recovered instead of unwrapped (a worker panic no longer cascades into every reader of the same PDF), and xref repair takes a blocking write lock instead of asserting a non-blocking one; (12) `SegmentList` (`util.rs`) returns `None` for an out-of-range index instead of panicking; (13) a page's `/UserUnit` scales `render_dimensions` and `initial_transform`, so the page and everything on it are sized in points, as Acrobat shows them; it is read as Acrobat reads it (the page's own entry, not inherited; a number from 1 up, at most 75,000; anything else counts as 1), and a page without it keeps exactly the transform it had; (14) revision 2–4 passwords are tried as given and then in PDFDocEncoding (`legacy_passwords` in `crypto/mod.rs`, which resolves the upstream `TODO: Convert to PDFDocEncoding`): passwords arrive as UTF-8 text, so a password with characters outside ASCII (`Çok güzel`, `ılık€`) written in PDFDocEncoding, as ISO 32000-2 §7.6.4.3.2 asks and qpdf and Acrobat write it, was always wrong; (15) page content is read a piece at a time (`page::ContentReader`, `Page::content_reader`): plain streams and Flate streams without a predictor are decoded as they are read, with a budget of 64 times their compressed size and never less than `MAX_DECODED_STREAM` (a stream that expands past it is cut and reported); other streams are decoded whole, as before, and share `MAX_DECODED_STREAM`. The content tokenizer reads a window at a time (`UntypedIter::new_window`, `TypedIter::needs_more`, `TypedIter::committed`): an instruction that runs past the window is not decided until more data follows, which `Reader` records by flagging reads and `at_end` past the end of the data, including through the clones the parser makes. The Flate decoder keeps its own 32 KiB history, so it pauses at any output size. `ContentReader::new` reuses raw/decrypted stream bytes for budget accounting and piece creation, and `decoded_raw_within` applies the existing Whole-stream filters and limits to those bytes without decrypting twice; (16) revision 5–6 (AES-256) passwords are tried in their SASLprep form (RFC 4013, then UTF-8, at most 127 bytes, as ISO 32000-2 §7.6.4.3.3 Algorithm 2.A asks and pdfcraft-crypt writes them) and then, if that differs, as given (`aes256_passwords` in `crypto/mod.rs`, which resolves the upstream `TODO: Do the above.`; `stringprep`, already in the tree through pdfcraft-crypt, is an optional dependency enabled by `std`, and without `std` passwords are used as given): the renderer used the password as typed, so one that SASLprep changes (a decomposed `café`, a no-break space, the fullwidth `ab` a Japanese IME types in full-width mode) was wrong, although pdfcraft-cos accepted it: protecting a document with such an open password was rolled back ("the password is incorrect"), and a file with such a password did not open with it.; (17) CCITT `/Rows 0` means the height is not predetermined (ISO 32000-2 Table 11), not zero rows: scanned Group 4 pages written that way rendered blank; (18) The streaming Flate decoder (`lzw_flate::flate::fallback`) copies a back-reference in runs of up to its distance (`extend_from_within`), not byte by byte. The copy reads only bytes already in the output, and a copy whose source does not exist still writes nothing, so the bytes are those a byte-by-byte copy writes. Inflating the A1 sample's content stream (278.6 MB) takes about 14% less time (0.66 s to 0.56 s); the output is identical; (19) CCITT `/Rows` below the image's `/Height` decodes to the height: a plotter driver wrote `/Rows` equal to `/Columns`, so only the first rows were decoded and the rest drawn black (#3308) (20) `stream` followed by spaces or tabs and then the line break (`stream \r\n`, as Microsoft Reporting Services writes it) starts the data after the line break; the content stream was taken to start at the space and the page rendered blank.; (21) a JP2 colour specification naming an enumerated colour space the decoder refuses (CIEJab in a veraPDF fixture, YCbCr, PhotoYCC, YPbPr, bilevel…) no longer fails the image: it is decoded again from a copy whose `colr` names the space the decoder assumes without one, greyscale, sRGB or CMYK by component count, as MuPDF and Poppler draw it (`without_refused_enumerated_colour`; the page drew blank, #3306); (22) an object stream's index of `objnum offset` pairs is parsed once per stream and kept beside its decoded data (`ObjectStreamIndexes`, `XRef::with_object_stream_indexes`), and an offset past `usize` is malformed instead of wrapping: `XRef::get_with` parsed the whole index again for every object it fetched from the stream, so reading all of a stream of N objects took N² steps (a Firefox page whose 65,542 objects share one stream took 37 s to draw and 40 s to extract its text); (23) the inline-image end-of-data look-ahead no longer rejects a candidate `EI` because a byte above 0x7F lies between it and the next one: when the candidate is the end, those bytes are the content stream that follows the image, and its strings carry such bytes (WinAnsi `(Grüße) Tj`, the two-byte codes of any Type 0 font), so the operator check that follows decides alone, bounded as in (4) (a scanned page whose OCR text held an accented word and a later capital "EI" — EINGANG, BEI, KLEIN — drew and extracted nothing after the image: 40 of 275 pages across five real documents); (24) `xref::prev_offset`: a trailer `/Prev` written as a reference `N G R` is read as the offset that object holds when the section's own entries place it (iText's `indirectPrev.pdf`; each section's entries are read before its `Prev` is followed), and otherwise as offset N (tiff2pdf; JHOVE's PDF-HUL-157 files, #2366), as `pdfcraft-cos` and other readers take it, so the earlier sections' pages are seen instead of one; (25) direct arrays and dictionaries share a reader-local nesting cap of 128, counted while skipping and while parsing a dictionary (its lenient repair reads, rather than skips, an object found where a key belongs, so `<< << …` recursed through that read); deeper objects return `None` instead of overflowing the stack (including unused trailer values, trailer entries, content operands and marked-content properties); a chain of 65 direct stitching functions now stops at this cap, before the 64-function guard; (26) an xref stream subsection must be backed by its decoded data: all-zero `/W` widths (an entry reads no bytes) and a declared count longer than the data are refused before any entry is inserted, where a tiny stream with a large `/Size` or `/Index` count inserted that many entries (from storytold/craft-libs#239); a subsection running past object number `i32::MAX` is refused instead of overflowing or turning negative; (27) an xref stream's type field is read big-endian at its full `/W[0]` width, where only its first byte was read and wider fields shifted every later entry, and `/W[0]` over 8 bytes is refused like `/W[1]` (from storytold/craft-libs#241); (28) a content instruction keeps up to 256 operands (`MAX_OPERANDS`) instead of 10, and past that drops the oldest instead of ending the content stream: the eleventh operand of a twelve-ink DeviceN `scn` (ISO 32000-2 §8.6.6.5 allows any number of components) blanked the rest of the page (#4260); (29) a `/JBIG2Globals` stream that is itself JBIG2 isn't decoded: globals are raw segments, and a chain of 16,000 such streams overflowed the stack, which aborts the process; (30) a stream's own crypt filter: `/Crypt` in its chain read as the filter it names, `/Identity` without one, instead of the document's filter and then a filter that can't be decoded, which left such a stream unreadable in any spelling (ISO 32000-2 §7.4.10); (31) `set_page_jbig2_work`: the JBIG2 images decoded on a thread spend one allowance, set per page, 2^30 steps (`MAX_PAGE_JBIG2_WORK`), so a page of many images made to reach their own limit stops at one, and what they cost to read counts, whether they decode or not; unbounded until a page sets it; (32) a group of array items read together (a tuple: an image's `/Decode`, `/W`, `/W2`, and a function's `/Domain`, `/Range`, `/Encode` and `/Decode`) can start with a reference: it was taken for the whole group and lost, which ended the array, so an image with `/Decode [7 0 R 0]` didn't draw and CID widths fell back to `/DW`; (33) an object that holds only a reference stands for what that names, stored on its own or in an object stream, up to 32 references: read as a number it was the reference's own object number, so a `/Decode` or `/W` entry naming such an object took a wrong value, and as anything else it was lost. Past 32, as any cycle goes, it reads as a missing object. An object stream isn't reached that way, as its checks are of the object named; reading its index counts against the 64 nested loads and reads its dictionary alone, as a chain of them, each with a member of the next for its `/N`, `/First` or `/Length`, ran out of stack; and a comment inside a reference counts as white space; (34) a table rebuilt by scanning the file (`fallback_xref_map`) finds an object by its number when its header names another generation, including one no generation may be (`3 18446744073709551616 obj` in `poppler-85140-0.pdf`, #5580): the header is read leniently only in that scan (`read_object_header`), the object is also filed under generation 0 unless a real `N 0 obj` holds that key (a later header for the number moves the stand-in), `XRef::get_with` matches by object number only in a repaired table, and `XRef::objects` yields each location once; (35) PNG predictors (`lzw_flate::unfilter_png`) are undone a byte row at a time on slices, as the PNG filters are defined, instead of through the bit-level readers TIFF predictors need: a 993 × 936 RGB image took about 63 ms to unfilter whatever its row filters, and now takes about 3 ms (Up) to 16 ms (Paeth) in a release build (#5430); a row ending part-way through a pixel (three 4-bit samples, an odd width) keeps its last byte, where the bit-level readers dropped it and each later row started a byte early, shearing the picture; (36) a stream with no `endstream` left after it is the bytes its `/Length` gives, "how many bytes of the PDF file are used for the stream's data" (ISO 32000-2 7.3.8.2), when the file holds that many (`parse_declared`: first, before the search for `endstream`, when the `/Length` ends before a whole `endstream` token, or a whole `endstrea` or `endobj` token after which the object ends too (the end of the data, another object's `N G obj` header, `xref`, `trailer` or `startxref`: not payload such as `endobject` or `endobj more`, ISO 32000-2 7.2.3), or at the end of the data, so the search can't run on into a later object's stream; then after the search finds none; `parse_proper` and the search for `endstream` take only a whole token, not `endstreamer`): a producer that writes `endstrea` after every stream lost every font and page content, and its pages drew blank while other readers draw them; (37) a document that encrypts only its embedded files, authorised when one is opened (`/AuthEvent /EFOpen`, ISO 32000-2 §7.6.6, Table 25: every encrypting crypt filter is EFOpen, `/EFF` names one, `/StmF` and `/StrF` don't), opens without a password with the pass-through decryptor: its strings and streams are in the clear and nothing drawn reads an embedded file; (38) a page box that encloses no area is read as a missing one: a `/MediaBox` without area (`[0 0 0 0]`) as missing (an inherited one with area, else the default page, US Letter since patch 44), and a `/CropBox` without area, or with none in common with the media box, as missing (an inherited one, else the media box; ISO 32000-2 §14.11.2, Table 33); kept, the visible region had no area, so nothing was drawn and no text extracted (pdf.js `boundingBox_invalid`); (40) the last Flate filter of an image whose PNG predictor (`/Predictor` 10–15) its writer declared but never applied gives the image's samples as they are: when the inflated data is exactly `/Height` rows of the predictor's row length with no filter-type byte before each (where predicted rows would need one more byte each, ISO 32000-2 §7.4.4.4), `untagged_png_rows` takes it for unpredicted samples and reports it (`warn!`), as PDFium, MuPDF and Poppler read it; read with the predictor, each row's first sample was taken for a filter type and the image came out a row short and shifted, drawn in fragments (PDFBOX-3932). `Filter::apply` is given the image's rows for its last filter only; any other length is predicted as before; (41) CCITT `/Rows` above the image's `/Height` decodes to the height too, so the decoder is never asked for more rows than the image has (ISO 32000-2 Table 87): a scan with `/Rows 1084975104` on 2,592 columns had the size check refuse every page, and all 17 drew blank (PDFBOX-771); `/Rows` still counts for a stream decoded without an image's height; (42) TIFF predictor 2 on 8- and 16-bit samples (`lzw_flate::undo_tiff_bytes`) is undone a byte row at a time on slices, each sample added to the same component of the pixel to its left, wrapping at the sample's width (16-bit samples big-endian), instead of through the bit-level readers 1-, 2- and 4-bit samples still need; the bytes are those the bit-level path wrote, and a partial last row is still dropped. A page of 45 full-page 2445 × 4724 8-bit grey images so predicted (PDFBOX-1350) took about 23 s to render at 96 dpi and now takes about 4 s, with the same pixels; (43) a last update cut off before its trailer (the last `startxref` more than 4 KiB from the end of the file, as `pdfcraft-cos` judges the footer, with `N G obj` headers after it: `cut_off_update_follows`) is read by rebuilding the table from object headers (`Pdf::new_with_password` calls `fallback` first, after the table the last `startxref` names has refused a wrong password, and takes the rebuilt table only when it is encrypted exactly when that one is), the latest definition of each object winning, as `pdfcraft-cos` reads such a file; the older table the last `startxref` names is read only when the rebuilt table has no page (a file cut short inside the update, whose new page tree names pages that are not there). The renderer had shown the older revision while the document model read the newer one (PDFBOX-3934: 12 pages against 13; ISO 32000-2 §7.5.6). A `startxref` near the end, or one followed only by data that holds no object header, is read as before; (44) a page with no `/MediaBox`, its own or inherited, or one without area (`[0 0 0 0]`, which now counts as missing, so the page is placed and clipped by the region it is sized by and its content shows), is US Letter (`LETTER`, 612 × 792 pt), not A4, and so is the `base_dimensions` fallback for a visible region without area: ISO 32000-2 Table 31 requires the box and gives no default, other readers use Letter, and PdfCraft's page model and inspector already did (PDFBOX-4001); (45) `root_xref`: when the newest cross-reference section names no earlier one (no `/Prev`, ISO 32000-2 §7.5.6, §7.5.8.2; a hybrid section's `/XRefStm` stream belongs to the same update and doesn't count, §7.5.8.4), the file holds another `startxref`, and the catalog's `/Pages` can't be read, the table is rebuilt by scanning the file (`XRef::repair`) before the page tree is read, so the cut-off page tree is found instead of the page dictionaries `Pages::new_brute_force` turns up (the signed files of PDFBOX-2720 and PDFBOX-2942 showed 2 blank pages for 1, 10, 13 and 1754); `Pdf::rebuilt_unlinked_update` says so, and `pdfcraft-cos` rebuilds under the same conditions | (1) `cos/tests/writer.rs::page_tree_loops_in_object_streams_do_not_crash_readers`; (2) `raster::tests::absurd_mask_and_fax_sizes_are_refused_before_decoding`; (3) `raster::tests::absurd_predictor_columns_and_line_widths_render`; (4) `raster::tests::inline_images_with_ei_in_their_data_parse_in_linear_time`, `content::tests::inline_image_ei_may_be_followed_by_a_delimiter`; (5) `raster::tests::indirect_appearance_state_dict_is_not_another_stream`; (6) `raster::tests::jpx_stream_bit_depth_boundaries_do_not_panic`, `raster::tests::jpx_32_bit_image_does_not_prevent_other_page_content_from_rendering`, `raster::tests::jpx_gray_rgb_and_alpha_keep_eight_bit_samples`, `raster::tests::jpx_streams_reject_out_of_range_bit_depths`; (7) `raster::tests::decompression_bombs_stop_at_the_stream_limit`, `raster::tests::page_contents_and_nested_forms_share_one_budget`; (8) `inspect::tests::jspdf_high_precision_mediabox_does_not_overflow_dimensions`, `object::number::tests::high_precision_float_does_not_overflow_mantissa` (hayro-syntax); (9) `object::dict::lazy_index_tests`, `raster::tests::large_resource_indexes_preserve_pixels_and_text`; (10) `data::tests`, `xref::tests::concurrent_cold_object_stream_reads_all_succeed`, `raster::tests::shared_parser_is_lazy_and_initializes_once_under_contention`; (11) `data::tests::panicking_initializer_wakes_waiters_and_prevents_retries`, `xref::tests::concurrent_cold_object_stream_reads_all_succeed`; (12) `util::tests::segment_list_rejects_out_of_range_indices`; (13) `raster::tests::user_unit_scales_the_page_and_everything_on_it`; (14) `crypto::pdfcraft_password_tests`, `pdfcraft-engine` `tests::legacy_passwords_with_turkish_characters_open`; (15) `content::tests::windows_keep_every_instruction_at_every_split`, `content::tests::windows_keep_every_instruction_in_pieces_of_any_size`, `content::tests::windows_match_the_slice_on_random_content`, `content::tests::windows_drop_blanks_and_keep_a_comment_that_may_go_on`, `page::content_reader_tests::content_past_the_cap_is_read_to_the_end`, `page::content_reader_tests::a_stream_that_expands_past_the_budget_is_cut_there`, `page::content_reader_tests::a_small_expansion_under_the_cap_is_not_cut`, `raster::tests::page_content_past_the_old_cap_renders`, `raster::tests::content_reader_returns_the_same_bytes_in_any_pieces`, `raster::tests::content_bombs_stop_at_the_budget_and_say_so`, `page::content_reader_tests::plain_and_whole_pieces_keep_their_bytes`, `page::content_reader_tests::repeated_content_reference_counts_compressed_bytes_once`, `page::content_reader_tests::whole_cap_still_counts_later_unique_streams`; (16) `crypto::pdfcraft_password_tests::aes256_passwords_are_tried_in_their_saslprep_form_then_as_given`, `crypto::pdfcraft_password_tests::aes256_passwords_open_as_typed_and_in_their_saslprep_form`, `pdfcraft-engine` `tests::aes256_open_passwords_that_saslprep_changes_protect_and_reopen_as_typed`, `tests::aes256_files_whose_passwords_saslprep_changes_open_as_typed`; (17) `raster::tests::group4_fax_with_rows_zero_decodes_to_the_image_height`; (18) `filter::lzw_flate::tests::back_reference_longer_than_its_distance_repeats_its_source`, `back_reference_cut_by_the_limit_resumes_in_the_next_call` and `back_reference_reads_bytes_written_by_an_earlier_call`; each fails when the copy returns the wrong remaining count; (19) `raster::tests::group4_fax_with_rows_below_its_height_decodes_to_the_image_height`; (20) `raster::tests::space_before_the_line_break_after_stream_still_renders`; (21) `raster::tests::a_jp2_image_in_a_refused_enumerated_colour_space_is_drawn`, `filter::jpx::tests::refused_enumerated_colour_spaces_become_the_component_counts_default`, `filter::jpx::tests::what_the_decoder_handles_or_cant_be_read_is_left_alone` (`cargo test --manifest-path vendor/hayro-syntax/Cargo.toml`); (22) `raster::tests::objects_in_one_large_object_stream_are_read_in_linear_time`; (23) `text::tests::text_after_an_inline_image_survives_a_non_ascii_byte_before_a_later_ei`, `content::tests::an_inline_image_ends_at_its_own_ei_when_later_text_holds_a_non_ascii_byte` (hayro-syntax); (24) `xref::tests::prev_as_reference_reaches_the_earlier_section`, `xref::tests::prev_as_reference_to_an_object_holding_the_offset_reads_that_offset`, `pdfcraft-cos` `document::tests::a_prev_written_as_a_reference_still_reaches_the_earlier_sections`, `document::tests::a_prev_reference_to_an_object_holding_the_offset_reads_that_offset`; (25) `raster::tests::deeply_nested_direct_objects_do_not_overflow_the_stack`, `raster::tests::dictionaries_nested_without_keys_in_content_do_not_overflow_the_stack`, `raster::tests::dictionaries_nested_without_keys_in_the_trailer_do_not_overflow_the_stack`, `raster::tests::dictionaries_nested_without_keys_as_marked_content_properties_do_not_overflow_the_stack`, `raster::tests::dictionaries_nested_without_keys_are_read_up_to_the_nesting_cap`, `raster::tests::nesting_at_the_cap_fits_a_512_kib_stack` (native only), `raster::tests::function_limits_depth_guard_refuses_a_chain_of_references`; (26) `xref::tests::xref_stream_entries_need_backing_bytes`; (27) `xref::tests::xref_stream_type_field_uses_the_full_w0_width`, `xref::tests::xref_stream_loader_rejects_oversized_w0` (`cargo test --manifest-path vendor/hayro-syntax/Cargo.toml`); (28) `content::ops::tests::scn_with_twelve_components_keeps_them_all`, `content::ops::tests::operands_past_the_cap_keep_the_latest_in_order` (hayro-syntax), `raster::tests::devicen_inks::a_twelve_ink_devicen_colour_paints`, `raster::tests::devicen_inks::a_four_ink_devicen_colour_paints`, `raster::tests::devicen_inks::an_operator_with_hundreds_of_operands_does_not_end_the_content`; (29) `raster::tests::jbig2_globals_that_are_jbig2_themselves_are_not_decoded`, `raster::tests::jbig2_globals_are_read`, `raster::tests::the_jbig2_images_of_a_page_share_one_allowance`, `raster::tests::an_image_that_fails_spends_the_pages_allowance_too`; (30) `raster::tests::a_stream_with_a_crypt_filter_of_its_own_is_drawn`; (32) `raster::tests::a_decode_array_with_references_in_it_is_read`, `raster::tests::a_functions_arrays_can_start_with_a_reference` (pdfcraft-render), `font::cid::width_tests::a_width_group_can_start_with_a_reference` (hayro-interpret); (33) `raster::tests::a_reference_to_a_reference_is_what_it_names`, `raster::tests::a_decode_array_with_references_in_it_is_read` (pdfcraft-render), `font::cid::width_tests::a_width_group_can_start_with_a_reference` (hayro-interpret), `xref::tests::an_object_stream_isnt_reached_through_a_reference`, `xref::tests::a_chain_of_object_streams_ends_at_the_limit_of_nested_loads`, `xref::tests::a_chain_of_object_streams_takes_no_more_work_for_two_ways_on`, `xref::tests::a_reference_held_in_either_storage_is_followed`, `xref::tests::a_page_a_reference_is_held_to_is_found_once` (hayro-syntax); (34) `raster::tests::a_rebuilt_table_finds_an_object_whose_header_names_another_generation`, `raster::tests::a_rebuilt_tables_generation_0_stand_in_is_not_a_second_object` (pdfcraft-render), `xref::tests::an_object_stream_and_its_first_member_are_both_enumerated` (hayro-syntax); (35) `filter::lzw_flate::pdfcraft_png_predictor_tests` (hayro-syntax: every filter type at 1–16 bits and 1–4 colours against the PNG specification's byte-by-byte definition, a row ending part-way through a pixel, Average and Paeth over 6-byte pixels), `raster::tests::png_predicted_images::a_four_bit_rgb_image_of_odd_width_with_up_predicted_rows_is_not_sheared`; (36) `raster::tests::a_content_stream_without_endstream_is_drawn_to_its_length`, `raster::tests::a_content_stream_without_endstream_is_not_read_into_a_later_stream`, `raster::tests::hayro_reads_a_stream_without_endstream_to_its_length_before_a_later_one`, `raster::tests::hayro_does_not_end_a_stream_inside_a_longer_word` (pdfcraft-render); (37) `inspect::tests::a_document_encrypting_only_its_embedded_files_opens_without_a_password`; (38) `text::tests::text_on_a_page_whose_box_has_no_area_is_extracted` (pdfcraft-render); (40) `filter::lzw_flate::pdfcraft_png_predictor_tests::an_image_whose_rows_carry_no_png_tags_is_read_as_its_samples`, `an_image_whose_rows_carry_png_tags_still_has_its_predictor_undone`, `data_of_neither_length_decodes_as_it_did`, `rows_of_no_bytes_or_of_more_than_memory_holds_do_not_panic` (hayro-syntax), `raster::tests::png_predicted_images::an_image_and_soft_mask_whose_rows_carry_no_png_tags_draw_their_samples`, `an_image_one_byte_short_of_untagged_rows_still_goes_through_the_predictor`; (41) `raster::tests::group4_fax_with_rows_past_its_height_decodes_to_the_image_height`; (42) `filter::lzw_flate::pdfcraft_tiff_predictor_tests` (hayro-syntax: random 8- and 16-bit rows at 1–7 colours and 1–50 columns against the bit-level path, partial and empty rows, and a row counter showing whole-byte samples skip the bit-level path), `raster::tests::tiff_predicted_images`; (43) `raster::tests::an_update_cut_off_before_its_trailer_shows_its_pages_as_the_model_reads_them`, `raster::tests::a_complete_update_reads_as_before`, `raster::tests::trailing_data_without_objects_reads_as_before`, `raster::tests::a_short_cut_off_update_reads_as_before`, `raster::tests::a_cut_off_update_with_obj_followed_by_a_delimiter_is_rebuilt`, `raster::tests::a_cut_off_update_with_obj_followed_by_a_comment_is_rebuilt`, `raster::tests::trailing_data_with_an_object_header_reads_as_the_model_reads_it`, `raster::tests::an_update_cut_off_before_its_new_pages_keeps_the_older_table`, `raster::tests::an_encrypted_file_with_a_cut_off_update_still_needs_its_password`, `raster::tests::a_rebuild_whose_trailer_lacks_the_encryption_dictionary_is_not_taken` (pdfcraft-render); (44) `raster::tests::a_page_without_a_media_box_is_letter`, `raster::tests::raw_crop_unit_floor_preserves_nearly_zero_fallback` (pdfcraft-render), `pdfcraft-model::tests::{a_page_without_a_media_box_is_letter, a_page_whose_media_box_has_no_area_is_letter}`; (45) `inspect::core::unlinked_update_tests::an_update_without_prev_shows_the_page_tree_and_says_it_was_rebuilt`, `inspect::core::unlinked_update_tests::a_hybrid_update_without_prev_is_rebuilt_as_the_document_model_rebuilds_it` (pdfcraft-render) | | winit | 0.30.13 | Apache-2.0 | (1) on Windows 11 (build 22000 and later), `WM_DPICHANGED` applies the rect Windows suggests. It used to nudge the window onto the monitor `MonitorFromWindow` names, which during a drag is still the monitor being left; the nudge sent another `WM_DPICHANGED`, so a window dragged to a monitor with another scale factor grew and stayed on the wrong monitor at the wrong scale (#324). Backport of winit master 488c036a ([winit#4341](https://github.com/rust-windowing/winit/pull/4341)), which no 0.30 release has; Windows 10 keeps the old adjustment, as on winit master. (2) on Wayland, a seat's text input is destroyed when the seat goes away, not whenever it loses a capability: losing any capability (a touch screen or tablet unplugged, a remote-desktop session ending) destroyed it, and only an added capability recreated it, so IME stopped working until restart. Port of [winit#4747](https://github.com/rust-windowing/winit/pull/4747); (3) on Windows, an IME composition reports its cursor from `GCS_CURSORPOS` only when `WM_IME_COMPOSITION` says it is set, and at the end of the composition otherwise: the Microsoft Korean IME never sets it, so the cursor read as 0 (egui then drew no caret while composing; it now draws it after the syllable). Port of [winit#4746](https://github.com/rust-windowing/winit/pull/4746); (4) on Windows, a hidden window is not maximized until it is shown, and a hidden window shown while maximized gets one `ShowWindow(SW_MAXIMIZE)`: `SW_MAXIMIZE` shows a hidden window, so PdfCraft, which eframe keeps hidden until its first frame is drawn, appeared unpainted (white) when it restored a maximized window, and could lose its focus. Port of [winit#4587](https://github.com/rust-windowing/winit/pull/4587) (the later, focus-safe form of [winit#4575](https://github.com/rust-windowing/winit/pull/4575)); (5) on X11, when allowing or disallowing IME replaces the focused window's input context (egui does it each time a text field gains or loses focus), the new context is focused: no focus event follows, so it stayed unfocused until the window lost and regained focus. Port of [winit#4727](https://github.com/rust-windowing/winit/pull/4727); (6) on macOS, `insertText` commits through IME when a composition was started (`pending_commit`, set by `setMarkedText`) rather than when marked text is left: input methods that end a composition with `unmarkText` before `insertText` lost the text, and Apple Korean dropped the key typed after a syllable (`한` then `5` or `;`); other `insertText` calls stay `KeyboardInput`; a commit outside a key press (a candidate clicked with the mouse, dictation) clears the marked text and leaves no key to swallow, as in [winit#4748](https://github.com/rust-windowing/winit/pull/4748) (without it the next key, such as Backspace, was lost). `validAttributesForMarkedText` lists the underline and marked-clause attributes, without which some input methods don't compose. Port of [winit#4650](https://github.com/rust-windowing/winit/pull/4650), the 0.30 backport of [winit#4651](https://github.com/rust-windowing/winit/pull/4651) (merged on winit master), with the underline attribute spelled as AppKit's `NSUnderlineStyleAttributeName` (`NSUnderline`); (7) on macOS, disallowing IME with a composition in progress (focus leaving a text field) tells the input method to discard its marked text (`discardMarkedText`): it kept composing it once IME was allowed again, so the abandoned syllable came back (`하`, then `가` typed in the next field, became `학`, then `하` + `가`; Japanese `か` then `さ` became `傘`). Port of [winit#4745](https://github.com/rust-windowing/winit/pull/4745); (8) on macOS, text inserted outside a key press without a composition (the emoji picker) is reported as `Ime::Commit`; it was dropped. Port of the emoji-picker part of [winit#4749](https://github.com/rust-windowing/winit/pull/4749) (0.30 backport of [winit#4748](https://github.com/rust-windowing/winit/pull/4748)); its `selectedRange` change for dictation is left out because it switches on press-and-hold for every held key (an accent menu instead of key repeat, and the chosen accent added after the repeated letters); (9) on macOS, a key whose text was committed through IME isn't also sent as `KeyboardInput` unless AppKit forwards it as a command (Enter): Apple Korean answers Space with no syllable in progress with `setMarkedText(" ")`, `insertText(" ")`, `setMarkedText("")`, and the last call reset winit's state, so the space arrived twice. Same goal as [winit#4478](https://github.com/rust-windowing/winit/pull/4478), whose changes don't reach this call order (macOS 14); to be proposed upstream there; (10) on macOS, when the first key in a fresh app reaches Apple Korean's `insertText` as a raw compatibility jamo instead of starting a composition, that key is interpreted once more, synchronously, before it is sent as `KeyboardInput` (g k s typed ㅎㅏㄴ instead of 한); only an unmodified, non-repeated first key whose one callback inserts that same jamo, with IME enabled and nothing composed, focus and input source unchanged, is retried (`ime_startup.rs`). Port of [winit#4744](https://github.com/rust-windowing/winit/pull/4744), with its policy tests.; (11) on X11 without `Xft.dpi`, the scale factor comes from the X screen's configured DPI (its size in pixels and millimetres) before the output's physical density from RandR: Xorg can deliberately run a dense panel at 96 DPI, and only winit applications then looked zoomed; `Xft.dpi` still wins, `WINIT_X11_SCALE_FACTOR` still overrides, and a screen reporting no size keeps the RandR fallback. PdfCraft patch (#1150, replacing the launcher pin proposed in #908). (12) on macOS, `WinitView` answers `mouseDownCanMoveWindow` with the window's `movable_by_window_background` attribute (NO unless asked): NSView answers YES for a view that isn't opaque, so with the transparent title bar and full-size content view PdfCraft uses, AppKit moved the window for any drag that began in the 28 pt title-bar band before the app saw it, and document tabs (which live in that band) could not be dragged to reorder them. The empty strip still moves the window through `ViewportCommand::StartDrag`. PdfCraft patch. The published crate's asset files (`docs/res/*.svg`, `examples/data/*.png`) are left out, so its `window` example doesn't build; (13) on Wayland, pixel-only wheel, wheel-tilt and continuous-source scroll events report `Moved` rather than starting an unterminated finger gesture, so egui follows modifier release instead of keeping Ctrl zoom latched (#991). Finger gestures and unknown sources retain their existing phase policy | (1) `pdfcraft` `tests::winit_carries_the_windows_11_monitor_scale_fix` keeps the patch in the build; the drag itself needs two monitors with different scale factors on Windows 11; (2) `pdfcraft` `tests/winit_wayland_text_input.rs`; the capability change itself needs a Wayland session and an input method; (3) `pdfcraft` `tests/winit_windows_ime_cursor.rs`; composing itself needs the Microsoft Korean IME; (4) `pdfcraft` `tests/winit_windows_hidden_maximized.rs`; the start-up itself needs Windows and a window that was closed maximized; (5) `pdfcraft` `tests/winit_x11_ime_focus.rs`; switching fields itself needs an X11 session and an input method (IBus, fcitx5); (6) `pdfcraft` `tests/winit_macos_ime_commit.rs`; composing itself needs macOS and an input method; (7) `pdfcraft` `tests/winit_macos_ime_discard.rs`; composing itself needs macOS and an input method; (8) `pdfcraft` `tests/winit_macos_emoji_picker.rs`; the picker itself needs macOS; (9) `pdfcraft` `tests/winit_macos_korean_space.rs`; composing itself needs macOS and Apple Korean; (10) `pdfcraft` `tests/winit_macos_korean_first_key.rs` and `ime_startup.rs`'s own tests (run them with `rustc --test`); typing itself needs macOS and Apple Korean; (11) `pdfcraft` `tests/winit_x11_dpi.rs` keeps the patch in the build, and `util::randr` tests the policy (run them in the vendored crate); the scale itself needs an X11 session; (12) `pdfcraft` `tests/winit_macos_title_bar_drag.rs` keeps the patch in the build; the drag itself needs macOS and a pointer; (13) `pdfcraft` `tests/winit_wayland_scroll.rs` compiles the production phase policy and exercises modifier release through egui; live COSMIC confirmation remains open | | egui-wgpu | 0.36.2 | MIT OR Apache-2.0 | (1) `winit::surface_fit`: a window's surface is configured within the device's `max_texture_dimension_2d`, both sides shrunk by one factor that egui then draws at (`pixels_per_point × render_scale`), so the whole window is drawn into the surface (on DX12 it is stretched over the window and lines up with the pointer; wgpu's GL backend copies it unscaled into a corner); the longer side becomes exactly the limit; the depth and MSAA textures follow, and a recreated surface starts again from the window's own size. egui-wgpu configured the window's size as is, and `Surface::configure` panics beyond the limit: a window restored at another monitor's scale (opening an Outlook attachment on a mixed-DPI laptop asked an 8192-pixel device for 8600 × 3423, #577) or stretched across monitors closed PdfCraft, before its window appeared when it happened at start-up ([emilk/egui#8361](https://github.com/emilk/egui/issues/8361)). The published crate leaves out egui's licence files; `LICENSE-APACHE` and `LICENSE-MIT` are from the 0.36.2 tag; (2) `winit::catch_errors`: a new window's surface is configured inside wgpu error scopes (validation, out of memory, internal), and a failure is returned as `WgpuError::ConfigureSurface` (for the root window at start-up eframe returns it as `Error::Wgpu`, which PdfCraft retries with OpenGL); nothing is kept for the window. wgpu's default handler panicked instead, and eframe configures the root surface before the app is created, so a driver that can't configure one closed PdfCraft at launch (a 2015 Intel GPU through wgpu's GL backend timed out waiting for the GPU to go idle, `GpuWaitTimeout`, #519). Unchanged, so still a panic: an error in a later reconfigure (resize, a lost surface), a device lost during the first configure (wgpu reports it only to a callback), and a window shown at zero size (first configured on resize) | (1) `pdfcraft` `tests::a_window_larger_than_the_gpu_limit_gets_a_surface_within_it`, `tests::surface_fit_never_empties_or_overflows_a_surface`, and `tests::vendor_provenance_egui_wgpu_surface_fit_identity`, which keeps the patch in the build and in the paths that size a surface; (2) `pdfcraft` `tests::a_gpu_error_while_a_window_is_set_up_is_returned_not_a_panic` (a real device: an error inside `catch_errors` is returned, not a panic) and `tests::vendor_provenance_egui_wgpu_catch_errors_identity`, which keeps it around `add_surface`'s configure | | vello_common | 0.0.8 | Apache-2.0 OR MIT | (1) `clip::intersect` (the clip step every clipped path passes through) starts its row walk at the first row both paths have strips in, not the earlier of the two first rows. The walk visited every row from the top of the clip for each path (on the A1 sample's full-page canvas, about half its strip rows per stroke), and rows where one path has no strips produce no output, so the strips are unchanged (vello_common 0.1 starts at the later row too). Its cost grew with the page, and a full-page render of the A1 sample was about 30% faster; the output is byte-identical; (2) `StripStorage::room_for_path{,_within}`: whether one more path's coverage (at most the whole surface, counted twice) still fits below 2^31 values, the most a strip's packed alpha index reaches (`Strip::new` asserts it), or below a smaller limit; (4) `flatten::stroke`: when kurbo 0.13.1's stroker returns an outline containing NaN, which `fill` would drop whole, the path is stroked again from a copy (`robust_for_stroking`, one element out per element in) in which each curve whose control points lie within the stroke tolerance of its chord is a line (the #4907 fix, a nearly straight cubic given a NaN join, kurbo issues #617 and #519; its row here was lost in a merge and is restored with this entry) and each cubic whose second control point is its end point, as `y` builds (ISO 32000-2 §8.5.2.1 Table 58), has that control point moved the tolerance towards its first (`open_zero_end_handle`; the curve moves by at most 4/9 of the tolerance): kurbo's cusp search on such a curve's offset can stop on the end point, where the derivative is zero, and normalising it gives NaN, so at 16, 32 and 63 pt a two-curve `y` path drew nothing and a file in the PDFBox tracker lost its 32 pt "XX" outline. Only an exact zero triggers it (none of 192,000 random strokes with a handle 1e-9 long, nor of 256,000 `v` strokes, came back with NaN; 1,073 of 192,000 random `y`/`v` strokes at 1–64 pt did before and none after). A finite outline takes the old path unchanged | (1) `clip::tests::intersect_walks_only_rows_both_paths_have_strips` counts the rows the walk visits (`cargo test --manifest-path vendor/vello_common/Cargo.toml`) and fails without the patch; the render crate's pixel tests run through the patched crate; (2) `strip_generator::pdfcraft_tests::strips_have_room_until_a_surface_short_of_the_limit`; (4) `flatten::tests::a_tiny_degenerate_cubic_does_not_drop_the_stroke` and the render crate's `stroke_degenerate_cubic` fail without the near-line replacement, `flatten::tests::a_wide_stroke_of_a_curve_with_a_zero_end_tangent_is_drawn` (widths 1–64, every join and cap, `y` and the mirrored `v`) and the render crate's `stroke_zero_end_tangent` fail without the moved control point; `a_finite_outline_is_stroked_as_before` and `a_path_with_nan_is_still_dropped` pass either way | diff --git a/vendor/hayro-syntax/src/object/stream.rs b/vendor/hayro-syntax/src/object/stream.rs index cdc73763ff..92625b61f5 100644 --- a/vendor/hayro-syntax/src/object/stream.rs +++ b/vendor/hayro-syntax/src/object/stream.rs @@ -10,7 +10,7 @@ use crate::object::{Array, ObjectIdentifier}; use crate::object::{Object, ObjectLike, ObjectRefLike}; use crate::reader::Reader; use crate::reader::{Readable, ReaderContext, ReaderExt, Skippable}; -use crate::trivia::is_white_space_character; +use crate::trivia::{is_eol_character, is_regular_character, is_white_space_character}; use crate::util::{OptionLog, find_needle}; use alloc::borrow::Cow; use alloc::string::{String, ToString}; @@ -321,6 +321,11 @@ impl<'a> Readable<'a> for Stream<'a> { let offset = r.offset(); parse_proper(r, &dict) + .or_else(|| { + // PdfCraft patch (36): before searching on, which could reach a later object. + r.jump(offset); + parse_declared(r, &dict, true) + }) .or_else(|| { warn!("failed to parse stream, trying to parse it manually"); @@ -329,7 +334,7 @@ impl<'a> Readable<'a> for Stream<'a> { }) .or_else(|| { r.jump(offset); - parse_declared(r, &dict) + parse_declared(r, &dict, false) }) .error_none("was unable to manually parse the stream") } @@ -414,6 +419,11 @@ fn parse_proper<'a>(r: &mut Reader<'a>, dict: &Dict<'a>) -> Option> { let data = r.read_bytes(length as usize)?; r.skip_white_spaces(); r.forward_tag(b"endstream")?; + // PdfCraft patch (36): a whole token (ISO 32000-2 7.2.3), so a wrong `/Length` that stops + // before payload such as `endstreamer` does not end the stream there. + if r.tail().and_then(|t| t.first().copied()).is_some_and(is_regular_character) { + return None; + } Some(Stream::new(data, dict.clone())) } @@ -429,7 +439,7 @@ fn parse_fallback<'a>(r: &mut Reader<'a>, dict: &Dict<'a>) -> Option> forward_stream_eol(r)?; let tail = r.tail()?; - let endstream_offset = find_needle(tail, b"endstream")?; + let endstream_offset = find_endstream_token(tail)?; let data_end = trim_trailing_ascii_whitespace(&tail[..endstream_offset]); let data = tail.get(..data_end)?; @@ -443,20 +453,90 @@ fn parse_fallback<'a>(r: &mut Reader<'a>, dict: &Dict<'a>) -> Option> /// PdfCraft patch (36): a stream with no `endstream` after it, as a producer that writes /// `endstrea` leaves every stream, is the bytes its `/Length` gives, "how many bytes of the PDF /// file are used for the stream's data" (ISO 32000-2 7.3.8.2), as other readers take it. Neither -/// parse above found it, so every font and page content of such a file was lost and its pages -/// drew blank. Called only after [`parse_fallback`] found no `endstream` in the rest of the data, -/// so it doesn't search again, and only a `/Length` the data holds is read. -fn parse_declared<'a>(r: &mut Reader<'a>, dict: &Dict<'a>) -> Option> { +/// [`parse_proper`] nor [`parse_fallback`] found it, so every font and page content of such a file +/// was lost and its pages drew blank. Only a `/Length` the data holds is read. +/// +/// With `at_an_end`, tried before [`parse_fallback`], the `/Length` must also end where the +/// stream would ([`at_stream_end`]): before a whole `endstream`, before a misspelt one +/// (`endstrea`) or `endobj` where the object ends too, or at the end of the data. Otherwise the search for `endstream` would run on into a later object and take its +/// header and data as this stream's. Without it, tried last, after the search found no +/// `endstream` in the rest of the data, so it doesn't search again. +fn parse_declared<'a>(r: &mut Reader<'a>, dict: &Dict<'a>, at_an_end: bool) -> Option> { let length = dict.get::(LENGTH)?; r.skip_white_spaces_and_comments(); r.forward_tag(b"stream")?; forward_stream_eol(r)?; - let data = r.read_bytes(length as usize)?; + let data = r.read_bytes(usize::try_from(length).ok()?)?; + if at_an_end && !at_stream_end(r.tail().unwrap_or_default()) { + return None; + } Some(Stream::new(data, dict.clone())) } +/// PdfCraft patch (36): the first `endstream` in `data` that is a whole token (ISO 32000-2 +/// 7.2.3), not the start of payload bytes such as `endstreamer`. +fn find_endstream_token(data: &[u8]) -> Option { + let mut from = 0; + loop { + let at = from + find_needle(data.get(from..)?, b"endstream")?; + let end = at + b"endstream".len(); + if !data.get(end).copied().is_some_and(is_regular_character) { + return Some(at); + } + from = end; + } +} + +/// PdfCraft patch (36): whether `rest`, the data after a stream's `/Length` bytes, is where the +/// stream ends. A whole `endstream` token is. A misspelt one (`endstrea`), an `endobj` with the +/// `endstream` left out, or both, are only where the object ends as well, before the end of the +/// data, another object's `N G obj` header, or `xref`, `trailer` or `startxref`: payload such as +/// `endobj more`, where a wrong `/Length` stops, is not, and the search for `endstream` runs on. +fn at_stream_end(rest: &[u8]) -> bool { + let rest = skip_trivia(rest); + if keyword(rest, b"endstream").is_some() { + return true; + } + let (rest, misspelt) = keyword(rest, b"endstrea").map_or((rest, false), |r| (skip_trivia(r), true)); + let (rest, endobj) = keyword(rest, b"endobj").map_or((rest, false), |r| (skip_trivia(r), true)); + if !(misspelt || endobj) { + return rest.is_empty(); + } + rest.is_empty() + || [&b"xref"[..], b"trailer", b"startxref"].iter().any(|kw| keyword(rest, kw).is_some()) + || object_header(rest) +} + +/// Whether `rest` starts with an indirect object's `N G obj` header. +fn object_header(rest: &[u8]) -> bool { + fn number(r: &[u8]) -> Option<&[u8]> { + let digits = r.iter().take_while(|b| b.is_ascii_digit()).count(); + (digits > 0).then(|| skip_trivia(r.get(digits..).unwrap_or_default())) + } + number(rest).and_then(number).is_some_and(|r| keyword(r, b"obj").is_some()) +} + +/// What follows `kw` at the start of `data` when it is a whole token there. +fn keyword<'a>(data: &'a [u8], kw: &[u8]) -> Option<&'a [u8]> { + data.strip_prefix(kw).filter(|tail| !tail.first().copied().is_some_and(is_regular_character)) +} + +/// `data` with leading white space and comments skipped. +fn skip_trivia(mut data: &[u8]) -> &[u8] { + loop { + match data.first() { + Some(b) if is_white_space_character(*b) => data = data.get(1..).unwrap_or_default(), + Some(b'%') => { + let eol = data.iter().position(|b| is_eol_character(*b)).unwrap_or(data.len()); + data = data.get(eol..).unwrap_or_default(); + } + _ => return data, + } + } +} + fn trim_trailing_ascii_whitespace(data: &[u8]) -> usize { let mut end = data.len(); From 4d2aa29ee7c25b7f36cb3a18ead16c968f62494a Mon Sep 17 00:00:00 2001 From: jonathanwxh-cell Date: Mon, 9 Nov 2026 18:04:02 +0800 Subject: [PATCH 05/83] fix(preflight): resolve indirect spot families, null dict entries, and numeric equality (#6679) --- crates/preflight/src/spot.rs | 167 +++++++++++++++++++++++++++++++++-- 1 file changed, 158 insertions(+), 9 deletions(-) diff --git a/crates/preflight/src/spot.rs b/crates/preflight/src/spot.rs index 8367d8d28a..b75512fe37 100644 --- a/crates/preflight/src/spot.rs +++ b/crates/preflight/src/spot.rs @@ -64,7 +64,7 @@ impl Scan<'_> { } match o { Object::Array(a) => { - match a.first().and_then(Object::as_name) { + match a.first().map(|v| self.doc.resolve(v)).as_deref().and_then(Object::as_name) { Some(b"Separation") => self.separation(a), Some(b"DeviceN") => self.devicen(a), _ => {} @@ -138,14 +138,23 @@ impl Scan<'_> { } } - /// Whether `o` is `[/Separation name ...]` for the colorant `name`. + /// Whether `o` is `[/Separation name alternateSpace tintTransform]` for the colorant `name`, + /// with a non-null alternate space and tint transform (ISO 32000-1 8.6.6.4 and 7.3.9). fn separation_named(&self, o: &Object, name: &[u8]) -> bool { let o = self.doc.resolve(o); - let Some([kind, n, ..]) = o.as_array().map(Vec::as_slice) else { return false }; - kind.as_name() == Some(b"Separation") && self.doc.resolve(n).as_name() == Some(name) + let Some([kind, n, alt, tint, ..]) = o.as_array().map(Vec::as_slice) else { return false }; + self.doc.resolve(kind).as_name() == Some(b"Separation") + && self.doc.resolve(n).as_name() == Some(name) + && !matches!(&*self.doc.resolve(alt), Object::Null) + && !matches!(&*self.doc.resolve(tint), Object::Null) } } +/// Whether integer `i` and real `r` have the same numeric value (ISO 32000-1 7.3.3). +fn same_number(i: i64, r: f64) -> bool { + r.is_finite() && r.fract() == 0.0 && (i as f64) == r && (r as i64) == i && (i as f64 as i128) == i128::from(i) +} + /// Compares definitions, remembering which reference pairs it found equal, so a structure shared /// by many definitions is compared once, within [`MAX_COMPARE_STEPS`] and [`MAX_COMPARE_BYTES`]. struct Compare<'a> { @@ -158,13 +167,18 @@ struct Compare<'a> { } impl Compare<'_> { + /// Whether `v` is present (not `null` or a reference to no object, ISO 32000-1 7.3.7 and 7.3.9). + fn is_present(&self, v: &Object) -> bool { + !matches!(&*self.doc.resolve(v), Object::Null) + } + /// Whether `a` and `b` are the same object, wherever their parts are stored: references are /// followed (to [`MAX_COMPARE_DEPTH`]), so two identical streams under different object /// numbers are the same, and streams compare by their decoded data. Once the steps or the /// decode allowance run out, nothing more is called different. fn same(&mut self, a: &Object, b: &Object, depth: u8) -> bool { let pair = match (a, b) { - (Object::Ref(x), Object::Ref(y)) if x == y || self.equal.contains(&(*x, *y)) => return true, + (Object::Ref(x), Object::Ref(y)) if x == y || self.equal.contains(&(*x, *y)) || self.equal.contains(&(*y, *x)) => return true, (Object::Ref(x), Object::Ref(y)) => Some((*x, *y)), _ => None, }; @@ -179,6 +193,7 @@ impl Compare<'_> { (Object::Array(x), Object::Array(y)) => x.len() == y.len() && x.iter().zip(y).all(|(p, q)| self.same(p, q, deeper)), (Object::Dict(x), Object::Dict(y)) => self.same_dict(x, y, &[], deeper), (Object::Stream(x), Object::Stream(y)) => self.same_data(x, y, deeper) && self.same_dict(&x.dict, &y.dict, &ENCODING, deeper), + (Object::Int(x), Object::Real(y)) | (Object::Real(y), Object::Int(x)) => same_number(*x, *y), (x, y) => x == y, }; if same && let Some(pair) = pair { @@ -187,16 +202,24 @@ impl Compare<'_> { same } - /// Whether two dictionaries hold the same entries, those keyed in `skip` aside. + /// Whether two dictionaries hold the same non-null entries (ISO 32000-1 7.3.7), those keyed + /// in `skip` aside. fn same_dict(&mut self, a: &Dict, b: &Dict, skip: &[&[u8]], depth: u8) -> bool { - let kept = |d: &Dict| d.iter().filter(|(k, _)| !skip.contains(&k.as_slice())).count(); - kept(a) == kept(b) && a.iter().filter(|(k, _)| !skip.contains(&k.as_slice())).all(|(k, v)| b.get(k).is_some_and(|w| self.same(v, w, depth))) + let kept = |c: &Self, d: &Dict| d.iter().filter(|(k, v)| !skip.contains(&k.as_slice()) && c.is_present(v)).count(); + if kept(self, a) != kept(self, b) { + return false; + } + a.iter() + .filter(|(k, v)| !skip.contains(&k.as_slice()) && self.is_present(v)) + .collect::>() + .into_iter() + .all(|(k, v)| b.get(k).filter(|w| self.is_present(w)).is_some_and(|w| self.same(v, w, depth))) } /// Whether two streams hold the same data, however each is encoded. Data that can't be /// decoded within the allowance is not called different. fn same_data(&mut self, a: &Stream, b: &Stream, depth: u8) -> bool { - let stored_alike = |c: &mut Self, k: &[u8]| match (a.dict.get(k), b.dict.get(k)) { + let stored_alike = |c: &mut Self, k: &[u8]| match (a.dict.get(k).filter(|v| c.is_present(v)), b.dict.get(k).filter(|v| c.is_present(v))) { (None, None) => true, (Some(x), Some(y)) => c.same(x, y, depth), _ => false, @@ -210,3 +233,129 @@ impl Compare<'_> { } } } + +#[cfg(test)] +mod tests { + use std::collections::BTreeSet; + use std::sync::Arc; + + use pdfcraft_cos::{Dict, Document, Object}; + + use super::{Findings, check, same_number}; + use crate::{Level, objects, verify}; + + fn build(objs: &[&str]) -> Document { + let mut out = b"%PDF-1.7\n%\xE2\xE3\xCF\xD3\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()); + } + let id = "/ID [<00112233445566778899AABBCCDDEEFF> <00112233445566778899AABBCCDDEEFF>]"; + out.extend_from_slice(format!("trailer\n<< /Size {} /Root 1 0 R {id} >>\nstartxref\n{xref}\n%%EOF\n", objs.len() + 1).as_bytes()); + Document::open(Arc::new(out)).unwrap() + } + + fn spot_findings(spaces: &str, extra: &[&str]) -> Findings { + let page = format!("<< /Type /Page /Parent 2 0 R /Resources << /ColorSpace << {spaces} >> >> >>"); + let mut objs = vec![ + "<< /Type /Catalog /Pages 2 0 R >>", + "<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 200 200] >>", + &page, + "<< /FunctionType 2 /Domain [0 1] /C0 [1 1 1] /C1 [1 0 0] /N 1 >>", + "<< /FunctionType 2 /Domain [0 1] /C0 [1 1 1] /C1 [0 1 0] /N 1 >>", + "/Separation", + "/DeviceN", + ]; + objs.extend_from_slice(extra); + let doc = build(&objs); + let verified: Vec = verify(&doc, Level::A2b).into_iter().filter(|i| i.clause == "6.2.4.4").map(|i| i.message).collect(); + let found = check(&doc, &objects(&doc)); + assert_eq!(verified.len(), found.differing.len() + found.undefined.len()); + found + } + + #[test] + fn resolves_indirect_separation_and_devicen_family_names() { + // Object 6 is `/Separation` and object 7 is `/DeviceN`. + let found = spot_findings("/A [6 0 R /Gold /DeviceRGB 4 0 R] /B [/Separation /Gold /DeviceRGB 5 0 R]", &[]); + assert_eq!(found.differing, BTreeSet::from(["Gold".to_string()])); + + let found = spot_findings("/A [7 0 R [/Foil] /DeviceRGB 4 0 R << /Colorants << >> >>]", &[]); + assert_eq!(found.undefined, BTreeSet::from(["Foil".to_string()])); + + // A `/Colorants` entry whose `/Separation` family name is indirect defines the colorant. + let found = spot_findings("/A [7 0 R [/Foil] /DeviceRGB 4 0 R << /Colorants << /Foil [6 0 R /Foil /DeviceRGB 4 0 R] >> >>]", &[]); + assert_eq!(found, Findings::default()); + } + + #[test] + fn rejects_incomplete_or_null_operand_colorant_separations() { + for bad in [ + "[/Separation /Spot]", + "[/Separation /Spot /DeviceRGB]", + "[/Separation /Spot null 4 0 R]", + "[/Separation /Spot /DeviceRGB null]", + "[/Separation /Spot 99 0 R 4 0 R]", + "[/Separation /Spot /DeviceRGB 99 0 R]", + ] { + let spaces = format!("/A [/DeviceN [/Spot] /DeviceRGB 4 0 R << /Colorants << /Spot {bad} >> >>]"); + let found = spot_findings(&spaces, &[]); + assert_eq!(found.undefined, BTreeSet::from(["Spot".to_string()]), "{bad}"); + } + let valid = "/A [/DeviceN [/Spot] /DeviceRGB 4 0 R << /Colorants << /Spot [/Separation /Spot /DeviceRGB 4 0 R] >> >>]"; + assert_eq!(spot_findings(valid, &[]), Findings::default()); + } + + #[test] + fn ignores_null_dictionary_entries_and_matches_int_real_numbers() { + let extra = [ + "<< /FunctionType 2 /Domain [0.0 1.0] /C0 [1.0 1.0 1.0] /C1 [1.0 0.0 0.0] /N 1.0 /Range null /Encode 99 0 R >>", + "<< /FunctionType 4 /Domain [0 1] /Range [0 1 0 1 0 1] /Length 15 >>\nstream\n{ dup dup pop }\nendstream", + "<< /FunctionType 4 /Domain [0.0 1.0] /Range [0.0 1.0 0.0 1.0 0.0 1.0] /Filter null /DecodeParms null /Length 15 >>\nstream\n{ dup dup pop }\nendstream", + "<< /FunctionType 2 /Domain [0 1] /C0 [1 1 1] /C1 [1 0.5 0] /N 1 >>", + ]; + // Object 8 has float equivalents of object 4's integers plus null/dangling optional keys. + let found = spot_findings("/A [/Separation /Red /DeviceRGB 4 0 R] /B [/Separation /Red /DeviceRGB 8 0 R]", &extra); + assert_eq!(found, Findings::default()); + + // Object 9 and object 10 are identical Type 4 streams where object 10 has `/Filter null /DecodeParms null` and float domain/range. + let found = spot_findings("/A [/Separation /Red /DeviceRGB 9 0 R] /B [/Separation /Red /DeviceRGB 10 0 R]", &extra); + assert_eq!(found, Findings::default()); + + // Genuinely different numeric values still report differing. + let found = spot_findings("/A [/Separation /Red /DeviceRGB 8 0 R] /B [/Separation /Red /DeviceRGB 11 0 R]", &extra); + assert_eq!(found.differing, BTreeSet::from(["Red".to_string()])); + + // Precision-losing large integers do not falsely match rounded floats. + let mut doc = build(&[ + "<< /Type /Catalog /Pages 2 0 R >>", + "<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 200 200] >>", + "<< /Type /Page /Parent 2 0 R >>", + ]); + let mut fn_int = Dict::new(); + fn_int.set(b"N".to_vec(), Object::Int(i64::MAX)); + let mut fn_real = Dict::new(); + fn_real.set(b"N".to_vec(), Object::Real(i64::MAX as f64)); + let r_int = doc.add(Object::Dict(fn_int)); + let r_real = doc.add(Object::Dict(fn_real)); + let sep_a = Object::Array(vec![Object::name("Separation"), Object::name("Big"), Object::name("DeviceGray"), Object::Ref(r_int)]); + let sep_b = Object::Array(vec![Object::name("Separation"), Object::name("Big"), Object::name("DeviceGray"), Object::Ref(r_real)]); + doc.add(sep_a); + doc.add(sep_b); + let found = check(&doc, &objects(&doc)); + assert_eq!(found.differing, BTreeSet::from(["Big".to_string()])); + + assert!(same_number(0, -0.0)); + assert!(same_number(i64::MIN, i64::MIN as f64)); + assert!(!same_number(i64::MIN + 1, (i64::MIN + 1) as f64)); + assert!(!same_number(0, f64::NAN)); + assert!(!same_number(0, f64::INFINITY)); + assert!(!same_number(0, f64::NEG_INFINITY)); + } +} From 47961c20772874a13ac54e80f63e0b26443f5e52 Mon Sep 17 00:00:00 2001 From: jonathanwxh-cell Date: Mon, 9 Nov 2026 18:04:26 +0800 Subject: [PATCH 06/83] fix(preflight): walk soft-mask groups, indirect subtypes, and type 5 halftones (#6680) --- crates/preflight/src/gstate.rs | 70 +++++++++++- crates/preflight/src/transparency.rs | 155 +++++++++++++++++++++++++-- 2 files changed, 211 insertions(+), 14 deletions(-) diff --git a/crates/preflight/src/gstate.rs b/crates/preflight/src/gstate.rs index 64c19de116..9d24a8e054 100644 --- a/crates/preflight/src/gstate.rs +++ b/crates/preflight/src/gstate.rs @@ -28,7 +28,7 @@ const MAX_DEPTH: u8 = 128; /// The rendering intents of ISO 32000-1 Table 70. const INTENTS: [&[u8]; 4] = [b"AbsoluteColorimetric", b"RelativeColorimetric", b"Saturation", b"Perceptual"]; /// The entries of a type 5 halftone that aren't colorant halftones. -const HALFTONE_KEYS: [&[u8]; 3] = [b"Type", b"HalftoneType", b"HalftoneName"]; +const HALFTONE_KEYS: [&[u8]; 4] = [b"Type", b"HalftoneType", b"HalftoneName", b"TransferFunction"]; /// The primary colour components of DeviceGray, DeviceRGB and DeviceCMYK, whose halftones take /// the graphics state's transfer function. const PRIMARY: [&[u8]; 8] = [b"Gray", b"Red", b"Green", b"Blue", b"Cyan", b"Magenta", b"Yellow", b"Black"]; @@ -222,14 +222,76 @@ impl Scan<'_> { _ => self.findings.halftone_transfer |= transfer, } if colorant.is_none() && kind == Some(5) { - for (k, v) in ht.iter() { + for (k, _) in ht.iter() { if HALFTONE_KEYS.contains(&k.as_slice()) { continue; } - if let Some(component) = self.doc.resolve(v).as_dict() { - self.halftone(component, Some(k)); + let Some(entry) = self.present(ht, k) else { continue }; + match entry.as_dict() { + Some(component) => self.halftone(component, Some(k)), + None => { + self.findings.untyped = true; + if k != b"Default" && !PRIMARY.contains(&k.as_slice()) { + self.findings.missing_transfer.insert(String::from_utf8_lossy(k).into_owned()); + } + } } } } } } + +#[cfg(test)] +mod tests { + use std::collections::BTreeSet; + use std::sync::Arc; + + use pdfcraft_cos::Document; + + use super::{Findings, check}; + use crate::{Level, verify}; + + fn build(objs: &[&str]) -> Document { + let mut out = b"%PDF-1.7\n%\xE2\xE3\xCF\xD3\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()); + } + let id = "/ID [<00112233445566778899AABBCCDDEEFF> <00112233445566778899AABBCCDDEEFF>]"; + out.extend_from_slice(format!("trailer\n<< /Size {} /Root 1 0 R {id} >>\nstartxref\n{xref}\n%%EOF\n", objs.len() + 1).as_bytes()); + Document::open(Arc::new(out)).unwrap() + } + + #[test] + fn type5_halftone_transfer_function_is_not_treated_as_colorant() { + let doc = build(&[ + "<< /Type /Catalog /Pages 2 0 R >>", + "<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 200 200] >>", + "<< /Type /Page /Parent 2 0 R /Resources << /ExtGState << /G << /HT << /HalftoneType 5 /Default 5 0 R /Cyan 5 0 R /TransferFunction 4 0 R >> >> >> >> >>", + "<< /FunctionType 2 /Domain [0 1] /C0 [0] /C1 [1] /N 1 >>", + "<< /HalftoneType 1 /Frequency 60 /Angle 45 /SpotFunction /Round >>", + ]); + let found = check(&doc); + assert_eq!(found, Findings { halftone_transfer: true, ..Findings::default() }); + let messages: Vec = verify(&doc, Level::A2b).into_iter().filter(|i| i.clause == "6.2.5").map(|i| i.message).collect(); + assert_eq!(messages, ["A halftone has a transfer function, which only a non-primary colorant's halftone may have"]); + } + + #[test] + fn type5_halftone_non_dict_colorant_reports_untyped_and_missing_transfer() { + let doc = build(&[ + "<< /Type /Catalog /Pages 2 0 R >>", + "<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 200 200] >>", + "<< /Type /Page /Parent 2 0 R /Resources << /ExtGState << /G << /HT << /HalftoneType 5 /Default 4 0 R /Spot /Default /NullColorant null >> >> >> >> >>", + "<< /HalftoneType 1 /Frequency 60 /Angle 45 /SpotFunction /Round >>", + ]); + let found = check(&doc); + assert_eq!(found, Findings { untyped: true, missing_transfer: BTreeSet::from(["Spot".to_string()]), ..Findings::default() }); + } +} diff --git a/crates/preflight/src/transparency.rs b/crates/preflight/src/transparency.rs index 605bf4dfae..ad2fe97c0b 100644 --- a/crates/preflight/src/transparency.rs +++ b/crates/preflight/src/transparency.rs @@ -131,6 +131,16 @@ fn walk_page<'a>(doc: &'a Document, page: &Dict, shared: &'a mut Shared) -> Walk walk } +/// The default colour space resource name and component count for a device colour space name. +fn device_default_space(name: &[u8]) -> Option<(&'static [u8], i64)> { + match name { + b"DeviceGray" => Some((b"DefaultGray", 1)), + b"DeviceRGB" => Some((b"DefaultRGB", 3)), + b"DeviceCMYK" => Some((b"DefaultCMYK", 4)), + _ => None, + } +} + /// Whether `page` names a blending colour space PDF/A accepts without an output intent: its /// `/Group` has a `/CS` that is a blending colour space (ISO 32000-1 and 32000-2 11.3.4: /// `CalGray`, `CalRGB`, or an `ICCBased` space of 1, 3 or 4 components whose profile describes @@ -142,10 +152,19 @@ pub(crate) fn page_names_blending_space(doc: &Document, page: &Dict) -> bool { let group = page.get(b"Group").map(|g| doc.resolve(g)); let Some(cs) = group.as_deref().and_then(Object::as_dict).and_then(|g| g.get(b"CS")).map(|c| doc.resolve(c)) else { return false }; let (default, components): (&[u8], i64) = match &*cs { - Object::Name(n) if n == b"DeviceGray" => (b"DefaultGray", 1), - Object::Name(n) if n == b"DeviceRGB" => (b"DefaultRGB", 3), - Object::Name(n) if n == b"DeviceCMYK" => (b"DefaultCMYK", 4), - Object::Array(a) => return independent_blending_space(doc, a).is_some(), + Object::Name(n) => { + let Some(space) = device_default_space(n) else { return false }; + space + } + Object::Array(a) => match a.as_slice() { + [only] => { + let Some(space) = doc.resolve(only).as_name().and_then(device_default_space) else { + return independent_blending_space(doc, a).is_some(); + }; + space + } + _ => return independent_blending_space(doc, a).is_some(), + }, _ => return false, }; let resources = page.get(b"Resources").map(|r| doc.resolve(r)); @@ -268,7 +287,7 @@ impl Walk<'_> { /// A graphics state (ISO 32000-1 Table 58): whether it paints transparently, and the blend /// modes it names that ISO 32000-1 doesn't define. - fn ext_gstate(&mut self, g: &Object, _depth: usize) { + fn ext_gstate(&mut self, g: &Object, depth: usize) { let doc = self.doc; let g = held(doc, g); let Some(g) = g.as_dict() else { return }; @@ -276,11 +295,17 @@ impl Walk<'_> { let bm = g.get(b"BM").map(|b| held(doc, b)); let blend = bm.as_deref().is_some_and(|b| blends(doc, b)); // A soft mask is a dictionary; `/None`, null or anything else is none (ISO 32000-1 Table 58). - let mask = g.get(b"SMask").map(|m| held(doc, m)).is_some_and(|m| matches!(&*m, Object::Dict(_))); + let smask = g.get(b"SMask").map(|m| held(doc, m)); + let mask = smask.as_deref().is_some_and(|m| matches!(m, Object::Dict(_))); self.found.transparency |= below_one(b"CA") || below_one(b"ca") || blend || mask; if let Some(bm) = bm.as_deref() { unknown_blend_modes(doc, bm, &mut self.found); } + if let Some(Object::Dict(m)) = smask.as_deref() + && let Some(group) = m.get(b"G") + { + self.visit(group, depth + 1, Self::xobject); + } } /// An image with a soft mask, or a form XObject that is a transparency group or holds @@ -290,13 +315,16 @@ impl Walk<'_> { let x = held(doc, x); let Object::Stream(s) = &*x else { return }; let d = &s.dict; - if d.name(b"Subtype") == Some(b"Image") { + if d.get(b"Subtype").is_some_and(|s| held(doc, s).as_name() == Some(b"Image")) { let soft_mask = d.get(b"SMask").is_some_and(|m| matches!(&*held(doc, m), Object::Stream(_))); let in_data = d.get(b"SMaskInData").and_then(|v| held(doc, v).as_int()).is_some_and(|v| v > 0); self.found.transparency |= soft_mask || in_data; return; } - let group = d.get(b"Group").map(|g| held(doc, g)).is_some_and(|g| g.as_dict().is_some_and(|g| g.name(b"S") == Some(b"Transparency"))); + let group = d + .get(b"Group") + .map(|g| held(doc, g)) + .is_some_and(|g| g.as_dict().is_some_and(|g| g.get(b"S").is_some_and(|s| held(doc, s).as_name() == Some(b"Transparency")))); self.found.transparency |= group; if let Some(r) = d.get(b"Resources") { self.visit(r, depth + 1, Self::resources); @@ -323,9 +351,10 @@ impl Walk<'_> { /// A Type 3 font's resources, which its glyph procedures paint with. fn type3(&mut self, f: &Object, depth: usize) { - let f = held(self.doc, f); + let doc = self.doc; + let f = held(doc, f); let Some(d) = f.as_dict() else { return }; - if d.name(b"Subtype") != Some(b"Type3") { + if !d.get(b"Subtype").is_some_and(|s| held(doc, s).as_name() == Some(b"Type3")) { return; } if let Some(r) = d.get(b"Resources") { @@ -389,3 +418,109 @@ fn blends(doc: &Document, b: &Object) -> bool { _ => false, } } + +#[cfg(test)] +mod tests { + use std::sync::Arc; + + use pdfcraft_cos::Document; + + use super::{Shared, page_names_blending_space, page_painting}; + use crate::{Level, verify}; + + fn build(objs: &[&str]) -> Document { + let mut out = b"%PDF-1.7\n%\xE2\xE3\xCF\xD3\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()); + } + let id = "/ID [<00112233445566778899AABBCCDDEEFF> <00112233445566778899AABBCCDDEEFF>]"; + out.extend_from_slice(format!("trailer\n<< /Size {} /Root 1 0 R {id} >>\nstartxref\n{xref}\n%%EOF\n", objs.len() + 1).as_bytes()); + Document::open(Arc::new(out)).unwrap() + } + + #[test] + fn walks_soft_mask_transparency_group_for_unknown_blend_modes() { + let doc = build(&[ + "<< /Type /Catalog /Pages 2 0 R >>", + "<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 200 200] >>", + "<< /Type /Page /Parent 2 0 R /Group << /S /Transparency /CS [/CalRGB << /WhitePoint [0.9505 1 1.089] >>] >> /Resources << /ExtGState << /G0 << /SMask << /Type /Mask /S /Luminosity /G 4 0 R >> >> >> >> >>", + "<< /Type /XObject /Subtype /Form /BBox [0 0 10 10] /Resources << /ExtGState << /G1 << /BM /MaskCustom >> >> >> /Length 0 >>\nstream\n\nendstream", + ]); + let page = &pdfcraft_model::pages(&doc)[0]; + let painting = page_painting(&doc, &page.dict, &mut Shared::default()); + assert!(painting.transparency); + assert_eq!(painting.unknown_blend_modes, ["MaskCustom"]); + let messages: Vec = verify(&doc, Level::A2b).into_iter().filter(|i| i.clause == "6.2.10").map(|i| i.message).collect(); + assert_eq!(messages, ["A graphics state uses the blend mode MaskCustom, which ISO 32000-1 doesn't define"]); + } + + #[test] + fn resolves_indirect_subtypes_and_transparency_group_kinds() { + // Indirect `/Image` subtype on an image with `/SMaskInData 1`. + let doc = build(&[ + "<< /Type /Catalog /Pages 2 0 R >>", + "<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 200 200] >>", + "<< /Type /Page /Parent 2 0 R /Resources << /XObject << /Im 4 0 R >> >> >>", + "<< /Type /XObject /Subtype 5 0 R /Width 1 /Height 1 /SMaskInData 1 /Length 0 >>\nstream\n\nendstream", + "/Image", + ]); + let page = &pdfcraft_model::pages(&doc)[0]; + assert!(page_painting(&doc, &page.dict, &mut Shared::default()).transparency); + + // Indirect `/Transparency` group kind `/S` on a form XObject. + let doc = build(&[ + "<< /Type /Catalog /Pages 2 0 R >>", + "<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 200 200] >>", + "<< /Type /Page /Parent 2 0 R /Resources << /XObject << /Fm 4 0 R >> >> >>", + "<< /Type /XObject /Subtype /Form /BBox [0 0 10 10] /Group << /S 5 0 R >> /Length 0 >>\nstream\n\nendstream", + "/Transparency", + ]); + let page = &pdfcraft_model::pages(&doc)[0]; + assert!(page_painting(&doc, &page.dict, &mut Shared::default()).transparency); + + // Indirect `/Type3` subtype on a font with translucent resources and an unknown blend mode. + let doc = build(&[ + "<< /Type /Catalog /Pages 2 0 R >>", + "<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 200 200] >>", + "<< /Type /Page /Parent 2 0 R /Resources << /Font << /F0 4 0 R >> >> >>", + "<< /Type /Font /Subtype 5 0 R /Resources << /ExtGState << /G << /ca 0.5 /BM /T3Mode >> >> >> >>", + "/Type3", + ]); + let page = &pdfcraft_model::pages(&doc)[0]; + let painting = page_painting(&doc, &page.dict, &mut Shared::default()); + assert!(painting.transparency); + assert_eq!(painting.unknown_blend_modes, ["T3Mode"]); + } + + #[test] + fn accepts_single_element_device_colour_array_with_default_space() { + let cal_rgb = "[/CalRGB << /WhitePoint [0.9505 1 1.089] >>]"; + let doc = build(&[ + "<< /Type /Catalog /Pages 2 0 R >>", + "<< /Type /Pages /Kids [3 0 R 4 0 R 5 0 R 6 0 R] /Count 4 /MediaBox [0 0 200 200] >>", + &format!( + "<< /Type /Page /Parent 2 0 R /Group << /S /Transparency /CS [/DeviceRGB] >> /Resources << /ColorSpace << /DefaultRGB {cal_rgb} >> >> >>" + ), + &format!( + "<< /Type /Page /Parent 2 0 R /Group << /S /Transparency /CS [7 0 R] >> /Resources << /ColorSpace << /DefaultRGB {cal_rgb} >> >> >>" + ), + "<< /Type /Page /Parent 2 0 R /Group << /S /Transparency /CS [/DeviceRGB] >> /Resources << >> >>", + &format!( + "<< /Type /Page /Parent 2 0 R /Group << /S /Transparency /CS [/DeviceRGB /Extra] >> /Resources << /ColorSpace << /DefaultRGB {cal_rgb} >> >> >>" + ), + "/DeviceRGB", + ]); + let pages = pdfcraft_model::pages(&doc); + assert!(page_names_blending_space(&doc, &pages[0].dict)); + assert!(page_names_blending_space(&doc, &pages[1].dict)); + assert!(!page_names_blending_space(&doc, &pages[2].dict)); + assert!(!page_names_blending_space(&doc, &pages[3].dict)); + } +} From 8d6a8269d4a66f0486cd4c0968440a1026bd2ec0 Mon Sep 17 00:00:00 2001 From: Ethunxxx Date: Mon, 9 Nov 2026 11:04:53 +0100 Subject: [PATCH 07/83] M4: custom document properties are kept in XMP as pdfx: properties (organize.custom-properties) (#6686) --- crates/automation/src/tools.rs | 4 +- crates/organize/src/lib.rs | 17 +++-- crates/organize/src/tests.rs | 118 +++++++++++++++++++++++++++++- crates/organize/src/xmp.rs | 128 +++++++++++++++++++++++++++++---- parity/acrobat-features.toml | 6 +- 5 files changed, 248 insertions(+), 25 deletions(-) diff --git a/crates/automation/src/tools.rs b/crates/automation/src/tools.rs index 0cade83abd..8146f2b57b 100644 --- a/crates/automation/src/tools.rs +++ b/crates/automation/src/tools.rs @@ -187,7 +187,7 @@ pub fn tools() -> Vec { }), &["doc"], )), - t("doc_set_info", "Set document metadata", "Set a document information entry such as Title, Author, Subject or Keywords, or a custom property under any other name (no spaces, delimiters, / or #; at most 127 bytes; doc_info lists them under custom); an empty value removes it. When the file has XMP metadata, the matching XMP property (dc:title, dc:creator, dc:description, pdf:Keywords, xmp:CreatorTool, pdf:Producer) is changed too, so viewers that read XMP first show the new value. Undoable.") + t("doc_set_info", "Set document metadata", "Set a document information entry such as Title, Author, Subject or Keywords, or a custom property under any other name (no spaces, delimiters, / or #; at most 127 bytes; doc_info lists them under custom); an empty value removes it. When the file has XMP metadata, the matching XMP property (dc:title, dc:creator, dc:description, pdf:Keywords, xmp:CreatorTool, pdf:Producer) is changed too, so viewers that read XMP first show the new value, and a custom property is kept there as pdfx: when its name can be an XML name (in a PDF/A file only one already there is changed). Undoable.") .with(schema(json!({ "doc": doc(), "key": { "type": "string" }, "value": { "type": "string" } }), &["doc", "key", "value"])), t("page_render", "Render a page", "Render one page to a PNG image (default 96 dpi, at most 600). When the picture is incomplete or approximate (content cut at the page's safety budget, a colour painted in a fallback colour), a JSON part follows the image: { page, warnings: [sentence, ...] }.") .ro() @@ -1364,7 +1364,7 @@ pub fn tools() -> Vec { t("pdfa_convert", "Save as PDF/A", "Standards ▸ Save as PDF/A: fix what can be fixed for PDF/A-2b or 3b (XMP identification and metadata, an sRGB output intent, forbidden actions and JavaScript, annotation print flags, image interpolation, encryption, the file header, the file identifier). Returns what was fixed and what remains (e.g. fonts that aren't embedded). Undoable; save the document to keep it.") .cmd("standards.pdfa") .with(schema(json!({ "doc": doc(), "level": { "type": "string", "enum": ["2b", "3b"] } }), &["doc"])), - t("standards_remove_id", "Remove PDF/A or PDF/X identification", "Remove what identifies the document as PDF/A (its pdfaid: XMP properties) or PDF/X (GTS_PDFXVersion and GTS_PDFXConformance, in the document information and as pdfxid: XMP properties), e.g. after edits it no longer meets. Output intents and other metadata stay. Fails when the document declares nothing of the kind. Undoable.") + t("standards_remove_id", "Remove PDF/A or PDF/X identification", "Remove what identifies the document as PDF/A (its pdfaid: XMP properties) or PDF/X (GTS_PDFXVersion and GTS_PDFXConformance, in the document information and as pdfxid: or pdfx: XMP properties), e.g. after edits it no longer meets. Output intents and other metadata stay. Fails when the document declares nothing of the kind. Undoable.") .with(schema(json!({ "doc": doc(), "standard": { "type": "string", "enum": ["pdfa", "pdfx"] } }), &["doc", "standard"])), t("action_list", "List actions", "Action Wizard: the built-in actions (name, description, steps) and every step an action can use (id, label, whether it takes a text argument).") .ro() diff --git a/crates/organize/src/lib.rs b/crates/organize/src/lib.rs index d85cd15645..2fd997f549 100644 --- a/crates/organize/src/lib.rs +++ b/crates/organize/src/lib.rs @@ -685,7 +685,8 @@ pub fn has_info(doc: &Document, key: &str) -> bool { /// /// Documents with XMP metadata also carry Title, Author, Subject, Keywords, Creator and Producer /// in the XMP packet, and viewers that read XMP first show its value: the packet's property is -/// changed (or removed) to match (see `xmp`). +/// changed (or removed) to match, and a custom property is kept there as `pdfx:` as Acrobat +/// keeps it (see `xmp`). pub fn set_info(doc: &mut Document, key: &str, value: &str) -> Result<(), OrganizeError> { if !STANDARD_INFO_KEYS.contains(&key) { check_custom_key(key)?; @@ -745,8 +746,11 @@ impl Standard { /// Remove what identifies the document as meeting `standard`: the `pdfaid:` XMP properties of /// ISO 19005, or ISO 15930's `GTS_PDFXVersion` and `GTS_PDFXConformance` in the document -/// information and as `pdfxid:` XMP properties. Output intents and other metadata stay. Refused -/// when the document declares nothing of the kind. +/// information and as `pdfxid:` XMP properties, with the `pdfx:` copies that mirror those +/// information entries as custom properties (see `xmp`; a packet that binds `pdfx` to another +/// namespace keeps that namespace's properties). Output intents and other metadata stay. Refused, +/// changing nothing, when the document declares nothing of the kind: a `pdfx:` copy alone is not a +/// declaration. pub fn remove_standard_id(doc: &mut Document, standard: Standard) -> Result<(), OrganizeError> { let (props, info_keys): (&[&str], &[&str]) = match standard { Standard::PdfA => (&["pdfaid:part", "pdfaid:conformance", "pdfaid:amd", "pdfaid:rev"], &[]), @@ -759,7 +763,12 @@ pub fn remove_standard_id(doc: &mut Document, standard: Standard) -> Result<(), removed = true; } } - if removed { Ok(()) } else { Err(OrganizeError::Invalid(format!("the document doesn't declare {}", standard.label()))) } + if !removed { + return Err(OrganizeError::Invalid(format!("the document doesn't declare {}", standard.label()))); + } + // The information entries' custom-property copies go with them. + xmp::remove_custom_properties(doc, info_keys)?; + Ok(()) } #[cfg(test)] diff --git a/crates/organize/src/tests.rs b/crates/organize/src/tests.rs index ed95321006..4e8aa02c67 100644 --- a/crates/organize/src/tests.rs +++ b/crates/organize/src/tests.rs @@ -636,7 +636,7 @@ fn document_info_edits_update_the_xmp_packet() { set_info(&mut doc, "Author", "Demo Consulting").unwrap(); set_info(&mut doc, "Subject", "").unwrap(); set_info(&mut doc, "Keywords", "alpha, beta").unwrap(); - set_info(&mut doc, "Rating", "5").unwrap(); // no XMP counterpart: /Info only + set_info(&mut doc, "Rating", "5").unwrap(); // a custom property: pdfx:Rating let doc = save_and_reopen(&doc); assert_eq!(info(&doc, "Title").as_deref(), Some("Sales Guide v1.0 & ")); let xmp = xmp_text(&doc).unwrap(); @@ -644,6 +644,7 @@ fn document_info_edits_update_the_xmp_packet() { assert_eq!(xmp_values(&xmp, "dc:creator"), ["Demo Consulting"]); assert!(xmp_values(&xmp, "dc:description").is_empty(), "{xmp}"); assert_eq!(xmp_values(&xmp, "pdf:Keywords"), ["alpha, beta"]); + assert_eq!(xmp_values(&xmp, "pdfx:Rating"), ["5"]); assert_eq!(xmp_values(&xmp, "pdf:Producer"), ["Writer 1.0"]); assert!(xmp.starts_with(""), "{xmp}"); // Stored unfiltered, still typed as metadata. @@ -690,9 +691,10 @@ fn document_info_falls_back_to_the_xmp_packet() { #[test] fn document_info_edits_leave_files_without_usable_xmp_alone() { - // No packet: none is added. + // No packet: none is added, for a custom property either. let mut doc = open(fixture()); set_info(&mut doc, "Title", "New").unwrap(); + set_info(&mut doc, "Project", "Apollo").unwrap(); assert_eq!(xmp_text(&save_and_reopen(&doc)), None); // A packet that isn't UTF-8 is not rewritten. let mut latin1 = LIBREOFFICE_XMP.as_bytes().to_vec(); @@ -704,6 +706,96 @@ fn document_info_edits_leave_files_without_usable_xmp_alone() { assert_eq!(xmp_stream(&doc).unwrap().decoded().unwrap(), latin1); } +/// Acrobat keeps each custom property in the XMP packet too, as `pdfx:` (the +/// http://ns.adobe.com/pdfx/1.3/ schema), so tools that read XMP see the same properties as +/// Document Properties ▸ Custom. +#[test] +fn custom_document_properties_are_kept_in_the_xmp_packet_as_pdfx() { + // An Acrobat-style packet already holding one, in a description of its own. + let acrobat = LIBREOFFICE_XMP.replace( + " ", + " Sales\n ", + ); + let mut doc = doc_with_xmp(acrobat.as_bytes()); + set_info(&mut doc, "Project", "Apollo & ").unwrap(); + set_info(&mut doc, "Größe", "A4").unwrap(); + set_info(&mut doc, "Dept", "R&D").unwrap(); + let mut doc = save_and_reopen(&doc); + assert_eq!((info(&doc, "Project").as_deref(), info(&doc, "Dept").as_deref()), (Some("Apollo & "), Some("R&D"))); + let xmp = xmp_text(&doc).unwrap(); + assert_eq!(xmp_values(&xmp, "pdfx:Project"), ["Apollo & <X>"], "{xmp}"); + assert_eq!(xmp_values(&xmp, "pdfx:Größe"), ["A4"], "{xmp}"); + assert!(xmp.contains(""), "{xmp}"); + // One already there changes where it stands; the rest of the packet is as it was. + assert!(xmp.contains("xmlns:pdfx=\"http://ns.adobe.com/pdfx/1.3/\">R&D"), "{xmp}"); + assert_eq!(xmp_values(&xmp, "dc:title"), ["Untitled 1"]); + assert_eq!(xmp_values(&xmp, "pdf:Producer"), ["Writer 1.0"]); + // Changed again, then removed with the property. + set_info(&mut doc, "Project", "Gemini").unwrap(); + let xmp = xmp_text(&doc).unwrap(); + assert_eq!((xmp.matches("").count(), xmp_values(&xmp, "pdfx:Project")), (1, vec!["Gemini".to_string()]), "{xmp}"); + for key in ["Project", "Größe", "Dept"] { + set_info(&mut doc, key, "").unwrap(); + } + let xmp = xmp_text(&save_and_reopen(&doc)).unwrap(); + assert!(!xmp.contains("", + " \n ", + ); + let mut doc = doc_with_xmp(pdfa.as_bytes()); + set_info(&mut doc, "Project", "Apollo").unwrap(); + let doc = save_and_reopen(&doc); + assert_eq!(info(&doc, "Project").as_deref(), Some("Apollo")); + assert_eq!(xmp_text(&doc).unwrap(), pdfa, "no undescribed schema in a PDF/A packet"); + // The declaration counts however its value is written (here CDATA, which isn't read as text). + let cdata = LIBREOFFICE_XMP.replace( + " ", + " \n ", + ); + let mut doc = doc_with_xmp(cdata.as_bytes()); + set_info(&mut doc, "Project", "Apollo").unwrap(); + assert_eq!(xmp_text(&doc).unwrap(), cdata); + // One the packet already has (described, as an Acrobat PDF/A file would) is changed and removed. + let described = pdfa.replace( + " ", + " Sales\n ", + ); + let mut doc = doc_with_xmp(described.as_bytes()); + set_info(&mut doc, "Dept", "R&D").unwrap(); + assert_eq!(xmp_text(&doc).unwrap(), described.replace(">Sales<", ">R&D<")); + set_info(&mut doc, "Dept", "").unwrap(); + assert_eq!(xmp_text(&doc).unwrap(), described.replace("Sales", "")); + + // `pdfx` bound to the PDF/X identification namespace: neither changed nor added to. + let other = LIBREOFFICE_XMP.replace( + " ", + " keep\n ", + ); + let mut doc = doc_with_xmp(other.as_bytes()); + set_info(&mut doc, "Dept", "R&D").unwrap(); + set_info(&mut doc, "Project", "Apollo").unwrap(); + assert_eq!((info(&doc, "Dept").as_deref(), info(&doc, "Project").as_deref()), (Some("R&D"), Some("Apollo"))); + assert_eq!(xmp_text(&save_and_reopen(&doc)).unwrap(), other); +} + #[test] fn successive_incremental_saves_stack_revisions() { let mut doc = open(fixture()); @@ -1384,6 +1476,28 @@ fn removing_the_pdfx_identification_keeps_the_output_intent_and_other_claims() { let mut x1a = doc_print("CGATS TR 001", "icc", "/GTS_PDFXVersion (PDF/X-1:2001) /GTS_PDFXConformance (PDF/X-1a:2001)", None); remove_standard_id(&mut x1a, Standard::PdfX).unwrap(); assert_eq!((info(&x1a, "GTS_PDFXVersion"), info(&x1a, "GTS_PDFXConformance")), (None, None)); + + // Those information entries are custom properties, so XMP may also hold them as pdfx: (an + // element or an attribute); the copies go with them, and other custom properties stay. + let xmp = "\ + PDF/X-1:2001Print"; + let mut x1a = doc_print("CGATS TR 001", "icc", "/GTS_PDFXVersion (PDF/X-1:2001) /GTS_PDFXConformance (PDF/X-1a:2001)", Some(xmp)); + remove_standard_id(&mut x1a, Standard::PdfX).unwrap(); + let x = pdfx_of(&full_roundtrip(&x1a)); + assert!(x.xmp.as_deref().is_some_and(|x| !x.contains("GTS_") && x.contains("Print")), "{:?}", x.xmp); + + // A copy alone declares nothing: refused, and the copy stays. + let mut copy_only = doc_print("CGATS TR 001", "icc", "/Title (Flyer)", Some(xmp)); + assert!(matches!(remove_standard_id(&mut copy_only, Standard::PdfX), Err(OrganizeError::Invalid(_)))); + assert!(!copy_only.is_modified(), "a refused removal changes nothing"); + + // `pdfx` bound to another namespace: the declaration in /Info goes, that namespace's property stays. + let foreign = xmp.replace("http://ns.adobe.com/pdfx/1.3/", "http://example.com/other/"); + let mut x1a = doc_print("CGATS TR 001", "icc", "/GTS_PDFXVersion (PDF/X-1:2001)", Some(foreign.as_str())); + remove_standard_id(&mut x1a, Standard::PdfX).unwrap(); + assert_eq!(info(&x1a, "GTS_PDFXVersion"), None); + assert_eq!(pdfx_of(&full_roundtrip(&x1a)).xmp.as_deref(), Some(foreign.as_str())); } #[test] diff --git a/crates/organize/src/xmp.rs b/crates/organize/src/xmp.rs index adda9ab862..23eca1e1aa 100644 --- a/crates/organize/src/xmp.rs +++ b/crates/organize/src/xmp.rs @@ -6,7 +6,10 @@ //! information key maps to the XMP property PDF/A pairs it with (ISO 19005-1 §6.7.3, the same //! mapping `pdfcraft-preflight` writes): Title → `dc:title`, Author → `dc:creator`, Subject → //! `dc:description`, Keywords → `pdf:Keywords`, Creator → `xmp:CreatorTool`, Producer → -//! `pdf:Producer`. +//! `pdf:Producer`. A custom property (Document Properties ▸ Custom) is kept as `pdfx:`, plain +//! text in the `http://ns.adobe.com/pdfx/1.3/` schema where Acrobat keeps them, so tools that read +//! XMP see it too; one whose name can't be an XML element name stays in `/Info` only, and a +//! PDF/A packet gets no new one (see [`sync_info`]). //! //! The packet is edited as text, touching only the property that changed: everything else in it //! (other schemas, padding, unknown properties) stays byte for byte. A file without XMP gets @@ -24,6 +27,8 @@ const MAX_XMP: usize = 16 << 20; const DC: &str = "http://purl.org/dc/elements/1.1/"; const PDF: &str = "http://ns.adobe.com/pdf/1.3/"; const XMP: &str = "http://ns.adobe.com/xap/1.0/"; +/// Adobe's PDF extension schema: custom document properties, each under its own name. +const PDFX: &str = "http://ns.adobe.com/pdfx/1.3/"; /// How an XMP property holds its value. #[derive(Clone, Copy, Debug, PartialEq)] @@ -50,26 +55,97 @@ fn property(key: &str) -> Option<(&'static str, &'static str, Shape)> { } /// After the information entry `key` became `value` (empty: removed), make the catalog's XMP -/// packet say the same. Does nothing for keys XMP doesn't mirror, files without XMP, and packets -/// that can't be edited safely. +/// packet say the same: the standard keys' properties, and `pdfx:` for a custom property. +/// Does nothing for standard keys XMP doesn't mirror (the dates, Trapped), custom names that +/// aren't XML names, files without XMP, and packets that can't be edited safely. +/// +/// A custom property is left alone where `pdfx:` would not mean Adobe's schema (a packet binding +/// the prefix to another namespace keeps its own property), and in a packet that declares PDF/A +/// one is changed or removed where it stands but never added: PDF/A-1 to -3 allow a schema only +/// when the packet describes it in an extension schema (ISO 19005-2 §6.6.2.3), so a new `pdfx:` +/// property would end the file's conformance. `/Info` keeps it either way. pub(crate) fn sync_info(doc: &mut Document, key: &str, value: &str) -> Result<(), OrganizeError> { - let Some((name, ns, shape)) = property(key) else { return Ok(()) }; - edit_packet(doc, |xmp| set_property(xmp, name, ns, shape, value)).map(|_| ()) + if let Some((name, ns, shape)) = property(key) { + return edit_packet(doc, |xmp| set_property(xmp, name, ns, shape, value)).map(|_| ()); + } + if crate::STANDARD_INFO_KEYS.contains(&key) || !is_xml_name(key) { + return Ok(()); + } + let name = format!("pdfx:{key}"); + edit_packet(doc, |xmp| { + if binds_prefix_elsewhere(xmp, "pdfx", PDFX) { + return None; + } + // A declaration counts whatever its value says (CDATA, markup): it is the claim that matters. + if !has_property(xmp, &name) && has_property(xmp, "pdfaid:part") { + return None; + } + set_property(xmp, &name, PDFX, Shape::Text, value) + }) + .map(|_| ()) +} + +/// Remove the `pdfx:` copies of the custom properties `keys` (see [`sync_info`]), unless the packet +/// binds `pdfx` to another namespace, whose properties are its own; whether any was there. +pub(crate) fn remove_custom_properties(doc: &mut Document, keys: &[&str]) -> Result { + let names: Vec = keys.iter().map(|k| format!("pdfx:{k}")).collect(); + edit_packet(doc, |xmp| if binds_prefix_elsewhere(xmp, "pdfx", PDFX) { None } else { remove_all(xmp, &names) }) +} + +/// Whether `xmp` has the property `name`, as an element or an attribute, whatever its value. +fn has_property(xmp: &str, name: &str) -> bool { + element_span(xmp, name).is_some() || attribute_span(xmp, name).is_some() +} + +/// Whether `xmp` declares `prefix` for a namespace other than `ns` anywhere (`xmlns:pdfx="…"` on +/// any element), so a property written with that prefix may belong to another schema. A +/// declaration whose value never ends counts as another namespace. +fn binds_prefix_elsewhere(xmp: &str, prefix: &str, ns: &str) -> bool { + let attr = format!("xmlns:{prefix}"); + // Each declaration is read only up to its closing quote, and the first other value stops the + // search, so this is linear in the packet. + xmp.match_indices(&attr).any(|(i, _)| { + // `xmlns:pdfx` must end there (not `xmlns:pdfxid`), then `=` and a quoted value. + let Some(rest) = xmp.get(i + attr.len()..).and_then(|r| r.trim_start().strip_prefix('=')).map(str::trim_start) else { + return false; + }; + let Some(quote) = rest.chars().next().filter(|c| matches!(c, '"' | '\'')) else { return false }; + rest.get(1..).and_then(|v| v.get(..v.find(quote)?)).is_none_or(|value| value != ns) + }) +} + +/// Whether `name` can follow `pdfx:` in an element name: an XML name without a colon (XML 1.0 +/// §2.3 `NameStartChar` then `NameChar`s; Namespaces in XML `NCName`). Custom property names may +/// hold characters XML names can't (`1st`, `Q&A`, `a:b`); those are not mirrored rather than +/// written under an escaped name of our own. +fn is_xml_name(name: &str) -> bool { + let start = |c: char| { + matches!(c, + 'A'..='Z' | '_' | 'a'..='z' | '\u{C0}'..='\u{D6}' | '\u{D8}'..='\u{F6}' | '\u{F8}'..='\u{2FF}' | '\u{370}'..='\u{37D}' + | '\u{37F}'..='\u{1FFF}' | '\u{200C}'..='\u{200D}' | '\u{2070}'..='\u{218F}' | '\u{2C00}'..='\u{2FEF}' + | '\u{3001}'..='\u{D7FF}' | '\u{F900}'..='\u{FDCF}' | '\u{FDF0}'..='\u{FFFD}' | '\u{10000}'..='\u{EFFFF}') + }; + let rest = |c: char| start(c) || matches!(c, '-' | '.' | '0'..='9' | '\u{B7}' | '\u{300}'..='\u{36F}' | '\u{203F}'..='\u{2040}'); + let mut chars = name.chars(); + chars.next().is_some_and(start) && chars.all(rest) } /// Remove every occurrence of the properties `names` from the catalog's XMP packet; whether any /// was there. pub(crate) fn remove_properties(doc: &mut Document, names: &[&str]) -> Result { - edit_packet(doc, |xmp| { - let mut out: Option = None; - for name in names { - // Each removal shortens the packet, so this ends. - while let Some(next) = set_property(out.as_deref().unwrap_or(xmp), name, "", Shape::Text, "") { - out = Some(next); - } + edit_packet(doc, |xmp| remove_all(xmp, names)) +} + +/// `xmp` without any occurrence of the properties `names`; `None` when none was there. +fn remove_all(xmp: &str, names: &[impl AsRef]) -> Option { + let mut out: Option = None; + for name in names { + // Each removal shortens the packet, so this ends. + while let Some(next) = set_property(out.as_deref().unwrap_or(xmp), name.as_ref(), "", Shape::Text, "") { + out = Some(next); } - out - }) + } + out } /// Rewrite the catalog's XMP packet with `edit` (`None`: unchanged); whether it changed. Files @@ -432,6 +508,30 @@ mod tests { assert!(out.contains("New"), "{out}"); } + #[test] + fn only_names_an_element_can_have_are_mirrored() { + for name in ["Project", "_draft", "Größe", "a.b-c_1", "日本語", "x·y", "Ω"] { + assert!(is_xml_name(name), "{name}"); + } + for name in ["", "1st", "-x", ".x", "·x", "a:b", "Q&A", "it's", "a\"b", "×x", "a b", "a=b", "x\u{FFFE}"] { + assert!(!is_xml_name(name), "{name}"); + } + } + + #[test] + fn a_prefix_bound_to_another_namespace_is_found() { + for (xmp, other) in [ + ("", false), + ("", false), + ("", true), + (""; diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index bf39eb0cd4..c76929bca5 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -3586,8 +3586,8 @@ milestone = "M4" status = "partial" commands = ["file.properties"] tools = ["doc_info", "doc_set_info"] -evidence = ["crates/organize/src/tests.rs::custom_document_properties_are_listed_set_removed_and_their_names_checked", "crates/render/src/inspect/tests.rs::custom_document_properties_are_listed_apart_from_the_standard_ones", "crates/automation/tests/it/automation.rs::custom_document_properties_are_set_listed_and_cleared_through_the_tools", "crates/ui-egui/tests/it/editing.rs::custom_properties_are_added_in_the_custom_tab_as_one_undoable_step", "crates/ui-egui/tests/it/editing.rs::custom_properties_are_changed_and_deleted_in_the_custom_tab", "crates/ui-egui/tests/it/editing.rs::the_custom_tab_refuses_names_a_custom_property_cannot_have", "crates/ui-egui/tests/it/editing.rs::a_custom_property_whose_name_the_tools_refuse_is_listed_and_left_alone", "crates/ui-egui/tests/it/editing.rs::a_custom_property_is_deleted_from_an_info_dictionary_in_the_trailer", "crates/ui-egui/tests/it/editing.rs::add_stops_at_the_256_custom_properties_the_tab_lists", "crates/organize/src/tests.rs::document_info_entries_are_cleared_from_an_info_dictionary_in_the_trailer"] -notes = "Document Properties ▸ Custom lists them, with Name and Value fields and Add, Update (the value of the listed property the Name field names) and Delete; OK writes the changes as one undo step with the other tabs' changes. A property already in the file under a name the tools refuse (a space, a delimiter) or a name that is not UTF-8 is listed and cannot be changed or deleted there, and Add is off for a name the file already uses for an entry that is not listed (not text, only padding, or past the 256 listed), and once the tab or the file lists 256 properties. Through the tools: doc_set_info sets or clears an /Info entry under any name the spec does not use (names checked as Acrobat does: no standard name, no empty name, no spaces or delimiters, at most 127 bytes), and doc_info lists them under custom, apart from the standard entries. Not yet: mirroring them into XMP (pdfx:) as Acrobat does" +evidence = ["crates/organize/src/tests.rs::custom_document_properties_are_listed_set_removed_and_their_names_checked", "crates/render/src/inspect/tests.rs::custom_document_properties_are_listed_apart_from_the_standard_ones", "crates/automation/tests/it/automation.rs::custom_document_properties_are_set_listed_and_cleared_through_the_tools", "crates/ui-egui/tests/it/editing.rs::custom_properties_are_added_in_the_custom_tab_as_one_undoable_step", "crates/ui-egui/tests/it/editing.rs::custom_properties_are_changed_and_deleted_in_the_custom_tab", "crates/ui-egui/tests/it/editing.rs::the_custom_tab_refuses_names_a_custom_property_cannot_have", "crates/ui-egui/tests/it/editing.rs::a_custom_property_whose_name_the_tools_refuse_is_listed_and_left_alone", "crates/ui-egui/tests/it/editing.rs::a_custom_property_is_deleted_from_an_info_dictionary_in_the_trailer", "crates/ui-egui/tests/it/editing.rs::add_stops_at_the_256_custom_properties_the_tab_lists", "crates/organize/src/tests.rs::document_info_entries_are_cleared_from_an_info_dictionary_in_the_trailer", "crates/organize/src/tests.rs::custom_document_properties_are_kept_in_the_xmp_packet_as_pdfx", "crates/organize/src/tests.rs::custom_properties_add_nothing_to_pdfa_packets_or_another_schemas_pdfx"] +notes = "Document Properties ▸ Custom lists them, with Name and Value fields and Add, Update (the value of the listed property the Name field names) and Delete; OK writes the changes as one undo step with the other tabs' changes. A property already in the file under a name the tools refuse (a space, a delimiter) or a name that is not UTF-8 is listed and cannot be changed or deleted there, and Add is off for a name the file already uses for an entry that is not listed (not text, only padding, or past the 256 listed), and once the tab or the file lists 256 properties. Through the tools: doc_set_info sets or clears an /Info entry under any name the spec does not use (names checked as Acrobat does: no standard name, no empty name, no spaces or delimiters, at most 127 bytes), and doc_info lists them under custom, apart from the standard entries. In a file with XMP metadata each one set or cleared is also set or removed as pdfx: (the http://ns.adobe.com/pdfx/1.3/ schema, where Acrobat keeps them), so tools that read XMP see the same properties; a name that can't be an XML element name (1st, Q&A, a:b) stays in /Info only. In a file that declares PDF/A one the packet already has is changed or removed, but none is added, since PDF/A-1 to -3 allow a schema only when the packet describes it (ISO 19005-2 §6.6.2.3); a packet that binds the pdfx prefix to another namespace is left alone. Not yet: an XMP packet for a file without one, and reading custom properties a file states only in XMP" [[feature]] id = "organize.xmp-editor" @@ -8138,7 +8138,7 @@ status = "shipped" commands = ["standards.pdfa"] tools = ["standards_remove_id"] evidence = ["crates/organize/src/tests.rs::removing_the_pdfx_identification_keeps_the_output_intent_and_other_claims", "crates/organize/src/tests.rs::removing_the_pdfa_identification_removes_its_xmp_properties_in_either_form", "crates/automation/tests/it/automation.rs::pdfa_through_tools", "crates/ui-egui/tests/it/compare.rs::pdfa_dialog_verifies_and_converts"] -notes = "Standards ▸ PDF/A ▸ Remove PDF/A identification, and the standards_remove_id tool (pdfa or pdfx): removes the pdfaid: XMP properties, or GTS_PDFXVersion and GTS_PDFXConformance from the document information and the pdfxid: XMP properties. Output intents and other claims (PDF/UA) stay; a packet that can't be edited safely is left as it is. Not yet: PDF/X removal in the interface (there is no PDF/X dialog yet)" +notes = "Standards ▸ PDF/A ▸ Remove PDF/A identification, and the standards_remove_id tool (pdfa or pdfx): removes the pdfaid: XMP properties, or GTS_PDFXVersion and GTS_PDFXConformance from the document information and the pdfxid: XMP properties, with their pdfx: custom-property copies (a copy alone is no declaration, and is refused and kept; a pdfx prefix bound to another namespace is left alone). Output intents and other claims (PDF/UA) stay; a packet that can't be edited safely is left as it is. Not yet: PDF/X removal in the interface (there is no PDF/X dialog yet)" [[feature]] id = "optimize.pdfx5-reference-xobjects" From 13f5f00bcac81e758aed85f3669fe7268107bc6f Mon Sep 17 00:00:00 2001 From: ".CooCoo" Date: Mon, 9 Nov 2026 02:05:17 -0800 Subject: [PATCH 08/83] Updates: a ready update starts its helper when the app exits (#6688) --- crates/ui-egui/Cargo.toml | 3 ++- crates/ui-egui/src/app.rs | 5 +++++ crates/ui-egui/src/platform/updates.rs | 30 ++++++++++++++++++++++++++ 3 files changed, 37 insertions(+), 1 deletion(-) diff --git a/crates/ui-egui/Cargo.toml b/crates/ui-egui/Cargo.toml index c6c934171c..994dfc8a1b 100644 --- a/crates/ui-egui/Cargo.toml +++ b/crates/ui-egui/Cargo.toml @@ -29,7 +29,8 @@ pdfcraft-model = { workspace = true } pdfcraft-platform = { workspace = true } egui = { workspace = true, features = ["bytemuck", "default_fonts", "monochrome_emoji_fonts"] } egui_extras = { workspace = true } -eframe = { workspace = true, features = ["default_fonts", "wgpu", "accesskit", "persistence", "links", "clipboard"] } +# `glow` is always on: `eframe::App::on_exit` takes a GL context when that feature is enabled. +eframe = { workspace = true, features = ["default_fonts", "wgpu", "glow", "accesskit", "persistence", "links", "clipboard"] } serde = { workspace = true } serde_json = { workspace = true } log = { workspace = true } diff --git a/crates/ui-egui/src/app.rs b/crates/ui-egui/src/app.rs index a48de69111..f72a831744 100644 --- a/crates/ui-egui/src/app.rs +++ b/crates/ui-egui/src/app.rs @@ -468,6 +468,11 @@ impl eframe::App for PdfCraftApp { storage.set_string("pdfcraft", self.persist()); } + /// Starts an update that waits for the exit. On macOS the process ends before the app is dropped. + fn on_exit(&mut self, _gl: Option<&eframe::glow::Context>) { + self.updates.exiting(); + } + /// Each frame's work before drawing: opens, operating-system and control-channel requests, /// the quit guard, shortcuts, and the background jobs' results. /// diff --git a/crates/ui-egui/src/platform/updates.rs b/crates/ui-egui/src/platform/updates.rs index 4a5df450a8..2f6630afdb 100644 --- a/crates/ui-egui/src/platform/updates.rs +++ b/crates/ui-egui/src/platform/updates.rs @@ -27,6 +27,9 @@ pub(crate) struct Updates { startup_check: bool, #[cfg(not(target_arch = "wasm32"))] check_requested: bool, + /// How many times `on_exit` called [`Updates::exiting`]. + #[cfg(test)] + exits: u32, } impl Default for Updates { @@ -39,6 +42,20 @@ impl Default for Updates { startup_check: false, #[cfg(not(target_arch = "wasm32"))] check_requested: false, + #[cfg(test)] + exits: 0, + } + } +} + +impl Updates { + /// Starts the helper of an update that waits for the app to exit. Later calls do nothing. + pub(crate) fn exiting(&mut self) { + #[cfg(not(target_arch = "wasm32"))] + self.updater.start_pending_update(); + #[cfg(test)] + { + self.exits += 1; } } } @@ -122,3 +139,16 @@ pub(crate) fn dialog(app: &mut PdfCraftApp, ctx: &egui::Context) { #[cfg(target_arch = "wasm32")] let _ = (app, ctx); } + +#[cfg(test)] +mod tests { + use super::*; + use eframe::App as _; + + #[test] + fn on_exit_calls_the_update_exit_hook_once() { + let mut app = PdfCraftApp::new(); + app.on_exit(None); + assert_eq!(app.updates.exits, 1); + } +} From e63736d2e796ad607f8f5ec7397cc6e8105c462a Mon Sep 17 00:00:00 2001 From: regendorf-ux Date: Mon, 9 Nov 2026 05:08:46 -0500 Subject: [PATCH 09/83] Print production: Fix hairlines (dialog, edit, tool) (#1906) --- crates/automation/src/dispatch.rs | 1 + crates/automation/src/hairlines.rs | 40 ++ crates/automation/src/lib.rs | 1 + crates/automation/src/tools.rs | 11 + crates/automation/tests/it/automation.rs | 24 + crates/edit/src/hairlines.rs | 638 ++++++++++++++++++ crates/edit/src/lib.rs | 3 + crates/edit/src/page_streams.rs | 69 ++ crates/edit/src/tests.rs | 46 ++ crates/edit/tests/hairlines_extgstate_lw.rs | 88 +++ .../tests/hairlines_form_inherits_width.rs | 75 ++ crates/edit/tests/hairlines_paths.rs | 83 +++ crates/edit/tests/hairlines_shared_form.rs | 165 +++++ crates/edit/tests/hairlines_streams.rs | 92 +++ crates/engine/src/catalog.rs | 2 +- crates/engine/src/commands.rs | 1 + crates/engine/src/edit.rs | 16 +- crates/engine/src/edit/labels.rs | 1 + crates/engine/src/edit/run.rs | 7 + crates/engine/src/history.rs | 1 + crates/engine/src/lib.rs | 4 +- crates/engine/src/session.rs | 13 +- crates/engine/src/session/files.rs | 15 +- crates/ui-egui/src/app.rs | 1 + crates/ui-egui/src/app/control.rs | 6 + crates/ui-egui/src/commands.rs | 1 + crates/ui-egui/src/dialogs.rs | 11 + crates/ui-egui/src/dialogs/hairlines_ui.rs | 129 ++++ crates/ui-egui/src/lib.rs | 2 + crates/ui-egui/src/state.rs | 2 + crates/ui-egui/tests/it/editing.rs | 69 ++ docs/gaps.md | 6 +- parity/acrobat-features.toml | 10 +- 33 files changed, 1608 insertions(+), 25 deletions(-) create mode 100644 crates/automation/src/hairlines.rs create mode 100644 crates/edit/src/hairlines.rs create mode 100644 crates/edit/src/page_streams.rs create mode 100644 crates/edit/tests/hairlines_extgstate_lw.rs create mode 100644 crates/edit/tests/hairlines_form_inherits_width.rs create mode 100644 crates/edit/tests/hairlines_paths.rs create mode 100644 crates/edit/tests/hairlines_shared_form.rs create mode 100644 crates/edit/tests/hairlines_streams.rs create mode 100644 crates/ui-egui/src/dialogs/hairlines_ui.rs diff --git a/crates/automation/src/dispatch.rs b/crates/automation/src/dispatch.rs index 642c4b5b11..276642b930 100644 --- a/crates/automation/src/dispatch.rs +++ b/crates/automation/src/dispatch.rs @@ -747,6 +747,7 @@ impl Automation { "content_delete" => self.content_delete(&a)?, "content_copy" => self.content_copy(&a)?, "doc_print" => self.doc_print(&a)?, + "doc_fix_hairlines" => self.fix_hairlines(&a)?, "attachment_add" => { let path = self.resolve(a.str("path")?, false)?; let bytes = read_input(&path)?; diff --git a/crates/automation/src/hairlines.rs b/crates/automation/src/hairlines.rs new file mode 100644 index 0000000000..60e9dfe475 --- /dev/null +++ b/crates/automation/src/hairlines.rs @@ -0,0 +1,40 @@ +//! Print production ▸ Fix hairlines as a tool: strokes narrower than a threshold on the page get a +//! width (`doc_fix_hairlines`). + +use pdfcraft_engine::{Edit, Hairlines}; +use serde_json::{Value, json}; + +use crate::{Args, Automation, Result, ToolError}; + +impl Automation { + /// Strokes thinner than `narrower_than` points on the page (after every transformation; + /// zero-width lines always) are given `width` points; Type 3 glyphs and tiling patterns on + /// request. Nothing to fix is not an error: `fixed` is 0 and no edit is made. `elsewhere` + /// counts the glyphs and patterns changed that something outside `pages` also uses. + pub(crate) fn fix_hairlines(&mut self, a: &Args) -> Result { + let n = self.doc(a)?.info.pages.len(); + let pages = match a.opt_ints("pages")? { + Some(_) => self.pages(a, "pages")?, + None => (0..n).collect(), + }; + let settings = Hairlines { + narrower_than: a.opt_num("narrower_than")?.unwrap_or(0.003 * 72.0), + width: a.opt_num("width")?.unwrap_or(0.01 * 72.0), + type3: a.opt_bool("type3")?.unwrap_or(false), + patterns: a.opt_bool("patterns")?.unwrap_or(false), + }; + if !settings.in_range() { + return Err(ToolError::InvalidArgs(format!( + "narrower_than must be 0 or more and width above 0, both at most {} (points)", + Hairlines::MAX_POINTS + ))); + } + let id = self.doc(a)?.id; + let found = self.session.count_hairlines(id, &pages, &settings); + if found.lines == 0 { + return Ok(json!({ "fixed": 0, "pages": 0, "elsewhere": 0 })); + } + let r = self.apply(a, Edit::FixHairlines { pages, settings })?; + Ok(json!({ "fixed": found.lines, "pages": found.pages, "elsewhere": found.elsewhere, "undo": r["undo"] })) + } +} diff --git a/crates/automation/src/lib.rs b/crates/automation/src/lib.rs index 594a129d8b..7fa63d1a1d 100644 --- a/crates/automation/src/lib.rs +++ b/crates/automation/src/lib.rs @@ -22,6 +22,7 @@ mod content; mod conventions; pub mod file_access; mod forms; +mod hairlines; mod layers; mod links; #[cfg(feature = "mcp")] diff --git a/crates/automation/src/tools.rs b/crates/automation/src/tools.rs index 8146f2b57b..05d5753748 100644 --- a/crates/automation/src/tools.rs +++ b/crates/automation/src/tools.rs @@ -154,6 +154,17 @@ pub fn tools() -> Vec { "dpi": {"type":"number","minimum":72,"maximum":600}, "language": {"type":"string"}, "skip_text_pages": {"type":"boolean"} }), &["paths","out_dir","steps"])), + t("doc_fix_hairlines", "Fix hairlines", "Print production: strokes narrower than narrower_than points on the page (after every transformation; zero-width lines always) are given width points, in page content and form XObjects; Type 3 glyphs and tiling patterns only when asked, changed in place wherever they are used. Both widths at most 10. Returns fixed (strokes), pages, and elsewhere (glyphs and patterns changed that something outside pages also uses); nothing to fix gives 0 and no edit.") + .cmd("prepress.hairlines") + .with(schema( + json!({ + "doc": doc(), "pages": pages("to fix (default all)"), + "narrower_than": { "type": "number", "minimum": 0, "description": "Points on the page (default 0.216)." }, + "width": { "type": "number", "exclusiveMinimum": 0, "description": "Points on the page (default 0.72)." }, + "type3": { "type": "boolean" }, "patterns": { "type": "boolean" }, + }), + &["doc"], + )), t("attachment_add", "Attach a file", "Attach the file at path to the document (document level, the Attachments panel; not a paper-clip comment on a page). name defaults to the file's name; the attachment's key, unique in the document, is returned. Undoable.") .cmd("edit.attach") .with(schema(json!({"doc": doc(), "path": {"type": "string"}, "name": {"type": "string"}, "description": {"type": "string"}}), &["doc", "path"])), diff --git a/crates/automation/tests/it/automation.rs b/crates/automation/tests/it/automation.rs index e7ca9c12a3..40334fae65 100644 --- a/crates/automation/tests/it/automation.rs +++ b/crates/automation/tests/it/automation.rs @@ -10689,6 +10689,30 @@ fn a_root_less_write_output_is_atomic_too() { let _ = std::fs::remove_dir_all(&dir); } +/// Fix hairlines through the tools: counts what changed, and reports zero without an edit. +#[test] +fn fix_hairlines_through_tools() { + let dir = workdir("hairlines"); + let mut a = auto(&dir); + let doc = ok(&mut a, "doc_open", json!({ "path": "a.pdf" }))["doc"].as_u64().unwrap(); + // The fixture's text pages have no strokes. + let r = ok(&mut a, "doc_fix_hairlines", json!({ "doc": doc })); + assert_eq!((r["fixed"].as_u64(), r["undo"].is_null()), (Some(0), true)); + // A zero-width rule drawn by the page content tools counts as a hairline. + ok(&mut a, "comment_add", json!({ "doc": doc, "page": 1, "type": "line", "from": [10, 10], "to": [100, 10], "width": 0 })); + let r = ok(&mut a, "doc_fix_hairlines", json!({ "doc": doc, "pages": [1] })); + // Comments are annotations, not page content: still nothing in the page's own content. + assert_eq!(r["fixed"].as_u64(), Some(0), "{r}"); + assert_eq!(r["elsewhere"].as_u64(), Some(0), "{r}"); + for bad in + [json!({ "width": 0 }), json!({ "width": -1 }), json!({ "width": 10000 }), json!({ "narrower_than": -1 }), json!({ "narrower_than": 10000 })] + { + let mut args = bad.clone(); + args["doc"] = json!(doc); + assert!(matches!(a.call("doc_fix_hairlines", &args), Err(ToolError::InvalidArgs(_))), "{bad}"); + } +} + #[test] fn attaching_and_deleting_files_through_the_tools() { let dir = workdir("attach"); diff --git a/crates/edit/src/hairlines.rs b/crates/edit/src/hairlines.rs new file mode 100644 index 0000000000..f70fd50d76 --- /dev/null +++ b/crates/edit/src/hairlines.rs @@ -0,0 +1,638 @@ +//! Fix hairlines (Acrobat's Print production ▸ Fix Hairlines; Fix hairlines): strokes thinner +//! than a printer holds - zero-width lines, and lines whose width on the page, after every +//! transformation, is below a threshold - are given a chosen width. Page content and the Form +//! XObjects it draws are rewritten; Type 3 glyphs and tiling patterns on request. +//! +//! A form is rewritten for the scale and line width it is drawn under. One drawn under several +//! (or drawn by a page that is not being fixed) is rewritten as a copy for each use that needs +//! one, and the resources of what draws it point at the copy, so no other use changes. +//! +//! Type 3 glyphs and tiling patterns are each a content stream of their own, reached from the +//! resources of the page and of the forms it draws. They are rewritten in place, so a page +//! outside the run that uses one changes too; [`Fixed::elsewhere`] counts those. + +use pdfcraft_content::{Op, parse, serialize_ops}; +use pdfcraft_cos::{Dict, Document, Name, ObjRef, Object, Stream}; +use std::collections::{HashMap, HashSet}; + +use crate::page_streams::PageStreams; +use crate::{EditError, check, n, page_list}; + +/// The stream `r` holds, if it is one. +fn stream_of(doc: &Document, r: ObjRef) -> Option { + match &*doc.get(r) { + Object::Stream(s) => Some(s.clone()), + _ => None, + } +} + +/// What counts as a hairline and what it becomes, in points on the page. +#[derive(Clone, Debug, PartialEq)] +pub struct Hairlines { + /// Lines narrower than this (points, after the page's transformations) are replaced; + /// zero-width lines always are. Acrobat's default is 0.003 in. + pub narrower_than: f64, + /// The width they are given (points on the page). Acrobat's default is 0.01 in. + pub width: f64, + /// Also the strokes inside Type 3 font glyphs. + pub type3: bool, + /// Also the strokes inside tiling patterns. + pub patterns: bool, +} + +impl Default for Hairlines { + fn default() -> Self { + Self { narrower_than: 0.003 * 72.0, width: 0.01 * 72.0, type3: false, patterns: false } + } +} + +impl Hairlines { + /// The widest either width may be, in points: past this it is no hairline. + pub const MAX_POINTS: f64 = 10.0; + + /// Whether both widths are numbers a run accepts: a width above 0, a threshold of 0 or + /// more, neither past [`Self::MAX_POINTS`]. + pub fn in_range(&self) -> bool { + (0.0..=Self::MAX_POINTS).contains(&self.narrower_than) && self.width > 0.0 && self.width <= Self::MAX_POINTS + } +} + +/// What a run changed. +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub struct Fixed { + /// Strokes given a new width. + pub lines: usize, + /// Pages on which something changed. + pub pages: usize, + /// Type 3 glyphs and tiling patterns changed that a page outside the run, or an + /// annotation's appearance, also uses: what those draw changed too. + pub elsewhere: usize, +} + +/// The uniform scale a matrix applies to lengths (the geometric mean of its two axes). +fn scale_of(m: [f64; 6]) -> f64 { + (m[0] * m[3] - m[1] * m[2]).abs().sqrt() +} + +fn mul(a: [f64; 6], b: [f64; 6]) -> [f64; 6] { + // a then b. + [ + a[0] * b[0] + a[1] * b[2], + a[0] * b[1] + a[1] * b[3], + a[2] * b[0] + a[3] * b[2], + a[2] * b[1] + a[3] * b[3], + a[4] * b[0] + a[5] * b[2] + b[4], + a[4] * b[1] + a[5] * b[3] + b[5], + ] +} + +/// The graphics state a stream leaves for the next one on the same page: CTM and line width, +/// with the q/Q stack, and whether a path is still being built. +#[derive(Clone, Debug)] +struct State { + ctm: [f64; 6], + width: f64, + stack: Vec<([f64; 6], f64)>, + in_path: bool, +} + +impl State { + /// A stream drawn under `base` with line width `width` in effect. + fn new(base: [f64; 6], width: f64) -> Self { + Self { ctm: base, width, stack: Vec::new(), in_path: false } + } +} + +/// All a form's rewrite depends on: the scale it is drawn under and the line width in effect +/// at its `Do` (as bits, so it can be a map key). +type Key = (u64, u64); + +fn key_of(ctm: [f64; 6], width: f64) -> Key { + (scale_of(ctm).to_bits(), width.to_bits()) +} + +/// A Form XObject a stream draws: the resource name it is drawn by, the object, and the CTM +/// and line width at its `Do`. +struct Drawn { + name: Name, + r: ObjRef, + ctm: [f64; 6], + width: f64, +} + +/// The names a stream's `Do`s are given instead, by the name they had and the key they are +/// drawn under. +type Renames = HashMap<(Name, Key), Name>; + +/// How deep forms drawing forms are followed, and how many form rewrites one run makes; past +/// either, a form is drawn as it is. +const MAX_DEPTH: usize = 32; +const MAX_FORMS: usize = 4096; + +/// The width `/name gs` sets, when its ExtGState has an `/LW`. +fn line_width_of(doc: &Document, gstates: Option<&Dict>, name: &[u8]) -> Option { + let gs = doc.resolve(gstates?.get(name)?); + let lw = gs.as_dict()?.get(b"LW")?; + doc.resolve(lw).as_f64().filter(|w| w.is_finite()) +} + +/// Rewrite one content stream drawn under `state` (the CTM when it starts, scale only +/// matters), leaving in it the state at the stream's end. A thin stroke gets a `w` of its own +/// before its path begins (graphics-state operators are not allowed inside a path) and the +/// old width back after it is painted, so nothing else drawn changes width. +/// `forms` collects the Form XObjects it draws, with the CTM and width each is drawn under, +/// for the caller to rewrite in turn; a `Do` listed in `renames` draws the name given there. +/// Returns the new bytes when anything changed, and the count. +fn rewrite( + data: &[u8], + state: &mut State, + h: &Hairlines, + resources: Option<&Dict>, + doc: &Document, + forms: &mut Vec, + renames: &Renames, +) -> (Option>, usize) { + let parsed = parse(data); + let mut ops: Vec = parsed.ops; + let mut fixed = 0usize; + let mut changed = false; + // Where in `out` the path being built began (None when it began in an earlier stream). + let mut path_start: Option = None; + let mut out: Vec = Vec::with_capacity(ops.len() + 8); + let dict_of = |key: &[u8]| resources.and_then(|r| r.get(key)).map(|x| doc.resolve(x)).and_then(|x| x.as_dict().cloned()); + let xobjects = dict_of(&b"XObject"[..]); + let gstates = dict_of(&b"ExtGState"[..]); + for mut op in ops.drain(..) { + match op.op.as_slice() { + b"q" => { + state.stack.push((state.ctm, state.width)); + if state.stack.len() > 512 { + state.stack.remove(0); + } + } + b"Q" => { + if let Some((c, w)) = state.stack.pop() { + state.ctm = c; + state.width = w; + } + } + b"cm" => { + if let Some(m) = op.nums::<6>() { + state.ctm = mul(m, state.ctm); + } + } + b"w" => { + if let Some([w]) = op.nums::<1>() { + state.width = w; + } + } + b"gs" => { + // An ExtGState's /LW sets the width as `w` does (so the width put back after + // a fixed stroke is the right one). + if let Some(Object::Name(name)) = op.operands.first() + && let Some(w) = line_width_of(doc, gstates.as_ref(), name) + { + state.width = w; + } + } + b"m" | b"re" => { + if !state.in_path { + state.in_path = true; + path_start = Some(out.len()); + } + } + b"Do" => { + if let Some(Object::Name(name)) = op.operands.first() + && let Some(x) = xobjects.as_ref().and_then(|x| x.get(name)) + && let Some(r) = x.as_ref() + && matches!(&*doc.get(r), Object::Stream(s) if s.dict.name(b"Subtype") == Some(b"Form")) + { + let name = name.clone(); + if !renames.is_empty() + && let Some(to) = renames.get(&(name.clone(), key_of(state.ctm, state.width))) + && let Some(first) = op.operands.first_mut() + { + *first = Object::Name(to.clone()); + changed = true; + } + forms.push(Drawn { name, r, ctm: state.ctm, width: state.width }); + } + } + b"S" | b"s" | b"B" | b"B*" | b"b" | b"b*" => { + let scale = scale_of(state.ctm); + let on_page = state.width * scale; + let thin = state.width <= 0.0 || (scale > 0.0 && on_page < h.narrower_than); + // A path begun in an earlier stream cannot get its `w` before it here; it is + // left as it is rather than given an operator inside the path. + let at = match path_start { + Some(i) => Some(i), + None if !state.in_path => Some(out.len()), + None => None, + }; + state.in_path = false; + path_start = None; + if thin + && scale > 0.0 + && let Some(at) = at + { + let new_w = h.width / scale; + out.insert(at, Op::new("w", vec![Object::Real(new_w)])); + out.push(op); + out.push(Op::new("w", vec![Object::Real(state.width)])); + fixed += 1; + changed = true; + continue; + } + } + b"f" | b"F" | b"f*" | b"n" => { + state.in_path = false; + path_start = None; + } + _ => {} + } + out.push(op); + } + (changed.then(|| serialize_ops(&out)), fixed) +} + +/// What a run keeps across pages. +struct Run { + /// Each form already rewritten under a key, and the object drawn for it there. + done: HashMap<(ObjRef, Key), ObjRef>, + /// The forms drawn where this run does not rewrite: they are never changed in place. + shared: HashSet, + /// The forms already drawn as themselves: `true` when rewritten in place, `false` when + /// left as they were. Any other use that needs something else gets a copy. + used: HashMap, + /// Each form's stream as it was before the run. + original: HashMap, + /// Form rewrites left. + budget: usize, +} + +/// The XObjects (forms and images, and those the forms draw), tiling patterns and Type 3 +/// glyphs that the pages not in `pages` use, and those any page's annotation appearances use +/// (this run does not rewrite them): rewriting one of those in place would change what they +/// show. +fn drawn_elsewhere(doc: &Document, pages: &[usize]) -> HashSet { + let mut seen: HashSet = HashSet::new(); + let mut todo: Vec = Vec::new(); + let xobjects_of = |res: Option<&Object>, todo: &mut Vec| { + let Some(res) = res.map(|r| doc.resolve(r)) else { return }; + let Some(res) = res.as_dict() else { return }; + let dict_at = |key: &[u8]| res.get(key).map(|x| doc.resolve(x)).and_then(|x| x.as_dict().cloned()); + for dict in [dict_at(b"XObject"), dict_at(b"Pattern")].into_iter().flatten() { + todo.extend(dict.iter().map(|(_, o)| o.clone())); + } + for (_, font) in dict_at(b"Font").iter().flat_map(Dict::iter) { + let font = doc.resolve(font); + if let Some(procs) = font.as_dict().and_then(|f| f.get(b"CharProcs")).map(|p| doc.resolve(p)).and_then(|p| p.as_dict().cloned()) { + todo.extend(procs.iter().map(|(_, g)| g.clone())); + } + } + }; + for (i, page) in page_list(doc).into_iter().enumerate() { + if !pages.contains(&i) { + xobjects_of(page.dict.get(b"Resources"), &mut todo); + } + let annots = page.dict.get(b"Annots").map(|a| doc.resolve(a)).and_then(|a| a.as_array().cloned()).unwrap_or_default(); + for a in annots { + let Some(ap) = doc.resolve(&a).as_dict().and_then(|d| d.get(b"AP").cloned()) else { continue }; + let Some(ap) = doc.resolve(&ap).as_dict().cloned() else { continue }; + for (_, v) in ap.iter() { + match &*doc.resolve(v) { + Object::Dict(states) => todo.extend(states.iter().map(|(_, s)| s.clone())), + _ => todo.push(v.clone()), + } + } + } + } + while let Some(o) = todo.pop() { + let Some(r) = o.as_ref() else { continue }; + if !seen.insert(r) { + continue; + } + let res = match &*doc.get(r) { + Object::Stream(s) => s.dict.get(b"Resources").cloned(), + _ => None, + }; + xobjects_of(res.as_ref(), &mut todo); + } + seen +} + +/// A name for another copy drawn by `name` that `xobjects` does not hold yet. +fn fresh_name(xobjects: &Dict, name: &[u8]) -> Name { + let mut i = 1usize; + loop { + let mut candidate = name.to_vec(); + candidate.extend_from_slice(format!("_hl{i}").as_bytes()); + if !xobjects.contains(&candidate) { + return candidate; + } + i = i.saturating_add(1); + } +} + +/// Rewrite one drawing context - a page's streams, or a form's - that starts in `start`, and +/// the forms it draws. Returns each stream's new bytes (`None` where it did not change), the +/// strokes fixed (in the forms too), and the `/XObject` dictionary for the context's resources +/// when its names must now point at other objects. +fn fix_context( + doc: &mut Document, + run: &mut Run, + h: &Hairlines, + data: &[Vec], + start: &State, + resources: Option<&Dict>, + depth: usize, +) -> (Vec>>, usize, Option) { + let none = Renames::new(); + let mut drawn: Vec = Vec::new(); + let mut state = start.clone(); + let mut new: Vec>> = Vec::with_capacity(data.len()); + let mut fixed = 0usize; + for d in data { + let (bytes, count) = rewrite(d, &mut state, h, resources, doc, &mut drawn, &none); + fixed += count; + new.push(bytes); + } + if drawn.is_empty() { + return (new, fixed, None); + } + let Some(xo) = resources.and_then(|r| r.get(b"XObject")).map(|x| doc.resolve(x)).and_then(|x| x.as_dict().cloned()) else { + return (new, fixed, None); + }; + // The object each (name, key) is drawn as, and each name's objects in order of first use. + let mut chosen: HashMap<(Name, Key), ObjRef> = HashMap::new(); + let mut targets: Vec<(Name, Vec)> = Vec::new(); + for d in drawn { + let k = key_of(d.ctm, d.width); + if chosen.contains_key(&(d.name.clone(), k)) { + continue; + } + let (to, count) = fix_form(doc, run, h, d.r, d.ctm, d.width, depth + 1); + fixed += count; + chosen.insert((d.name.clone(), k), to); + match targets.iter_mut().find(|(name, _)| *name == d.name) { + Some((_, list)) => { + if !list.contains(&to) { + list.push(to); + } + } + None => targets.push((d.name, vec![to])), + } + } + // The first object keeps the name; each other one gets a name of its own, and the `Do`s + // drawing it are renamed. + let mut repointed = xo; + let mut changed = false; + let mut renames = Renames::new(); + for (name, list) in &targets { + for (i, to) in list.iter().enumerate() { + let at = if i == 0 { name.clone() } else { fresh_name(&repointed, name) }; + if repointed.get(&at).and_then(Object::as_ref) != Some(*to) { + repointed.set(at.clone(), Object::Ref(*to)); + changed = true; + } + if i > 0 { + for ((was, k), t) in &chosen { + if was == name && t == to { + renames.insert((was.clone(), *k), at.clone()); + } + } + } + } + } + if !renames.is_empty() { + // The same rewrite again, with the renamed `Do`s. + let mut state = start.clone(); + let mut again: Vec = Vec::new(); + new = data.iter().map(|d| rewrite(d, &mut state, h, resources, &*doc, &mut again, &renames).0).collect(); + } + (new, fixed, changed.then_some(repointed)) +} + +/// The object `r` is drawn as under `ctm` with line width `width` in effect, rewritten for +/// them, and the strokes that rewrite fixed. It is `r` itself, rewritten in place, unless +/// something else draws `r` differently; then it is a copy. +fn fix_form(doc: &mut Document, run: &mut Run, h: &Hairlines, r: ObjRef, ctm: [f64; 6], width: f64, depth: usize) -> (ObjRef, usize) { + let k = key_of(ctm, width); + if let Some(&to) = run.done.get(&(r, k)) { + return (to, 0); + } + if depth > MAX_DEPTH || run.budget == 0 { + return (r, 0); + } + run.budget -= 1; + // Marked before rewriting, so a form that draws itself ends. + run.done.insert((r, k), r); + let s = match run.original.get(&r) { + Some(s) => s.clone(), + None => { + let Some(s) = stream_of(doc, r) else { return (r, 0) }; + run.original.insert(r, s.clone()); + s + } + }; + let Ok(data) = s.decoded() else { return (r, 0) }; + let matrix = s.dict.get(b"Matrix").map(|m| doc.resolve(m)).and_then(|m| m.as_array().cloned()).and_then(|a| { + let v: Vec = a.iter().filter_map(|x| doc.resolve(x).as_f64()).collect(); + <[f64; 6]>::try_from(v).ok() + }); + let base = match matrix { + Some(m) => mul(m, ctm), + None => ctm, + }; + let res = s.dict.get(b"Resources").map(|x| doc.resolve(x)).and_then(|x| x.as_dict().cloned()); + let (new, fixed, xo) = fix_context(doc, run, h, std::slice::from_ref(&data), &State::new(base, width), res.as_ref(), depth); + let bytes = new.into_iter().next().flatten(); + let to = if bytes.is_none() && xo.is_none() { + if run.used.get(&r) == Some(&true) { + // `r` was rewritten in place for another use; this one draws it as it was. + doc.add(Object::Stream(s)) + } else { + run.used.insert(r, false); + r + } + } else { + let mut form = s; + if let Some(xo) = xo { + let mut res = res.unwrap_or_default(); + res.set(b"XObject".to_vec(), Object::Dict(xo)); + form.dict.set(b"Resources".to_vec(), Object::Dict(res)); + } + if let Some(bytes) = bytes { + let mut dict = form.dict; + for key in [&b"Length"[..], b"Filter", b"DecodeParms"] { + dict.remove(key); + } + form = Stream::flate(dict, &bytes); + } + if run.shared.contains(&r) || run.used.contains_key(&r) { + doc.add(Object::Stream(form)) + } else { + doc.set(r, Object::Stream(form)); + run.used.insert(r, true); + r + } + }; + run.done.insert((r, k), to); + (to, fixed) +} + +/// Fix the hairlines on `pages` (0-based). Each page's content, and each Form XObject it draws +/// (for the scale and line width it is drawn under), is rewritten where a stroke is thinner +/// than `h.narrower_than` points on the page. A form drawn under more than one, or drawn by a +/// page not in `pages` or by an annotation's appearance, is rewritten as a copy for the uses +/// that need one, and the page's (or drawing form's) resources point at the copy; a resource +/// dictionary other pages share is not changed. Type 3 glyphs and tiling patterns are rewritten +/// in place, once, at a nominal scale (see `fix_in_resources`), and those other uses of them are +/// counted in [`Fixed::elsewhere`]. +pub fn fix_hairlines(doc: &mut Document, pages: &[usize], h: &Hairlines) -> Result { + let all = page_list(doc); + check(pages, all.len())?; + if !h.in_range() { + return Err(EditError::Invalid(format!("the hairline widths must be numbers of points, above 0 and at most {}", Hairlines::MAX_POINTS))); + } + let mut result = Fixed::default(); + let mut run = + Run { done: HashMap::new(), shared: drawn_elsewhere(doc, pages), used: HashMap::new(), original: HashMap::new(), budget: MAX_FORMS }; + let mut done_glyphs: HashSet = HashSet::new(); + for &i in pages { + let Some(page) = page_list(doc).get(i).cloned() else { continue }; + let resources = page.dict.get(b"Resources").map(|r| doc.resolve(r)).and_then(|r| r.as_dict().cloned()); + let mut page_fixed = 0usize; + // The page's streams, each rewritten on its own (state carries across them), and the + // forms they draw. A page with a stream that cannot be read is left alone. + if let Some(streams) = PageStreams::read(doc, &page.dict) { + let start = State::new([1.0, 0.0, 0.0, 1.0, 0.0, 0.0], 1.0); + let (new, fixed, xo) = fix_context(doc, &mut run, h, &streams.data, &start, resources.as_ref(), 0); + page_fixed += fixed; + streams.write(doc, page.obj, new)?; + if let Some(xo) = xo { + // This page's own resources point at the copies; a resource dictionary other + // pages share is not changed. + let mut res = resources.clone().unwrap_or_default(); + res.set(b"XObject".to_vec(), Object::Dict(xo)); + doc.update_dict(page.obj, |d| d.set(b"Resources".to_vec(), Object::Dict(res)))?; + } + } + if h.type3 || h.patterns { + for res in resources_below(doc, resources.as_ref()) { + page_fixed += fix_in_resources(doc, Some(&res), h, &mut done_glyphs, &run.shared, &mut result.elsewhere)?; + } + } + if page_fixed > 0 { + result.lines += page_fixed; + result.pages += 1; + } + } + Ok(result) +} + +/// `resources` and the resources of every form they reach (a form that names none uses its +/// parent's), each once. +fn resources_below(doc: &Document, resources: Option<&Dict>) -> Vec { + let mut all: Vec = resources.into_iter().cloned().collect(); + let mut seen: HashSet = HashSet::new(); + let mut at = 0; + while let Some(res) = all.get(at).cloned() { + at += 1; + let Some(xo) = res.get(b"XObject").map(|x| doc.resolve(x)).and_then(|x| x.as_dict().cloned()) else { continue }; + for (_, x) in xo.iter() { + let Some(r) = x.as_ref() else { continue }; + if !seen.insert(r) { + continue; + } + let Some(s) = stream_of(doc, r) else { continue }; + if s.dict.name(b"Subtype") == Some(b"Form") + && let Some(inner) = s.dict.get(b"Resources").map(|x| doc.resolve(x)).and_then(|x| x.as_dict().cloned()) + { + all.push(inner); + } + } + } + all +} + +/// Strokes inside Type 3 glyph procedures and tiling patterns of `resources`, on request. Their +/// drawing scale is the font matrix or the pattern matrix (the text size and placement are +/// not known here, so the glyph's own scale stands in). They are rewritten in place, once per +/// run: a pattern's space is set by its matrix whatever CTM it is painted under, and a glyph +/// is measured at a nominal size, so there is no per-use scale to make a copy for. The forms +/// they draw are not followed. Each one changed that is in `shared` is added to `elsewhere`. +fn fix_in_resources( + doc: &mut Document, + resources: Option<&Dict>, + h: &Hairlines, + done: &mut HashSet, + shared: &HashSet, + elsewhere: &mut usize, +) -> Result { + let Some(res) = resources else { return Ok(0) }; + let mut fixed = 0usize; + let mut targets: Vec<(ObjRef, [f64; 6])> = Vec::new(); + if h.type3 + && let Some(fonts) = res.get(b"Font").map(|f| doc.resolve(f)).and_then(|f| f.as_dict().cloned()) + { + for (_, f) in fonts.iter() { + let fd = doc.resolve(f); + let Some(fd) = fd.as_dict() else { continue }; + if fd.name(b"Subtype") != Some(b"Type3") { + continue; + } + let fm = fd.get(b"FontMatrix").map(|m| doc.resolve(m)).and_then(|m| m.as_array().cloned()).and_then(|a| { + let v: Vec = a.iter().filter_map(|x| doc.resolve(x).as_f64()).collect(); + <[f64; 6]>::try_from(v).ok() + }); + // Glyph space → text space at a nominal 10 pt. + let base = fm.map(|m| mul(m, [10.0, 0.0, 0.0, 10.0, 0.0, 0.0])).unwrap_or([0.01, 0.0, 0.0, 0.01, 0.0, 0.0]); + if let Some(procs) = fd.get(b"CharProcs").map(|p| doc.resolve(p)).and_then(|p| p.as_dict().cloned()) { + for (_, g) in procs.iter() { + if let Some(r) = g.as_ref() { + targets.push((r, base)); + } + } + } + } + } + if h.patterns + && let Some(pats) = res.get(b"Pattern").map(|p| doc.resolve(p)).and_then(|p| p.as_dict().cloned()) + { + for (_, p) in pats.iter() { + let Some(r) = p.as_ref() else { continue }; + let Some(s) = stream_of(doc, r) else { continue }; + if s.dict.int(b"PatternType") != Some(1) { + continue; + } + let pm = s.dict.get(b"Matrix").map(|m| doc.resolve(m)).and_then(|m| m.as_array().cloned()).and_then(|a| { + let v: Vec = a.iter().filter_map(|x| doc.resolve(x).as_f64()).collect(); + <[f64; 6]>::try_from(v).ok() + }); + targets.push((r, pm.unwrap_or([1.0, 0.0, 0.0, 1.0, 0.0, 0.0]))); + } + } + let none = Renames::new(); + for (r, base) in targets { + if !done.insert(r) { + continue; + } + let Some(s) = stream_of(doc, r) else { continue }; + let Ok(data) = s.decoded() else { continue }; + let res = s.dict.get(b"Resources").map(|x| doc.resolve(x)).and_then(|x| x.as_dict().cloned()); + let mut inner = Vec::new(); + let (new, n_fixed) = rewrite(&data, &mut State::new(base, 1.0), h, res.as_ref(), doc, &mut inner, &none); + if let Some(bytes) = new { + let mut dict = s.dict.clone(); + dict.remove(b"Length"); + dict.remove(b"Filter"); + dict.remove(b"DecodeParms"); + doc.set(r, Object::Stream(Stream::flate(dict, &bytes))); + fixed += n_fixed; + *elsewhere += usize::from(n_fixed > 0 && shared.contains(&r)); + } + } + let _ = n; + Ok(fixed) +} diff --git a/crates/edit/src/lib.rs b/crates/edit/src/lib.rs index dda38c7c08..2d508f6878 100644 --- a/crates/edit/src/lib.rs +++ b/crates/edit/src/lib.rs @@ -64,6 +64,9 @@ pub use text::{ BlockKey, BlockStyle, LineEdit, MAX_FORM_DEPTH, MAX_FORM_VISITS, RangeStyle, ReadingBlocks, TextBlock, TextLine, TextSession, block_overlapping, clear_block, glyph_text_lines, reading_blocks, reading_blocks_checked, replace_block, replace_line, rewrite_block, text_blocks, text_lines, }; +pub mod hairlines; +mod page_streams; +pub use hairlines::{Fixed as HairlinesFixed, Hairlines, fix_hairlines}; pub mod added; pub use added::{ Added, AddedImage, AddedText, Align, Content, Family, Ink, MarkerScan, add_content, delete_content, first_undrawable, isolate_content, diff --git a/crates/edit/src/page_streams.rs b/crates/edit/src/page_streams.rs new file mode 100644 index 0000000000..eae00399da --- /dev/null +++ b/crates/edit/src/page_streams.rs @@ -0,0 +1,69 @@ +//! A page's content streams rewritten one at a time: each stream keeps its own dictionary +//! (and so its marks, such as `/PCMark`), and only the streams that changed are replaced. + +use pdfcraft_cos::{Dict, Document, ObjRef, Object, Stream}; + +use crate::EditError; + +/// The decoded content streams of one page, in drawing order. +pub(crate) struct PageStreams { + /// Each stream's object and dictionary. + entries: Vec<(ObjRef, Dict)>, + /// Each stream's decoded data, in the same order. + pub(crate) data: Vec>, +} + +impl PageStreams { + /// The streams of `page_dict`'s `/Contents`. `None` when one of them is not a stream or + /// cannot be decoded: rewriting the others would draw the page differently, so the page is + /// left alone. A page without content has no streams. + pub(crate) fn read(doc: &Document, page_dict: &Dict) -> Option { + let refs: Vec = match page_dict.get(b"Contents") { + None => Vec::new(), + Some(c) => match &*doc.resolve(c) { + Object::Array(a) => a.clone(), + _ => vec![c.clone()], + }, + }; + let mut entries = Vec::with_capacity(refs.len()); + let mut data = Vec::with_capacity(refs.len()); + for e in &refs { + let r = e.as_ref()?; + let (dict, bytes) = match &*doc.get(r) { + Object::Stream(s) => (s.dict.clone(), s.decoded().ok()?), + _ => return None, + }; + data.push(bytes); + entries.push((r, dict)); + } + Some(Self { entries, data }) + } + + /// Put `new` in place of the streams that changed (`None` keeps a stream as it is). Each + /// replacement is a new object with the old dictionary (minus its encoding), so a stream + /// another page shares is not touched. Returns whether anything changed. + pub(crate) fn write(self, doc: &mut Document, page: ObjRef, new: Vec>>) -> Result { + if new.iter().all(Option::is_none) { + return Ok(false); + } + let mut refs: Vec = Vec::with_capacity(self.entries.len()); + for (i, (r, dict)) in self.entries.into_iter().enumerate() { + match new.get(i).and_then(Option::as_ref) { + Some(bytes) => { + let mut dict = dict; + for k in [&b"Length"[..], b"Filter", b"DecodeParms", b"DL"] { + dict.remove(k); + } + refs.push(Object::Ref(doc.add(Object::Stream(Stream::flate(dict, bytes))))); + } + None => refs.push(Object::Ref(r)), + } + } + let contents = match <[Object; 1]>::try_from(refs) { + Ok([one]) => one, + Err(many) => Object::Array(many), + }; + doc.update_dict(page, |d| d.set(b"Contents".to_vec(), contents))?; + Ok(true) + } +} diff --git a/crates/edit/src/tests.rs b/crates/edit/src/tests.rs index 6bf35331db..eb54414b63 100644 --- a/crates/edit/src/tests.rs +++ b/crates/edit/src/tests.rs @@ -9019,6 +9019,52 @@ fn removing_marks_strips_wrappers_that_closed_open_state() { assert_eq!(streams(&doc, 0), ["q 2 0 0 2 0 0 cm"]); } +/// Fix hairlines: zero-width strokes and strokes thinner than the threshold on +/// the page (after the CTM) get the chosen width, in page content and the forms it draws; wider +/// lines, and glyphs and patterns unless asked, are left alone. +#[test] +fn hairlines_are_widened_in_page_content_and_forms() { + use crate::hairlines::{Hairlines, fix_hairlines}; + let mut doc = fixture(); + let page = page_list(&doc)[0].clone(); + // A form drawn at half scale with a 0.1 pt line (0.05 pt on the page) and a 4 pt line. + let mut fd = Dict::new(); + fd.set(b"Type".to_vec(), Object::name("XObject")); + fd.set(b"Subtype".to_vec(), Object::name("Form")); + fd.set(b"BBox".to_vec(), Object::Array([0.0, 0.0, 100.0, 100.0].iter().map(|v| Object::Real(*v)).collect())); + let form = doc.add(Object::Stream(Stream::flate(fd, b"0.1 w 0 0 m 50 50 l S 4 w 0 50 m 50 0 l S"))); + let content = b"0 w 10 10 m 100 10 l S 2 w 10 20 m 100 20 l S q 0.5 0 0 0.5 0 0 cm 0.2 w 0 0 m 10 10 l S /Fm1 Do Q 0.5 w 10 30 m 100 30 l S"; + let cs = doc.add(Object::Stream(Stream::flate(Dict::new(), content))); + let mut xo = Dict::new(); + xo.set(b"Fm1".to_vec(), Object::Ref(form)); + let mut res = Dict::new(); + res.set(b"XObject".to_vec(), Object::Dict(xo)); + doc.update_dict(page.obj, |d| { + d.set(b"Contents".to_vec(), Object::Ref(cs)); + d.set(b"Resources".to_vec(), Object::Dict(res)); + }) + .unwrap(); + let fixed = fix_hairlines(&mut doc, &[0], &Hairlines::default()).unwrap(); + // Page: the 0 w line and the 0.2 w line at half scale (0.1 pt); the 2 pt and 0.5 pt lines stay. + // Form: the 0.1 w line (0.05 pt on the page); the 4 pt line stays. + assert_eq!(fixed, crate::hairlines::Fixed { lines: 3, pages: 1, elsewhere: 0 }); + let flat = |b: &[u8]| String::from_utf8_lossy(b).split_whitespace().collect::>().join(" "); + let text = flat(&page_content_bytes(&doc, 0)); + assert!(text.contains("0.72 w 10 10 m 100 10 l S"), "zero width → 0.72 pt: {text}"); + assert!(text.contains("2 w 10 20 m 100 20 l S") && text.contains("0.5 w 10 30 m 100 30 l S"), "wider lines untouched: {text}"); + assert!(text.contains("1.44 w 0 0 m 10 10 l S"), "under a half-scale CTM the width is doubled: {text}"); + let Object::Stream(fs) = &*doc.get(form) else { panic!() }; + let ft = flat(&fs.decoded().unwrap()); + assert!(ft.contains("1.44 w 0 0 m 50 50 l S") && ft.contains("4 w 0 50 m 50 0 l S"), "{ft}"); + // Nothing left to fix: a second run changes nothing. + assert_eq!(fix_hairlines(&mut doc, &[0], &Hairlines::default()).unwrap().lines, 0); + // A stricter threshold catches the 0.5 pt line too, with a custom width. + let fixed = fix_hairlines(&mut doc, &[0], &Hairlines { narrower_than: 0.6, width: 1.0, ..Hairlines::default() }).unwrap(); + assert_eq!(fixed.lines, 1); + assert!(flat(&page_content_bytes(&doc, 0)).contains("1 w 10 30 m 100 30 l S")); + assert!(fix_hairlines(&mut doc, &[0], &Hairlines { width: 0.0, ..Hairlines::default() }).is_err()); +} + /// #3087: flattening comments leaves alone what isn't a comment: links, and the non-markup /// multimedia and print-production annotations (3D, Screen, Movie, RichMedia, PrinterMark, /// TrapNet, Watermark), which it used to bake into the page and delete with their data. diff --git a/crates/edit/tests/hairlines_extgstate_lw.rs b/crates/edit/tests/hairlines_extgstate_lw.rs new file mode 100644 index 0000000000..a7e3cb4333 --- /dev/null +++ b/crates/edit/tests/hairlines_extgstate_lw.rs @@ -0,0 +1,88 @@ +//! Fix hairlines follows the line width an ExtGState's `/LW` sets through `gs`: a stroke it +//! leaves thin is found, and the width put back after a widened stroke is that one. + +use pdfcraft_cos::{Document, ObjRef, Object}; +use pdfcraft_edit::{Hairlines, fix_hairlines}; +use std::sync::Arc; + +/// A document of `objects` (object `i + 1` is the `i`th: a dictionary, and stream data when +/// it is a stream); object 1 is the catalog. +fn pdf(objects: &[(&str, Option<&[u8]>)]) -> Document { + let mut out = b"%PDF-1.7\n".to_vec(); + let mut offs = Vec::new(); + for (i, (dict, data)) in objects.iter().enumerate() { + offs.push(out.len()); + out.extend_from_slice(format!("{} 0 obj\n", i + 1).as_bytes()); + match data { + Some(data) => { + out.extend_from_slice(format!("<< {dict} /Length {} >>\nstream\n", data.len()).as_bytes()); + out.extend_from_slice(data); + out.extend_from_slice(b"\nendstream"); + } + None => out.extend_from_slice(dict.as_bytes()), + } + out.extend_from_slice(b"\nendobj\n"); + } + let x = out.len(); + out.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objects.len() + 1).as_bytes()); + for o in offs { + out.extend_from_slice(format!("{o:010} 00000 n \n").as_bytes()); + } + out.extend_from_slice(format!("trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{x}\n%%EOF\n", objects.len() + 1).as_bytes()); + Document::open(Arc::new(out)).unwrap() +} + +/// A stream's data, its tokens joined by single spaces. +fn flat(doc: &Document, r: ObjRef) -> String { + match &*doc.get(r) { + Object::Stream(s) => String::from_utf8_lossy(&s.decoded().unwrap()).split_whitespace().collect::>().join(" "), + _ => panic!("not a stream"), + } +} + +/// Page `i`'s content (a single stream), flattened. +fn page_text(doc: &Document, i: usize) -> String { + let page = pdfcraft_model::pages(doc).swap_remove(i); + flat(doc, page.dict.get(b"Contents").and_then(Object::as_ref).unwrap()) +} + +/// A one-page document drawing `content`, with `/GS0` setting `/LW lw` and `/GS1` no width. +fn with_gstate(lw: &str, content: &[u8]) -> Document { + let page = format!( + "<< /Type /Page /Parent 2 0 R /Contents 4 0 R /Resources << /ExtGState << /GS0 << /Type /ExtGState /LW {lw} >> /GS1 << /Type /ExtGState /CA 1 >> >> >> >>" + ); + pdf(&[ + ("<< /Type /Catalog /Pages 2 0 R >>", None), + ("<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 600 800] >>", None), + (page.as_str(), None), + ("", Some(content)), + ]) +} + +#[test] +fn the_width_put_back_is_the_one_gs_set() { + // 2 pt under a tenth scale is 0.2 pt on the page: both strokes are widened, and each gets + // the 2 pt back after it. + let mut doc = with_gstate("2", b"/GS0 gs 0.1 0 0 0.1 0 0 cm 0 0 m 1 0 l S 0 1 m 1 1 l S"); + let fixed = fix_hairlines(&mut doc, &[0], &Hairlines::default()).unwrap(); + assert_eq!(fixed.lines, 2); + let t = page_text(&doc, 0); + assert_eq!(t.matches("l S 2 w").count(), 2, "{t}"); + assert!(!t.contains(" 1 w"), "no stroke is put back to the default width: {t}"); +} + +#[test] +fn a_zero_width_set_by_gs_is_a_hairline() { + let mut doc = with_gstate("0", b"/GS0 gs 0 0 m 1 0 l S"); + let fixed = fix_hairlines(&mut doc, &[0], &Hairlines::default()).unwrap(); + assert_eq!(fixed.lines, 1); + assert_eq!(page_text(&doc, 0), "/GS0 gs 0.72 w 0 0 m 1 0 l S 0 w"); +} + +#[test] +fn a_gs_without_a_width_keeps_the_one_in_effect() { + let mut doc = with_gstate("2", b"0.1 w /GS1 gs 0 0 m 1 0 l S"); + let fixed = fix_hairlines(&mut doc, &[0], &Hairlines::default()).unwrap(); + assert_eq!(fixed.lines, 1); + assert_eq!(page_text(&doc, 0), "0.1 w /GS1 gs 0.72 w 0 0 m 1 0 l S 0.1 w"); +} diff --git a/crates/edit/tests/hairlines_form_inherits_width.rs b/crates/edit/tests/hairlines_form_inherits_width.rs new file mode 100644 index 0000000000..a8ab4e456f --- /dev/null +++ b/crates/edit/tests/hairlines_form_inherits_width.rs @@ -0,0 +1,75 @@ +//! Fix hairlines measures a form's strokes with the line width in effect where the form is +//! drawn (a form inherits the graphics state of its `Do`), and puts that width back after a +//! stroke it widens, in forms drawn by forms too. + +use pdfcraft_cos::{Document, ObjRef, Object}; +use pdfcraft_edit::{Hairlines, fix_hairlines}; +use std::sync::Arc; + +/// A document of `objects` (object `i + 1` is the `i`th: a dictionary, and stream data when +/// it is a stream); object 1 is the catalog. +fn pdf(objects: &[(&str, Option<&[u8]>)]) -> Document { + let mut out = b"%PDF-1.7\n".to_vec(); + let mut offs = Vec::new(); + for (i, (dict, data)) in objects.iter().enumerate() { + offs.push(out.len()); + out.extend_from_slice(format!("{} 0 obj\n", i + 1).as_bytes()); + match data { + Some(data) => { + out.extend_from_slice(format!("<< {dict} /Length {} >>\nstream\n", data.len()).as_bytes()); + out.extend_from_slice(data); + out.extend_from_slice(b"\nendstream"); + } + None => out.extend_from_slice(dict.as_bytes()), + } + out.extend_from_slice(b"\nendobj\n"); + } + let x = out.len(); + out.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objects.len() + 1).as_bytes()); + for o in offs { + out.extend_from_slice(format!("{o:010} 00000 n \n").as_bytes()); + } + out.extend_from_slice(format!("trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{x}\n%%EOF\n", objects.len() + 1).as_bytes()); + Document::open(Arc::new(out)).unwrap() +} + +/// A stream's data, its tokens joined by single spaces. +fn flat(doc: &Document, r: ObjRef) -> String { + match &*doc.get(r) { + Object::Stream(s) => String::from_utf8_lossy(&s.decoded().unwrap()).split_whitespace().collect::>().join(" "), + _ => panic!("not a stream"), + } +} + +const CATALOG: (&str, Option<&[u8]>) = ("<< /Type /Catalog /Pages 2 0 R >>", None); +const PAGES: (&str, Option<&[u8]>) = ("<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 600 800] >>", None); +const PAGE: (&str, Option<&[u8]>) = ("<< /Type /Page /Parent 2 0 R /Contents 4 0 R /Resources << /XObject << /Fm0 5 0 R >> >> >>", None); +const FORM: &str = "/Type /XObject /Subtype /Form /BBox [0 0 100 100]"; + +#[test] +fn a_form_without_its_own_width_is_measured_with_the_width_at_its_do() { + // 0.1 pt is in effect where the form is drawn; the form sets no width of its own. + let mut doc = pdf(&[CATALOG, PAGES, PAGE, ("", Some(b"0.1 w /Fm0 Do")), (FORM, Some(b"0 0 m 10 0 l S"))]); + let fixed = fix_hairlines(&mut doc, &[0], &Hairlines::default()).unwrap(); + assert_eq!(fixed.lines, 1); + assert_eq!(flat(&doc, ObjRef { num: 5, generation: 0 }), "0.72 w 0 0 m 10 0 l S 0.1 w"); +} + +#[test] +fn a_widened_stroke_in_a_form_gets_the_inherited_width_back() { + // 0.4 pt at half scale is 0.2 pt on the page; after the stroke the form's later strokes + // must draw with the 0.4 they inherited, not the 1 a fresh graphics state has. + let mut doc = pdf(&[CATALOG, PAGES, PAGE, ("", Some(b"0.5 0 0 0.5 0 0 cm 0.4 w /Fm0 Do")), (FORM, Some(b"0 0 m 1 0 l S"))]); + let fixed = fix_hairlines(&mut doc, &[0], &Hairlines::default()).unwrap(); + assert_eq!(fixed.lines, 1); + assert_eq!(flat(&doc, ObjRef { num: 5, generation: 0 }), "1.44 w 0 0 m 1 0 l S 0.4 w"); +} + +#[test] +fn a_form_drawn_by_a_form_inherits_the_width_too() { + let fm0 = "/Type /XObject /Subtype /Form /BBox [0 0 100 100] /Resources << /XObject << /Fm1 6 0 R >> >>"; + let mut doc = pdf(&[CATALOG, PAGES, PAGE, ("", Some(b"0.1 w /Fm0 Do")), (fm0, Some(b"/Fm1 Do")), (FORM, Some(b"0 0 m 10 0 l S"))]); + let fixed = fix_hairlines(&mut doc, &[0], &Hairlines::default()).unwrap(); + assert_eq!(fixed.lines, 1); + assert_eq!(flat(&doc, ObjRef { num: 6, generation: 0 }), "0.72 w 0 0 m 10 0 l S 0.1 w"); +} diff --git a/crates/edit/tests/hairlines_paths.rs b/crates/edit/tests/hairlines_paths.rs new file mode 100644 index 0000000000..a9f6720ce0 --- /dev/null +++ b/crates/edit/tests/hairlines_paths.rs @@ -0,0 +1,83 @@ +//! Fix hairlines gives a thin stroke a width of its own: set before its path begins (a `w` +//! inside a path is not allowed) and put back after it is painted, so the `w` other strokes +//! share, and strokes under another transformation, keep their width. + +use pdfcraft_cos::{Document, Object}; +use pdfcraft_edit::{Hairlines, fix_hairlines}; +use std::sync::Arc; + +/// A one-page document drawing `content`. +fn one_page(content: &[u8]) -> Document { + let mut objs: Vec> = vec![ + b"<< /Type /Catalog /Pages 2 0 R >>".to_vec(), + b"<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 600 800] >>".to_vec(), + b"<< /Type /Page /Parent 2 0 R /Contents 4 0 R >>".to_vec(), + ]; + let mut s = format!("<< /Length {} >>\nstream\n", content.len()).into_bytes(); + s.extend_from_slice(content); + s.extend_from_slice(b"\nendstream"); + objs.push(s); + let mut out = b"%PDF-1.7\n".to_vec(); + let mut offs = Vec::new(); + for (i, o) in objs.iter().enumerate() { + offs.push(out.len()); + out.extend_from_slice(format!("{} 0 obj\n", i + 1).as_bytes()); + out.extend_from_slice(o); + out.extend_from_slice(b"\nendobj\n"); + } + let x = out.len(); + out.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objs.len() + 1).as_bytes()); + for o in offs { + out.extend_from_slice(format!("{o:010} 00000 n \n").as_bytes()); + } + out.extend_from_slice(format!("trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{x}\n%%EOF\n", objs.len() + 1).as_bytes()); + Document::open(Arc::new(out)).unwrap() +} + +/// The page's content, its tokens joined by single spaces. +fn content_text(doc: &Document) -> String { + let page = pdfcraft_model::pages(doc).swap_remove(0); + let list: Vec = match &*doc.resolve(page.dict.get(b"Contents").unwrap()) { + Object::Array(a) => a.clone(), + _ => vec![page.dict.get(b"Contents").unwrap().clone()], + }; + let mut text = String::new(); + for e in list { + if let Object::Stream(s) = &*doc.resolve(&e) { + text.push_str(&String::from_utf8_lossy(&s.decoded().unwrap())); + text.push(' '); + } + } + text.split_whitespace().collect::>().join(" ") +} + +#[test] +fn a_thin_stroke_does_not_change_the_width_other_strokes_share() { + // The first line is 0.1 pt on the page; the second, under a tenfold scale, is 1 pt. + let mut doc = one_page(b"0.1 w 0 0 m 10 0 l S 10 0 0 10 0 0 cm 0 1 m 1 1 l S"); + let fixed = fix_hairlines(&mut doc, &[0], &Hairlines::default()).unwrap(); + assert_eq!(fixed.lines, 1); + let t = content_text(&doc); + assert!(t.starts_with("0.1 w 0.72 w 0 0 m 10 0 l S 0.1 w 10 0 0 10 0 0 cm"), "the shared w is kept: {t}"); + assert!(!t.contains("7.2"), "the scaled stroke keeps its 1 pt: {t}"); +} + +#[test] +fn a_stroke_inside_q_does_not_change_the_outer_width() { + let mut doc = one_page(b"0.1 w q 0 0 m 1 0 l S Q 0 1 m 1 1 l S"); + let fixed = fix_hairlines(&mut doc, &[0], &Hairlines::default()).unwrap(); + assert_eq!(fixed.lines, 2); + let t = content_text(&doc); + assert!(t.starts_with("0.1 w q 0.72 w 0 0 m 1 0 l S 0.1 w Q 0.72 w 0 1 m 1 1 l S 0.1 w"), "{t}"); +} + +#[test] +fn the_new_width_is_set_before_the_path_not_inside_it() { + // No `w` before the stroke: the default 1 pt at half scale is 0.5 pt on the page. + let mut doc = one_page(b"0.5 0 0 0.5 0 0 cm 0 0 m 10 0 l S 5 5 10 10 re f"); + let fixed = fix_hairlines(&mut doc, &[0], &Hairlines { narrower_than: 0.6, width: 1.0, ..Hairlines::default() }).unwrap(); + assert_eq!(fixed.lines, 1); + let t = content_text(&doc); + assert!(t.contains("cm 2 w 0 0 m 10 0 l S 1 w 5 5 10 10 re f"), "{t}"); + assert!(!t.contains("l 2 w S"), "no w inside the path: {t}"); +} diff --git a/crates/edit/tests/hairlines_shared_form.rs b/crates/edit/tests/hairlines_shared_form.rs new file mode 100644 index 0000000000..99f05499b6 --- /dev/null +++ b/crates/edit/tests/hairlines_shared_form.rs @@ -0,0 +1,165 @@ +//! Fix hairlines rewrites a form for the scale it is drawn under: a form a page not being +//! fixed also draws, or one drawn under two scales, is rewritten as a copy for the use that +//! needs it, so the other uses keep drawing it as it was. + +use pdfcraft_cos::{Document, ObjRef, Object}; +use pdfcraft_edit::{Hairlines, fix_hairlines}; +use std::sync::Arc; + +/// A document of `objects` (object `i + 1` is the `i`th: a dictionary, and stream data when +/// it is a stream); object 1 is the catalog. +fn pdf(objects: &[(&str, Option<&[u8]>)]) -> Document { + let mut out = b"%PDF-1.7\n".to_vec(); + let mut offs = Vec::new(); + for (i, (dict, data)) in objects.iter().enumerate() { + offs.push(out.len()); + out.extend_from_slice(format!("{} 0 obj\n", i + 1).as_bytes()); + match data { + Some(data) => { + out.extend_from_slice(format!("<< {dict} /Length {} >>\nstream\n", data.len()).as_bytes()); + out.extend_from_slice(data); + out.extend_from_slice(b"\nendstream"); + } + None => out.extend_from_slice(dict.as_bytes()), + } + out.extend_from_slice(b"\nendobj\n"); + } + let x = out.len(); + out.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objects.len() + 1).as_bytes()); + for o in offs { + out.extend_from_slice(format!("{o:010} 00000 n \n").as_bytes()); + } + out.extend_from_slice(format!("trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{x}\n%%EOF\n", objects.len() + 1).as_bytes()); + Document::open(Arc::new(out)).unwrap() +} + +/// A stream's data, its tokens joined by single spaces. +fn flat(doc: &Document, r: ObjRef) -> String { + match &*doc.get(r) { + Object::Stream(s) => String::from_utf8_lossy(&s.decoded().unwrap()).split_whitespace().collect::>().join(" "), + _ => panic!("not a stream"), + } +} + +/// Page `i`'s content (a single stream), flattened. +fn page_text(doc: &Document, i: usize) -> String { + let page = pdfcraft_model::pages(doc).swap_remove(i); + flat(doc, page.dict.get(b"Contents").and_then(Object::as_ref).unwrap()) +} + +/// The object page `i`'s resources draw as `name`. +fn xobject(doc: &Document, i: usize, name: &[u8]) -> ObjRef { + let page = pdfcraft_model::pages(doc).swap_remove(i); + let res = doc.resolve(page.dict.get(b"Resources").unwrap()); + let xo = doc.resolve(res.as_dict().unwrap().get(b"XObject").unwrap()); + xo.as_dict().unwrap().get(name).and_then(Object::as_ref).unwrap() +} + +const FORM: &str = "/Type /XObject /Subtype /Form /BBox [0 0 100 100]"; +const ORIGINAL: &str = "0.4 w 0 0 m 1 0 l S"; + +#[test] +fn a_form_another_page_draws_is_fixed_as_a_copy() { + // Both pages share one resource dictionary; the first draws the form at half scale (its + // 0.4 pt line is 0.2 pt there), the second at full size. + let mut doc = pdf(&[ + ("<< /Type /Catalog /Pages 2 0 R >>", None), + ("<< /Type /Pages /Kids [3 0 R 4 0 R] /Count 2 /MediaBox [0 0 600 800] >>", None), + ("<< /Type /Page /Parent 2 0 R /Contents 5 0 R /Resources 7 0 R >>", None), + ("<< /Type /Page /Parent 2 0 R /Contents 6 0 R /Resources 7 0 R >>", None), + ("", Some(b"0.5 0 0 0.5 0 0 cm /Fm0 Do")), + ("", Some(b"/Fm0 Do")), + ("<< /XObject << /Fm0 8 0 R >> >>", None), + (FORM, Some(ORIGINAL.as_bytes())), + ]); + let form = ObjRef { num: 8, generation: 0 }; + let fixed = fix_hairlines(&mut doc, &[0], &Hairlines::default()).unwrap(); + assert_eq!(fixed.lines, 1); + assert_eq!(flat(&doc, form), ORIGINAL, "the form the second page draws is unchanged"); + assert_eq!(xobject(&doc, 1, b"Fm0"), form, "the second page still draws it"); + let copy = xobject(&doc, 0, b"Fm0"); + assert_ne!(copy, form, "the first page draws a copy"); + assert_eq!(flat(&doc, copy), "0.4 w 1.44 w 0 0 m 1 0 l S 0.4 w"); +} + +#[test] +fn a_form_drawn_under_two_scales_gets_one_object_for_each() { + let mut doc = pdf(&[ + ("<< /Type /Catalog /Pages 2 0 R >>", None), + ("<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 600 800] >>", None), + ("<< /Type /Page /Parent 2 0 R /Contents 4 0 R /Resources << /XObject << /Fm0 5 0 R >> >> >>", None), + ("", Some(b"q 0.5 0 0 0.5 0 0 cm /Fm0 Do Q /Fm0 Do")), + (FORM, Some(ORIGINAL.as_bytes())), + ]); + let fixed = fix_hairlines(&mut doc, &[0], &Hairlines::default()).unwrap(); + assert_eq!(fixed.lines, 1); + let text = page_text(&doc, 0); + let words: Vec<&str> = text.split(' ').collect(); + let drawn: Vec<&str> = words.windows(2).filter(|w| w[1] == "Do").map(|w| w[0]).collect(); + assert_eq!(drawn.len(), 2, "{text}"); + assert_eq!(drawn[0], "/Fm0", "{text}"); + assert_ne!(drawn[1], "/Fm0", "the full-size use draws another name: {text}"); + let half = xobject(&doc, 0, b"Fm0"); + let full = xobject(&doc, 0, drawn[1].trim_start_matches('/').as_bytes()); + assert_ne!(half, full); + assert_eq!(flat(&doc, half), "0.4 w 1.44 w 0 0 m 1 0 l S 0.4 w"); + assert_eq!(flat(&doc, full), ORIGINAL, "at full size the 0.4 pt line is left as it was"); +} + +const SHADED: &str = "/Pattern cs /P0 scn 0 0 10 10 re f"; +const PATTERN: &str = "/Type /Pattern /PatternType 1 /PaintType 1 /TilingType 1 /BBox [0 0 9 9] /XStep 9 /YStep 9 /Resources << >>"; +const THIN: &str = "0 w 0 0 m 9 9 l S"; + +#[test] +fn a_pattern_another_page_uses_is_reported() { + let mut doc = pdf(&[ + ("<< /Type /Catalog /Pages 2 0 R >>", None), + ("<< /Type /Pages /Kids [3 0 R 4 0 R] /Count 2 /MediaBox [0 0 600 800] >>", None), + ("<< /Type /Page /Parent 2 0 R /Contents 5 0 R /Resources 6 0 R >>", None), + ("<< /Type /Page /Parent 2 0 R /Contents 5 0 R /Resources 6 0 R >>", None), + ("", Some(SHADED.as_bytes())), + ("<< /Pattern << /P0 7 0 R >> >>", None), + (PATTERN, Some(THIN.as_bytes())), + ]); + let h = Hairlines { patterns: true, ..Hairlines::default() }; + let mut both = doc.clone(); + assert_eq!(fix_hairlines(&mut both, &[0, 1], &h).unwrap().elsewhere, 0, "every use is in the run"); + let fixed = fix_hairlines(&mut doc, &[0], &h).unwrap(); + assert_eq!((fixed.lines, fixed.elsewhere), (1, 1)); +} + +#[test] +fn a_pattern_a_form_uses_is_fixed_too() { + let mut doc = pdf(&[ + ("<< /Type /Catalog /Pages 2 0 R >>", None), + ("<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 600 800] >>", None), + ("<< /Type /Page /Parent 2 0 R /Contents 4 0 R /Resources << /XObject << /Fm0 5 0 R >> >> >>", None), + ("", Some(b"/Fm0 Do")), + // The form lists itself: walking the resources ends all the same. + ( + "/Type /XObject /Subtype /Form /BBox [0 0 100 100] /Resources << /Pattern << /P0 6 0 R >> /XObject << /Fm0 5 0 R >> >>", + Some(SHADED.as_bytes()), + ), + (PATTERN, Some(THIN.as_bytes())), + ]); + let fixed = fix_hairlines(&mut doc, &[0], &Hairlines { patterns: true, ..Hairlines::default() }).unwrap(); + assert_eq!(fixed.lines, 1); + assert_ne!(flat(&doc, ObjRef { num: 6, generation: 0 }), THIN); +} + +#[test] +fn widths_out_of_range_are_refused() { + let mut doc = pdf(&[ + ("<< /Type /Catalog /Pages 2 0 R >>", None), + ("<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 600 800] >>", None), + ("<< /Type /Page /Parent 2 0 R /Contents 4 0 R >>", None), + ("", Some(THIN.as_bytes())), + ]); + for (narrower_than, width) in [(0.2, -1.0), (0.2, f64::NAN), (0.2, f64::INFINITY), (0.2, 10_000.0), (-1.0, 0.7), (f64::NAN, 0.7), (10_000.0, 0.7)] + { + let h = Hairlines { narrower_than, width, ..Hairlines::default() }; + assert!(fix_hairlines(&mut doc, &[0], &h).is_err(), "{narrower_than} {width}"); + } + let most = Hairlines { narrower_than: Hairlines::MAX_POINTS, width: Hairlines::MAX_POINTS, ..Hairlines::default() }; + assert_eq!(fix_hairlines(&mut doc, &[0], &most).unwrap().lines, 1); +} diff --git a/crates/edit/tests/hairlines_streams.rs b/crates/edit/tests/hairlines_streams.rs new file mode 100644 index 0000000000..f6508822f5 --- /dev/null +++ b/crates/edit/tests/hairlines_streams.rs @@ -0,0 +1,92 @@ +//! Fix hairlines rewrites each of a page's content streams on its own: a stream keeps its +//! dictionary (its `/PCMark` among it), streams that did not change stay as they are, the +//! graphics state carries from one stream to the next, and a page with a stream that cannot +//! be read is left alone instead of losing that stream. + +use pdfcraft_cos::{Document, ObjRef, Object}; +use pdfcraft_edit::{Hairlines, fix_hairlines}; +use std::sync::Arc; + +/// A one-page document whose `/Contents` lists `streams` (dictionary entries, data). +fn one_page(streams: &[(&str, &[u8])]) -> Document { + let refs: Vec = (0..streams.len()).map(|i| format!("{} 0 R", i + 4)).collect(); + let mut objs: Vec> = vec![ + b"<< /Type /Catalog /Pages 2 0 R >>".to_vec(), + b"<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 600 800] >>".to_vec(), + format!("<< /Type /Page /Parent 2 0 R /Contents [{}] >>", refs.join(" ")).into_bytes(), + ]; + for (dict, data) in streams { + let mut s = format!("<< {dict} /Length {} >>\nstream\n", data.len()).into_bytes(); + s.extend_from_slice(data); + s.extend_from_slice(b"\nendstream"); + objs.push(s); + } + let mut out = b"%PDF-1.7\n".to_vec(); + let mut offs = Vec::new(); + for (i, o) in objs.iter().enumerate() { + offs.push(out.len()); + out.extend_from_slice(format!("{} 0 obj\n", i + 1).as_bytes()); + out.extend_from_slice(o); + out.extend_from_slice(b"\nendobj\n"); + } + let x = out.len(); + out.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objs.len() + 1).as_bytes()); + for o in offs { + out.extend_from_slice(format!("{o:010} 00000 n \n").as_bytes()); + } + out.extend_from_slice(format!("trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{x}\n%%EOF\n", objs.len() + 1).as_bytes()); + Document::open(Arc::new(out)).unwrap() +} + +/// The page's `/Contents` references. +fn contents(doc: &Document) -> Vec { + let page = pdfcraft_model::pages(doc).swap_remove(0); + match &*doc.resolve(page.dict.get(b"Contents").unwrap()) { + Object::Array(a) => a.iter().filter_map(Object::as_ref).collect(), + _ => page.dict.get(b"Contents").and_then(Object::as_ref).into_iter().collect(), + } +} + +fn text_of(doc: &Document, r: ObjRef) -> String { + match &*doc.get(r) { + Object::Stream(s) => String::from_utf8_lossy(&s.decoded().unwrap()).split_whitespace().collect::>().join(" "), + _ => panic!("not a stream"), + } +} + +#[test] +fn each_stream_keeps_its_dictionary_and_only_changed_ones_are_replaced() { + let mut doc = one_page(&[("", b"2 w 0 0 m 1 1 l S"), ("/PCMark /Redaction", b"0 w 0 0 m 1 1 l S")]); + let before = contents(&doc); + let fixed = fix_hairlines(&mut doc, &[0], &Hairlines::default()).unwrap(); + assert_eq!(fixed.lines, 1); + let after = contents(&doc); + assert_eq!(after.len(), 2, "still two streams"); + assert_eq!(after[0], before[0], "the unchanged stream is the same object"); + assert_ne!(after[1], before[1]); + let mark = match &*doc.get(after[1]) { + Object::Stream(s) => s.dict.name(b"PCMark").map(<[u8]>::to_vec), + _ => None, + }; + assert_eq!(mark.as_deref(), Some(&b"Redaction"[..]), "the mark is kept"); + assert!(text_of(&doc, after[1]).contains("0.72 w 0 0 m 1 1 l S")); +} + +#[test] +fn the_graphics_state_carries_from_one_stream_to_the_next() { + // 0.3 pt under the first stream's half scale is 0.15 pt on the page. + let mut doc = one_page(&[("", b"0.5 0 0 0.5 0 0 cm"), ("", b"0.3 w 0 0 m 1 1 l S")]); + let fixed = fix_hairlines(&mut doc, &[0], &Hairlines::default()).unwrap(); + assert_eq!(fixed.lines, 1); + let after = contents(&doc); + assert!(text_of(&doc, after[1]).contains("1.44 w 0 0 m 1 1 l S")); +} + +#[test] +fn a_page_with_an_unreadable_stream_is_left_alone() { + let mut doc = one_page(&[("", b"0 w 0 0 m 1 1 l S"), ("/Filter /DCTDecode", b"not a picture")]); + let before = contents(&doc); + let fixed = fix_hairlines(&mut doc, &[0], &Hairlines::default()).unwrap(); + assert_eq!(fixed.lines, 0); + assert_eq!(contents(&doc), before, "no stream is dropped"); +} diff --git a/crates/engine/src/catalog.rs b/crates/engine/src/catalog.rs index d6be8fd4eb..e78786bf8b 100644 --- a/crates/engine/src/catalog.rs +++ b/crates/engine/src/catalog.rs @@ -412,7 +412,7 @@ pub static TOOL_GROUPS: &[ToolGroup] = &[ item("Convert colors", "palette", "prepress.convert_colors", Planned("M11")), item("Set page boxes", "square-dashed-mouse-pointer", "page.boxes", Ready), item("Add printer marks", "ruler", "prepress.marks", Planned("M11")), - item("Fix hairlines", "minus", "prepress.hairlines", Planned("M11")), + item("Fix hairlines", "minus", "prepress.hairlines", Ready), ], }], }, diff --git a/crates/engine/src/commands.rs b/crates/engine/src/commands.rs index 7cee14657e..3f4a9bc50c 100644 --- a/crates/engine/src/commands.rs +++ b/crates/engine/src/commands.rs @@ -403,6 +403,7 @@ pub const COMMANDS: &[CommandSpec] = &[ c("page.extract", "Extract pages…", PAGES, None, Assembly, "file-output"), c("page.split", "Split document…", PAGES, None, Assembly, "scissors"), c("page.number", "Number pages…", PAGES, None, Assembly, "hash"), + c("prepress.hairlines", "Fix hairlines…", None, None, Modification, "minus"), c("page.properties", "Page properties…", PAGES, None, Modification, "info"), c("help.shortcuts", "Keyboard shortcuts", HELP, None, Nothing, "circle-help"), c("help.discord", "Join the ArtCraft Discord", HELP, None, Nothing, "messages-square"), diff --git a/crates/engine/src/edit.rs b/crates/engine/src/edit.rs index 6ead3ca09c..af34efff47 100644 --- a/crates/engine/src/edit.rs +++ b/crates/engine/src/edit.rs @@ -3,11 +3,11 @@ use std::sync::Arc; use crate::{ - AddedContent, AddedText, Background, BorderShape, BoxSpec, EditError, FieldProps, FieldValue, FillMark, HeaderFooter, Hidden, ImageEdit, - InitialView, Label, LineEnding, LinkAction, LinkStyle, MarkFile, MarkKind, Markup, NewAnnotation, NewField, NoteIcon, ObjectTarget, PageBox, - Protection, ReviewState, Rgb, Shape, Standard, Style, TabOrder, TransitionSpec, Watermark, a11y, copy_image, decode_data_file, embed_image, - field_table, fit_picture, image_content_type, is_xfa_data_file, js, mark_source, measure, model_poster, open_source, strip_xfa, unrestricted, - xfa, xfa_after_data_change, + AddedContent, AddedText, Background, BorderShape, BoxSpec, EditError, FieldProps, FieldValue, FillMark, Hairlines, HeaderFooter, Hidden, + ImageEdit, InitialView, Label, LineEnding, LinkAction, LinkStyle, MarkFile, MarkKind, Markup, NewAnnotation, NewField, NoteIcon, ObjectTarget, + PageBox, Protection, ReviewState, Rgb, Shape, Standard, Style, TabOrder, TransitionSpec, Watermark, a11y, copy_image, decode_data_file, + embed_image, field_table, fit_picture, image_content_type, is_xfa_data_file, js, mark_source, measure, model_poster, open_source, strip_xfa, + unrestricted, xfa, xfa_after_data_change, }; mod labels; @@ -349,6 +349,12 @@ pub enum Edit { }, /// Document Properties ▸ Initial View (and the language and binding). SetInitialView(Box), + /// Print production ▸ Fix hairlines: strokes narrower than `settings.narrower_than` points on + /// the page (after every transformation; zero-width lines always) get `settings.width`. + FixHairlines { + pages: Vec, + settings: Hairlines, + }, /// Edit ▸ Attach a file: a document-level attachment (`/Names /EmbeddedFiles`) under /// `name`, made unique; `mime` is the embedded file's `/Subtype` when known. AddAttachment { diff --git a/crates/engine/src/edit/labels.rs b/crates/engine/src/edit/labels.rs index 762e8ced2f..879601488a 100644 --- a/crates/engine/src/edit/labels.rs +++ b/crates/engine/src/edit/labels.rs @@ -250,6 +250,7 @@ impl Edit { Edit::SetTabOrder { .. } | Edit::MoveInTabOrder { .. } => Label::of("Set tab order"), Edit::SetCalculationOrder { .. } => Label::of("Set calculation order"), Edit::SetInitialView(_) => Label::of("Change initial view"), + Edit::FixHairlines { .. } => Label::of("Fix hairlines"), Edit::AddAttachment { name, .. } => Label::with("Attach {name}", name.as_str()), Edit::DeleteAttachment { key } => Label::with("Delete attachment {key}", key.as_str()), Edit::AutoTagText => Label::of("Automatically tag text"), diff --git a/crates/engine/src/edit/run.rs b/crates/engine/src/edit/run.rs index c5444c35e0..ff60906be1 100644 --- a/crates/engine/src/edit/run.rs +++ b/crates/engine/src/edit/run.rs @@ -134,6 +134,7 @@ pub(crate) fn check_permission(edit: &Edit, p: &pdfcraft_cos::Permissions) -> Re | Edit::MoveInTabOrder { .. } | Edit::SetCalculationOrder { .. } | Edit::SetInitialView(_) + | Edit::FixHairlines { .. } | Edit::AddAttachment { .. } | Edit::DeleteAttachment { .. } | Edit::AutoTagText @@ -598,6 +599,12 @@ pub(crate) fn run_edit(doc: &mut pdfcraft_cos::Document, edit: &Edit, cx: &mut E Edit::EraseInk { page, index, path, radius } => { pdfcraft_annot::erase_ink(doc, *page, *index, path, *radius, &cx.meta())?; } + Edit::FixHairlines { pages, settings } => { + let fixed = pdfcraft_edit::fix_hairlines(doc, pages, settings)?; + if fixed.lines == 0 { + return Err(EditError::Edit(pdfcraft_edit::EditError::Invalid("no hairlines found on those pages".into()))); + } + } Edit::AddAttachment { name, bytes, description, mime } => { pdfcraft_organize::add_attachment(doc, name, bytes, description.as_deref(), mime.as_deref(), cx.date.as_deref())?; } diff --git a/crates/engine/src/history.rs b/crates/engine/src/history.rs index 04d848eb8d..441fb7d1b5 100644 --- a/crates/engine/src/history.rs +++ b/crates/engine/src/history.rs @@ -96,6 +96,7 @@ pub(crate) fn scope_of(edit: &Edit) -> Scope { | Edit::AddWatermark { .. } | Edit::AddBackground { .. } | Edit::RemoveMarks { .. } + | Edit::FixHairlines { .. } // Recognized text is an invisible text layer stamped on its page, with its font in the // page's resources (#6310): a full re-inspection made applying one word cost seconds on // a large file. diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 9be9666590..78f87e48f6 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -95,8 +95,8 @@ pub use pdfcraft_edit::added::{ }; pub use pdfcraft_edit::paragraph_align; pub use pdfcraft_edit::{ - Added, AddedImage, AddedText, Align as TextAlign, Background, Content as AddedContent, Family as FontFamily, HeaderFooter, Ink as TextInk, - MarkKind, Watermark, + Added, AddedImage, AddedText, Align as TextAlign, Background, Content as AddedContent, Family as FontFamily, Hairlines, HairlinesFixed, + HeaderFooter, Ink as TextInk, MarkKind, Watermark, }; pub use pdfcraft_forms::{ BorderStyle, CalculatedField, CalculationIssue, CalculationOrder, CheckStyle, Field as FormField, FieldAction, FieldChange, FieldFont, diff --git a/crates/engine/src/session.rs b/crates/engine/src/session.rs index fe2d62aeb3..4f7951b236 100644 --- a/crates/engine/src/session.rs +++ b/crates/engine/src/session.rs @@ -10,12 +10,13 @@ use crate::check_refresh_panic; use crate::edit::{EditCtx, check_permission, run_edit}; use crate::history::{Keys, MAX_UNDO, Scope, keys_after, scope_of, uses_scripts}; use crate::{ - BookmarkPage, BookmarkRow, CombineSource, Combined, DataFormat, DocId, Document, Edit, EditError, Hidden, ImageResolution, - LOW_RESOLUTION_PRINT_DPI, Label, ObjectKind, OptimizeReport, RecoverySnapshot, SignOptions, SourceKind, SplitPart, StatusCaptions, SummarySort, - TrustStore, XFA_REMOVED_PREFIX, XFA_SCRIPTS_OFF, apply_layer_state, carry_verdict_marks, comment_list, comment_summary, decode_text, - display_bytes, display_config, guard, info_fields, js, local_utc_offset, note_warnings, open_source, open_source_with, optimize, outline_rows, - parse_ymd, print, print_layer_states, print_raster, render_threads, scan_colours, set_default_layer_state, shown_under_redactions, signatures_of, - source_kind, sync_xfa_state, undrawn_notices, use_layer_choices, writable, xfa, xfa_sync_datasets, + BookmarkPage, BookmarkRow, CombineSource, Combined, DataFormat, DocId, Document, Edit, EditError, Hairlines, HairlinesFixed, Hidden, + ImageResolution, LOW_RESOLUTION_PRINT_DPI, Label, ObjectKind, OptimizeReport, RecoverySnapshot, SignOptions, SourceKind, SplitPart, + StatusCaptions, SummarySort, TrustStore, XFA_REMOVED_PREFIX, XFA_SCRIPTS_OFF, apply_layer_state, carry_verdict_marks, comment_list, + comment_summary, decode_text, display_bytes, display_config, guard, info_fields, js, local_utc_offset, note_warnings, open_source, + open_source_with, optimize, outline_rows, parse_ymd, print, print_layer_states, print_raster, render_threads, scan_colours, + set_default_layer_state, shown_under_redactions, signatures_of, source_kind, sync_xfa_state, undrawn_notices, use_layer_choices, writable, xfa, + xfa_sync_datasets, }; mod editing; diff --git a/crates/engine/src/session/files.rs b/crates/engine/src/session/files.rs index 8d6020553e..b5db2312f8 100644 --- a/crates/engine/src/session/files.rs +++ b/crates/engine/src/session/files.rs @@ -1,7 +1,7 @@ use super::{ - Arc, CombineSource, Combined, DataFormat, DocId, EditError, Hidden, ImageResolution, LOW_RESOLUTION_PRINT_DPI, OptimizeReport, SaveOptions, - Session, SourceKind, SplitPart, decode_text, guard, open_source, open_source_with, optimize, print, print_layer_states, print_raster, - set_default_layer_state, source_kind, writable, write_full, + Arc, CombineSource, Combined, DataFormat, DocId, EditError, Hairlines, HairlinesFixed, Hidden, ImageResolution, LOW_RESOLUTION_PRINT_DPI, + OptimizeReport, SaveOptions, Session, SourceKind, SplitPart, decode_text, guard, open_source, open_source_with, optimize, print, + print_layer_states, print_raster, set_default_layer_state, source_kind, writable, write_full, }; impl Session { @@ -114,6 +114,15 @@ impl Session { .map_err(EditError::Write) } + /// How many hairlines [`Edit::FixHairlines`] would change on `pages` of `id`, and on how many + /// pages, without changing the document (the edit itself refuses when there is nothing to fix). + pub fn count_hairlines(&self, id: DocId, pages: &[usize], settings: &Hairlines) -> HairlinesFixed { + let Some(doc) = self.get(id) else { return HairlinesFixed::default() }; + let Some(editor) = doc.editor.as_ref() else { return HairlinesFixed::default() }; + let mut cos = editor.cos.clone(); + pdfcraft_edit::fix_hairlines(&mut cos, pages, settings).unwrap_or_default() + } + /// XFDF with these comments (0-based page, position in the page's `/Annots`) and their /// replies: what Copy puts on the clipboard; [`Edit::PasteComments`] takes it back in. pub fn export_comments(&self, id: DocId, which: &[(usize, usize)]) -> Result { diff --git a/crates/ui-egui/src/app.rs b/crates/ui-egui/src/app.rs index f72a831744..31000f95bc 100644 --- a/crates/ui-egui/src/app.rs +++ b/crates/ui-egui/src/app.rs @@ -328,6 +328,7 @@ impl PdfCraftApp { replace_draft: None, number_draft: NumberDraft { from: 1, to: 1, style: pdfcraft_engine::LabelStyle::Decimal, prefix: String::new(), start: 1 }, word_count_draft: word_count_ui::Draft::default(), + hairlines_draft: Default::default(), } } diff --git a/crates/ui-egui/src/app/control.rs b/crates/ui-egui/src/app/control.rs index 35a214d9aa..b5c1f02de1 100644 --- a/crates/ui-egui/src/app/control.rs +++ b/crates/ui-egui/src/app/control.rs @@ -142,6 +142,12 @@ impl PdfCraftApp { self.execute("page.number"); Some(Dialog::NumberPages) } + "fix-hairlines" => { + // Through the command, so it is refused with no document or none that + // can be modified, as the menu is. + self.execute("prepress.hairlines"); + return Ok(()); + } "page-properties" => { // Same path as the panel, so the selected pages and their order are seeded. self.execute("page.properties"); diff --git a/crates/ui-egui/src/commands.rs b/crates/ui-egui/src/commands.rs index d455d0a74b..7104f64ca9 100644 --- a/crates/ui-egui/src/commands.rs +++ b/crates/ui-egui/src/commands.rs @@ -218,6 +218,7 @@ impl PdfCraftApp { self.protect_draft = Default::default(); self.dialog = Some(Dialog::Protect); } + "prepress.hairlines" => self.dialog = Some(Dialog::FixHairlines), "protect.remove" => { if self.apply_edit(Edit::RemoveProtection) { self.notify_tr("Security will be removed when you save"); diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 34e282ea21..1db3be1218 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -11,6 +11,7 @@ pub(super) mod appearance; pub mod bookmark_styles_ui; pub(super) mod comment_props; pub(super) mod credits; +pub(super) mod hairlines_ui; pub(super) mod marks_ui; pub(super) mod pageboxes; pub(super) mod palette; @@ -43,6 +44,7 @@ struct Pending { link_command: Option<&'static str>, protect_now: bool, boxes_now: bool, + fix_hairlines_now: bool, marks_now: bool, export_now: bool, props_now: bool, @@ -446,6 +448,12 @@ fn dialog_body(ui: &mut egui::Ui, app: &mut PdfCraftApp, dialog: Dialog, req: &m req.close = req.apply || cancel; return false; } + Dialog::FixHairlines => { + let (go, cancel) = crate::hairlines_ui::body(ui, app, &t); + req.fix_hairlines_now = go; + req.close = go || cancel; + return false; + } Dialog::BookmarksFromTextStyles => { req.close = crate::bookmark_styles_ui::body(ui, app, &t); return false; @@ -1977,6 +1985,9 @@ fn finish_edits(app: &mut PdfCraftApp, dialog: Dialog, req: &mut Pending) { app.protect_draft = Default::default(); app.notify_tr("Password protection will be applied when you save"); } + if req.fix_hairlines_now { + app.fix_hairlines(); + } if req.apply_number && let Some(edit) = req.number_now.take() { diff --git a/crates/ui-egui/src/dialogs/hairlines_ui.rs b/crates/ui-egui/src/dialogs/hairlines_ui.rs new file mode 100644 index 0000000000..6b5c093c76 --- /dev/null +++ b/crates/ui-egui/src/dialogs/hairlines_ui.rs @@ -0,0 +1,129 @@ +//! Print production ▸ Fix hairlines: lines narrower than a width on the page become a chosen +//! width; Type 3 glyphs and pattern strokes on request; the pages. Fix says how many strokes +//! changed, and refuses nothing: a document with no hairlines is told so. + +use pdfcraft_engine::{Edit, Hairlines}; + +use crate::pageboxes::Range; +use crate::theme::{self, Tokens}; +use crate::units::Unit; +use crate::{PdfCraftApp, widgets}; + +#[derive(Clone, Debug, PartialEq)] +pub struct HairlinesDraft { + /// Both widths in points (shown in the preferences' unit). + pub settings: Hairlines, + pub range: Range, +} + +impl Default for HairlinesDraft { + fn default() -> Self { + Self { settings: Hairlines::default(), range: Range::All } + } +} + +impl HairlinesDraft { + pub fn pages(&self, current: usize, count: usize) -> Vec { + match self.range { + Range::All => (0..count).collect(), + Range::Current => vec![current.min(count.saturating_sub(1))], + Range::Pages(a, b) => (a.max(1) - 1..b.min(count)).collect(), + } + } +} + +/// A width in points edited in `unit`, named by the label `label`. +fn width_field(ui: &mut egui::Ui, pts: &mut f64, unit: Unit, label: egui::Id) { + let k = unit.per_point(); + let mut v = *pts * k; + let step = 0.001 * k; + if ui + .add(egui::DragValue::new(&mut v).range(0.0..=Hairlines::MAX_POINTS * k).speed(step.max(0.0005)).fixed_decimals(unit.decimals().max(3))) + .labelled_by(label) + .changed() + { + *pts = v / k; + } + ui.label(unit.label()); +} + +/// The dialog: (fix, cancel). +pub(crate) fn body(ui: &mut egui::Ui, app: &mut PdfCraftApp, t: &Tokens) -> (bool, bool) { + let Some((i, id)) = app.active_ids() else { return (false, true) }; + let count = app.session.get(id).map(|d| d.info.pages.len()).unwrap_or(0); + let current = app.views[i].current; + let unit = app.units; + let d = &mut app.hairlines_draft; + ui.label(egui::RichText::new(tl!("Fix hairlines")).font(theme::semibold(18.0))); + ui.add_space(8.0); + egui::Grid::new("hairlines").num_columns(2).spacing([12.0, 8.0]).show(ui, |ui| { + let label = ui.label(tl!("Narrower than")).id; + ui.horizontal(|ui| width_field(ui, &mut d.settings.narrower_than, unit, label)); + ui.end_row(); + let label = ui.label(tl!("New width")).id; + ui.horizontal(|ui| width_field(ui, &mut d.settings.width, unit, label)); + ui.end_row(); + }); + ui.label(egui::RichText::new(tl!("Zero-width lines always count as narrower.")).small().color(t.text_muted)); + widgets::section_title(ui, tl!("Also")); + ui.horizontal(|ui| { + ui.add(widgets::checkbox(&mut d.settings.type3, tl!("Type 3 font glyphs"))); + ui.add(widgets::checkbox(&mut d.settings.patterns, tl!("Pattern strokes"))); + }); + ui.label(egui::RichText::new(tl!("Lines drawn inside Type 3 glyphs and tiling patterns; off by default.")).small().color(t.text_muted)); + widgets::section_title(ui, tl!("Pages")); + ui.horizontal(|ui| { + widgets::radio_value(ui, &mut d.range, Range::All, tl!("All")); + widgets::radio_value(ui, &mut d.range, Range::Current, crate::i18n::fmt(tl!("Current page ({n})"), &[("n", &(current + 1).to_string())])); + let (mut a, mut b) = match d.range { + Range::Pages(a, b) => (a, b), + _ => (1, count.max(1)), + }; + let on = matches!(d.range, Range::Pages(..)); + if ui.add(widgets::radio(on, tl!("From"))).clicked() { + d.range = Range::Pages(a, b); + } + let ra = ui.add(egui::DragValue::new(&mut a).range(1..=count.max(1))); + ui.label(tl!("to")); + let rb = ui.add(egui::DragValue::new(&mut b).range(1..=count.max(1))); + if ra.changed() || rb.changed() { + d.range = Range::Pages(a.min(b), b.max(a)); + } + }); + ui.add_space(12.0); + let (mut go, mut cancel) = (false, false); + let ready = count > 0 && d.settings.width > 0.0; + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + if ui.add_enabled_ui(ready, |ui| widgets::pill_button(ui, tl!("Fix"), true)).inner.clicked() { + go = true; + } + if widgets::pill_button(ui, tl!("Cancel"), false).clicked() { + cancel = true; + } + }); + (go, cancel) +} + +impl PdfCraftApp { + /// Fix: count first (the edit refuses when there is nothing to fix), then apply and say. + pub(crate) fn fix_hairlines(&mut self) { + let Some((i, id)) = self.active_ids() else { return }; + let count = self.session.get(id).map(|d| d.info.pages.len()).unwrap_or(0); + let pages = self.hairlines_draft.pages(self.views[i].current, count); + let settings = self.hairlines_draft.settings.clone(); + let found = self.session.count_hairlines(id, &pages, &settings); + if found.lines == 0 { + self.notify_tr("No hairlines found on those pages"); + return; + } + if self.apply_edit(Edit::FixHairlines { pages, settings }) { + let (n, p) = (found.lines.to_string(), found.pages.to_string()); + match (found.lines, found.pages) { + // One stroke can only be on one page. + (1, _) => self.notify_tr("Fixed 1 hairline on 1 page"), + (_, 1) => self.notify_fmt("Fixed {n} hairlines on 1 page", &[("n", &n)]), + _ => self.notify_fmt("Fixed {n} hairlines on {p} pages", &[("n", &n), ("p", &p)]), + } + } + } +} diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index d1440afda6..a06ffac94d 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -110,6 +110,7 @@ use dialogs::appearance; pub use dialogs::bookmark_styles_ui; use dialogs::comment_props; use dialogs::credits; +use dialogs::hairlines_ui; use dialogs::marks_ui; use dialogs::pageboxes; use dialogs::palette; @@ -708,6 +709,7 @@ pub struct PdfCraftApp { /// Number pages dialog settings (1-based pages). pub number_draft: NumberDraft, pub(crate) word_count_draft: word_count_ui::Draft, + pub hairlines_draft: hairlines_ui::HairlinesDraft, /// Protect Using Password dialog state. pub protect_draft: protect::ProtectDraft, /// New bookmarks from text styles dialog state (#1693). diff --git a/crates/ui-egui/src/state.rs b/crates/ui-egui/src/state.rs index 915d40e6ec..f03d2d4936 100644 --- a/crates/ui-egui/src/state.rs +++ b/crates/ui-egui/src/state.rs @@ -109,6 +109,8 @@ pub enum Dialog { Split, /// Pages ▸ Number pages… (page labels). NumberPages, + /// Print production ▸ Fix hairlines. + FixHairlines, /// Organize pages ▸ Page properties (the tab order of the selected pages). PageProps, /// Protect Using Password. diff --git a/crates/ui-egui/tests/it/editing.rs b/crates/ui-egui/tests/it/editing.rs index c5d197f64c..9752f0ff24 100644 --- a/crates/ui-egui/tests/it/editing.rs +++ b/crates/ui-egui/tests/it/editing.rs @@ -9428,6 +9428,75 @@ fn test_cropped_image_selection_and_bounding_box() { assert!(h.state().views[0].picked_image.is_none(), "clicking cropped-away region must not hit-test or select the image"); } +/// Print production ▸ Fix hairlines: the dialog widens zero-width and too-thin +/// strokes and says how many; with nothing to fix it says so and changes nothing. +#[test] +fn fix_hairlines_from_the_dialog() { + // A page with a zero-width line and a 2 pt line. + let mut doc = pdfcraft_cos::Document::open(std::sync::Arc::new(fixture(1))).unwrap(); + let page = pdfcraft_model::pages(&doc).remove(0); + let cs = doc + .add(pdfcraft_cos::Object::Stream(pdfcraft_cos::Stream::flate(pdfcraft_cos::Dict::new(), b"0 w 10 10 m 100 10 l S 2 w 10 20 m 100 20 l S"))); + doc.update_dict(page.obj, |d| d.set(b"Contents".to_vec(), pdfcraft_cos::Object::Ref(cs))).unwrap(); + let bytes = pdfcraft_cos::write_full(&doc, &Default::default()).unwrap(); + 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("lines.pdf", None, bytes.clone()).expect("opens"); + app + }); + h.run_steps(4); + let id = h.state().views[0].id; + assert!(h.state_mut().execute("prepress.hairlines")); + h.run_steps(2); + assert_eq!(h.state().dialog, Some(Dialog::FixHairlines)); + h.get_by_label("Type 3 font glyphs"); + h.get_by_label("Fix").click(); + h.run_steps(3); + assert_eq!(h.state().toast.clone().unwrap().0, "Fixed 1 hairline on 1 page"); + assert_eq!(h.state().session.get(id).unwrap().can_undo(), Some("Fix hairlines")); + // Nothing left: told, and no new undo step. + assert!(h.state_mut().execute("prepress.hairlines")); + h.run_steps(2); + h.get_by_label("Fix").click(); + h.run_steps(3); + assert_eq!(h.state().toast.clone().unwrap().0, "No hairlines found on those pages"); + assert_eq!(h.state().session.get(id).unwrap().can_undo(), Some("Fix hairlines")); +} + +/// Several strokes on one page: the notice says "hairlines" and "1 page". +#[test] +fn fix_hairlines_notice_uses_singular_and_plural() { + let mut doc = pdfcraft_cos::Document::open(std::sync::Arc::new(fixture(2))).unwrap(); + for (n, page) in pdfcraft_model::pages(&doc).into_iter().enumerate() { + let lines = if n == 0 { "0 w 10 10 m 100 10 l S 0 w 10 20 m 100 20 l S" } else { "2 w 10 10 m 100 10 l S" }; + let cs = doc.add(pdfcraft_cos::Object::Stream(pdfcraft_cos::Stream::flate(pdfcraft_cos::Dict::new(), lines.as_bytes()))); + doc.update_dict(page.obj, |d| d.set(b"Contents".to_vec(), pdfcraft_cos::Object::Ref(cs))).unwrap(); + } + let bytes = pdfcraft_cos::write_full(&doc, &Default::default()).unwrap(); + 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("lines.pdf", None, bytes.clone()).expect("opens"); + app + }); + h.run_steps(4); + // The dialog's default range is every page; only the first has hairlines. + assert!(h.state_mut().execute("prepress.hairlines")); + h.run_steps(2); + h.get_by_label("Fix").click(); + h.run_steps(3); + assert_eq!(h.state().toast.clone().unwrap().0, "Fixed 2 hairlines on 1 page"); +} + +/// The `dialog` option goes through the command: with no document it does not open the dialog. +#[test] +fn fix_hairlines_option_needs_a_document() { + let mut app = PdfCraftApp::new(); + app.set_option("dialog", "fix-hairlines").unwrap(); + assert_eq!(app.dialog, None); +} + /// The Read mode command (not the mode bar) applies a paragraph still open in the editor, as /// putting the Edit text tool down does; left open, it would stay drawn in Read mode with no tool. #[test] diff --git a/docs/gaps.md b/docs/gaps.md index e11eef9642..8168ba4053 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -363,9 +363,9 @@ update the numbers. ### 18. Print production tools absent - **Missing:** Output Preview, separations, total area coverage, overprint preview, ink manager, - colour conversion, printer marks, fix hairlines, flattener preview, colour management settings; - print as image; PostScript output. -- **Evidence:** 18 print-production features planned (L area). + colour conversion, printer marks, flattener preview, colour management settings; + print as image; PostScript output. Fix hairlines is in (Print production ▸ Fix hairlines…). +- **Evidence:** 17 print-production features planned (L area). - **Estimate:** 25–45 h (part of the L area's 30–55). **Doc:** [target-app-parity.md](target-app-parity.md). ### 19. Preferences mostly missing diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index c76929bca5..0c614c67fb 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -8558,11 +8558,11 @@ title = "Fix hairlines" area = "L" tier = "P1" milestone = "M11" -status = "planned" -commands = [] -tools = [] -evidence = [] -notes = "" +status = "shipped" +commands = ["prepress.hairlines"] +tools = ["doc_fix_hairlines"] +evidence = ["crates/edit/src/tests.rs::hairlines_are_widened_in_page_content_and_forms", "crates/ui-egui/tests/it/editing.rs::fix_hairlines_from_the_dialog", "crates/automation/tests/it/automation.rs::fix_hairlines_through_tools"] +notes = "Print production ▸ Fix hairlines…: strokes narrower than a threshold (default 0.003 in; zero-width lines always) get a width (default 0.01 in), in page content and form XObjects, measured after every transformation; Type 3 glyphs and tiling patterns only when asked; all, current or a range of pages; the dialog says how many strokes changed and tells when there is nothing to fix (no edit, no undo step). Tool doc_fix_hairlines. Still missing against Acrobat: nothing of the dialog; hairlines inside annotation appearances are left alone. Type 3 glyphs and tiling patterns change in place, so a page outside the range using one changes too: the tool's elsewhere counts those, the dialog does not say so yet." [[feature]] id = "print.flattener-preview" From cc1d76f3fd9fab7d8ce9d9a140b74d49a4076431 Mon Sep 17 00:00:00 2001 From: mtlcl410 <2368091+mtlcl410@users.noreply.github.com> Date: Mon, 9 Nov 2026 18:09:20 +0800 Subject: [PATCH 10/83] M3: whole-word Find matches inside Chinese, Japanese and Thai text (view.find-options) (#2075) --- crates/render/src/text/search.rs | 37 +++++++++++- crates/render/src/text/tests.rs | 42 ++++++++++++++ crates/render/tests/it/search.rs | 99 +++++++++++++++++++++++++++++++- parity/acrobat-features.toml | 4 +- 4 files changed, 177 insertions(+), 5 deletions(-) diff --git a/crates/render/src/text/search.rs b/crates/render/src/text/search.rs index 71670cc271..bf64334d6e 100644 --- a/crates/render/src/text/search.rs +++ b/crates/render/src/text/search.rs @@ -73,7 +73,11 @@ pub(super) fn find(page: &PageText, query: &str, sensitive: bool, whole_words: b } } matched = 0; - let word = |i: Option| i.and_then(|i| chars.get(i)).is_some_and(|c| c.0.is_alphanumeric()); + // A neighbour bounds a word only in a script that puts spaces between words. Han, kana, Thai + // and the like run their words together, so a character of theirs next to the match is no + // reason to reject it: Acrobat finds 台北 inside 台北市 with Whole words only on. Digits still + // bound, in any script: ๑ is not a whole word inside the number ๑๒. + let word = |i: Option| i.and_then(|i| chars.get(i)).is_some_and(|c| c.0.is_alphanumeric() && (c.0.is_numeric() || !unspaced_script(c.0))); let mut hits = Vec::new(); for (i, &(c, owner)) in chars.iter().enumerate() { matched = advance(&needle, &prefix, matched, c); @@ -205,3 +209,34 @@ fn advance(needle: &[char], prefix: &[usize], mut matched: usize, c: char) -> us } if needle.get(matched) == Some(&c) { matched.saturating_add(1) } else { 0 } } + +/// Whether `c` is a letter of a script written without spaces between words (CJK ideographs, +/// kana, bopomofo, Thai, Lao, Myanmar, Khmer, Tibetan), so that a whole-word search cannot take a +/// neighbouring letter as a word boundary. +fn unspaced_script(c: char) -> bool { + matches!( + u32::from(c), + 0x0E00..=0x0EFF // Thai, Lao + | 0x0F00..=0x0FFF // Tibetan + | 0x1000..=0x109F // Myanmar + | 0x1780..=0x17FF // Khmer + | 0x2E80..=0x2FDF // CJK and Kangxi radicals + | 0x3005..=0x3007 // 々 〆 〇 + | 0x3031..=0x3035 // vertical kana repeat marks + | 0x303B..=0x303C // 〻 〼 + | 0x3040..=0x30FF // hiragana, katakana + | 0x3100..=0x312F // bopomofo + | 0x31A0..=0x31BF // bopomofo extended + | 0x31F0..=0x31FF // katakana phonetic extensions + | 0x3400..=0x4DBF // CJK extension A + | 0x4E00..=0x9FFF // CJK unified ideographs + | 0xA9E0..=0xA9FF // Myanmar extended-B + | 0xAA60..=0xAA7F // Myanmar extended-A + | 0xF900..=0xFAFF // CJK compatibility ideographs + | 0xFF66..=0xFF9F // halfwidth katakana + | 0x1AFF0..=0x1B16F // kana extended-B, kana supplement, kana extended-A, small kana + // The supplementary and tertiary ideographic planes, all CJK ideographs (extensions B + // to J, compatibility supplement). + | 0x20000..=0x3FFFF + ) +} diff --git a/crates/render/src/text/tests.rs b/crates/render/src/text/tests.rs index a3c67696fe..c7601998b8 100644 --- a/crates/render/src/text/tests.rs +++ b/crates/render/src/text/tests.rs @@ -1052,6 +1052,48 @@ fn upright_mapping_round_trips() { assert_eq!(to_upright([290.0, 0.0, 300.0, 10.0], 1, 300.0, 200.0), [0.0, 0.0, 10.0, 10.0]); } +#[test] +fn whole_words_ignore_neighbours_in_scripts_written_without_spaces() { + // "台北市的PDF檔案" in one run: Chinese writes no spaces between words, so "台北" is a + // whole word inside "台北市" and "PDF" inside "的PDF檔", as Acrobat finds them with Whole + // words only on. A Latin neighbour still bounds: "cat" is not a whole word in "category". + let mut v = Vec::new(); + word(&mut v, "台北市的PDF檔案", 10.0, 10.0, 6.0); + word(&mut v, "category", 74.0, 10.0, 6.0); + let t = layout(v); + assert_eq!(t.plain_text(), "台北市的PDF檔案 category"); + assert_eq!(t.find_opts("台北", false, true), vec![0..2], "a word inside a longer run of ideographs"); + assert_eq!(t.find_opts("北市", false, true), vec![1..3]); + assert_eq!(t.find_opts("PDF", false, true), vec![4..7], "Latin between ideographs"); + assert_eq!(t.find_opts("pdf檔", false, true), vec![4..8]); + assert!(t.find_opts("cat", false, true).is_empty(), "Latin neighbours still bound a word"); + assert_eq!(t.find_opts("category", false, true), vec![9..17]); + // Thai and Japanese kana are written without spaces too. + let mut v = Vec::new(); + word(&mut v, "ภาษาไทย", 10.0, 30.0, 6.0); + word(&mut v, "ひらがなとカタカナ", 62.0, 30.0, 6.0); + let t = layout(v); + assert_eq!(t.find_opts("ไทย", false, true), vec![4..7]); + assert_eq!(t.find_opts("カタカナ", false, true), vec![12..16]); +} + +/// #2075 review: digits still bound a whole word in those scripts (๑ is not a word inside +/// the number ๑๒), and letters from the later Myanmar and Han blocks don't. +#[test] +fn whole_words_in_unspaced_scripts_keep_digits_and_later_blocks() { + let one = |text: &str| { + let mut v = Vec::new(); + word(&mut v, text, 10.0, 10.0, 6.0); + layout(v) + }; + assert!(one("๑๒").find_opts("๑", false, true).is_empty(), "a Thai digit beside it bounds"); + assert!(one("台北3").find_opts("台北", false, true).is_empty(), "so does an ASCII digit"); + // Myanmar Extended-A (U+AA60), Myanmar Extended-B (U+A9E0), CJK Extension H (U+31350). + for text in ["\u{AA60}PDF", "\u{A9E0}PDF", "\u{31350}PDF"] { + assert_eq!(one(text).find_opts("PDF", false, true), vec![1..4], "{text}"); + } +} + #[test] fn turkish_dotted_and_dotless_i_match_in_any_case() { // "İSTANBUL IRMAK KIŞ" on one line: each word typed in lower case must find it, and the diff --git a/crates/render/tests/it/search.rs b/crates/render/tests/it/search.rs index 5b2a1e4c46..0c20e2c7b5 100644 --- a/crates/render/tests/it/search.rs +++ b/crates/render/tests/it/search.rs @@ -9,6 +9,84 @@ fn page(parts: &[&str]) -> PageText { } } +/// The letters of the property test's alphabet that come from scripts written without spaces +/// between words: 界 (CJK Unified Ideographs, U+4E00..U+9FFF) and ก (Thai, U+0E00..U+0E7F). +/// 한 (Hangul Syllables) is written with spaces and bounds a word. The reference knows only +/// these three letters, so it does not share the block table in `text/search.rs`; that table is +/// checked against `UNSPACED_BLOCK_SAMPLES` instead. +fn unspaced(c: char) -> bool { + matches!(c, '界' | 'ก') +} + +/// Letters at the edges of each block of a script written without spaces (`true`), and letters +/// just outside them (`false`), named by their block as Unicode's Blocks.txt gives it. Each one is +/// alphabetic and not a digit, so whether it bounds a whole word depends on the table alone. +const UNSPACED_BLOCK_SAMPLES: &[(char, bool, &str)] = &[ + ('\u{0DC6}', false, "Sinhala (0D80..0DFF), before Thai"), + ('\u{0E01}', true, "Thai (0E00..0E7F), first letter"), + ('\u{0E46}', true, "Thai, MAIYAMOK"), + ('\u{0E81}', true, "Lao (0E80..0EFF), first letter"), + ('\u{0EDF}', true, "Lao, last letter"), + ('\u{0F00}', true, "Tibetan (0F00..0FFF), first letter"), + ('\u{0F8C}', true, "Tibetan, last letter"), + ('\u{1000}', true, "Myanmar (1000..109F), first letter"), + ('\u{108E}', true, "Myanmar, last letter"), + ('\u{10A0}', false, "Georgian (10A0..10FF), after Myanmar"), + ('\u{1770}', false, "Tagbanwa (1760..177F), before Khmer"), + ('\u{1780}', true, "Khmer (1780..17FF), first letter"), + ('\u{17DC}', true, "Khmer, last letter"), + ('\u{1820}', false, "Mongolian (1800..18AF), after Khmer"), + ('\u{3005}', true, "CJK Symbols and Punctuation (3000..303F), ideographic iteration mark"), + ('\u{3006}', true, "CJK Symbols and Punctuation, ideographic closing mark"), + ('\u{3031}', true, "CJK Symbols and Punctuation, vertical kana repeat mark"), + ('\u{3035}', true, "CJK Symbols and Punctuation, vertical kana repeat mark lower half"), + ('\u{303B}', true, "CJK Symbols and Punctuation, vertical ideographic iteration mark"), + ('\u{303C}', true, "CJK Symbols and Punctuation, masu mark"), + ('\u{3041}', true, "Hiragana (3040..309F), first letter"), + ('\u{309F}', true, "Hiragana, last letter"), + ('\u{30A1}', true, "Katakana (30A0..30FF), first letter"), + ('\u{30FF}', true, "Katakana, last letter"), + ('\u{3105}', true, "Bopomofo (3100..312F), first letter"), + ('\u{312F}', true, "Bopomofo, last letter"), + ('\u{3131}', false, "Hangul Compatibility Jamo (3130..318F), after Bopomofo"), + ('\u{318E}', false, "Hangul Compatibility Jamo, last letter, before Bopomofo Extended"), + ('\u{31A0}', true, "Bopomofo Extended (31A0..31BF), first letter"), + ('\u{31BF}', true, "Bopomofo Extended, last letter"), + ('\u{31F0}', true, "Katakana Phonetic Extensions (31F0..31FF), first letter"), + ('\u{31FF}', true, "Katakana Phonetic Extensions, last letter"), + ('\u{3400}', true, "CJK Unified Ideographs Extension A (3400..4DBF), first"), + ('\u{4DBF}', true, "CJK Unified Ideographs Extension A, last"), + ('\u{4E00}', true, "CJK Unified Ideographs (4E00..9FFF), first"), + ('\u{9FFF}', true, "CJK Unified Ideographs, last"), + ('\u{A000}', false, "Yi Syllables (A000..A48F), after CJK Unified Ideographs"), + ('\u{A9CF}', false, "Javanese (A980..A9DF), before Myanmar Extended-B"), + ('\u{A9E0}', true, "Myanmar Extended-B (A9E0..A9FF), first letter"), + ('\u{A9FE}', true, "Myanmar Extended-B, last letter"), + ('\u{AA00}', false, "Cham (AA00..AA5F), after Myanmar Extended-B"), + ('\u{AA4B}', false, "Cham, before Myanmar Extended-A"), + ('\u{AA60}', true, "Myanmar Extended-A (AA60..AA7F), first letter"), + ('\u{AA7F}', true, "Myanmar Extended-A, last letter"), + ('\u{AA80}', false, "Tai Viet (AA80..AADF), after Myanmar Extended-A"), + ('\u{D7FB}', false, "Hangul Jamo Extended-B (D7B0..D7FF), before CJK Compatibility Ideographs"), + ('\u{F900}', true, "CJK Compatibility Ideographs (F900..FAFF), first"), + ('\u{FAD9}', true, "CJK Compatibility Ideographs, last"), + ('\u{FB00}', false, "Alphabetic Presentation Forms (FB00..FB4F), after CJK Compatibility Ideographs"), + ('\u{FF5A}', false, "Halfwidth and Fullwidth Forms (FF00..FFEF), fullwidth z, before halfwidth katakana"), + ('\u{FF66}', true, "Halfwidth and Fullwidth Forms, first halfwidth katakana"), + ('\u{FF9F}', true, "Halfwidth and Fullwidth Forms, last halfwidth katakana"), + ('\u{FFA1}', false, "Halfwidth and Fullwidth Forms, halfwidth Hangul, after halfwidth katakana"), + ('\u{1AFF0}', true, "Kana Extended-B (1AFF0..1AFFF), first letter"), + ('\u{1B000}', true, "Kana Supplement (1B000..1B0FF), first letter"), + ('\u{1B122}', true, "Kana Extended-A (1B100..1B12F), last letter"), + ('\u{1B132}', true, "Small Kana Extension (1B130..1B16F), first letter"), + ('\u{1B167}', true, "Small Kana Extension, last letter"), + ('\u{1B170}', false, "Nushu (1B170..1B2FF), after Small Kana Extension"), + ('\u{20000}', true, "CJK Unified Ideographs Extension B (20000..2A6DF), first"), + ('\u{2A6DF}', true, "CJK Unified Ideographs Extension B, last"), + ('\u{2F800}', true, "CJK Compatibility Ideographs Supplement (2F800..2FA1F), first"), + ('\u{323AF}', true, "CJK Unified Ideographs Extension H (31350..323AF), last"), +]; + // The original straightforward search is an independent oracle for valid page metadata. fn reference(page: &PageText, query: &str, sensitive: bool, whole: bool, limit: usize) -> Vec> { let fold = |s: &str| if sensitive { s.to_owned() } else { fold_case(s) }; @@ -50,7 +128,9 @@ fn reference(page: &PageText, query: &str, sensitive: bool, whole: bool, limit: } } } - let word = |i: Option| i.and_then(|i| chars.get(i)).is_some_and(|&(c, ..)| c.is_alphanumeric()); + // A neighbour bounds a word only in a script written with spaces between words, or when it + // is a digit. + let word = |i: Option| i.and_then(|i| chars.get(i)).is_some_and(|&(c, ..)| c.is_alphanumeric() && (c.is_numeric() || !unspaced(c))); // A word ends at a skipped zero width space as it does at a space. let edge = |i: usize| chars.get(i).is_some_and(|&(.., zwsp)| zwsp); let mut hits = Vec::new(); @@ -92,9 +172,24 @@ fn whole_word_rejection_keeps_overlapping_candidates() { assert_eq!(page(&["aaaaa"]).find("aa"), vec![0..1, 0..1]); } +/// A letter from a block in `UNSPACED_BLOCK_SAMPLES` next to a whole-word match leaves it standing +/// when the block's script is written without spaces, and bounds it otherwise, on either side. +#[test] +fn whole_words_follow_the_unicode_blocks_of_unspaced_scripts() { + for &(c, unspaced, block) in UNSPACED_BLOCK_SAMPLES { + let (neighbour, cp) = (c.to_string(), u32::from(c)); + assert!(c.is_alphanumeric() && !c.is_numeric(), "U+{cp:04X} ({block}) is a letter, so the sample tests the table"); + // Case-sensitive, so that case folding does not move a neighbour into another block + // (Georgian U+10A0 folds to U+2D00). + let (before, after) = if unspaced { (Some(1..2), Some(0..1)) } else { (None, None) }; + assert_eq!(page(&[&neighbour, "PDF"]).find_opts("PDF", true, true), Vec::from_iter(before), "U+{cp:04X} ({block}) before the match"); + assert_eq!(page(&["PDF", &neighbour]).find_opts("PDF", true, true), Vec::from_iter(after), "U+{cp:04X} ({block}) after the match"); + } +} + #[test] fn search_matches_reference_for_unicode_gaps_limits_and_word_boundaries() { - let alphabet = ["a", "b", " ", "-", "İ", "ı", "ς", "Σ", "\t", "\n", "ffi", "", "界", "e\u{301}", "العربية", "\u{AD}", "a\u{200B}"]; + let alphabet = ["a", "b", " ", "-", "İ", "ı", "ς", "Σ", "\t", "\n", "ffi", "", "界", "ก", "한", "e\u{301}", "العربية", "\u{AD}", "a\u{200B}"]; let mut seed = 1234567u64; let mut next = || { seed = seed.wrapping_mul(6364136223846793005).wrapping_add(1); diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index 0c614c67fb..4ec064518b 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -1430,8 +1430,8 @@ milestone = "M3" status = "shipped" commands = ["edit.find"] tools = [] -evidence = ["crates/ui-egui/tests/it/viewer.rs::view_history_select_all_and_find_options"] -notes = "whole words only, case-sensitive" +evidence = ["crates/ui-egui/tests/it/viewer.rs::view_history_select_all_and_find_options", "crates/render/src/text/tests.rs::whole_words_ignore_neighbours_in_scripts_written_without_spaces", "crates/render/tests/it/search.rs::whole_words_follow_the_unicode_blocks_of_unspaced_scripts"] +notes = "whole words only, case-sensitive. Whole words: a neighbouring letter bounds the match only in a script that writes spaces between words; next to Han, kana, bopomofo, Thai, Lao, Myanmar, Khmer or Tibetan letters the match stands, so 台北 is found in 台北市 as in Acrobat" [[feature]] id = "view.find-in-comments-bookmarks" From bd5db5dc35093e02ddcfb245864cadc8a7e327ab Mon Sep 17 00:00:00 2001 From: mtlcl410 <2368091+mtlcl410@users.noreply.github.com> Date: Mon, 9 Nov 2026 18:09:37 +0800 Subject: [PATCH 11/83] M2: a page whose content was cut at the renderer's limits says so (#623) (#2113) --- Cargo.lock | 1 + ROADMAP.md | 1 + crates/automation/src/pages.rs | 4 ++ crates/automation/src/tools.rs | 2 +- crates/automation/tests/it/automation.rs | 28 +++++++++++ crates/engine/src/js.rs | 3 +- crates/engine/src/rich_copy.rs | 2 +- crates/render/examples/search_bench.rs | 2 +- crates/render/src/raster/tests.rs | 22 ++++++++ crates/render/src/text.rs | 6 ++- crates/render/src/text/device.rs | 22 ++++++-- crates/render/src/text/layout.rs | 2 +- crates/render/src/text/tests.rs | 7 ++- crates/render/tests/it/search.rs | 1 + crates/ui-egui/Cargo.toml | 2 + crates/ui-egui/src/canvas.rs | 7 +++ crates/ui-egui/src/canvas/bars.rs | 38 +++++++++++++- crates/ui-egui/src/canvas/cache.rs | 12 +++++ crates/ui-egui/src/canvas/tests.rs | 57 +++++++++++++++++++++ crates/ui-egui/src/control/host.rs | 2 + crates/ui-egui/src/tools/comments/tests.rs | 2 +- crates/ui-egui/tests/it/view.rs | 58 ++++++++++++++++++++++ parity/acrobat-features.toml | 4 +- 23 files changed, 269 insertions(+), 16 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 0c1bcdec8d..f9e243ba0b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4767,6 +4767,7 @@ dependencies = [ "egui", "egui_extras", "egui_kittest", + "flate2", "fontconfig-parser", "getrandom 0.4.3", "image", diff --git a/ROADMAP.md b/ROADMAP.md index c2c7c6ad62..50576e6145 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -523,6 +523,7 @@ Ranked; detail and the full milestone table in [docs/roadmap.md](docs/roadmap.md - **2026-10-15 (M3, #1248):** A fresh install follows the desktop's appearance: the theme preference defaults to System (dark on a dark desktop, Light where the desktop says nothing); saved settings and `--theme` still win. Dark page rendering is Reading colours, and the page reading themes are #1732. - **2026-10-15 (M3, #1767):** Restore last view settings when reopening documents, as Acrobat's preference does: a file opened again shows the page, zoom and page display it was last shown with (remembered for the last 100 files when a tab closes or settings are saved), over the file's own initial view; on by default, Preferences ▸ Documents and view turns it off. +- **2026-10-14 (M2, #623):** A page whose content was cut at the renderer's limits no longer looks complete: a notice names it (and how many), `ui.state` lists the affected pages under `active.cut_pages`, and `text_extract` marks its text `truncated`, since the cut part's text can't be found or copied. The renderer already reported the cut; nothing showed it. - **2026-10-13 (M3, native-opening candidate):** Chosen tools survive preparation, conversion and password retries; startup intake waits for pending preparation. Regression tests cover completion, cancellation and document identity. Estimates unchanged. - **2026-10-13 (community, M3, #531 touchpad momentum):** On Wayland a two-finger swipe keeps gliding after the fingers lift and slows to a stop, measured from the gesture's last 0.1 s and decaying with a 0.35 s time constant through the #759 1:1 path. Any wheel input, click, key, zoom or edge ends it (fingers resting on the touchpad don't yet: the vendored winit binds no Wayland hold gestures); single-page paging, macOS (which sends its own momentum), the web, and Animation effects Off never glide. Unit tests for the physics and two headless view tests. Estimates unchanged. - **2026-10-13 (M3, native-open admission):** Later prepared replies stay queued while a modal or native picker owns the foreground. Picker completion, cancellation and abandonment wake retained opens. Regressions cover recipient ownership, exactly-once admission, source/history preservation and one-shot repaint requests. Estimates unchanged. diff --git a/crates/automation/src/pages.rs b/crates/automation/src/pages.rs index 04ab8dace4..f9ddec40a3 100644 --- a/crates/automation/src/pages.rs +++ b/crates/automation/src/pages.rs @@ -235,6 +235,10 @@ impl Automation { let (mut html_left, mut spent, mut cut) = (html_budget, false, false); for (&p, t) in pages.iter().zip(texts) { let mut page = json!({ "page": p + 1, "text": text(&t) }); + // Content cut at the renderer's limits (#623): the text is incomplete, and said so. + if t.truncated { + page["truncated"] = json!(true); + } if html { // Copy with formatting: the same glyphs as an HTML fragment, within one budget for // the whole call; a page past it gets none and the call says so. diff --git a/crates/automation/src/tools.rs b/crates/automation/src/tools.rs index 05d5753748..59fd154c14 100644 --- a/crates/automation/src/tools.rs +++ b/crates/automation/src/tools.rs @@ -246,7 +246,7 @@ pub fn tools() -> Vec { .with(schema(json!({ "doc": doc(), "pages": pages("to count (default: all)"), "exclude_tables": { "type": "boolean" }, "exclude_code": { "type": "boolean" }, "exclude_images": { "type": "boolean" } }), &["doc"])), - t("text_extract", "Extract text", "Extract the text of some or all pages, in reading order. With rect, only the text inside that rectangle on each page, as Column select takes it: one row per visual line, side-by-side pieces (table cells) separated by a tab. With format \"html\", each page also carries html, the same text as Copy with formatting puts on the clipboard (paragraphs, bold, italic, size, colour and font family), and styles_incomplete when some styles had to be left out. With rect, the html is that text in line order, not split into the column's rows and cells. The HTML of one call is capped at 32 MiB in total; pages past the cap carry empty html with styles_incomplete. html_truncated is set when that cap is reached or when a page's own copy was cut at its span or size cap.") + t("text_extract", "Extract text", "Extract the text of some or all pages, in reading order. With rect, only the text inside that rectangle on each page, as Column select takes it: one row per visual line, side-by-side pieces (table cells) separated by a tab. With format \"html\", each page also carries html, the same text as Copy with formatting puts on the clipboard (paragraphs, bold, italic, size, colour and font family), and styles_incomplete when some styles had to be left out. With rect, the html is that text in line order, not split into the column's rows and cells. A page whose content was cut at the renderer's limits carries truncated: true; its text is incomplete. The HTML of one call is capped at 32 MiB in total; pages past the cap carry empty html with styles_incomplete. html_truncated is set when that cap is reached or when a page's own copy was cut at its span or size cap.") .ro() .cmd("edit.column_select") .with(schema( diff --git a/crates/automation/tests/it/automation.rs b/crates/automation/tests/it/automation.rs index 40334fae65..8758a6b0b4 100644 --- a/crates/automation/tests/it/automation.rs +++ b/crates/automation/tests/it/automation.rs @@ -11652,6 +11652,34 @@ fn simple_rule_scan_drawn(content: &[u8]) -> Vec { write_full(&doc, &SaveOptions::default()).unwrap() } +/// text_extract says when a page's text is incomplete because its content was cut at the +/// renderer's limits (#623); a page read whole carries no such mark. +#[test] +fn text_extract_says_when_a_pages_content_was_cut() { + use std::io::Write as _; + let dir = workdir("cut-content"); + // 288 MiB of whitespace, past the renderer's 256 MiB per-stream limit, as one Flate stream. + let mut z = flate2::write::ZlibEncoder::new(Vec::new(), flate2::Compression::best()); + let chunk = vec![0u8; 1 << 20]; + for _ in 0..288 { + z.write_all(&chunk).unwrap(); + } + let stream = z.finish().unwrap(); + let mut pdf = b"%PDF-1.7\n1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj\n2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 >> endobj\n3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 200 300] /Contents 4 0 R >> endobj\n".to_vec(); + pdf.extend_from_slice(format!("4 0 obj << /Filter /FlateDecode /Length {} >> stream\n", stream.len()).as_bytes()); + pdf.extend_from_slice(&stream); + pdf.extend_from_slice(b"\nendstream endobj\ntrailer << /Root 1 0 R >>\n%%EOF\n"); + std::fs::write(dir.join("cut.pdf"), pdf).unwrap(); + let mut a = auto(&dir); + let cut = ok(&mut a, "doc_open", json!({ "path": "cut.pdf" }))["doc"].as_u64().unwrap(); + let out = ok(&mut a, "text_extract", json!({ "doc": cut })); + assert_eq!(out["pages"][0]["truncated"], json!(true), "{out}"); + let whole = ok(&mut a, "doc_open", json!({ "path": "a.pdf" }))["doc"].as_u64().unwrap(); + let out = ok(&mut a, "text_extract", json!({ "doc": whole, "pages": [1] })); + assert!(out["pages"][0].get("truncated").is_none(), "{out}"); + let _ = std::fs::remove_dir_all(&dir); +} + /// #3005: a table and a small signature drawn inside one transparency-group Form XObject (how /// HTML-to-PDF converters write a block with a translucent picture). Edit a PDF sees one piece /// of artwork and no text; `image_edit ungroup` draws it in place with the same pixels, its diff --git a/crates/engine/src/js.rs b/crates/engine/src/js.rs index dce233279c..3867d7c533 100644 --- a/crates/engine/src/js.rs +++ b/crates/engine/src/js.rs @@ -604,7 +604,8 @@ mod review_tests { }; let mut glyphs: Vec<_> = (0..n).map(|i| glyph(i, "_")).collect(); glyphs.push(glyph(n, "x")); - let text = pdfcraft_render::PageText { line_of: vec![0; glyphs.len()], space_before: vec![false; glyphs.len()], glyphs }; + // Built by hand, not read from a page the renderer cut short, so the text is complete. + let text = pdfcraft_render::PageText { line_of: vec![0; glyphs.len()], space_before: vec![false; glyphs.len()], glyphs, truncated: false }; UNDERSCORE_CHARS_READ.with(|c| c.set(0)); let words = words_with(&text, |x, y| [x, y]); assert_eq!(words.iter().map(|(w, _)| w.len()).collect::>(), [n, 1]); diff --git a/crates/engine/src/rich_copy.rs b/crates/engine/src/rich_copy.rs index 2e579adb19..a551ad2e9c 100644 --- a/crates/engine/src/rich_copy.rs +++ b/crates/engine/src/rich_copy.rs @@ -382,7 +382,7 @@ mod tests { let bytes = Arc::new(pdfcraft_cos::write_full(&cos, &Default::default()).unwrap()); let mut s = Session::new(); let id = s.open("locked.pdf", None, bytes, None).unwrap(); - let text = pdfcraft_render::PageText { glyphs: Vec::new(), line_of: Vec::new(), space_before: Vec::new() }; + let text = pdfcraft_render::PageText { glyphs: Vec::new(), line_of: Vec::new(), space_before: Vec::new(), truncated: false }; let err = s.get(id).unwrap().rich_copy(0, &text, &[]).unwrap_err(); assert!(matches!(err, crate::EditError::NotPermitted(_)), "{err:?}"); } diff --git a/crates/render/examples/search_bench.rs b/crates/render/examples/search_bench.rs index f65d959c5e..4e7d77c1ae 100644 --- a/crates/render/examples/search_bench.rs +++ b/crates/render/examples/search_bench.rs @@ -13,7 +13,7 @@ fn main() { let glyphs: Vec<_> = text.chars().map(|c| TextGlyph { text: c.to_string(), rect: [0.0; 4], quad: [[0.0; 2]; 4], direction: [1.0, 0.0] }).collect(); let n = glyphs.len(); - let page = PageText { glyphs, line_of: vec![0; n], space_before: vec![false; n] }; + let page = PageText { glyphs, line_of: vec![0; n], space_before: vec![false; n], truncated: false }; black_box(page.find(&query)); let mut times = Vec::new(); for _ in 0..7 { diff --git a/crates/render/src/raster/tests.rs b/crates/render/src/raster/tests.rs index fbe6dcede8..fab4e1f1ba 100644 --- a/crates/render/src/raster/tests.rs +++ b/crates/render/src/raster/tests.rs @@ -5778,6 +5778,28 @@ fn content_bombs_stop_at_the_budget_and_say_so() { assert!(page.warnings.contains(&RenderWarning::ContentTruncated), "the cut content is observable"); } +/// The text of a cut page says so too (#623): what was extracted is incomplete, and a page +/// read whole is not marked. +#[test] +fn cut_content_marks_the_pages_text_incomplete() { + let text_of = |pdf: Vec| { + let (tx, rx) = std::sync::mpsc::channel(); + std::thread::spawn(move || { + let mut r = PageRenderer::new(Arc::new(pdf), RenderConfig::default()); + let _ = tx.send(r.render(RenderRequest { page: 0, kind: RequestKind::Text, tile: None, scale: 1.0, tag: 0, preview: false })); + }); + rx.recv_timeout(std::time::Duration::from_secs(60)).expect("text extraction must not stall") + }; + let bomb = zlib_zeros(hayro::hayro_syntax::MAX_DECODED_STREAM + (32 << 20)); + let page = text_of(one_page_pdf(&bomb, "/Filter /FlateDecode")); + assert!(page.error.is_none(), "{:?}", page.error); + assert!(page.text.as_ref().is_some_and(|t| t.truncated), "the text says it is incomplete"); + assert!(page.warnings.contains(&RenderWarning::ContentTruncated), "and the page says so"); + let page = text_of(one_page_pdf(b"1 0 0 rg 0 0 4 4 re f", "")); + assert!(page.text.as_ref().is_some_and(|t| !t.truncated), "a page read whole is not marked"); + assert!(page.warnings.is_empty()); +} + /// A stream read in pieces of any size gives the same bytes: stored and dynamic blocks, and in /// a `/Contents` array a stream that is not Flate-coded, between two that are. #[test] diff --git a/crates/render/src/text.rs b/crates/render/src/text.rs index bc132466b1..dcdb9f2820 100644 --- a/crates/render/src/text.rs +++ b/crates/render/src/text.rs @@ -11,7 +11,8 @@ use hayro::hayro_interpret::font::Glyph; use hayro::hayro_interpret::hayro_syntax::Pdf; use hayro::hayro_interpret::{ - BlendMode, ClipPath, Context, Device, GlyphDrawMode, Image, InterpreterCache, InterpreterSettings, Paint, PathDrawMode, SoftMask, interpret_page, + BlendMode, ClipPath, Context, Device, GlyphDrawMode, Image, InterpreterCache, InterpreterSettings, InterpreterWarning, Paint, PathDrawMode, + SoftMask, interpret_page, }; use kurbo::{Affine, BezPath, Rect, Shape}; use std::collections::{HashMap, HashSet}; @@ -100,6 +101,9 @@ pub struct PageText { pub line_of: Vec, /// `true` when a word break precedes the glyph on the same line. pub space_before: Vec, + /// The page's content was cut at the renderer's limits (#623), so this text is incomplete: + /// whatever the cut part drew is missing, and a search over it can miss. + pub truncated: bool, } impl PageText { diff --git a/crates/render/src/text/device.rs b/crates/render/src/text/device.rs index a04e380788..afbe5150d7 100644 --- a/crates/render/src/text/device.rs +++ b/crates/render/src/text/device.rs @@ -1,7 +1,7 @@ use super::layout::{Flow, along_baseline, arrange_flow, compatible, continues, from_upright, to_upright, unrolled}; use super::{ - Affine, BezPath, BlendMode, ClipPath, Context, Device, Glyph, GlyphDrawMode, HashMap, Image, InterpreterCache, InterpreterSettings, PageText, - Paint, PathDrawMode, Pdf, Rect, Shape, SoftMask, TextFilter, TextGlyph, TransformExt, interpret_page, + Affine, BezPath, BlendMode, ClipPath, Context, Device, Glyph, GlyphDrawMode, HashMap, Image, InterpreterCache, InterpreterSettings, + InterpreterWarning, PageText, Paint, PathDrawMode, Pdf, Rect, Shape, SoftMask, TextFilter, TextGlyph, TransformExt, interpret_page, }; /// Largest sine of the angle between a glyph's writing direction and a flow's for the glyph to be @@ -573,7 +573,22 @@ pub(crate) fn extract_page_filtered(pdf: &Pdf, page: usize, settings: &Interpret let cache = InterpreterCache::new(); // Annotation appearances are included: form-field values and comment text are searchable, // as in Acrobat, and match what other extractors report. - let settings = settings.clone(); + // The interpreter says when the content was cut at its limits (#623): the text is then + // marked incomplete, and the caller's own sink still hears it. + let cut = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)); + let settings = { + let flag = cut.clone(); + let inner = settings.warning_sink.clone(); + InterpreterSettings { + warning_sink: std::sync::Arc::new(move |warning| { + if matches!(warning, InterpreterWarning::ContentTruncated) { + flag.store(true, std::sync::atomic::Ordering::Relaxed); + } + inner(warning); + }), + ..settings.clone() + } + }; let initial = p.initial_transform(true).to_kurbo(); let c = p.intersected_crop_box(); let mut ctx = Context::new(initial, Rect::new(0.0, 0.0, f64::from(w), f64::from(h)), &cache, p.xref(), settings); @@ -700,5 +715,6 @@ pub(crate) fn extract_page_filtered(pdf: &Pdf, page: usize, settings: &Interpret for f in extra { emit(&mut text, &f.ids, &f.line_of, &f.space_before, None); } + text.truncated = cut.load(std::sync::atomic::Ordering::Relaxed); Ok(text) } diff --git a/crates/render/src/text/layout.rs b/crates/render/src/text/layout.rs index e147cda84d..eb858d39c0 100644 --- a/crates/render/src/text/layout.rs +++ b/crates/render/src/text/layout.rs @@ -189,7 +189,7 @@ pub fn layout(glyphs: Vec) -> PageText { let mut slots: Vec> = glyphs.into_iter().map(Some).collect(); // Each glyph is emitted exactly once. let glyphs = order.iter().filter_map(|i| slots.get_mut(*i).and_then(Option::take)).collect(); - PageText { glyphs, line_of, space_before } + PageText { glyphs, line_of, space_before, truncated: false } } /// [`layout`] as the reading order of indices into `glyphs`, with each one's line and word gap. diff --git a/crates/render/src/text/tests.rs b/crates/render/src/text/tests.rs index c7601998b8..f0653059cd 100644 --- a/crates/render/src/text/tests.rs +++ b/crates/render/src/text/tests.rs @@ -1255,7 +1255,7 @@ fn a_match_ending_on_a_separator_stops_before_the_next_glyph() { let mut space_before = vec![false; 10]; space_before[5] = !line_break; let line_of = (0..10).map(|i| u32::from(line_break && i >= 5)).collect(); - let page = PageText { glyphs: glyphs.clone(), line_of, space_before }; + let page = PageText { glyphs: glyphs.clone(), line_of, space_before, truncated: false }; let plain = if line_break { "quick\nbrown" } else { "quick brown" }; assert_eq!(page.plain_text(), plain); assert_eq!(page.find("quick "), vec![0..5]); @@ -1280,7 +1280,7 @@ fn phrase_search_ignores_repeated_spaces() { v.push(g(" ", 52.0, 10.0, 58.0)); word(&mut v, "brown", 58.0, 10.0, 6.0); let n = v.len(); - let t = PageText { glyphs: v, line_of: vec![0; n], space_before: vec![false; n] }; + let t = PageText { glyphs: v, line_of: vec![0; n], space_before: vec![false; n], truncated: false }; assert_eq!(t.plain_text(), "quick brown"); assert_eq!(t.find("quick brown"), vec![0..12]); assert_eq!(t.find("quick brown"), vec![0..12], "the query's own run of spaces is collapsed too"); @@ -1983,6 +1983,7 @@ fn words_and_lines_around_a_glyph() { })), line_of: vec![0, 0, 0, 0, 0, 1, 1, 1], space_before: vec![false, false, false, false, false, false, true, false], + truncated: false, }; assert_eq!(t.word_at(1), Some((0, 1))); assert_eq!(t.word_at(3), Some((3, 4))); @@ -2482,6 +2483,7 @@ fn vertical_lines_keep_reading_order() { glyphs: vec![cell("日", 40.0, 10.0), cell("本", 40.0, 20.0), cell("語", 20.0, 10.0), cell("文", 20.0, 20.0)], line_of: vec![0, 0, 1, 1], space_before: vec![false; 4], + truncated: false, }; let all = t.glyphs_in([0.0, 0.0, 60.0, 40.0]); assert_eq!(t.column_text(&all), "日本\n語文"); @@ -3176,6 +3178,7 @@ fn the_glyph_at_a_point_is_the_first_whose_box_holds_it() { glyphs: vec![em_box("a", 0.0, 0.0, 10.0, 10.0), em_box("b", 8.0, 0.0, 20.0, 10.0)], line_of: vec![0, 0], space_before: vec![false, false], + truncated: false, }; assert_eq!(text.glyph_at(5.0, 5.0), Some(0)); assert_eq!(text.glyph_at(9.0, 5.0), Some(0), "the first of overlapping boxes"); diff --git a/crates/render/tests/it/search.rs b/crates/render/tests/it/search.rs index 0c20e2c7b5..d78b18087a 100644 --- a/crates/render/tests/it/search.rs +++ b/crates/render/tests/it/search.rs @@ -6,6 +6,7 @@ fn page(parts: &[&str]) -> PageText { glyphs: parts.iter().map(|text| TextGlyph { text: (*text).into(), rect: [0.0; 4], quad: [[0.0; 2]; 4], direction: [1.0, 0.0] }).collect(), line_of: vec![0; parts.len()], space_before: vec![false; parts.len()], + truncated: false, } } diff --git a/crates/ui-egui/Cargo.toml b/crates/ui-egui/Cargo.toml index 994dfc8a1b..7f9f39894e 100644 --- a/crates/ui-egui/Cargo.toml +++ b/crates/ui-egui/Cargo.toml @@ -94,6 +94,8 @@ web-sys = { version = "0.3", features = ["Window", "Document", "Element", "HtmlE [dev-dependencies] craft-update = { workspace = true } pdfcraft-xfa = { workspace = true } +# A content stream that inflates past the renderer's limits, for the cut-content notice test. +flate2 = { workspace = true } egui_kittest = { workspace = true, features = ["eframe", "wgpu"] } # Tests draw PdfCraft text without a system provider: the bundled and monochrome emoji faces must # be enabled on the test graph itself (a dependency's dev-features do not propagate). diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 26d1bd4946..5676ed1cae 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -563,6 +563,10 @@ pub struct DocView { pages: HashMap, /// Pages the renderer could not draw, with the reason (never re-requested). errors: HashMap, + /// Pages whose content was cut at the renderer's limits (#623): drawn and searched in part. + cut_pages: std::collections::BTreeSet, + /// Cut pages whose notice the reader dismissed: a page cut later shows it again. + cut_notice_dismissed: std::collections::BTreeSet, /// When each still-missing page was first shown, to flag unusually slow renders. waiting_since: HashMap, thumbs: HashMap, @@ -1021,6 +1025,8 @@ impl DocView { presentation: None, pages: HashMap::new(), errors: HashMap::new(), + cut_pages: Default::default(), + cut_notice_dismissed: Default::default(), waiting_since: HashMap::new(), thumbs: HashMap::new(), thumb_demand: HashMap::new(), @@ -1183,6 +1189,7 @@ impl DocView { fresh.shown = self.shown; fresh.highlight_fields = self.highlight_fields; fresh.notice_dismissed = self.notice_dismissed; + fresh.cut_notice_dismissed = std::mem::take(&mut self.cut_notice_dismissed); fresh.snapshot = self.snapshot.clone(); fresh.close_requested = self.close_requested; fresh.cover = self.cover; diff --git a/crates/ui-egui/src/canvas/bars.rs b/crates/ui-egui/src/canvas/bars.rs index 3e12bd6ce3..a8a538e86c 100644 --- a/crates/ui-egui/src/canvas/bars.rs +++ b/crates/ui-egui/src/canvas/bars.rs @@ -59,8 +59,8 @@ pub(super) enum Notice { } /// The notice bar above the pages: a file changed on disk first, then what a historical -/// snapshot is, then the signature status bar, a form's scripts having been turned -/// off, security, forms and warnings. +/// snapshot is, then pages cut at the renderer's limits, then the signature status bar, a +/// form's scripts having been turned off, security, forms and warnings. pub(super) fn notices( view: &mut DocView, doc: &pdfcraft_engine::Document, @@ -119,6 +119,13 @@ pub(super) fn notices( return Some(Notice::Close); } } + // Content cut at the renderer's limits (#623): what is missing is not visible, so the page + // is named in a bar of its own, which the signature, form and security bars below can't + // replace and dismissing them doesn't hide. Dismissing it covers the pages cut so far: a + // page found cut later shows it again. + if view.cut_pages.iter().any(|p| !view.cut_notice_dismissed.contains(p)) { + cut_pages_bar(view, ui, t); + } if let Some((icon, color, template, arg)) = signed { let mut open = false; notice_bar(ui, t, icon, color, crate::i18n::fmt(tl!(template), &[("by", &arg)]), |ui| { @@ -242,6 +249,33 @@ pub(super) fn notices( open_security.then_some(Notice::Security).or(toggled) } +/// The bar naming the pages whose content was cut at the renderer's limits (#623). +fn cut_pages_bar(view: &mut DocView, ui: &mut egui::Ui, t: &Tokens) { + let Some(&first) = view.cut_pages.iter().next() else { return }; + let page = first.saturating_add(1).to_string(); + let n = view.cut_pages.len(); + let text = if n == 1 { + crate::i18n::fmt( + tl!("Page {page} holds more content than PdfCraft can read: part of it isn't drawn, and only part of its text can be found or copied."), + &[("page", &page)], + ) + } else { + crate::i18n::fmt( + tl!( + "{n} pages hold more content than PdfCraft can read, page {page} first: parts of them aren't drawn, and only part of their text can be found or copied." + ), + &[("n", &n.to_string()), ("page", &page)], + ) + }; + let mut dismiss = false; + notice_bar(ui, t, "triangle-alert", t.accent_text, text, |ui| { + dismiss = icons::button(ui, "x", 22.0, false, tl!("Dismiss")).clicked(); + }); + if dismiss { + view.cut_notice_dismissed.extend(view.cut_pages.iter().copied()); + } +} + /// One notice bar: an icon and a message on the left, `buttons` (added right to left) on the /// right. The buttons are laid out first, so the message only gets the width they leave: it /// wraps there (or, when very little is left, is cut short with the full text on hover) instead diff --git a/crates/ui-egui/src/canvas/cache.rs b/crates/ui-egui/src/canvas/cache.rs index 9a0abad953..c8f1d60f09 100644 --- a/crates/ui-egui/src/canvas/cache.rs +++ b/crates/ui-egui/src/canvas/cache.rs @@ -140,6 +140,12 @@ impl DocView { } } + /// Pages whose content was cut at the renderer's limits, so part of each is not drawn and + /// its text is incomplete (#623; for automation; 0-based, in order). + pub fn cut_pages(&self) -> Vec { + self.cut_pages.iter().copied().collect() + } + /// Pages that could not be rendered, with the reason (for automation; 0-based pages). pub fn page_errors(&self) -> Vec<(usize, &str)> { let mut v: Vec<(usize, &str)> = self.errors.iter().map(|(p, e)| (*p, e.as_str())).collect(); @@ -186,6 +192,7 @@ impl DocView { self.texts.clear(); self.text_failed.clear(); self.errors.clear(); + self.cut_pages.clear(); self.slow_pages.clear(); self.waiting_since.clear(); self.last_queue.clear(); @@ -229,6 +236,7 @@ impl DocView { self.texts.remove(&page); self.text_failed.remove(&page); self.errors.remove(&page); + self.cut_pages.remove(&page); self.last_queue.clear(); if self.selection.is_some_and(|s| s.page == page) { self.selection = None; @@ -565,6 +573,10 @@ impl DocView { return 0; } ctx.request_repaint(); + // Whether drawn or read for its text, a page cut at the limits is named (#623). + if r.warnings.contains(&pdfcraft_render::RenderWarning::ContentTruncated) { + self.cut_pages.insert(r.request.page); + } if r.request.kind == RequestKind::Text { match r.text { Some(t) => { diff --git a/crates/ui-egui/src/canvas/tests.rs b/crates/ui-egui/src/canvas/tests.rs index d8a5259251..85c7d3fac3 100644 --- a/crates/ui-egui/src/canvas/tests.rs +++ b/crates/ui-egui/src/canvas/tests.rs @@ -484,6 +484,7 @@ fn history_search_view() -> DocView { }], line_of: vec![0], space_before: vec![false], + truncated: false, } .into(), ); @@ -550,6 +551,7 @@ fn find_test_text(s: &str) -> PageText { .collect(), line_of: s.chars().map(|_| 0).collect(), space_before: s.chars().map(|_| false).collect(), + truncated: false, } } @@ -1178,6 +1180,7 @@ fn select_all_of_the_document_only_while_scrolling_through_several_pages() { .collect(), line_of: s.chars().map(|_| 0).collect(), space_before: s.chars().map(|_| false).collect(), + truncated: false, }; let mut v = DocView::new(DocId(1), &info, ViewDefaults::default()); for p in 0..3 { @@ -1208,6 +1211,7 @@ fn text_layers_keep_the_selection_page() { glyphs: vec![pdfcraft_render::TextGlyph { text: "x".into(), rect: [0.0, 0.0, 10.0, 10.0], quad: [[0.0, 0.0]; 4], direction: [1.0, 0.0] }], line_of: vec![0], space_before: vec![false], + truncated: false, }; for p in 0..100 { v.texts.insert(p, Arc::new(text.clone())); @@ -2064,6 +2068,59 @@ fn edit_text_boxes_stay_drawn_while_page_input_is_blocked() { assert_eq!(app.views[0].edit_lines.get(&0).map(|(g, _)| *g), Some(generation), "the boxes follow the edit while input is blocked"); } +/// A cut page (#623) is named in a bar of its own: a static XFA form's notice, which comes +/// first among the form notices, still shows beside it instead of replacing it (review on #2113). +#[test] +fn a_cut_page_is_named_beside_an_xfa_form_notice() { + use egui_kittest::kittest::Queryable; + + let mut h = egui_kittest::Harness::builder().with_size(vec2(1400.0, 900.0)).build_eframe(|_cc| { + let mut app = PdfCraftApp::new(); + app.set_option("language", "en").unwrap(); + let bytes = pdfcraft_xfa::fixtures::static_shell("Ada"); + app.open_bytes("form.pdf", None, bytes).unwrap(); + app + }); + h.run_steps(4); + h.get_by_label_contains("XFA data are kept in step"); + h.state_mut().views[0].cut_pages.insert(0); + h.run_steps(2); + h.get_by_label_contains("Page 1 holds more content than PdfCraft can read"); + h.get_by_label_contains("XFA data are kept in step"); +} + +/// Dismissing another notice doesn't hide a page found cut later (#623), and dismissing the +/// cut-page notice lasts until another page is found cut (review on #2113). +#[test] +fn a_page_found_cut_after_a_dismissal_is_still_named() { + use egui_kittest::kittest::Queryable; + + let mut h = egui_kittest::Harness::builder().with_size(vec2(1400.0, 900.0)).build_eframe(|_cc| { + let mut app = PdfCraftApp::new(); + app.set_option("language", "en").unwrap(); + let bytes = app.session.create_blank(300.0, 400.0, 2).unwrap(); + app.open_bytes("blank.pdf", None, bytes.as_ref().clone()).unwrap(); + app + }); + h.run_steps(3); + // The reader dismissed the document's notice before the render found the cut page. + h.state_mut().views[0].notice_dismissed = true; + h.state_mut().views[0].cut_pages.insert(0); + h.run_steps(2); + h.get_by_label_contains("Page 1 holds more content than PdfCraft can read"); + h.get_by_label("Dismiss").click(); + h.run_steps(2); + assert!(h.query_by_label_contains("holds more content").is_none(), "the cut-page notice can be dismissed"); + // Re-reading the same page (an undo, a layer toggle) doesn't bring it back. + h.state_mut().views[0].invalidate_content(); + h.state_mut().views[0].cut_pages.insert(0); + h.run_steps(2); + assert!(h.query_by_label_contains("holds more content").is_none(), "the dismissed page stays dismissed"); + h.state_mut().views[0].cut_pages.insert(1); + h.run_steps(2); + h.get_by_label_contains("2 pages hold more content than PdfCraft can read, page 1 first"); +} + #[test] fn virtualized_organizer_keeps_insert_gaps_at_document_positions() { use egui_kittest::kittest::Queryable; diff --git a/crates/ui-egui/src/control/host.rs b/crates/ui-egui/src/control/host.rs index 12c98007cc..acccf5ead3 100644 --- a/crates/ui-egui/src/control/host.rs +++ b/crates/ui-egui/src/control/host.rs @@ -100,6 +100,8 @@ impl Host for crate::PdfCraftApp { "viewport": [v.viewport_rect().min.x, v.viewport_rect().min.y, v.viewport_rect().max.x, v.viewport_rect().max.y], "find_open": v.find.is_some(), "page_errors": v.page_errors().iter().map(|(p, e)| json!({ "page": p + 1, "error": e })).collect::>(), + // Pages cut at the renderer's limits (#623), 1-based. + "cut_pages": v.cut_pages().iter().map(|p| p + 1).collect::>(), // Where pages are on screen (points), to aim ui.click / ui.drag at page content. "pages_on_screen": v.visible_page_rects().iter().map(|(p, r)| json!({ "page": p + 1, "rect": [r.min.x, r.min.y, r.max.x, r.max.y] })).collect::>(), "selected_comment": v.comments.selected.map(|(p, i)| json!({ "page": p + 1, "index": i + 1 })), diff --git a/crates/ui-egui/src/tools/comments/tests.rs b/crates/ui-egui/src/tools/comments/tests.rs index acb4008d3b..6e4623f818 100644 --- a/crates/ui-egui/src/tools/comments/tests.rs +++ b/crates/ui-egui/src/tools/comments/tests.rs @@ -176,7 +176,7 @@ fn line(chars: &str, x: f32, y: f32, w: f32, em: f32, direction: [f32; 2]) -> Pa }) .collect::>(); let n = glyphs.len(); - PageText { glyphs, line_of: vec![0; n], space_before: vec![false; n] } + PageText { glyphs, line_of: vec![0; n], space_before: vec![false; n], truncated: false } } fn annotation(subtype: &str, contents: Option<&str>, fill_sign: bool) -> Annotation { diff --git a/crates/ui-egui/tests/it/view.rs b/crates/ui-egui/tests/it/view.rs index d912fa18a4..de90573ea1 100644 --- a/crates/ui-egui/tests/it/view.rs +++ b/crates/ui-egui/tests/it/view.rs @@ -2630,3 +2630,61 @@ fn a_document_press_releases_pages_keyboard_focus_without_escape() { sidebar_key(&mut h, Key::A, Modifiers::COMMAND); assert!(h.state().views[0].selected.is_empty(), "Ctrl+A now belongs to the viewer, not page thumbnails"); } + +/// A one-page PDF whose content inflates past the renderer's per-stream limit (#623): a window +/// of whitespace the interpreter can never complete, so the page is cut and says so. +fn cut_content_pdf() -> Vec { + use std::io::Write as _; + let mut z = flate2::write::ZlibEncoder::new(Vec::new(), flate2::Compression::best()); + let chunk = vec![0u8; 1 << 20]; + for _ in 0..288 { + z.write_all(&chunk).unwrap(); + } + let stream = z.finish().unwrap(); + let mut pdf = b"%PDF-1.7\n1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj\n2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 >> endobj\n3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 200 300] /Contents 4 0 R >> endobj\n".to_vec(); + pdf.extend_from_slice(format!("4 0 obj << /Filter /FlateDecode /Length {} >> stream\n", stream.len()).as_bytes()); + pdf.extend_from_slice(&stream); + pdf.extend_from_slice(b"\nendstream endobj\ntrailer << /Root 1 0 R >>\n%%EOF\n"); + pdf +} + +/// A page whose content was cut at the renderer's limits is named in a notice, and listed for +/// automation (`ui.state`, by page number), instead of looking complete (#623). +#[test] +fn a_page_whose_content_was_cut_is_named_in_a_notice() { + let _gpu = gpu(); + let pdf = cut_content_pdf(); + let slot: std::sync::Arc>> = Default::default(); + let s = slot.clone(); + let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).build_eframe(move |cc| { + let mut app = PdfCraftApp::new(); + app.set_option("language", "en").unwrap(); + *s.lock().unwrap() = Some(app.attach_control(&cc.egui_ctx)); + app.open_bytes("cut.pdf", None, pdf).expect("opens"); + app + }); + let deadline = std::time::Instant::now() + std::time::Duration::from_secs(30); + while h.state().views[0].cut_pages().is_empty() { + assert!(std::time::Instant::now() < deadline, "the cut page was never reported; errors={:?}", h.state().views[0].page_errors()); + h.run_steps(2); + } + h.run_steps(2); + assert_eq!(h.state().views[0].cut_pages(), [0]); + assert!(h.state().views[0].page_errors().is_empty(), "a cut page still draws"); + h.get_by_label_contains("Page 1 holds more content than PdfCraft can read"); + // Automation counts pages from 1, as the notice does (review on #2113). + let client = slot.lock().unwrap().take().expect("the control channel"); + let rx = client.send("ui.state", serde_json::json!({})); + // Bounded by wall-clock time rather than frames, so a busy runner doesn't fail it, and a + // dropped channel fails at once instead of stalling the run. + let deadline = std::time::Instant::now() + std::time::Duration::from_secs(30); + let state = loop { + h.run_steps(1); + match rx.try_recv() { + Ok(reply) => break reply.expect("ui.state"), + Err(std::sync::mpsc::TryRecvError::Disconnected) => panic!("ui.state: the control channel closed without a reply"), + Err(std::sync::mpsc::TryRecvError::Empty) => assert!(std::time::Instant::now() < deadline, "ui.state: no reply within 30 s"), + } + }; + assert_eq!(state["active"]["cut_pages"], serde_json::json!([1]), "{state}"); +} diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index 4ec064518b..b15cc78541 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -1879,8 +1879,8 @@ milestone = "M3" status = "shipped" commands = [] tools = [] -evidence = ["crates/ui-egui/tests/perf.rs::scrolling_a_500_page_document_stays_within_the_frame_budget", "crates/ui-egui/tests/perf.rs::panels_with_hundreds_of_items_stay_within_the_frame_budget"] -notes = "UI work per frame ~6 ms scrolling 500 pages (comments panel with 500 cards ~6 ms, thumbnails ~2 ms); rendering is off-thread and tiled" +evidence = ["crates/ui-egui/tests/perf.rs::scrolling_a_500_page_document_stays_within_the_frame_budget", "crates/ui-egui/tests/perf.rs::panels_with_hundreds_of_items_stay_within_the_frame_budget", "crates/render/src/raster/tests.rs::cut_content_marks_the_pages_text_incomplete", "crates/ui-egui/tests/it/view.rs::a_page_whose_content_was_cut_is_named_in_a_notice", "crates/automation/tests/it/automation.rs::text_extract_says_when_a_pages_content_was_cut"] +notes = "UI work per frame ~6 ms scrolling 500 pages (comments panel with 500 cards ~6 ms, thumbnails ~2 ms); rendering is off-thread and tiled. A page cut at the renderer's limits is named in a notice, listed under active.cut_pages by ui.state, and its text_extract text carries truncated: true (#623)" # ================================================================================================== # C. Content editing From 239491cbfe59d4bea88a07639a42fc02a6519929 Mon Sep 17 00:00:00 2001 From: regendorf-ux Date: Mon, 9 Nov 2026 05:10:39 -0500 Subject: [PATCH 12/83] feat(print): custom paper sizes and the paper remembered per printer (page setup) (#2381) --- crates/automation/src/printing.rs | 35 ++- crates/automation/src/tools.rs | 2 +- crates/automation/tests/it/automation.rs | 26 ++ crates/ui-egui/src/app/prefs.rs | 24 +- crates/ui-egui/src/lib.rs | 1 + 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 | 10 +- 15 files changed, 622 insertions(+), 25 deletions(-) create mode 100644 crates/ui-egui/src/workflows/print_paper.rs create mode 100644 crates/ui-egui/tests/it/print_custom_paper_names.rs create 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 2f1cf5e136..8a6f825e6a 100644 --- a/crates/automation/src/printing.rs +++ b/crates/automation/src/printing.rs @@ -38,8 +38,14 @@ 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; US Letter when none is given. +/// index and size (or a size such as "8.5x14in", whose index is past the list); 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(), @@ -51,7 +57,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 {}", + "unknown paper {:?}; choose one of {}, or a size such as 8.5x14in or 210x297mm (1 to 200 inches a side)", name.unwrap_or_default(), PAPERS.iter().map(|(name, _)| *name).collect::>().join(", ") )) @@ -237,6 +243,19 @@ 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::*; @@ -289,7 +308,19 @@ 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 59fd154c14..680759e035 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" }, + "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)." }, "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 8758a6b0b4..4889ebfecf 100644 --- a/crates/automation/tests/it/automation.rs +++ b/crates/automation/tests/it/automation.rs @@ -10651,6 +10651,32 @@ 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/ui-egui/src/app/prefs.rs b/crates/ui-egui/src/app/prefs.rs index aa40ea0d24..525bf0ae3f 100644 --- a/crates/ui-egui/src/app/prefs.rs +++ b/crates/ui-egui/src/app/prefs.rs @@ -160,6 +160,9 @@ 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. @@ -168,8 +171,10 @@ impl PdfCraftApp { v["comment_tool_pinned"] = serde_json::Value::Bool(self.comment_prefs.pinned); v["show_secured_notice"] = serde_json::Value::Bool(self.show_secured_notice); 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. - v["print_paper"] = serde_json::json!(pdfcraft_engine::print::PAPERS.get(self.print_draft.paper).map(|(name, _)| *name)); + // 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())); v.to_string() } @@ -286,11 +291,16 @@ impl PdfCraftApp { if let Some(on) = v["always_default_view"].as_bool() { self.always_default_view = on; } - 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)) - { + // 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)) { 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 a06ffac94d..ced03c3a48 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -196,6 +196,7 @@ 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/workflows.rs b/crates/ui-egui/src/workflows.rs index 7b1bbeb9f3..f20be70394 100644 --- a/crates/ui-egui/src/workflows.rs +++ b/crates/ui-egui/src/workflows.rs @@ -15,6 +15,7 @@ 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 new file mode 100644 index 0000000000..30edb4dc91 --- /dev/null +++ b/crates/ui-egui/src/workflows/print_paper.rs @@ -0,0 +1,224 @@ +//! 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 7ea7a18602..880dccbd00 100644 --- a/crates/ui-egui/src/workflows/print_ui.rs +++ b/crates/ui-egui/src/workflows/print_ui.rs @@ -171,8 +171,10 @@ impl PdfCraftApp { sheet: 0, selected, which, + custom_new: None, ..keep }; + self.print_draft.apply_remembered(); self.dialog = Some(crate::Dialog::Print); } @@ -314,7 +316,13 @@ 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; - d.printer = default_printer(&printers); + // 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.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 690dc3142e..d38272a001 100644 --- a/crates/ui-egui/src/workflows/print_ui/body.rs +++ b/crates/ui-egui/src/workflows/print_ui/body.rs @@ -158,6 +158,7 @@ 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 @@ -202,15 +203,35 @@ pub(crate) fn body( ui.end_row(); } let paper_label = ui.label(tl!("Paper:")); - let paper_name = PAPERS.get(d.paper).map_or("US Letter", |p| p.0); + d.paper = d.paper.min(d.paper_count().saturating_sub(1)); let before = d.paper; - 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)); + 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())), + }; } - }) - .response - .labelled_by(paper_label.id); + 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(); + } + }); // 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); @@ -218,6 +239,11 @@ 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. @@ -559,6 +585,11 @@ 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") }; @@ -569,5 +600,35 @@ 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 27080a7329..988880a550 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, PAPERS, PageOrder, SizeMode, Subset, print, spool}; +use super::{Binding, BookletSubset, Content, Layout, Orientation, PageOrder, SizeMode, Subset, print, spool}; #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum Which { @@ -62,7 +62,16 @@ 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). @@ -126,6 +135,10 @@ 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, @@ -206,7 +219,7 @@ impl PrintDraft { }; Ok(print::Settings { pages, - paper: PAPERS[self.paper.min(PAPERS.len() - 1)].1, + paper: self.paper_size(), 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 972bc78b82..3396207d41 100644 --- a/crates/ui-egui/tests/it/main.rs +++ b/crates/ui-egui/tests/it/main.rs @@ -66,6 +66,8 @@ 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 3e0fec7a6e..a6346ca1e2 100644 --- a/crates/ui-egui/tests/it/print.rs +++ b/crates/ui-egui/tests/it/print.rs @@ -918,6 +918,98 @@ 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 new file mode 100644 index 0000000000..ba083b3c0b --- /dev/null +++ b/crates/ui-egui/tests/it/print_custom_paper_names.rs @@ -0,0 +1,62 @@ +//! 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 new file mode 100644 index 0000000000..b32772df20 --- /dev/null +++ b/crates/ui-egui/tests/it/print_late_printer_list.rs @@ -0,0 +1,66 @@ +//! 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 b15cc78541..f8bfd29018 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -8378,11 +8378,11 @@ title = "Page setup" area = "L" tier = "P1" milestone = "M10" -status = "planned" -commands = [] -tools = [] -evidence = [] -notes = "" +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." [[feature]] id = "print.print-ready-pdf" From c028b51b3d4088c4add7dd16a8463b11727043e0 Mon Sep 17 00:00:00 2001 From: mtlcl410 <2368091+mtlcl410@users.noreply.github.com> Date: Mon, 9 Nov 2026 18:10:56 +0800 Subject: [PATCH 13/83] M14: "Combine files in PdfCraft" on File Explorer's PDF menu (#585) (#2503) --- README.md | 5 +- ROADMAP.md | 1 + apps/pdfcraft/src/main.rs | 16 ++- apps/pdfcraft/src/single_instance.rs | 131 +++++++++++++----- apps/pdfcraft/src/startup.rs | 5 + crates/ui-egui/src/app.rs | 10 +- crates/ui-egui/src/hooks.rs | 3 + .../src/workflows/combine_ui/session.rs | 27 ++++ .../ui-egui/src/workflows/combine_ui/tests.rs | 23 +++ crates/ui-egui/tests/it/ui.rs | 71 ++++++++++ docs/releasing.md | 20 +-- packaging/windows/installer-ui.wxs | 2 +- packaging/windows/pdfcraft.wxs | 7 + packaging/windows/test-msi.ps1 | 20 ++- parity/acrobat-features.toml | 4 +- 15 files changed, 290 insertions(+), 55 deletions(-) diff --git a/README.md b/README.md index 7ca789c6e0..74288bff8f 100644 --- a/README.md +++ b/README.md @@ -476,7 +476,8 @@ logs and crash recovery in a `PdfCraftData` folder next to `pdfcraft.exe`, so no 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 for all users. It adds "Edit with PdfCraft" -to the right-click menu of PDF files and "Create PDF with PdfCraft…" to the right-click menu of +and "Combine files in PdfCraft" (select several PDFs: they land in the Combine files tab of the +running PdfCraft) to the right-click menu of PDF files and "Create PDF with PdfCraft…" to the right-click menu of images (.png .jpg .jpeg .tif .tiff .gif .bmp .jp2 .j2k .jpx) in File Explorer (on Windows 11, under "Show more options"), without changing which app opens them by default. For unattended deployment without a desktop shortcut (add `ALLUSERS=1`, from an elevated terminal, for an all-users install): @@ -488,7 +489,7 @@ msiexec /i "pdfcraft--windows-x64.msi" /qn /norestart INSTALLDESKTOPSHORTCU | Property | `=0` leaves out | |---|---| | `INSTALLDESKTOPSHORTCUT` | the desktop shortcut | -| `INSTALLPDFCONTEXTMENU` | "Edit with PdfCraft" on PDF files | +| `INSTALLPDFCONTEXTMENU` | "Edit with PdfCraft" and "Combine files in PdfCraft" on PDF files | | `INSTALLIMAGECONTEXTMENU` | "Create PDF with PdfCraft…" on images | All three are on for a first install. An upgrade keeps what the installed version has: a shortcut diff --git a/ROADMAP.md b/ROADMAP.md index 50576e6145..95b304b708 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -452,6 +452,7 @@ Ranked; detail and the full milestone table in [docs/roadmap.md](docs/roadmap.md - **2026-10-14 (M12, reading colours, #2059):** Pending page backgrounds use the document's reading colour immediately, preventing white flashes while scrolling to uncached pages. A headless canvas regression covers three pending pages in every preset and a custom background. Estimates unchanged. - **2026-10-15 (community, M6, #2608):** In Prepare a form the selected field moves with the arrow keys: a point per press, ten with Shift, as the object nudge and dragging it do. Each press is one undo step ("Change field properties"), and the keys no longer turn the page while a field is selected. Estimates unchanged. - **2026-10-15 (M3, closable community card):** The ArtCraft community card on Home has a close button that hides it for the rest of the run; it returns on the next launch, and its links stay in the top bar's Discord button and the Help menu. A UI regression covers closing and the fresh-launch default. Estimates unchanged. +- **2026-10-15 (M14, Combine files from File Explorer, #585):** The Windows MSI adds "Combine files in PdfCraft" to PDF files' right-click menu (with the Edit verb, same switch); each selected file starts `pdfcraft --combine`, and the single-instance hand-off (`"combine": true`, `OsEvent::Combine`) collects them into the running PdfCraft's Combine files tab. Tests: the request round trip and hand-off in `single_instance.rs`, the OS event in `tests/ui.rs`, the verb rows in `test-msi.ps1`. Estimates unchanged. - **2026-10-15 (M14, MSI install folder, #1139):** The Windows installer's welcome dialog has an "Install to" row with a native folder browser, starting on the folder chosen for the per-user or per-machine scope (the recorded one on an update). `test-msi.ps1` checks the dialog wiring: both Install buttons set the folder before the dialog closes, and Browse's OK and Cancel. Estimates unchanged. - **2026-10-13 (community, M3, #1569 review):** Pixel-only Wayland wheel, wheel-tilt and continuous input use standalone moved events, so Ctrl zoom does not remain latched after the modifier is released (#991); vendored winit patch (13), with a policy test. Estimates unchanged. - **2026-10-18 (M1, PR #1143 repair policy):** A live in-file reference resolves when its generation matches the xref row or a same-number header at that indexed offset. Neither-match and free slots remain null, including when old object bytes remain. Opening records bounded xref/header disagreements without parsing object bodies or expanding compact xref rows. Synthetic controls cover read order, clones, overlays, dictionary edits, full classic/packed saves and an independent reader of incremental output. **Correction to the 2026-10-15 note below:** its async-opening-test sentence was not evidenced by this COS change; no fresh async-opening or GUI validation is claimed. The full-reference stream-length recursion key is defensive hardening, not an independently isolated regression fix. Estimates unchanged. diff --git a/apps/pdfcraft/src/main.rs b/apps/pdfcraft/src/main.rs index 4de423db7f..58ce6ba8ec 100644 --- a/apps/pdfcraft/src/main.rs +++ b/apps/pdfcraft/src/main.rs @@ -12,6 +12,8 @@ //! --cover on|off --default-layout single|continuous|two-up|two-page --default-continuous on|off --default-zoom fit-width|fit-page| --always-default-view on|off //! --page-gaps on|off --default-page-gaps on|off --recent-files 0..50 //! --combine-view grid|list --combine-zoom 60..200` +//! `--combine files…` adds the files to the Combine files tab (File Explorer's "Combine files in +//! PdfCraft", #585), of the PdfCraft already running when there is one. //! //! `--new-window` opens a window of its own. Without it, on Windows, a launch that only names files //! hands them to the PdfCraft already running, where they open as tabs (`single_instance`). @@ -69,6 +71,7 @@ Options: --two-paged open command-line PDFs in noncontinuous two-page view --new-window open a window of its own instead of passing the files to a running PdfCraft --create-images [images…] put the images in one PDF and ask for the page DPI + --combine files… add the files to the Combine files tab, of a running PdfCraft if there is one --control enable the UI control channel (pdfcraft-cli ui --control …) --page N, --zoom 150, --layout continuous, --theme dark, … view settings applied once the files are open @@ -231,7 +234,7 @@ fn main() -> eframe::Result { } startup.files = rejoin_split_paths(startup.files, |p| std::path::Path::new(p).is_file()); let may_hand_off = startup.may_hand_off(); - let startup::Arguments { files, options, control_file, create_images, two_paged, .. } = startup; + let startup::Arguments { files, options, control_file, create_images, combine, two_paged, .. } = startup; let integrated = cfg!(target_os = "macos"); // GNOME on Wayland leaves the window frame to the app (src/decorations.rs). let window_controls = if cfg!(any(target_os = "macos", target_os = "windows")) { None } else { decorations::app_drawn() }; @@ -255,13 +258,13 @@ fn main() -> eframe::Result { // Absolute for this instance too: links relative to a document resolve against its folder, // never the working directory. let files = single_instance::absolute(&files); - let instance = match single_instance::claim(&files, may_hand_off) { + let instance = match single_instance::claim(&files, may_hand_off, combine) { single_instance::Claim::HandedOff => return Ok(()), single_instance::Claim::Primary(server) => Some(server), single_instance::Claim::Alone => None, }; let choice = renderer_choice(std::env::var("PDFCRAFT_RENDERER").ok().as_deref()); - let launch = Launch { files, options, control_file, create_images, integrated, window_controls, two_paged }; + let launch = Launch { files, options, control_file, create_images, combine, integrated, window_controls, two_paged }; // Finder, Open With and the Dock deliver files as Apple events, not arguments; catch the one // that launched us as well as later ones. Lives until the event loop returns. #[cfg(target_os = "macos")] @@ -368,6 +371,8 @@ struct Launch { options: Vec<(String, String)>, control_file: Option, create_images: bool, + /// `--combine`: the files go to the Combine files tab. + combine: bool, integrated: bool, /// The window buttons the app draws, when the desktop leaves the frame to it. window_controls: Option, @@ -392,7 +397,7 @@ fn app_creator<'a>( instance: Option<&'a single_instance::Server>, #[cfg(target_os = "macos")] apple_events: &'a apple_events::AppleEvents, ) -> eframe::AppCreator<'a> { - let Launch { files, options, control_file, create_images, integrated, window_controls, two_paged } = launch; + let Launch { files, options, control_file, create_images, combine, integrated, window_controls, two_paged } = launch; Box::new(move |cc| { started.set(true); let mut app = PdfCraftApp::new(); @@ -466,6 +471,9 @@ fn app_creator<'a>( if let Err(e) = app.begin_image_import_paths(&files) { app.notify(e); } + } else if combine { + app.reopen_last_files(&[]); + app.combine_paths(&files); } else { // With the preference on, last session's files come back first; files named on // the command line open after them, in front (#442). diff --git a/apps/pdfcraft/src/single_instance.rs b/apps/pdfcraft/src/single_instance.rs index 448eb40d68..60f5d1d493 100644 --- a/apps/pdfcraft/src/single_instance.rs +++ b/apps/pdfcraft/src/single_instance.rs @@ -14,7 +14,9 @@ //! intercept a launch or open files in this user's window. Inside a Flatpak, `$XDG_RUNTIME_DIR` is //! the app's own and is shared by every run of it, so the handoff works there too. The exchange is //! one line each way, a JSON request naming absolute paths and `ok`, and the running app opens the -//! paths exactly as File ▸ Open would. +//! paths exactly as File ▸ Open would. A `--combine` launch (File Explorer's "Combine files in +//! PdfCraft", #585, one launch per selected file) hands them over the same way with +//! `"combine": true`, and the running app adds them to its Combine files tab instead. //! //! Every failure falls back to the old behaviour: a launch that can't hand its files over within //! a few seconds opens its own window. `--new-window`, any other option, or no files at all also @@ -58,7 +60,10 @@ pub enum Claim { /// Files that later launches handed over, and how to wake the UI for them. #[derive(Default)] struct Inbox { - paths: Vec, + /// The requests in the order they arrived: [`OsEvent::Open`] or, for `--combine`, + /// [`OsEvent::Combine`]. One queue, so the UI handles an Open that came after a Combine after + /// it, and that Open comes to the front instead of opening behind the Combine files tab. + events: Vec, wake: Option>, } @@ -74,11 +79,20 @@ impl Server { Self { inbox: Arc::default() } } - /// Queue files from another launch and wake the UI. + /// Queue files from another launch (to open, or to combine) and wake the UI. #[cfg_attr(not(any(windows, all(unix, not(target_os = "macos")))), allow(dead_code))] - fn deliver(&self, paths: Vec) { + fn deliver(&self, paths: Vec, combine: bool) { + if paths.is_empty() { + return; + } let mut inbox = self.inbox.lock().unwrap_or_else(PoisonError::into_inner); - inbox.paths.extend(paths); + // A run of requests of one kind (File Explorer starts one launch per selected file) stays + // one event; a request of the other kind starts a new one after it. + match (inbox.events.last_mut(), combine) { + (Some(OsEvent::Combine(queued)), true) | (Some(OsEvent::Open(queued)), false) => queued.extend(paths), + (_, true) => inbox.events.push(OsEvent::Combine(paths)), + (_, false) => inbox.events.push(OsEvent::Open(paths)), + } if let Some(wake) = &inbox.wake { wake(); } @@ -95,8 +109,8 @@ impl Server { let inbox = Arc::clone(&self.inbox); let ctx = ctx.clone(); Box::new(move || { - let paths = std::mem::take(&mut inbox.lock().unwrap_or_else(PoisonError::into_inner).paths); - if paths.is_empty() { + let events = std::mem::take(&mut inbox.lock().unwrap_or_else(PoisonError::into_inner).events); + if events.is_empty() { return Vec::new(); } ctx.send_viewport_cmd(egui::ViewportCommand::Minimized(false)); @@ -104,27 +118,37 @@ impl Server { if native_wayland { ctx.send_viewport_cmd(egui::ViewportCommand::RequestUserAttention(egui::UserAttentionType::Informational)); } - vec![OsEvent::Open(paths)] + events }) } } -/// The request a launch sends: `{"open":[…]}` and a newline. +/// The request a launch sends: `{"open":[…]}` (with `"combine":true` for `--combine`) and a +/// newline. #[cfg_attr(not(any(windows, all(unix, not(target_os = "macos")))), allow(dead_code))] -pub fn request(paths: &[String]) -> String { - let mut line = serde_json::json!({ "open": paths }).to_string(); +pub fn request(paths: &[String], combine: bool) -> String { + let mut value = serde_json::json!({ "open": paths }); + if combine { + value["combine"] = serde_json::Value::Bool(true); + } + let mut line = value.to_string(); line.push('\n'); line } -/// The paths a request names, or `None` when it isn't a request: anything but one JSON object -/// whose `open` is a non-empty list of non-empty strings. +/// The paths a request names and whether they are for Combine files, or `None` when it isn't a +/// request: anything but one JSON object whose `open` is a non-empty list of non-empty strings +/// (`combine`, when there, must be a boolean). #[cfg_attr(not(any(windows, all(unix, not(target_os = "macos")))), allow(dead_code))] -pub fn parse_request(line: &str) -> Option> { +pub fn parse_request(line: &str) -> Option<(Vec, bool)> { let value: serde_json::Value = serde_json::from_str(line.trim_end()).ok()?; let open = value.get("open")?.as_array()?; let paths = open.iter().map(|p| p.as_str().filter(|s| !s.is_empty()).map(str::to_string)).collect::>>()?; - (!paths.is_empty()).then_some(paths) + let combine = match value.get("combine") { + None => false, + Some(c) => c.as_bool()?, + }; + (!paths.is_empty()).then_some((paths, combine)) } /// File arguments made absolute against this launch's working folder, which the running app @@ -134,18 +158,19 @@ pub fn absolute(paths: &[String]) -> Vec { } /// Decide what this launch does. `files` are absolute ([`absolute`]); `may_hand_off` is false -/// when the launch asked for anything besides opening files, so it gets its own window. -pub fn claim(files: &[String], may_hand_off: bool) -> Claim { +/// when the launch asked for anything besides opening (or, with `combine`, combining) files, so +/// it gets its own window. +pub fn claim(files: &[String], may_hand_off: bool, combine: bool) -> Claim { #[cfg(any(windows, all(unix, not(target_os = "macos"))))] { match socket_path() { - Some(path) => claim_at(&path, files, may_hand_off), + Some(path) => claim_at(&path, files, may_hand_off, combine), None => Claim::Alone, } } #[cfg(not(any(windows, all(unix, not(target_os = "macos")))))] { - let _ = (files, may_hand_off); + let _ = (files, may_hand_off, combine); Claim::Alone } } @@ -239,7 +264,7 @@ fn election(socket: &std::path::Path) -> Option { /// [`claim`] with the socket at `path`. #[cfg(any(windows, all(unix, not(target_os = "macos"))))] -fn claim_at(path: &std::path::Path, files: &[String], may_hand_off: bool) -> Claim { +fn claim_at(path: &std::path::Path, files: &[String], may_hand_off: bool, combine: bool) -> Claim { // Released when this function returns: by then this launch listens, or has handed over. let _election = election(path); // Two launches can start together: the one that loses the race to bind hands over to the @@ -250,7 +275,7 @@ fn claim_at(path: &std::path::Path, files: &[String], may_hand_off: bool) -> Cla if !may_hand_off { return Claim::Alone; } - return match hand_off(&stream, files) { + return match hand_off(&stream, files, combine) { Ok(()) => Claim::HandedOff, Err(e) => { log::warn!("couldn't hand the files to the running PdfCraft ({e}); opening a new window"); @@ -296,12 +321,12 @@ fn claim_at(path: &std::path::Path, files: &[String], may_hand_off: bool) -> Cla /// Send `files` over `stream` and wait for the running app's `ok`. #[cfg(any(windows, all(unix, not(target_os = "macos"))))] -fn hand_off(stream: &UnixStream, files: &[String]) -> std::io::Result<()> { +fn hand_off(stream: &UnixStream, files: &[String], combine: bool) -> std::io::Result<()> { use std::io::{BufRead, Read, Write}; stream.set_read_timeout(Some(TIMEOUT))?; stream.set_write_timeout(Some(TIMEOUT))?; let mut writer = stream; - writer.write_all(request(files).as_bytes())?; + writer.write_all(request(files, combine).as_bytes())?; let mut reply = String::new(); std::io::BufReader::new(stream.take(16)).read_line(&mut reply)?; if reply == "ok\n" { Ok(()) } else { Err(std::io::Error::other("the running PdfCraft didn't confirm")) } @@ -343,8 +368,8 @@ fn receive(stream: &UnixStream, server: &Server) -> std::io::Result<()> { if line.is_empty() { return Ok(()); } - let paths = parse_request(&line).ok_or_else(|| std::io::Error::new(std::io::ErrorKind::InvalidData, "not an open request"))?; - server.deliver(paths); + let (paths, combine) = parse_request(&line).ok_or_else(|| std::io::Error::new(std::io::ErrorKind::InvalidData, "not an open request"))?; + server.deliver(paths, combine); let mut writer = stream; writer.write_all(b"ok\n") } @@ -356,16 +381,31 @@ mod tests { #[test] fn a_request_names_the_paths_and_round_trips() { let paths = vec![r"C:\Users\me\Documents\Quote.pdf".to_string(), r"D:\Scans\Plan „A“ 1.pdf".to_string()]; - let line = request(&paths); + let line = request(&paths, false); assert!(line.ends_with('\n') && !line.trim_end().contains('\n'), "one line"); - assert_eq!(parse_request(&line), Some(paths)); + assert_eq!(parse_request(&line), Some((paths.clone(), false))); + assert!(!line.contains("combine"), "an open request says nothing about combining"); + let line = request(&paths, true); + assert_eq!(parse_request(&line), Some((paths, true)), "a --combine launch's request"); } #[test] fn anything_but_a_list_of_paths_is_refused() { - for line in ["", "ok\n", "{}", r#"{"open":[]}"#, r#"{"open":[""]}"#, r#"{"open":["a.pdf",3]}"#, r#"{"open":"a.pdf"}"#, "[\"a.pdf\"]"] { + for line in [ + "", + "ok\n", + "{}", + r#"{"open":[]}"#, + r#"{"open":[""]}"#, + r#"{"open":["a.pdf",3]}"#, + r#"{"open":"a.pdf"}"#, + "[\"a.pdf\"]", + r#"{"open":["a.pdf"],"combine":"yes"}"#, + r#"{"open":["a.pdf"],"combine":1}"#, + ] { assert_eq!(parse_request(line), None, "{line:?}"); } + assert_eq!(parse_request(r#"{"open":["a.pdf"],"combine":false}"#), Some((vec!["a.pdf".to_string()], false))); } #[test] @@ -398,13 +438,31 @@ mod tests { fn delivered_files_reach_the_ui_once_wake_it_and_bring_the_window_forward() { let server = Server::new(); // Before the window exists: queued, nothing to wake yet. - server.deliver(vec!["a.pdf".into()]); + server.deliver(vec!["a.pdf".into()], false); let ctx = egui::Context::default(); let mut poll = server.connect(&ctx, false); - server.deliver(vec!["b.pdf".into()]); + server.deliver(vec!["b.pdf".into()], false); let events = poll(); assert!(matches!(events.as_slice(), [OsEvent::Open(p)] if p == &["a.pdf", "b.pdf"])); assert!(poll().is_empty(), "each file is delivered once"); + // Files to combine (one launch each, from File Explorer) arrive as one Combine event, + // apart from files to open. + server.deliver(vec!["c.pdf".into()], true); + server.deliver(vec!["e.pdf".into()], true); + let events = poll(); + assert!(matches!(events.as_slice(), [OsEvent::Combine(c)] if c == &["c.pdf", "e.pdf"]), "{events:?}"); + // #2503 review: requests reach the UI in the order they arrived, so an Open after a + // Combine is handled after it and comes to the front instead of opening behind the + // Combine files tab. + server.deliver(vec!["f.pdf".into()], true); + server.deliver(vec!["g.pdf".into()], false); + server.deliver(vec!["h.pdf".into()], true); + let events = poll(); + assert!( + matches!(events.as_slice(), [OsEvent::Combine(f), OsEvent::Open(g), OsEvent::Combine(h)] if f == &["f.pdf"] && g == &["g.pdf"] && h == &["h.pdf"]), + "{events:?}" + ); + assert!(poll().is_empty()); } #[test] @@ -442,12 +500,15 @@ mod tests { #[test] fn a_second_launch_hands_its_files_to_the_first() { let path = socket("hand-off"); - let Claim::Primary(first) = claim_at(&path, &[], true) else { panic!("the first launch listens") }; + let Claim::Primary(first) = claim_at(&path, &[], true, false) else { panic!("the first launch listens") }; let mut poll = first.connect(&egui::Context::default(), false); - assert!(matches!(claim_at(&path, &["C:\\a.pdf".into()], true), Claim::HandedOff)); + assert!(matches!(claim_at(&path, &["C:\\a.pdf".into()], true, false), Claim::HandedOff)); assert!(matches!(poll().as_slice(), [OsEvent::Open(p)] if p == &["C:\\a.pdf"])); + // A --combine launch hands its file over for the Combine files tab (#585). + assert!(matches!(claim_at(&path, &["C:\\c.pdf".into()], true, true), Claim::HandedOff)); + assert!(matches!(poll().as_slice(), [OsEvent::Combine(p)] if p == &["C:\\c.pdf"])); // A launch with options keeps its own window and doesn't take over the socket. - assert!(matches!(claim_at(&path, &["C:\\b.pdf".into()], false), Claim::Alone)); + assert!(matches!(claim_at(&path, &["C:\\b.pdf".into()], false, false), Claim::Alone)); assert!(poll().is_empty()); } @@ -462,7 +523,7 @@ mod tests { let (path, barrier) = (path.clone(), Arc::clone(&barrier)); std::thread::spawn(move || { barrier.wait(); - claim_at(&path, &[format!("C:\\{i}.pdf")], true) + claim_at(&path, &[format!("C:\\{i}.pdf")], true, false) }) }) .collect(); @@ -483,6 +544,6 @@ mod tests { })); // `symlink_metadata`, not `exists`, which follows the socket's reparse point and can fail. assert!(std::fs::symlink_metadata(&path).is_ok(), "the socket file stays behind"); - assert!(matches!(claim_at(&path, &["C:\\a.pdf".into()], true), Claim::Primary(_))); + assert!(matches!(claim_at(&path, &["C:\\a.pdf".into()], true, false), Claim::Primary(_))); } } diff --git a/apps/pdfcraft/src/startup.rs b/apps/pdfcraft/src/startup.rs index be25da45b6..23db00a7b7 100644 --- a/apps/pdfcraft/src/startup.rs +++ b/apps/pdfcraft/src/startup.rs @@ -7,6 +7,8 @@ pub(super) struct Arguments { pub options: Vec<(String, String)>, pub control_file: Option, pub create_images: bool, + /// `--combine`: the files go to the Combine files tab. + pub combine: bool, pub new_window: bool, pub two_paged: bool, pub version: bool, @@ -33,6 +35,9 @@ impl Arguments { } "--control" => parsed.control_file = args.next(), "--create-images" => parsed.create_images = true, + // File Explorer's "Combine files in PdfCraft" (#585): the files go to the Combine + // files tab, of the running PdfCraft when there is one. + "--combine" => parsed.combine = true, "--new-window" => parsed.new_window = true, "--two-paged" => parsed.two_paged = true, flag if flag.starts_with("--two-paged=") => { diff --git a/crates/ui-egui/src/app.rs b/crates/ui-egui/src/app.rs index 31000f95bc..f0a8079174 100644 --- a/crates/ui-egui/src/app.rs +++ b/crates/ui-egui/src/app.rs @@ -609,8 +609,16 @@ impl PdfCraftApp { ctx.send_viewport_cmd(egui::ViewportCommand::Minimized(false)); ctx.send_viewport_cmd(egui::ViewportCommand::Focus); } + // Combine from File Explorer's PDF menu, also handed to the running app: the dialog + // it opens has to be seen, so the window comes to the front as for Open. + #[cfg(not(target_arch = "wasm32"))] + OsEvent::Combine(paths) => { + self.combine_paths(&paths); + ctx.send_viewport_cmd(egui::ViewportCommand::Minimized(false)); + ctx.send_viewport_cmd(egui::ViewportCommand::Focus); + } #[cfg(target_arch = "wasm32")] - OsEvent::Open(_) => {} + OsEvent::Open(_) | OsEvent::Combine(_) => {} // Like closing the window: `guard_quit` asks about unsaved changes. OsEvent::Quit => ctx.send_viewport_cmd(egui::ViewportCommand::Close), } diff --git a/crates/ui-egui/src/hooks.rs b/crates/ui-egui/src/hooks.rs index 8a79506a1a..3499ca778c 100644 --- a/crates/ui-egui/src/hooks.rs +++ b/crates/ui-egui/src/hooks.rs @@ -11,6 +11,9 @@ pub type FailedInbox = std::sync::Arc>>; pub enum OsEvent { /// Open these files. Open(Vec), + /// Add these files to Combine files and show it (`pdfcraft --combine`, File Explorer's + /// "Combine files in PdfCraft", #585). + Combine(Vec), /// Quit (the Dock's Quit, logging out), asking about unsaved changes first. Quit, } diff --git a/crates/ui-egui/src/workflows/combine_ui/session.rs b/crates/ui-egui/src/workflows/combine_ui/session.rs index 7ef50d5ffc..2297e70933 100644 --- a/crates/ui-egui/src/workflows/combine_ui/session.rs +++ b/crates/ui-egui/src/workflows/combine_ui/session.rs @@ -401,6 +401,33 @@ impl PdfCraftApp { let _ = recursive; } + /// Add the files at `paths` to the Combine files list and show it: `pdfcraft --combine` and + /// File Explorer's "Combine files in PdfCraft" (#585), where each selected file arrives as a + /// launch of its own and the running PdfCraft collects them here. Nothing to add shows the + /// tab all the same. + #[cfg(not(target_arch = "wasm32"))] + pub fn combine_paths(&mut self, paths: &[String]) { + // A file still opening from before (another launch's Open), or waiting for its password, + // opens in the background, so finishing doesn't hide the tab shown here; a later Open + // still comes to the front. An open that something waits on keeps its focus: a chosen + // tool, a link's destination or a recovery runs on the document once it is in front. + for pending in &mut self.pending_opens { + if pending.then.is_none() && pending.after_open.is_none() && pending.recovered.is_none() { + pending.activate = false; + } + } + if let Some(prompt) = self.password_prompt.as_mut() + && prompt.then.is_none() + && prompt.after_open.is_none() + && prompt.recovered.is_none() + { + prompt.activate = false; + } + self.open_combine_tab(); + let paths: Vec = paths.iter().map(std::path::PathBuf::from).collect(); + self.use_paths(crate::files::FilePurpose::Combine, &paths); + } + /// Add the PDFs, images and text files in `dir` to the Combine files list, as one undo step. #[cfg(not(target_arch = "wasm32"))] pub(crate) fn add_folder_to_combine(&mut self, dir: &std::path::Path, recursive: bool) { diff --git a/crates/ui-egui/src/workflows/combine_ui/tests.rs b/crates/ui-egui/src/workflows/combine_ui/tests.rs index b882dcf52d..7df65d134b 100644 --- a/crates/ui-egui/src/workflows/combine_ui/tests.rs +++ b/crates/ui-egui/src/workflows/combine_ui/tests.rs @@ -210,3 +210,26 @@ fn removing_pages_drops_emptied_parts_and_joins_what_meets() { // A page that isn't there: nothing changes, and it says so. assert_eq!(removed_pages(&two, &BTreeSet::from([(1, 9)]), &mut new_id), None); } + +/// #2503 review: a PDF waiting for its password when Combine arrives opens behind the Combine +/// files tab once unlocked, like one still opening; a prompt that a chosen tool waits on keeps +/// its focus, since the tool runs on the document in front. +#[test] +#[cfg(not(target_arch = "wasm32"))] +fn a_password_answered_after_combine_opens_behind_the_combine_tab() { + let mut app = PdfCraftApp::new(); + app.open_bytes("locked.pdf", None, locked()).unwrap(); + assert!(app.password_prompt.is_some() && app.views.is_empty()); + app.combine_paths(&[]); + assert!(app.combine_showing()); + app.submit_password(Some("pw".into())); + assert!(app.password_prompt.is_none()); + assert_eq!(app.views.len(), 1, "the document is open"); + assert!(app.combine_showing(), "and the Combine files tab still shows"); + + let mut app = PdfCraftApp::new(); + app.open_bytes("locked.pdf", None, locked()).unwrap(); + app.password_prompt.as_mut().unwrap().then = Some("export.text"); + app.combine_paths(&[]); + assert!(app.password_prompt.as_ref().unwrap().activate, "a tool's open keeps its focus"); +} diff --git a/crates/ui-egui/tests/it/ui.rs b/crates/ui-egui/tests/it/ui.rs index a464595615..8b58a08aa5 100644 --- a/crates/ui-egui/tests/it/ui.rs +++ b/crates/ui-egui/tests/it/ui.rs @@ -1278,6 +1278,77 @@ fn files_and_quit_from_the_operating_system() { assert!(closing); } +/// #585: File Explorer's "Combine files in PdfCraft" starts one launch per selected file, and the +/// running PdfCraft collects them (an `OsEvent::Combine` each) into its Combine files tab. +#[test] +fn files_to_combine_from_the_operating_system() { + use pdfcraft_ui_egui::OsEvent; + let dir = std::env::temp_dir().join(format!("pdfcraft-os-combine-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let paths: Vec = ["one.pdf", "two.pdf"] + .iter() + .map(|n| { + let p = dir.join(n); + std::fs::write(&p, FIXTURE).unwrap(); + p.to_string_lossy().into_owned() + }) + .collect(); + let queue = std::rc::Rc::new(std::cell::RefCell::new(vec![OsEvent::Combine(vec![paths[0].clone()])])); + let q = queue.clone(); + let mut h = harness(move |app| app.os_events = Some(Box::new(move || q.borrow_mut().drain(..).collect()))); + assert!(h.state().combine_showing(), "the Combine files tab shows"); + assert!(h.state().views.is_empty(), "nothing opens as a document"); + queue.borrow_mut().push(OsEvent::Combine(vec![paths[1].clone()])); + h.run_steps(3); + let _ = std::fs::remove_dir_all(&dir); + let names: Vec<_> = h.state().combine_draft.iter().map(|f| f.name.clone()).collect(); + assert_eq!(names, ["one.pdf", "two.pdf"], "each launch's file joins the list, in order"); + assert!(h.state().combine_showing() && h.state().views.is_empty()); +} + +/// #2503 review: a PDF still opening when Combine arrives opens behind the Combine files tab +/// instead of hiding it; an Open that comes after Combine still shows its document. +#[test] +fn an_open_finishing_after_combine_keeps_the_combine_tab() { + use pdfcraft_ui_egui::OsEvent; + let dir = std::env::temp_dir().join(format!("pdfcraft-os-combine-pending-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let paths: Vec = ["opening.pdf", "combined.pdf", "later.pdf"] + .iter() + .map(|n| { + let p = dir.join(n); + std::fs::write(&p, FIXTURE).unwrap(); + p.to_string_lossy().into_owned() + }) + .collect(); + let queue = std::rc::Rc::new(std::cell::RefCell::new(Vec::new())); + let q = queue.clone(); + let mut h = harness(move |app| app.os_events = Some(Box::new(move || q.borrow_mut().drain(..).collect()))); + h.run_steps(2); + // Both in one frame: the open goes to the worker and is still pending when Combine shows. + queue.borrow_mut().extend([OsEvent::Open(vec![paths[0].clone()]), OsEvent::Combine(vec![paths[1].clone()])]); + for _ in 0..200 { + h.run_steps(1); + if !h.state().views.is_empty() { + break; + } + std::thread::sleep(std::time::Duration::from_millis(10)); + } + assert_eq!(h.state().views.len(), 1, "the earlier open finished"); + assert!(h.state().combine_showing(), "and the Combine files tab still shows"); + queue.borrow_mut().push(OsEvent::Open(vec![paths[2].clone()])); + for _ in 0..200 { + h.run_steps(1); + if h.state().views.len() == 2 { + break; + } + std::thread::sleep(std::time::Duration::from_millis(10)); + } + let _ = std::fs::remove_dir_all(&dir); + assert_eq!(h.state().views.len(), 2); + assert!(!h.state().combine_showing() && h.state().active == Some(1), "a later Open shows its document"); +} + #[test] fn default_workspace_mode_persists_and_tolerates_invalid_settings() { use pdfcraft_ui_egui::Mode; diff --git a/docs/releasing.md b/docs/releasing.md index eaeb4a63f1..c0bb41fd5b 100644 --- a/docs/releasing.md +++ b/docs/releasing.md @@ -130,13 +130,14 @@ neither the MSI nor the portable zip needs the Visual C++ redistributable. dialogs. Full UI confirms success with Finish; failures and cancellations have distinct messages. The welcome dialog has an "Install to" row (a `PathEdit`, and Browse… into `BrowseFolder`, a native folder browser: drive list, folder list, Up, New folder, typed path; #1139), a "Create a - desktop shortcut" checkbox and an "Edit with PdfCraft" right-click menu checkbox, both ticked by - default. Install sets the folder (`SetTargetPath`) before the dialog closes. `INSTALLFOLDER` is - secure, so the choice reaches the elevated install, and `INSTALLFOLDER=` works on the - command line. The row starts on the folder `install-policy.wxi` chose for the scope picked on the - first page (the recorded folder on an update), and an update installs into the recorded folder - unless the command line gives one (`INSTALLATION.md`). `/qn` and `/qb` stay unattended and create - both unless `INSTALLDESKTOPSHORTCUT=0` or `INSTALLPDFCONTEXTMENU=0` is passed; + desktop shortcut" checkbox and an "Add Edit and Combine files to the right-click menu of PDF + files" checkbox, both ticked by default. Install sets the folder (`SetTargetPath`) before the + dialog closes. `INSTALLFOLDER` is secure, so the choice reaches the elevated install, and + `INSTALLFOLDER=` works on the command line. The row starts on the folder + `install-policy.wxi` chose for the scope picked on the first page (the recorded folder on an + update), and an update installs into the recorded folder unless the command line gives one + (`INSTALLATION.md`). `/qn` and `/qb` stay unattended and create both unless + `INSTALLDESKTOPSHORTCUT=0` or `INSTALLPDFCONTEXTMENU=0` is passed; `INSTALLIMAGECONTEXTMENU=0` (no checkbox) leaves out the image menu. An update (the launcher runs `msiexec /passive` with no properties) keeps the desktop shortcut only if `PdfCraft.lnk` is still on the all-users desktop, so deleting it once keeps it away (#1058), and keeps each menu only if @@ -150,7 +151,10 @@ neither the MSI nor the portable zip needs the Visual C++ redistributable. limit for a `Document` verb) start one `pdfcraft.exe ""` each, and the single-instance hand-off opens them as tabs. On Windows 11 it is under "Show more options"; a first-level entry needs an `IExplorerCommand` handler and package identity (a sparse MSIX package), which the MSI - doesn't have. + doesn't have. "Combine files in PdfCraft" (#585) is a second verb in the same component, + `PdfCraft.Combine`, running `pdfcraft --combine ""` once per selected file; the + single-instance hand-off sends them with `"combine": true`, and the running app adds them to + its Combine files tab (`OsEvent::Combine`). Both verbs come and go with `INSTALLPDFCONTEXTMENU`. - The image right-click entry (#190) is the same kind of verb, `PdfCraft.CreatePdf`, for ten image types, in its own component (`PdfcraftImageContextMenu`) since it became optional. Its rows moved out of `PdfcraftApp`; that's safe because `MajorUpgrade` removes the old version before installing diff --git a/packaging/windows/installer-ui.wxs b/packaging/windows/installer-ui.wxs index 43a3a7087d..328d5e760b 100644 --- a/packaging/windows/installer-ui.wxs +++ b/packaging/windows/installer-ui.wxs @@ -19,7 +19,7 @@ - + diff --git a/packaging/windows/pdfcraft.wxs b/packaging/windows/pdfcraft.wxs index f11ca83a6f..2d0d5f39fc 100644 --- a/packaging/windows/pdfcraft.wxs +++ b/packaging/windows/pdfcraft.wxs @@ -159,6 +159,13 @@ + + + + +