From a2ad6b77f8fd5e0dcb1d53884df33a876768bbf7 Mon Sep 17 00:00:00 2001 From: mtlcl410 <2368091+mtlcl410@users.noreply.github.com> Date: Mon, 9 Nov 2026 18:22:25 +0800 Subject: [PATCH 01/41] M7: replacing a line keeps the source font for the words it can show (#2378) (#2955) --- crates/edit/src/tests.rs | 169 +++++++++++++++++++++++++++++++ crates/edit/src/text.rs | 8 +- crates/edit/src/text/fallback.rs | 43 +++++++- crates/edit/src/text/interp.rs | 6 ++ crates/edit/src/text/lines.rs | 10 +- crates/edit/src/text/rewrite.rs | 107 ++++++++++++++++++- parity/acrobat-features.toml | 4 +- 7 files changed, 334 insertions(+), 13 deletions(-) diff --git a/crates/edit/src/tests.rs b/crates/edit/src/tests.rs index 0cb7366716..2640eadd5a 100644 --- a/crates/edit/src/tests.rs +++ b/crates/edit/src/tests.rs @@ -3213,6 +3213,175 @@ fn win_ansi_high_glyphs_use_helvetica_not_the_fallback_font() { assert_eq!(lines.iter().map(|l| l.text.as_str()).collect::>().join(" "), text); } +/// #2378: a word the line's subset can't show goes to Helvetica, and only that word. The words +/// it can show keep the source font (here `F2`, a subset of `a` and `b` without a space glyph), +/// one font per word, and the line reads back whole and in place: one line, in its own font, so +/// editing it again replaces all of it (review on #2955), also when it starts with a substitute +/// word. +#[test] +fn only_the_words_a_subset_cannot_show_are_substituted() { + let mut doc = text_page("BT /F2 10 Tf 72 600 Td (ab) Tj ET"); + let r = text::replace_line(&mut doc, 0, 0, "ab ba cab ba").unwrap(); + assert_eq!(r.substituted.as_deref(), Some("Helvetica")); + let mut doc = reopen(&doc); + let content = String::from_utf8_lossy(&page_content_bytes(&doc, 0)).into_owned(); + assert!(content.contains("/F2") && content.contains("/PCEdHelvetica"), "both fonts are used: {content}"); + let lines = text::text_lines(&doc, 0).unwrap(); + let read: Vec<(&str, &str)> = lines.iter().map(|l| (l.text.trim(), l.font.as_str())).collect(); + assert_eq!(read, [("ab ba cab ba", "F2")]); + // Each word where its font puts it, the substituted `cab` included: no gap or overlap opens + // where the font changes (review on #2955). + let starts: Vec = lines[0].ink.iter().map(|b| b[0]).collect(); + assert_eq!(starts.len(), 4, "{:?}", lines[0].ink); + for (got, want) in starts.iter().zip([72.0, 85.5, 98.48, 117.9]) { + assert!((got - want).abs() < 0.01, "word starts {starts:?}, want {want} among them"); + } + assert!(lines[0].ink.windows(2).all(|w| w[0][2] < w[1][0]), "no word box overlaps the next: {:?}", lines[0].ink); + for (text, first) in [("cab ab", "PCEdHelvetica"), ("ba cab", "F2")] { + text::replace_line(&mut doc, 0, 0, text).unwrap(); + doc = reopen(&doc); + let lines = text::text_lines(&doc, 0).unwrap(); + let read: Vec<(&str, &str)> = lines.iter().map(|l| (l.text.trim(), l.font.as_str())).collect(); + assert_eq!(read, [(text, "F2")], "after replacing with {text:?} (starting with {first})"); + } +} + +/// Review on #2955: replacing a line written in two fonts again replaces its `/PCEdLine` +/// sequence too. Kept, each edit nested one more, and its `EMC`s outnumbered what the reader +/// counts once 256 deep, closing the sequence around the line (here a `/Span`). The content +/// doesn't grow from one edit to the next. +#[test] +fn replacing_a_mixed_font_line_again_replaces_its_sequence() { + let mut doc = text_page("/Span BMC BT /F2 10 Tf 72 600 Td (ab) Tj ET EMC BT /F2 10 Tf 72 500 Td (ba) Tj ET"); + let count = |content: &str, op: &str| content.split_whitespace().filter(|w| *w == op).count(); + let mut sizes = Vec::new(); + for text in ["ab ba cab ba", "cab ab", "ab cab", "ab cab", "ab ba", "ba ab"] { + text::replace_line(&mut doc, 0, 0, text).unwrap(); + doc = reopen(&doc); + let content = String::from_utf8_lossy(&page_content_bytes(&doc, 0)).into_owned(); + let mixed = text.contains('c'); + assert_eq!(content.matches("/PCEdLine").count(), usize::from(mixed), "after {text:?}: {content}"); + assert_eq!((count(&content, "BMC"), count(&content, "EMC")), (1 + usize::from(mixed), 1 + usize::from(mixed)), "after {text:?}: {content}"); + assert_eq!(count(&content, "Tf"), 2 + if mixed { 4 } else { 1 }, "after {text:?}: {content}"); + let lines = text::text_lines(&doc, 0).unwrap(); + let read: Vec<&str> = lines.iter().map(|l| l.text.trim()).collect(); + assert_eq!(read, [text, "ba"], "after {text:?}"); + assert_eq!(lines[0].baseline(), [72.0, 600.0], "after {text:?}: in place"); + sizes.push(content.split_whitespace().count()); + } + assert_eq!(sizes[2], sizes[3], "the same text again, the same content"); + // A sequence holding marked content of its own goes whole, and what follows it keeps its + // font and size unless the replacement sets the same again. + for (after, font, size) in [("/F1 12 Tf", "F1", 12.0), ("/F2 12 Tf", "F2", 12.0), ("/F1 10 Tf", "F1", 10.0)] { + let mut doc = text_page(&format!( + "BT /F2 10 Tf 72 600 Td /PCEdLine BMC /F2 10 Tf /Span <<>> BDC (ab) Tj EMC /F1 10 Tf ( cab) Tj EMC {after} 0 -20 Td (ba) Tj ET" + )); + text::replace_line(&mut doc, 0, 0, "ab ab").unwrap(); + let doc = reopen(&doc); + let content = String::from_utf8_lossy(&page_content_bytes(&doc, 0)).into_owned(); + assert_eq!((count(&content, "BMC"), count(&content, "BDC"), count(&content, "EMC")), (0, 0, 0), "{after}: {content}"); + let lines = text::text_lines(&doc, 0).unwrap(); + let read: Vec<(&str, &str, f64)> = lines.iter().map(|l| (l.text.trim(), l.font.as_str(), l.size)).collect(); + assert_eq!(read, [("ab ab", "F2", 10.0), ("ba", font, size)], "{after}: {content}"); + } +} + +/// Review on #2955: a leading space is not a word the line's font shows. " cab" (no word the +/// subset can show) was split into an empty source-font run and a substitute run, so it was +/// written as a two-font `/PCEdLine` with an empty `Tj` in the line's font; read back, the line +/// was the substitute's alone, and the next edit took Helvetica for the line's own font. It is +/// substituted as a whole instead, as a line with no word in the source font always was. +#[test] +fn a_leading_space_does_not_make_a_source_font_run() { + let mut doc = text_page("BT /F2 10 Tf 72 600 Td (ab) Tj ET"); + text::replace_line(&mut doc, 0, 0, " cab").unwrap(); + let doc = reopen(&doc); + let content = String::from_utf8_lossy(&page_content_bytes(&doc, 0)).into_owned(); + assert!(!content.contains("/PCEdLine") && !content.contains("() Tj"), "{content}"); + // With a word in the source font after it, the line is still written in both. + let mut doc = text_page("BT /F2 10 Tf 72 600 Td (ab) Tj ET"); + text::replace_line(&mut doc, 0, 0, " ab cab").unwrap(); + let doc = reopen(&doc); + let content = String::from_utf8_lossy(&page_content_bytes(&doc, 0)).into_owned(); + assert!(content.contains("/PCEdLine") && !content.contains("() Tj"), "{content}"); + let lines = text::text_lines(&doc, 0).unwrap(); + assert_eq!(lines.iter().map(|l| (l.text.trim(), l.font.as_str())).collect::>(), [("ab cab", "F2")]); + // The spaces the subset has no glyph for are moves, each one kept: the leading one before + // the first word, two between words as two. + let flat = |doc: &Document| String::from_utf8_lossy(&page_content_bytes(doc, 0)).split_whitespace().collect::>().join(" "); + assert!(flat(&doc).contains("/F2 10 Tf [-330] TJ (ab) Tj"), "{}", flat(&doc)); + let mut doc = text_page("BT /F2 10 Tf 72 600 Td (ab) Tj ET"); + text::replace_line(&mut doc, 0, 0, "ab ba cab").unwrap(); + let doc = reopen(&doc); + assert!(flat(&doc).contains("(ab) Tj [-330] TJ [-330] TJ (ba) Tj"), "{}", flat(&doc)); + // Before a substitute word, the leading spaces are the substitute's to show, all of them. + let mut doc = text_page("BT /F2 10 Tf 72 600 Td (ab) Tj ET"); + text::replace_line(&mut doc, 0, 0, " cab ab").unwrap(); + let doc = reopen(&doc); + let content = String::from_utf8_lossy(&page_content_bytes(&doc, 0)).into_owned(); + assert!(content.contains("( cab) Tj"), "{content}"); +} + +/// Review on #2955: a paragraph rewrite keeps the `/PCEdLine` delimiters of a line an earlier +/// replacement wrote in two fonts, so a rewritten paragraph's rows can end up inside one. A +/// later replacement of one of those rows must not take the sequence's other rows with it. +#[test] +fn replacing_one_row_of_a_sequence_keeps_the_other_rows() { + let content = "BT /F2 10 Tf 72 600 Td /PCEdLine BMC /F2 10 Tf (ab) Tj /F1 10 Tf ( cab) Tj 0 -20 Td /F2 10 Tf (ba) Tj EMC ET"; + let rows = text::text_lines(&text_page(content), 0).unwrap(); + assert_eq!(rows.iter().map(|l| l.text.trim()).collect::>(), ["ab cab", "ba"]); + for (row, text, other) in [(0, "ab ab", "ba"), (1, "ab", "ab cab")] { + let mut doc = text_page(content); + text::replace_line(&mut doc, 0, row, text).unwrap(); + let doc = reopen(&doc); + let after: Vec = text::text_lines(&doc, 0).unwrap().iter().map(|l| l.text.trim().to_string()).collect(); + assert!(after.iter().any(|t| t == text) && after.iter().any(|t| t == other), "replacing row {row}: {after:?}"); + } +} + +/// Review on #2955: a row inside a shared `/PCEdLine` sequence that starts with a substitute run +/// has the substitute's font in effect at its first operator. Replaced by text the row's own font +/// shows, it must set that font again, or the new text is drawn in the substitute. +#[test] +fn a_row_replaced_inside_a_shared_sequence_starts_in_its_own_font() { + // A real two-font line, starting with the substitute run, then a second row put inside its + // sequence, as a paragraph rewrite that keeps the delimiters can leave it. + let mut doc = text_page("BT /F2 10 Tf 72 600 Td (ab) Tj ET"); + text::replace_line(&mut doc, 0, 0, " cab ab").unwrap(); + let doc = reopen(&doc); + let content = String::from_utf8_lossy(&page_content_bytes(&doc, 0)).into_owned(); + assert!(content.contains("/PCEdLine") && content.contains("( cab) Tj"), "{content}"); + let mut doc = doc; + let shared = content.replacen("EMC", "0 -20 Td (ba) Tj EMC", 1); + let stream = Object::Ref(doc.add(Object::Stream(Stream::from_raw(Dict::new(), shared.into_bytes())))); + set_page_contents(&mut doc, stream, None); + let rows = text::text_lines(&doc, 0).unwrap(); + assert_eq!(rows.iter().map(|l| (l.text.trim(), l.font.as_str())).collect::>(), [("cab ab", "F2"), ("ba", "F2")]); + text::replace_line(&mut doc, 0, 0, "ab").unwrap(); + let doc = reopen(&doc); + let after = text::text_lines(&doc, 0).unwrap(); + assert_eq!(after.iter().map(|l| (l.text.trim(), l.font.as_str())).collect::>(), [("ab", "F2"), ("ba", "F2")]); +} + +/// Review on #2955: a sequence holding one row can also hold that row's positioning and text +/// state, as a paragraph rewrite writes it inside kept delimiters. Replacing the row drops the +/// delimiters, fonts and text, not the `Tm` that places it. +#[test] +fn replacing_the_only_row_of_a_sequence_keeps_its_position() { + let content = "BT /F2 10 Tf /PCEdLine BMC 1 0 0 1 72 600 Tm /F2 10 Tf (ab) Tj /F1 10 Tf ( cab) Tj EMC ET"; + let mut doc = text_page(content); + let before = text::text_lines(&doc, 0).unwrap(); + assert_eq!(before.iter().map(|l| l.text.trim()).collect::>(), ["ab cab"]); + text::replace_line(&mut doc, 0, 0, "ab").unwrap(); + let doc = reopen(&doc); + let content = String::from_utf8_lossy(&page_content_bytes(&doc, 0)).into_owned(); + assert!(!content.contains("PCEdLine") && !content.contains("cab"), "{content}"); + let after = text::text_lines(&doc, 0).unwrap(); + assert_eq!(after.len(), 1, "{content}"); + let (was, now) = (before[0].rect, after[0].rect); + assert!((was[0] - now[0]).abs() < 0.01 && (was[1] - now[1]).abs() < 0.01, "moved from {was:?} to {now:?}: {content}"); +} + #[test] fn missing_glyphs_substitute_helvetica_and_impossible_text_is_refused() { let mut doc = text_page("BT /F2 10 Tf 72 600 Td (ab) Tj ET"); diff --git a/crates/edit/src/text.rs b/crates/edit/src/text.rs index 2dbf302685..c08edb1e86 100644 --- a/crates/edit/src/text.rs +++ b/crates/edit/src/text.rs @@ -6,8 +6,9 @@ //! dropped, and every positioning operator stays, so the lines after it keep their places //! (`Td`, `T*` and friends move from the line matrix, which showing text doesn't change). //! -//! The new text uses the line's own font when every character has a code and a glyph in it; -//! otherwise it is set in Helvetica (WinAnsi), and the result says the font was substituted. +//! The new text uses the line's own font when every character has a code and a glyph in it. +//! Otherwise the words the font can show keep it and only the others are set in Helvetica +//! (WinAnsi), run by run, and the result says the font was substituted. //! Reflowing a paragraph to a new width is not done here (see `edit.text-reflow`). use std::collections::HashMap; @@ -132,6 +133,9 @@ pub struct TextLine { /// Chinese/Japanese word split), from `/PCSoft`'s `/N`; 1 when the marker carried none /// (#2529). Meaningless otherwise. soft_gap: usize, + /// The `/PCEdLine` marked-content sequence a replacement mixing fonts wrote the line in + /// (see [`fallback::continues_font`]). + edited: Option, } impl TextLine { diff --git a/crates/edit/src/text/fallback.rs b/crates/edit/src/text/fallback.rs index f7c795ca65..91e5258b31 100644 --- a/crates/edit/src/text/fallback.rs +++ b/crates/edit/src/text/fallback.rs @@ -1,4 +1,4 @@ -use super::interp::Ts; +use super::interp::{Shown, Ts}; use super::lines::page_dict; use super::{ CraftFont, Dict, Document, EditError, GlyphError, HashMap, Metrics, Object, Op, PdfString, Stream, TextLine, japanese_glyph_from, parse, @@ -148,6 +148,23 @@ fn fonts_shown(doc: &Document, page: &Dict, resources: &Dict) -> Option bool { + same_font(line, &s.font, &s.base_font, s.face.as_deref()) || (s.edited.is_some() && line.edited == s.edited) +} + +/// A line that began with a substitute run takes the source font of a later run, so the next +/// replacement starts from the line's own font again. +pub(super) fn adopt_source_font(line: &mut TextLine, s: &Shown) { + if line.edited.is_some() && line.font.starts_with("PCEd") && !s.font.starts_with(b"PCEd") { + line.font = String::from_utf8_lossy(&s.font).into_owned(); + line.base_font = s.base_font.clone(); + line.face = s.face.clone(); + } +} + /// Whether a run shown in `font` (a resource name), with `/BaseFont` `base_font` and the Japanese /// or Hebrew fallback face it subsets, if any, continues `line`: the same font resource, another /// resource for the same font (a writer may split one font into several subsets, #3661), or @@ -730,6 +747,30 @@ pub(crate) fn source_show(metrics: &Metrics, text: &str, size: f64, space: f64, Ok(out) } +/// `text` split into runs of words `metrics` can show (`true`) and words it can't (`false`), in +/// order. A word is shown in one font, so a word with any character the font lacks goes to the +/// substitute whole rather than switching fonts letter by letter. Spaces join the run they are +/// in, since [`source_show`] draws spaces the font lacks with a displacement. +pub(super) fn source_runs(metrics: &Metrics, text: &str) -> Vec<(bool, String)> { + let mut runs: Vec<(bool, String)> = Vec::new(); + // Spaces before the first word go with it: on their own they would make a run of the line's + // font with nothing to show (review on #2955). + let mut lead = String::new(); + for (i, word) in text.split(' ').enumerate() { + let piece = if i == 0 { word.to_string() } else { format!(" {word}") }; + if runs.is_empty() && word.is_empty() { + lead.push_str(&piece); + continue; + } + let own = word.is_empty() || metrics.encode(word).is_some(); + match runs.last_mut() { + Some((last, run)) if *last == own || word.is_empty() => run.push_str(&piece), + _ => runs.push((own, std::mem::take(&mut lead) + &piece)), + } + } + runs +} + #[cfg(test)] mod tests { use super::*; diff --git a/crates/edit/src/text/interp.rs b/crates/edit/src/text/interp.rs index b6c2d59cd1..019edf982a 100644 --- a/crates/edit/src/text/interp.rs +++ b/crates/edit/src/text/interp.rs @@ -359,6 +359,7 @@ pub(super) struct Shown { /// `/PCSoft` with no `/N` (a file saved before this was tracked, or by another writer), /// matching the single space a rejoin always used before #2529. pub(super) soft_gap: usize, + pub(super) edited: Option, } impl Shown { @@ -537,6 +538,8 @@ type Invocation = (usize, Vec, Ts); struct MarkedContent { regions: Vec>, layers: Vec>>, + // The `/PCEdLine` sequences around the text (a replacement mixing fonts), innermost last. + edited: Vec>, actual_text: Vec>, } @@ -550,6 +553,7 @@ impl MarkedContent { let layer = op.is("BDC") && op.name(0) == Some(b"OC".as_slice()); self.layers .push(layer.then(|| op.operands.get(1).and_then(Object::as_name).map_or_else(|| format!("@{index}").into_bytes(), <[u8]>::to_vec))); + self.edited.push(if op.name(0) == Some(b"PCEdLine") { Some(index) } else { self.edited.last().copied().flatten() }); // The property list is inline or named in the resources' /Properties (§14.6.2). let list = logical_text.and_then(|properties| match op.operands.get(1) { Some(Object::Name(name)) => properties.get(name).and_then(|object| doc.dict(object)), @@ -565,6 +569,7 @@ impl MarkedContent { fn end(&mut self, index: usize, shown: &mut Vec) { self.regions.pop(); self.layers.pop(); + self.edited.pop(); if let Some(Some((start, open, logical))) = self.actual_text.pop() { let tail = shown.split_off(start.min(shown.len())); if tail.iter().any(|s| s.glyph_count > 0) { @@ -814,6 +819,7 @@ fn show_text(op: &Op, i: usize, state: &mut TextRunState<'_>) -> Result<(), Edit soft_break: state.soft_break, hard_break: state.hard_break, soft_gap: state.soft_gap, + edited: marked.edited.last().copied().flatten(), }); Ok(()) } diff --git a/crates/edit/src/text/lines.rs b/crates/edit/src/text/lines.rs index 2755d6bb71..54c249dbf1 100644 --- a/crates/edit/src/text/lines.rs +++ b/crates/edit/src/text/lines.rs @@ -1,6 +1,6 @@ use std::sync::Arc; -use super::fallback::{same_font, same_paint}; +use super::fallback::{adopt_source_font, continues_font, same_paint}; use super::interp::{Carry, Origin, Pen, Shown, TextState, Ts, direction, interpret, to_frame, union}; use super::{DecodeBudget, Dict, Document, EditError, HashMap, Matrix, Object, Op, Pieces, Stream, TextLine, parse, serialize_ops}; @@ -340,7 +340,7 @@ fn text_lines_in(doc: &Document, p: &pdfcraft_model::Page, actual_text: bool, in piece == si && lines.last().is_some_and(|l| l.scope == s.scope && l.paint == s.paint && l.logical_order == s.logical_order) && (bt == s.bt || lines.last().is_some_and(|l| compatible_word(l, &s))) - && (s.text.trim().is_empty() || lines.last().is_some_and(|l| same_font(l, &s.font, &s.base_font, s.face.as_deref()))) + && (s.text.trim().is_empty() || lines.last().is_some_and(|l| continues_font(l, &s))) && (s.text.trim().is_empty() || lines.last().is_some_and(|l| { (s.size - l.size).abs() < 0.01 @@ -374,6 +374,7 @@ fn text_lines_in(doc: &Document, p: &pdfcraft_model::Page, actual_text: bool, in if l.unreadable_mode.is_none() { l.unreadable_mode = s.state.unreadable.as_deref().map(str::to_owned); } + adopt_source_font(l, &s); // Spaces may use a separate subset font; they do not change the visible style. } else { lines.push(TextLine { @@ -401,6 +402,7 @@ fn text_lines_in(doc: &Document, p: &pdfcraft_model::Page, actual_text: bool, in soft_break: s.soft_break, hard_break: s.hard_break, soft_gap: s.soft_gap, + edited: s.edited, ops: std::iter::once(s.op).chain(s.extra_ops.iter().copied()).collect(), origin: Origin { tm: s.tm.0, @@ -461,7 +463,7 @@ fn add_shown(lines: &mut Vec, last: &mut Option, s: Shown, stream let bt = pen.bt; lines.last().is_some_and(|l| l.scope == s.scope && l.paint == s.paint && l.logical_order == s.logical_order) && (bt == s.bt || lines.last().is_some_and(|l| compatible_word(l, &s))) - && (s.text.trim().is_empty() || lines.last().is_some_and(|l| same_font(l, &s.font, &s.base_font, s.face.as_deref()))) + && (s.text.trim().is_empty() || lines.last().is_some_and(|l| continues_font(l, &s))) && (s.text.trim().is_empty() || lines.last().is_some_and(|l| { (s.size - l.size).abs() < 0.01 @@ -492,6 +494,7 @@ fn add_shown(lines: &mut Vec, last: &mut Option, s: Shown, stream if l.unreadable_mode.is_none() { l.unreadable_mode = s.state.unreadable.as_deref().map(str::to_owned); } + adopt_source_font(l, &s); } else { lines.push(TextLine { text: s.text.clone(), @@ -518,6 +521,7 @@ fn add_shown(lines: &mut Vec, last: &mut Option, s: Shown, stream soft_break: s.soft_break, hard_break: s.hard_break, soft_gap: s.soft_gap, + edited: s.edited, ops: std::iter::once(s.op).chain(s.extra_ops.iter().copied()).collect(), origin: Origin { tm: s.tm.0, diff --git a/crates/edit/src/text/rewrite.rs b/crates/edit/src/text/rewrite.rs index c146ffcbc0..1bf27d2b9e 100644 --- a/crates/edit/src/text/rewrite.rs +++ b/crates/edit/src/text/rewrite.rs @@ -1,7 +1,7 @@ use super::blocks::{BlockStyle, TextBlock, paragraph_separator, row_break, wrap}; use super::fallback::{ Encoder, MAX_TYPE3_GLYPHS, Type3Fallback, Type3Run, drop_unused_fallback_fonts, is_variation_selector, needs_type3, no_japanese_font, - source_encodes, source_family, source_program_completes, source_show, source_space, source_width, type3_encode, type3_font, + source_encodes, source_family, source_program_completes, source_runs, source_show, source_space, source_width, type3_encode, type3_font, }; use super::interp::{Carry, Origin, TextState, interpret}; use super::lines::{content_streams, joined, page_dict, rewritten_contents, splice, text_lines}; @@ -110,10 +110,24 @@ pub(crate) fn replace_line_with_limit(doc: &mut Document, page: usize, line: usi replacement.push(Op::new("Tf", vec![Object::name(&target.font), pdfcraft_content::num(size)])); substituted = Some(format!("{} Type3", fallback.family)); } else { + // The words the line's font can show keep it; only the others are set in the + // substitute (#2378): a subset without `V` or `ü` no longer turns the whole line + // into Helvetica. Reordered text (right to left, Indic) keeps one run as before. + let mixed = font + .as_ref() + .filter(|_| !visual_indic && fallback_shown == text) + .map(|m| source_runs(m, &text)) + .filter(|runs| runs.iter().any(|(own, _)| *own)); + // What the substitute has to show: only the runs the line's font can't. + let for_substitute: String = match &mixed { + Some(runs) => runs.iter().filter(|(own, _)| !*own).map(|(_, run)| run.as_str()).collect(), + None => fallback_shown.clone(), + }; let win = pdfcraft_fonts::win_ansi(&fallback_shown); // WinAnsi turns what it can't show into '?'; refuse rather than print the wrong thing. - let back: String = win.iter().map(|b| char::from_u32(u32::from(*b)).unwrap_or('?')).collect(); - if fallback_shown.chars().zip(back.chars()).any(|(a, b)| b == '?' && a != '?') { + let checked = pdfcraft_fonts::win_ansi(&for_substitute); + let back: String = checked.iter().map(|b| char::from_u32(u32::from(*b)).unwrap_or('?')).collect(); + if for_substitute.chars().zip(back.chars()).any(|(a, b)| b == '?' && a != '?') { return Err(EditError::Invalid(format!("\"{text}\" has characters neither {} nor Helvetica can show", target.base_font))); } // The size in text space: the current Tf's size. @@ -121,8 +135,36 @@ pub(crate) fn replace_line_with_limit(doc: &mut Document, page: usize, line: usi let family = source_family(&target.base_font); let base = family.base_font(target.bold, target.italic); let substitute_name = format!("PCEd{}", base.replace('-', "")); - replacement.push(Op::new("Tf", vec![Object::name(&substitute_name), pdfcraft_content::num(size)])); - replacement.push(Op::new("Tj", vec![Object::String(PdfString::literal(win))])); + match mixed { + Some(runs) => { + let space = font.as_ref().map_or(0.33, |m| source_space(doc, &fonts_res, m)); + // Marked as one line, so it reads back as one (see `continues_font`). + replacement.push(Op::new("BMC", vec![Object::name("PCEdLine")])); + for (own, run) in runs { + if own { + replacement.push(Op::new("Tf", vec![Object::name(&target.font), pdfcraft_content::num(size)])); + if let Some(m) = font.as_ref() { + replacement.extend(source_show( + m, + &run, + target.origin.state.font.as_ref().map_or(target.size, |(_, size)| *size), + space, + target.origin.state.char_spacing, + target.origin.state.word_spacing, + )?); + } + } else { + replacement.push(Op::new("Tf", vec![Object::name(&substitute_name), pdfcraft_content::num(size)])); + replacement.push(Op::new("Tj", vec![Object::String(PdfString::literal(pdfcraft_fonts::win_ansi(&run)))])); + } + } + replacement.push(Op::new("EMC", Vec::new())); + } + None => { + replacement.push(Op::new("Tf", vec![Object::name(&substitute_name), pdfcraft_content::num(size)])); + replacement.push(Op::new("Tj", vec![Object::String(PdfString::literal(win))])); + } + } replacement.push(Op::new("Tf", vec![Object::name(&target.font), pdfcraft_content::num(size)])); substituted = Some(base.to_string()); let mut f = Dict::new(); @@ -135,6 +177,43 @@ pub(crate) fn replace_line_with_limit(doc: &mut Document, page: usize, line: usi } } // Rebuild: the line's first operator becomes the replacement, its others go. + // A line an earlier replacement wrote in two fonts is in a `/PCEdLine` sequence of its own, + // with its fonts and the moves standing in for spaces the source font lacks: those go + // with the line, or each edit would nest one more (review on #2955). The line then starts in + // its own font, set again where the sequence was. + if let Some((open, close)) = target.edited.and_then(|open| Some((open, matching_emc(&ops, open)?))) { + // Only when the sequence holds nothing but this line: a paragraph rewrite keeps the + // delimiters, so other rows can be inside it (review on #2955). Then the sequence stays + // as it is, since it is also what reads its own two-font line back as one, and only + // this line's operators go. + let others_inside = lines.iter().any(|l| l.ops.iter().any(|o| (open..=close).contains(o) && !drops.contains(o))); + // Either way the replacement starts in the line's own font: inside the sequence the font + // in effect at the line's first operator can be the substitute's, which a replacement + // the line's font shows by itself would otherwise inherit (review on #2955). + let size = font_size_before(&ops, first).unwrap_or(target.size); + replacement.insert(0, Op::new("Tf", vec![Object::name(&target.font), pdfcraft_content::num(size)])); + if !others_inside { + // What goes is what the replacement stands in for: the marked-content delimiters + // (nested ones too, which come in pairs), the fonts and the text-showing operators + // (the line's runs and the moves between them). A paragraph rewrite can also write a + // row's positioning and text state (`Tm`, `Td`, `Tw`, ...), a `q`/`Q` or a `BT`/`ET` + // inside kept delimiters; those stay, or the replacement loses its place and the + // content its balance (review on #2955). + drops.extend( + (open..=close) + .filter(|&i| ops.get(i).is_some_and(|o| [b"BMC".as_slice(), b"BDC", b"EMC", b"Tf", b"Tj", b"TJ"].contains(&o.op.as_slice()))), + ); + let own_font = |i: usize| ops.get(i).is_some_and(|o| o.is("Tf") && o.name(0) == Some(target.font.as_bytes()) && o.num(1) == Some(size)); + // The same font set just before the sequence (by the last edit) is set again here. + if let Some(i) = open.checked_sub(1).filter(|&i| own_font(i)) { + drops.insert(i); + } + // And set back after it: the replacement ends in that font too. + if let Some(i) = close.checked_add(1).filter(|&i| own_font(i)) { + drops.insert(i); + } + } + } let new_data = splice(&pieces, &ops, |i| if i == first { (std::mem::take(&mut replacement), false) } else { (Vec::new(), !drops.contains(&i)) }); let contents = rewritten_contents(doc, &streams, new_data)?; let page_ref = p.obj; @@ -150,6 +229,24 @@ pub(crate) fn replace_line_with_limit(doc: &mut Document, page: usize, line: usi Ok(LineEdit { substituted }) } +/// The `EMC` that closes the marked-content sequence `BMC`/`BDC` operator `open` begins. +fn matching_emc(ops: &[Op], open: usize) -> Option { + let mut depth = 0usize; + for (i, op) in ops.iter().enumerate().skip(open) { + match op.op.as_slice() { + b"BMC" | b"BDC" => depth = depth.saturating_add(1), + b"EMC" => { + depth = depth.checked_sub(1)?; + if depth == 0 { + return Some(i); + } + } + _ => {} + } + } + None +} + /// The size of the last `Tf` before operator `at`. fn font_size_before(ops: &[Op], at: usize) -> Option { ops[..at].iter().rev().find(|o| o.is("Tf")).and_then(|o| o.num(1)) diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index 39d00cacbc..084b06dbae 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -1907,8 +1907,8 @@ milestone = "M7" status = "partial" commands = ["edit.edit_text"] tools = ["text_prepare", "text_lines", "text_paragraphs", "text_edit", "text_edit_layout", "text_font_files", "text_font_import", "text_fonts"] -evidence = ["crates/edit/src/tests.rs::editing_legacy_thai_keeps_the_embedded_face_weight_size_spacing_and_paint_after_reopen", "crates/edit/src/tests.rs::editing_legacy_thai_shapes_a_run_across_subsets_of_the_same_face", "crates/edit/src/tests.rs::grouped_text_nested_past_the_level_limit_is_reported", "crates/edit/src/tests.rs::grouped_image_artwork_is_not_prepared", "crates/edit/src/tests.rs::grouped_text_in_an_rgb_group_needs_a_page_that_blends_in_rgb", "crates/fonts/src/pdf/thai.rs::recovered_thai_map_checks_every_kept_glyph_against_the_font_program", "crates/automation/tests/it/automation.rs::text_prepare_refuses_pages_without_grouped_text", "crates/fonts/src/pdf/tests.rs::composite_subset_without_cmap_uses_pdf_glyph_ids_in_editor_and_shaper_without_replacing_the_program", "crates/automation/tests/it/automation.rs::grouped_existing_thai_body_edits_saves_reopens_and_undo_restores_original_glyphs", "crates/edit/src/tests.rs::grouped_text_preparation_keeps_unsafe_compositing_paths_and_malformed_forms_intact", "crates/automation/tests/it/automation.rs::explicit_bundled_thai_font_choice_layout_edit_undo_and_reopen", "crates/edit/src/tests.rs::existing_thai_subset_uses_shaped_matching_face_without_embedding_variable_font", "crates/edit/src/tests.rs::paragraphs_are_found_and_rewrapped", "crates/edit/src/tests.rs::text_lines_are_found_and_replaced_in_place", "crates/ui-egui/tests/it/editing.rs::editing_existing_text_in_place", "crates/automation/tests/it/automation.rs::editing_existing_text_through_tools", "crates/edit/src/tests.rs::a_line_whose_operator_starts_the_next_stream_can_be_edited", "crates/automation/tests/it/automation.rs::a_line_split_across_content_streams_is_listed_and_edited", "crates/ui-egui/tests/it/editing.rs::selected_text_formatting_keeps_the_rest_of_the_paragraph_and_reopens", "crates/edit/src/tests.rs::marked_paragraph_boundaries_and_cross_stream_transforms_survive_rewrite", "crates/automation/tests/it/automation.rs::explicit_font_and_disjoint_ranges_save_reopen_through_tools", "crates/edit/src/tests.rs::disjoint_style_ranges_preserve_unselected_runs_and_validate_utf8_atomically", "crates/engine/tests/it/document_fonts.rs::opentype_cff_document_font_preserves_program_codes_and_rendering_after_reopen", "crates/ui-egui/tests/it/editing.rs::cropped_images_do_not_intercept_text_and_stay_inside_each_page", "crates/automation/tests/it/automation.rs::clipped_image_bounds_and_text_edit_save_reopen_through_tools", "crates/edit/src/tests.rs::mixed_styles_and_matrix_sizes_form_visual_regions_without_merging_columns", "crates/edit/src/tests.rs::rich_region_edits_preserve_styles_positions_and_delete_all_runs_after_reopen", "crates/ui-egui/tests/it/editing.rs::one_editor_contains_mixed_styles_and_visual_rows", "crates/ui-egui/tests/it/editing.rs::inline_editor_preserves_source_font_and_previews_live_bounds", "crates/fonts/src/pdf/tests.rs::mac_subset_cmap_uses_pdf_unicode_without_changing_source_program", "crates/automation/tests/it/automation.rs::mixed_visual_region_edit_preview_save_reopen_and_undo_through_tools", "crates/ui-egui/tests/it/ui.rs::the_edit_tab_turns_on_edit_text_and_images", "crates/ui-egui/tests/it/editing.rs::the_caret_on_a_small_row_is_as_tall_as_that_row", "crates/edit/src/tests.rs::uzbek_and_kazakh_cyrillic_use_the_bundled_latin_face", "crates/automation/tests/it/automation.rs::editing_uzbek_and_kazakh_cyrillic_through_tools", "crates/edit/src/tests.rs::styled_paragraph_batches_cyrillic_without_changing_source_fonts", "crates/edit/src/tests.rs::styled_paragraph_over_240_fallback_glyphs_splits_and_refuses_atomically", "crates/edit/src/tests.rs::styled_fallback_batches_keep_face_choices_and_range_styles", "crates/edit/src/tests.rs::styled_fallback_shards_keep_text_advances_and_alignment", "crates/edit/src/tests.rs::styled_fallback_budget_is_enforced_before_objects_are_written", "crates/edit/src/tests.rs::styled_fallback_same_base_font_and_character_keep_both_weights", "crates/automation/tests/it/automation.rs::styled_cyrillic_fallback_is_batched_through_tools_and_reopen", "crates/ui-egui/tests/it/editing.rs::escape_in_an_open_paragraph_selects_its_box_for_delete", "crates/edit/src/tests.rs::ungrouping_form_artwork_makes_its_text_editable", "crates/automation/tests/it/automation.rs::ungrouping_a_table_drawn_in_a_form_makes_its_text_editable", "crates/ui-egui/tests/it/editing.rs::ungroup_in_the_artwork_menu_makes_a_forms_text_editable", "crates/edit/src/tests.rs::ungroup_carries_the_placement_into_the_patterns_it_selects", "crates/automation/tests/it/automation.rs::ungrouping_keeps_patterns_where_the_forms_placement_put_them", "crates/edit/src/tests.rs::ungroup_refuses_a_form_with_its_own_default_colour_space", "crates/edit/src/tests.rs::ungroup_renames_the_name_operand_the_renderer_reads", "crates/ui-egui/tests/it/editing.rs::one_click_on_grouped_text_opens_it_for_editing", "crates/edit/src/tests.rs::grouped_text_at_says_which_artwork_preparation_would_open", "crates/edit/src/tests.rs::legacy_thai_map_repair_needs_no_grouped_text", "crates/edit/src/tests.rs::ungroup_refuses_a_form_drawn_inside_text_or_a_path", "crates/edit/src/tests.rs::ungroup_reads_what_a_group_draws_within_one_allowance", "crates/edit/src/tests.rs::unicode_row_breaks_break_the_row_instead_of_needing_a_glyph"] -notes = "In Edit text, one click on text in a supported grouped Form prepares its page (one undoable step) and opens the paragraph clicked, so its existing source glyphs are edited directly; nothing is prepared while pages are only shown, a drag still moves the selected group, and a click on grouped artwork that preparation wouldn't open (a logo, a refused group) selects it as before. text_prepare does the same headlessly, with the same code as Ungroup. Unsafe groups remain intact and are reported by text_prepare and in the repair log when the page is prepared, as is text grouped more than eight levels deep; image-only forms are left grouped. Narrow legacy THSarabunPSK character-map recovery, checked glyph by glyph against the subset's own advances, is recorded, also for text that was never grouped (text_prepare). Legacy Thai source editing reuses original font programs, positional glyphs and same-face subsets (a Thai run split across subsets is shaped cluster by cluster; what no subset can show in its codes is shaped whole by a complete program of the face, such as one from Load font), preserving Regular/Bold, point size, spacing and paint. A different Thai family requires explicit selection; the Noto replacement is Regular only; mixed contextual style runs remain limited. The Edit tab turns Edit text & images on at once, as in Acrobat (leaving the tab puts the tool down and applies an edit in progress); Esc in an open paragraph leaves its text and selects its box, so Delete removes it (the unapplied draft is discarded); click a paragraph box and retype it in place (font reused or Helvetica substituted, colour and spacing kept, rewrapped); a page's /Contents pieces are parsed joined, so a line whose operands and operator sit in different pieces is listed and edited (lines and paragraphs still don't join across pieces; Edit image still parses each piece alone); drag a box to move it or its right-edge handle to rewrap it (edit.text-move-resize); per-character formatting changes are next Since #1116: mixed-style visual regions retain source rows, subset encodings, colour and baseline anchors in one textless-background editor. Explicit paragraph/heading scopes constrain grouping; compatible graphics planes permit cross-stream regions. Full matching document fonts complete absent subset glyphs; installed/file selection, TrueType/OpenType-CFF embedding and disjoint UTF-8 range styles have save/reopen coverage. Single-face OpenType shaping/bidi retains logical text through ToUnicode/ActualText. Replacement text the paragraph's font can't show takes the craft-fonts Japanese faces when they draw all of it (Uzbek қ, Kazakh ә are in none of them), else the bundled Inter face, so those letters write in every build (#2593). Legacy non-SFNT preview, mixed-font/style contextual runs, full structure-tree preservation and author intent in untagged PDFs remain limited; Styled-path outline fallback batches by actual face with 240 glyphs per font and preserves source styles; adjacent equal-font/style characters form runs. At most 15360 distinct fallback glyphs per styled paragraph; missing glyphs are refused before fallback objects are written. The Unicode row breaks (U+2028, U+2029, U+0085, vertical tab, form feed and the information separators) break the row instead of being looked up as glyphs (#6272). Glyph coverage, mixed contextual shaping, single-style fallback and stale-font cleanup are unchanged; see docs/editing-existing-text.md. Text drawn inside a Form XObject (HTML-to-PDF output, stamped or imposed pages) is grouped artwork until Ungroup (artwork right-click menu; image_edit action ungroup) draws that placement in place with its resources merged; knockout groups and transparency groups that blend inside, are drawn translucent or blend in a colour space other than the page's are refused, as are forms with resources of their own whose /DefaultGray, /DefaultRGB or /DefaultCMYK differs from the page's, either side lacking it included (ISO 32000-2 §8.6.5.6), and forms drawn inside a text object or an unfinished path, each resource name is read from the operand the renderer uses (a surplus operand before it is ignored; an operator whose operands can't be read is refused), and a pattern the form fills with is drawn from a copy whose /Matrix carries the placement (ISO 32000-2 §8.7.2), so ungrouping never changes the pixels." +evidence = ["crates/edit/src/tests.rs::editing_legacy_thai_keeps_the_embedded_face_weight_size_spacing_and_paint_after_reopen", "crates/edit/src/tests.rs::editing_legacy_thai_shapes_a_run_across_subsets_of_the_same_face", "crates/edit/src/tests.rs::grouped_text_nested_past_the_level_limit_is_reported", "crates/edit/src/tests.rs::grouped_image_artwork_is_not_prepared", "crates/edit/src/tests.rs::grouped_text_in_an_rgb_group_needs_a_page_that_blends_in_rgb", "crates/fonts/src/pdf/thai.rs::recovered_thai_map_checks_every_kept_glyph_against_the_font_program", "crates/automation/tests/it/automation.rs::text_prepare_refuses_pages_without_grouped_text", "crates/fonts/src/pdf/tests.rs::composite_subset_without_cmap_uses_pdf_glyph_ids_in_editor_and_shaper_without_replacing_the_program", "crates/automation/tests/it/automation.rs::grouped_existing_thai_body_edits_saves_reopens_and_undo_restores_original_glyphs", "crates/edit/src/tests.rs::grouped_text_preparation_keeps_unsafe_compositing_paths_and_malformed_forms_intact", "crates/automation/tests/it/automation.rs::explicit_bundled_thai_font_choice_layout_edit_undo_and_reopen", "crates/edit/src/tests.rs::existing_thai_subset_uses_shaped_matching_face_without_embedding_variable_font", "crates/edit/src/tests.rs::paragraphs_are_found_and_rewrapped", "crates/edit/src/tests.rs::text_lines_are_found_and_replaced_in_place", "crates/ui-egui/tests/it/editing.rs::editing_existing_text_in_place", "crates/automation/tests/it/automation.rs::editing_existing_text_through_tools", "crates/edit/src/tests.rs::a_line_whose_operator_starts_the_next_stream_can_be_edited", "crates/automation/tests/it/automation.rs::a_line_split_across_content_streams_is_listed_and_edited", "crates/ui-egui/tests/it/editing.rs::selected_text_formatting_keeps_the_rest_of_the_paragraph_and_reopens", "crates/edit/src/tests.rs::marked_paragraph_boundaries_and_cross_stream_transforms_survive_rewrite", "crates/automation/tests/it/automation.rs::explicit_font_and_disjoint_ranges_save_reopen_through_tools", "crates/edit/src/tests.rs::disjoint_style_ranges_preserve_unselected_runs_and_validate_utf8_atomically", "crates/engine/tests/it/document_fonts.rs::opentype_cff_document_font_preserves_program_codes_and_rendering_after_reopen", "crates/ui-egui/tests/it/editing.rs::cropped_images_do_not_intercept_text_and_stay_inside_each_page", "crates/automation/tests/it/automation.rs::clipped_image_bounds_and_text_edit_save_reopen_through_tools", "crates/edit/src/tests.rs::mixed_styles_and_matrix_sizes_form_visual_regions_without_merging_columns", "crates/edit/src/tests.rs::rich_region_edits_preserve_styles_positions_and_delete_all_runs_after_reopen", "crates/ui-egui/tests/it/editing.rs::one_editor_contains_mixed_styles_and_visual_rows", "crates/ui-egui/tests/it/editing.rs::inline_editor_preserves_source_font_and_previews_live_bounds", "crates/fonts/src/pdf/tests.rs::mac_subset_cmap_uses_pdf_unicode_without_changing_source_program", "crates/automation/tests/it/automation.rs::mixed_visual_region_edit_preview_save_reopen_and_undo_through_tools", "crates/ui-egui/tests/it/ui.rs::the_edit_tab_turns_on_edit_text_and_images", "crates/ui-egui/tests/it/editing.rs::the_caret_on_a_small_row_is_as_tall_as_that_row", "crates/edit/src/tests.rs::uzbek_and_kazakh_cyrillic_use_the_bundled_latin_face", "crates/automation/tests/it/automation.rs::editing_uzbek_and_kazakh_cyrillic_through_tools", "crates/edit/src/tests.rs::styled_paragraph_batches_cyrillic_without_changing_source_fonts", "crates/edit/src/tests.rs::styled_paragraph_over_240_fallback_glyphs_splits_and_refuses_atomically", "crates/edit/src/tests.rs::styled_fallback_batches_keep_face_choices_and_range_styles", "crates/edit/src/tests.rs::styled_fallback_shards_keep_text_advances_and_alignment", "crates/edit/src/tests.rs::styled_fallback_budget_is_enforced_before_objects_are_written", "crates/edit/src/tests.rs::styled_fallback_same_base_font_and_character_keep_both_weights", "crates/automation/tests/it/automation.rs::styled_cyrillic_fallback_is_batched_through_tools_and_reopen", "crates/ui-egui/tests/it/editing.rs::escape_in_an_open_paragraph_selects_its_box_for_delete", "crates/edit/src/tests.rs::ungrouping_form_artwork_makes_its_text_editable", "crates/automation/tests/it/automation.rs::ungrouping_a_table_drawn_in_a_form_makes_its_text_editable", "crates/ui-egui/tests/it/editing.rs::ungroup_in_the_artwork_menu_makes_a_forms_text_editable", "crates/edit/src/tests.rs::ungroup_carries_the_placement_into_the_patterns_it_selects", "crates/automation/tests/it/automation.rs::ungrouping_keeps_patterns_where_the_forms_placement_put_them", "crates/edit/src/tests.rs::ungroup_refuses_a_form_with_its_own_default_colour_space", "crates/edit/src/tests.rs::ungroup_renames_the_name_operand_the_renderer_reads", "crates/ui-egui/tests/it/editing.rs::one_click_on_grouped_text_opens_it_for_editing", "crates/edit/src/tests.rs::grouped_text_at_says_which_artwork_preparation_would_open", "crates/edit/src/tests.rs::legacy_thai_map_repair_needs_no_grouped_text", "crates/edit/src/tests.rs::ungroup_refuses_a_form_drawn_inside_text_or_a_path", "crates/edit/src/tests.rs::ungroup_reads_what_a_group_draws_within_one_allowance", "crates/edit/src/tests.rs::unicode_row_breaks_break_the_row_instead_of_needing_a_glyph", "crates/edit/src/tests.rs::only_the_words_a_subset_cannot_show_are_substituted"] +notes = "In Edit text, one click on text in a supported grouped Form prepares its page (one undoable step) and opens the paragraph clicked, so its existing source glyphs are edited directly; nothing is prepared while pages are only shown, a drag still moves the selected group, and a click on grouped artwork that preparation wouldn't open (a logo, a refused group) selects it as before. text_prepare does the same headlessly, with the same code as Ungroup. Unsafe groups remain intact and are reported by text_prepare and in the repair log when the page is prepared, as is text grouped more than eight levels deep; image-only forms are left grouped. Narrow legacy THSarabunPSK character-map recovery, checked glyph by glyph against the subset's own advances, is recorded, also for text that was never grouped (text_prepare). Legacy Thai source editing reuses original font programs, positional glyphs and same-face subsets (a Thai run split across subsets is shaped cluster by cluster; what no subset can show in its codes is shaped whole by a complete program of the face, such as one from Load font), preserving Regular/Bold, point size, spacing and paint. A different Thai family requires explicit selection; the Noto replacement is Regular only; mixed contextual style runs remain limited. The Edit tab turns Edit text & images on at once, as in Acrobat (leaving the tab puts the tool down and applies an edit in progress); Esc in an open paragraph leaves its text and selects its box, so Delete removes it (the unapplied draft is discarded); click a paragraph box and retype it in place (font reused or Helvetica substituted, colour and spacing kept, rewrapped; replacing one line keeps the source font for every word it can show and sets only the other words in Helvetica (#2378), while a paragraph rewrite still substitutes the whole paragraph for a Latin character its subset lacks); a page's /Contents pieces are parsed joined, so a line whose operands and operator sit in different pieces is listed and edited (lines and paragraphs still don't join across pieces; Edit image still parses each piece alone); drag a box to move it or its right-edge handle to rewrap it (edit.text-move-resize); per-character formatting changes are next Since #1116: mixed-style visual regions retain source rows, subset encodings, colour and baseline anchors in one textless-background editor. Explicit paragraph/heading scopes constrain grouping; compatible graphics planes permit cross-stream regions. Full matching document fonts complete absent subset glyphs; installed/file selection, TrueType/OpenType-CFF embedding and disjoint UTF-8 range styles have save/reopen coverage. Single-face OpenType shaping/bidi retains logical text through ToUnicode/ActualText. Replacement text the paragraph's font can't show takes the craft-fonts Japanese faces when they draw all of it (Uzbek қ, Kazakh ә are in none of them), else the bundled Inter face, so those letters write in every build (#2593). Legacy non-SFNT preview, mixed-font/style contextual runs, full structure-tree preservation and author intent in untagged PDFs remain limited; Styled-path outline fallback batches by actual face with 240 glyphs per font and preserves source styles; adjacent equal-font/style characters form runs. At most 15360 distinct fallback glyphs per styled paragraph; missing glyphs are refused before fallback objects are written. The Unicode row breaks (U+2028, U+2029, U+0085, vertical tab, form feed and the information separators) break the row instead of being looked up as glyphs (#6272). Glyph coverage, mixed contextual shaping, single-style fallback and stale-font cleanup are unchanged; see docs/editing-existing-text.md. Text drawn inside a Form XObject (HTML-to-PDF output, stamped or imposed pages) is grouped artwork until Ungroup (artwork right-click menu; image_edit action ungroup) draws that placement in place with its resources merged; knockout groups and transparency groups that blend inside, are drawn translucent or blend in a colour space other than the page's are refused, as are forms with resources of their own whose /DefaultGray, /DefaultRGB or /DefaultCMYK differs from the page's, either side lacking it included (ISO 32000-2 §8.6.5.6), and forms drawn inside a text object or an unfinished path, each resource name is read from the operand the renderer uses (a surplus operand before it is ignored; an operator whose operands can't be read is refused), and a pattern the form fills with is drawn from a copy whose /Matrix carries the placement (ISO 32000-2 §8.7.2), so ungrouping never changes the pixels." [[feature]] id = "edit.text-reflow" From cb919f0d362512ff5629d1320f62b9c1dced24d8 Mon Sep 17 00:00:00 2001 From: MarekSpodBiedry <73122968+MarekSpodBiedry@users.noreply.github.com> Date: Mon, 9 Nov 2026 11:23:10 +0100 Subject: [PATCH 02/41] Search: centre the active match in the viewport (#3530) (#3547) --- crates/ui-egui/src/canvas.rs | 8 ++++---- crates/ui-egui/src/canvas/find.rs | 26 +++++++++++++++++++++++++- crates/ui-egui/src/canvas/tests.rs | 15 ++++++++++++++- 3 files changed, 43 insertions(+), 6 deletions(-) diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index f20cfca29f..8cfd56bc05 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -628,8 +628,8 @@ pub struct DocView { viewport_w: f32, viewport_h: f32, page_count: usize, - /// Page heights in points (view space), for mapping text positions to scroll offsets. - page_heights: Vec, + /// Page sizes (width, height) in points (view space), for mapping text positions to scroll offsets. + page_sizes: Vec<(f32, f32)>, /// Screen rects of the pages drawn last frame (hit-testing, tests, automation). screen_rects: Vec<(usize, Rect)>, screen_xforms: Vec<(usize, PageXform)>, @@ -1064,7 +1064,7 @@ impl DocView { viewport_w: 800.0, viewport_h: 600.0, page_count: info.pages.len(), - page_heights: info.pages.iter().map(|p| p.height).collect(), + page_sizes: info.pages.iter().map(|p| (p.width, p.height)).collect(), screen_rects: Vec::new(), screen_xforms: Vec::new(), screen_ppp: 1.0, @@ -1154,7 +1154,7 @@ impl DocView { self.invalidate_content(); self.frame_geometry_changed = true; self.page_count = info.pages.len(); - self.page_heights = info.pages.iter().map(|p| p.height).collect(); + self.page_sizes = info.pages.iter().map(|p| (p.width, p.height)).collect(); let last = self.page_count.saturating_sub(1); self.current = self.current.min(last); self.selected.retain(|p| *p <= last); diff --git a/crates/ui-egui/src/canvas/find.rs b/crates/ui-egui/src/canvas/find.rs index 22d6e02d84..9d750cfdb2 100644 --- a/crates/ui-egui/src/canvas/find.rs +++ b/crates/ui-egui/src/canvas/find.rs @@ -180,16 +180,40 @@ impl DocView { self.flash_match(p); } + /// The centre of the current match as a fraction of its displayed page `[x, y]` (0..1 from + /// the top-left corner, after the view rotation, as `goto_point` takes it). + fn match_centre(&self) -> Option<[f32; 2]> { + let f = self.find.as_ref()?; + let (p, r) = f.matches.get(f.current?)?; + let glyphs = self.texts.get(p)?.glyphs.get(r.clone())?; + let [x0, y0, x1, y1] = glyphs.iter().map(|g| g.rect).reduce(|a, b| [a[0].min(b[0]), a[1].min(b[1]), a[2].max(b[2]), a[3].max(b[3])])?; + let &(w, h) = self.page_sizes.get(*p).filter(|(w, h)| *w > 0.0 && *h > 0.0)?; + let (u, v) = ((((x0 + x1) / 2.0) / w).clamp(0.0, 1.0), (((y0 + y1) / 2.0) / h).clamp(0.0, 1.0)); + // Turned clockwise by the view rotation, as `PageInfo::dest_fraction` does. + Some(match self.rotation % 360 { + 90 => [1.0 - v, u], + 180 => [1.0 - u, 1.0 - v], + 270 => [v, 1.0 - u], + _ => [u, v], + }) + } + fn flash_match(&mut self, page: usize) { let frac = self .find .as_ref() .and_then(|f| f.current.and_then(|c| f.matches.get(c))) .and_then(|(p, r)| self.texts.get(p).and_then(|t| t.glyphs.get(r.start)).map(|g| g.rect[1])) - .zip(self.page_heights.get(page).copied()) + .zip(self.page_sizes.get(page).map(|&(_, h)| h)) .map(|(y, h)| ((y - 60.0) / h).clamp(0.0, 1.0)) .unwrap_or(0.0); self.goto = Some((page, frac)); + // Then put the match's centre at the window's centre, as Acrobat does (#3530) and as a + // /FitR destination's rectangle is: a page wider or taller than the window no longer + // leaves it off to one side or near an edge. + if let Some([fx, fy]) = self.match_centre() { + self.goto_point = Some((page, Some(fx), Some(fy), vec2(self.viewport_w, self.viewport_h) / 2.0)); + } self.current = page; self.reset_coarse_state(); } diff --git a/crates/ui-egui/src/canvas/tests.rs b/crates/ui-egui/src/canvas/tests.rs index 85c7d3fac3..c05a03bb7b 100644 --- a/crates/ui-egui/src/canvas/tests.rs +++ b/crates/ui-egui/src/canvas/tests.rs @@ -510,7 +510,8 @@ fn explicit_search_jumps_record_history_and_keep_match_destination() { // Chrome refreshes the idle page box; navigation must not overwrite a draft // that may still be focused. DocView does not own the editor's focus state. assert_eq!(v.page_input, "draft in progress"); - assert!(v.goto_point.is_none(), "an earlier link destination cannot override the search offset"); + let middle = vec2(v.viewport_w, v.viewport_h) / 2.0; + assert_eq!(v.goto_point, Some((3, Some(40.0 / 300.0), Some(0.675), middle)), "the match centre goes to the middle of the viewport"); v.go_to_match(1); assert_eq!(v.back, vec![0], "same-page matches do not duplicate history"); assert!(v.view_history(false)); @@ -524,6 +525,18 @@ fn explicit_search_jumps_record_history_and_keep_match_destination() { } } +#[test] +fn search_centres_the_match_on_a_rotated_view() { + // The match's centre is 40 pt from the left and 270 pt down a 300 × 400 page. + for (rotation, fx, fy) in [(90, 1.0 - 0.675, 40.0 / 300.0), (180, 1.0 - 40.0 / 300.0, 1.0 - 0.675), (270, 0.675, 1.0 - 40.0 / 300.0)] { + let mut v = history_search_view(); + v.rotation = rotation; + v.find_step(true); + let middle = vec2(v.viewport_w, v.viewport_h) / 2.0; + assert_eq!(v.goto_point, Some((3, Some(fx), Some(fy), middle)), "view rotated {rotation}°"); + } +} + #[test] fn automatic_search_updates_do_not_record_history_or_discard_forward() { let mut v = history_search_view(); From 66a44f529e3a0cea5b16625cf8b322ae4b96bb6c Mon Sep 17 00:00:00 2001 From: mtlcl410 <2368091+mtlcl410@users.noreply.github.com> Date: Mon, 9 Nov 2026 18:23:26 +0800 Subject: [PATCH 03/41] M2: the content crate's parser is checked against hayro's, operator by operator (#3107, #3108, #3111, #3177) (#3563) --- Cargo.lock | 1 + crates/render/Cargo.toml | 3 + crates/render/tests/content_differential.rs | 476 ++++++++++++++++++++ 3 files changed, 480 insertions(+) create mode 100644 crates/render/tests/content_differential.rs diff --git a/Cargo.lock b/Cargo.lock index f9e243ba0b..b7ffc051f1 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4696,6 +4696,7 @@ dependencies = [ "kurbo 0.13.1", "log", "lopdf", + "pdfcraft-content", "pdfcraft-cos", "pdfcraft-fonts", "pdfcraft-geom", diff --git a/crates/render/Cargo.toml b/crates/render/Cargo.toml index 5777d987ca..b5fc0e1bf1 100644 --- a/crates/render/Cargo.toml +++ b/crates/render/Cargo.toml @@ -34,6 +34,9 @@ unicode-bidi = "0.3" getrandom = { version = "0.4", features = ["wasm_js"] } [dev-dependencies] +# The content crate's parser, compared operator by operator against hayro's in +# `tests/content_differential.rs` (L3 may depend on L2). +pdfcraft-content = { workspace = true } pdfcraft-testkit = { workspace = true } # Builds the decompression-bomb streams in the raster tests. flate2 = { workspace = true } diff --git a/crates/render/tests/content_differential.rs b/crates/render/tests/content_differential.rs new file mode 100644 index 0000000000..00c3158472 --- /dev/null +++ b/crates/render/tests/content_differential.rs @@ -0,0 +1,476 @@ +//! The two content-stream parsers PdfCraft runs, operator by operator on the same bytes. +//! +//! The renderer, text extraction and Find read a page through hayro's tokenizer +//! (`hayro_syntax::content::UntypedIter`, typed through `TypedIter`). Edit text, redaction, +//! the redaction audit, Office export, accessibility and the automation tools read it through +//! `pdfcraft_content::parse`. Where the two disagree on the same bytes, a feature acts on a +//! page the renderer never showed: #3107, #3108, #3111 and #3177 are that class. +//! +//! Each fixture here is parsed by both and the operator sequences compared. Where the parsers +//! agree, that is asserted. Where they are known to disagree, the test pins what *each* side +//! does today and names the issue, so a change on either side fails here and the expectation +//! is updated with it. Nothing in this file touches product code, and no oracle runs here: the +//! MuPDF and pdftotext readings quoted in comments were taken by hand on one-page files built +//! from the same bytes. +//! +//! Several tests pin what `vendor/hayro-syntax` does today, so a hayro upgrade or a new patch +//! to it can fail them on purpose. When that happens, update the pinned side to the new +//! behaviour and note the upstream change (the version or patch, and its PR) in that test's +//! comment, as the tests under "divergences closed" do. + +use std::fmt::Write as _; + +use hayro_syntax::content::ops::TypedInstruction; +use hayro_syntax::content::{TypedIter, UntypedIter}; +use hayro_syntax::object::{MaybeRef, Object as HObject}; +use pdfcraft_cos::Object as CObject; + +/// One operator with its operands, normalized so the two parsers can be compared as text. +#[derive(Clone, Debug, PartialEq, Eq)] +struct Tok { + op: String, + operands: Vec, +} + +fn tok(op: &str, operands: &[&str]) -> Tok { + Tok { op: op.into(), operands: operands.iter().map(|s| s.to_string()).collect() } +} + +fn num(v: f64) -> String { + let r = (v * 1e6).round() / 1e6; + if r.fract() == 0.0 && r.abs() < 1e15 { format!("{}", r as i64) } else { format!("{r}") } +} + +fn hex(bytes: &[u8]) -> String { + bytes.iter().fold(String::new(), |mut s, b| { + let _ = write!(s, "{b:02x}"); + s + }) +} + +fn norm_cos(o: &CObject) -> String { + match o { + CObject::Null => "null".into(), + CObject::Bool(b) => b.to_string(), + CObject::Int(i) => num(*i as f64), + CObject::Real(r) => num(*r), + CObject::String(s) => format!("({})", hex(&s.bytes)), + CObject::Name(n) => format!("/{}", String::from_utf8_lossy(n)), + CObject::Array(a) => format!("[{}]", a.iter().map(norm_cos).collect::>().join(" ")), + CObject::Dict(d) => { + let mut entries: Vec = d.iter().map(|(k, v)| format!("/{} {}", String::from_utf8_lossy(k), norm_cos(v))).collect(); + entries.sort(); + format!("<<{}>>", entries.join(" ")) + } + CObject::Ref(r) => format!("{} {} R", r.num, r.generation), + CObject::Stream(_) => "stream".into(), + } +} + +fn norm_hayro(o: &HObject<'_>) -> String { + match o { + HObject::Null(_) => "null".into(), + HObject::Boolean(b) => b.to_string(), + HObject::Number(n) => num(n.as_f64()), + HObject::String(s) => format!("({})", hex(s.as_bytes())), + HObject::Name(n) => format!("/{}", String::from_utf8_lossy(n.as_ref())), + HObject::Array(a) => format!("[{}]", a.raw_iter().map(|m| norm_maybe(&m)).collect::>().join(" ")), + HObject::Dict(d) => { + let mut entries: Vec = d.entries().map(|(k, v)| format!("/{} {}", String::from_utf8_lossy(k.as_ref()), norm_maybe(&v))).collect(); + entries.sort(); + format!("<<{}>>", entries.join(" ")) + } + // An inline image: its dictionary and data. ISO 32000-2 §8.9.7 (p.268) defines the data as + // the bytes between `ID` and `EI` "excluding the white-space delimiting those operators", + // which is what the content crate keeps. hayro keeps exactly the samples of an unfiltered + // image whose length its parameters give (#3438), but a filtered image, which it ends at + // a candidate `EI`, still keeps the white-space byte before that `EI` as its last byte. + // `hayro_keeps_the_delimiter_before_ei_only_in_filtered_image_data` pins both. The + // comparisons strip that byte from filtered images only, so that every other difference + // stands out: stripping it from an unfiltered image would cut a sample that happens to + // be white space. + HObject::Stream(s) => { + let raw = s.raw_data(); + let filtered = s.dict().contains_key(b"F") || s.dict().contains_key(b"Filter"); + let data = match raw.split_last() { + Some((b'\0' | b'\t' | b'\n' | b'\x0C' | b'\r' | b' ', rest)) if filtered => rest, + _ => &raw[..], + }; + format!("{} data:{}", norm_hayro(&HObject::Dict(s.dict().clone())), hex(data)) + } + } +} + +fn norm_maybe(m: &MaybeRef>) -> String { + match m { + MaybeRef::NotRef(o) => norm_hayro(o), + MaybeRef::Ref(r) => format!("{} {} R", r.obj_number, r.gen_number), + } +} + +/// The content crate's operators. +fn content(data: &[u8]) -> Vec { + pdfcraft_content::parse(data) + .ops + .iter() + .map(|op| Tok { + op: String::from_utf8_lossy(&op.op).into_owned(), + operands: match &op.inline { + Some((dict, image)) => vec![format!("{} data:{}", norm_cos(&CObject::Dict(dict.clone())), hex(image))], + None => op.operands.iter().map(norm_cos).collect(), + }, + }) + .collect() +} + +/// hayro's untyped instructions: every operator the tokenizer reads, with its operand stack. +fn hayro(data: &[u8]) -> Vec { + let mut iter = UntypedIter::new(data); + let mut out = Vec::new(); + while let Some(instr) = iter.next() { + out.push(Tok { op: String::from_utf8_lossy(instr.operator).into_owned(), operands: instr.operands().map(norm_hayro).collect() }); + } + out +} + +/// How many instructions hayro's typed iterator, the layer the renderer consumes, yields before +/// it stops. +fn hayro_typed_len(data: &[u8]) -> usize { + let mut iter = TypedIter::new(data); + let mut n = 0; + while iter.next().is_some() { + n += 1; + } + n +} + +fn ops(toks: &[Tok]) -> Vec<&str> { + toks.iter().map(|t| t.op.as_str()).collect() +} + +/// Both parsers on `data`, asserting they agree and that the operators are `expected`. +fn agree(name: &str, data: &[u8], expected: &[&str]) { + let (c, h) = (content(data), hayro(data)); + assert_eq!(c, h, "{name}: content crate (left) and hayro (right) differ on {:?}", String::from_utf8_lossy(data)); + assert_eq!(ops(&c), expected, "{name}"); +} + +/// An unfiltered 4 × 1 grey inline image whose samples are the four bytes of `samples`. +fn inline_gray(samples: &[u8; 4], after: &[u8]) -> Vec { + let mut v = b"q BI /W 4 /H 1 /BPC 8 /CS /G ID ".to_vec(); + v.extend_from_slice(samples); + v.extend_from_slice(b"\nEI Q "); + v.extend_from_slice(after); + v +} + +const GRAY_4X1: &str = "<>"; +const GRAY_1X1: &str = "<>"; + +// ── where the two parsers agree ───────────────────────────────────────────────────────────── + +#[test] +fn the_two_parsers_agree_on_well_formed_streams_and_common_damage() { + agree("plain", b"q 1 0 0 1 10 20 cm BT /F1 12 Tf (Hi) Tj ET Q", &["q", "cm", "BT", "Tf", "Tj", "ET", "Q"]); + agree("number forms", b"0.5 .5 6. -.5 +3 -0 5 w", &["w"]); + assert_eq!(content(b"0.5 .5 6. -.5 +3 -0 5 w")[0].operands, ["0.5", "0.5", "6", "-0.5", "3", "0", "5"]); + agree("strings", b"BT (a(b)c) Tj (es\\)c\\\\ape) Tj (line\\\ncont) Tj <48 65 6c> Tj ET", &["BT", "Tj", "Tj", "Tj", "Tj", "ET"]); + assert_eq!(content(b"(line\\\ncont) Tj")[0].operands, [format!("({})", hex(b"linecont"))], "a backslash joins the lines"); + agree("unterminated string ends both streams", b"BT (open Tj ET q Q", &["BT"]); + agree("names with escapes", b"/A#20B gs /#41 gs", &["gs", "gs"]); + assert_eq!(content(b"/A#20B gs /#41 gs").iter().map(|t| t.operands[0].as_str()).collect::>(), ["/A B", "/A"]); + agree("array and dictionary operands", b"/Span <> BDC [(a) -250 (b) 1.5] TJ EMC", &["BDC", "TJ", "EMC"]); + agree("ten operands", b"1 2 3 4 5 6 7 8 9 10 d0 q Q", &["d0", "q", "Q"]); + agree("unknown operators", b"q foo 1 bar Q BT ET", &["q", "foo", "bar", "Q", "BT", "ET"]); + agree("comments everywhere", b"q % c1\n1 0 % c2\n0 1 0 0 cm [1 % c3\n 2] TJ /N % c4\ngs Q % end", &["q", "cm", "TJ", "gs", "Q"]); + agree("comment without end of line", b"q Q % trailing", &["q", "Q"]); + agree("unbalanced q/Q and BT/ET", b"Q Q q BT BT ET q", &["Q", "Q", "q", "BT", "BT", "ET", "q"]); + agree("operands without an operator at the end", b"q 1 0 0 1 5", &["q"]); + agree("empty", b"", &[]); + agree("only a comment", b"% nothing", &[]); +} + +/// The content crate exposes two operand conventions: `Op::nums::` takes the last N, as +/// hayro's typed operators do (`0 20 Tc` is `Tc 20` to the renderer), while `Op::num(i)` counts +/// from the front. Both tokenizers keep every operand, so the choice is each consumer's: the +/// edit reader reads `Tc` through `num(0)` and gets 0 where the renderer gets 20, so the editor +/// and the renderer disagree on the character spacing (MuPDF renders the 20). No issue is filed +/// for the convention. +#[test] +fn surplus_operands_are_read_from_the_end_by_hayro_and_either_way_by_the_content_crate() { + let data = b"BT 0 20 Tc (x) Tj ET"; + agree("surplus operand before Tc", data, &["BT", "Tc", "Tj", "ET"]); + let parsed = pdfcraft_content::parse(data); + let tc = &parsed.ops[1]; + assert_eq!(tc.nums::<1>(), Some([20.0]), "last N, as the renderer"); + assert_eq!(tc.num(0), Some(0.0), "the i-th from the front"); + let mut iter = TypedIter::new(data); + let mut typed_tc = None; + while let Some(instr) = iter.next() { + if let TypedInstruction::CharacterSpacing(c) = instr { + typed_tc = Some(c.0.as_f64()); + } + } + assert_eq!(typed_tc, Some(20.0), "hayro's typed Tc takes the last operand"); +} + +// ── divergences closed since this file was written: both parsers now agree ───────────────── + +/// hayro's typed layer used to end the whole stream at a path operator whose operands don't +/// convert, so the renderer and text extraction showed nothing after it. +/// Closed by #3636 (30ed249a2): `TypedIter::next` (`vendor/hayro-syntax/src/content/mod.rs`) +/// now yields such an operator as `Fallback`, which adds nothing to the path, and reads on, as +/// the content crate, MuPDF and pdftotext do (`After` is shown). +#[test] +fn both_parsers_read_on_past_a_path_operator_with_too_few_operands() { + for (data, expected) in + [(&b"0 0 100 re f BT /F1 12 Tf (After) Tj ET"[..], &["re", "f", "BT", "Tf", "Tj", "ET"][..]), (b"5 m 0 0 100 100 re f", &["m", "re", "f"])] + { + agree("untyped: both keep the short operator", data, expected); + assert_eq!(hayro_typed_len(data), expected.len(), "hayro's typed stream reads on past the short operator"); + } + assert_eq!(hayro_typed_len(b"0 0 100 100 re f BT /F1 12 Tf (After) Tj ET"), 6, "control: a complete re is read through"); +} + +/// hayro's operand stack used to hold ten entries, and an eleventh ended its stream. Closed by +/// #4345 (hayro-syntax patch 28, #4260): an instruction keeps up to 256 operands and past that +/// drops the oldest, so both parsers read all eleven and go on, as MuPDF and pdftotext do +/// (`AFTER` is shown). +#[test] +fn both_parsers_keep_more_than_ten_operands() { + let data = b"1 2 3 4 5 6 7 8 9 10 11 d0 q Q"; + agree("eleven operands", data, &["d0", "q", "Q"]); + assert_eq!(content(data)[0].operands.len(), 11); +} + +/// A stray closing delimiter used to end hayro's stream while the content crate skipped it. +/// Closed by #3636 (30ed249a2): hayro steps over the malformed token too. MuPDF reports a +/// syntax error and goes on (`AFTER` is shown). +#[test] +fn both_parsers_skip_a_stray_closing_delimiter() { + agree("stray delimiters", b"q ) > ] } { Q", &["q", "Q"]); +} + +/// A name whose `#` escape is not followed by two hex digits (ISO 32000-2 §7.3.5, p.28) used to +/// end hayro's stream. Since #3636 (30ed249a2) hayro steps over the name and reads on, so both +/// parsers now read the same operators. One difference is still pinned: the content crate keeps +/// the name as written as the `gs` operand, while hayro drops it, so `gs` reaches the renderer +/// with no operand and does nothing, where the editor reads `/C#2`. No issue is filed. An empty +/// name (`/`) is read by both. MuPDF goes on in both cases, reporting the resource as missing. +#[test] +fn both_parsers_read_on_past_a_malformed_name_escape_but_only_the_content_crate_keeps_the_name() { + let data = b"/C#2 gs q Q"; + assert_eq!(content(data), [tok("gs", &["/C#2"]), tok("q", &[]), tok("Q", &[])]); + assert_eq!(hayro(data), [tok("gs", &[]), tok("q", &[]), tok("Q", &[])], "hayro: the operators, without the name"); + agree("an empty name", b"/ gs q Q", &["gs", "q", "Q"]); +} + +/// #3111 (closed by #3299) made the cos lexer, and so the content crate, read `--280` as -280. +/// hayro used to read it as the two numbers 0 and -280, so `cm` took an extra operand and the +/// renderer drew the page with the matrix `0 0 1 0 -280 10`. Closed by #3630 (766ff9d05): a +/// doubled sign is one number to hayro too, so both read the matrix MuPDF applies (text at +/// x = 120). The content crate skips no byte: without the lexer's tolerance it would skip the +/// first `-` and read the same `-280` from the rest, so the operands alone don't show it. +#[test] +fn both_parsers_read_a_doubled_minus_as_one_number() { + let data = b"1 0 0 1 --280 10 cm"; + agree("doubled minus", data, &["cm"]); + assert_eq!(content(data)[0].operands, ["1", "0", "0", "1", "-280", "10"]); + assert_eq!(pdfcraft_content::parse(data).skipped, 0, "--280 is read as one number, not recovered from -280"); +} + +// ── known divergences, pinned on both sides ───────────────────────────────────────────────── + +/// No issue filed; #2877 (open) changes number reading in cos and render. ISO 32000-2 §7.3.3 +/// (p.24) says a writer "shall not use … exponential format", so neither reading is the +/// spec's. The content crate reads `1e3` as the number 1000; hayro reads it as an unknown +/// operator and drops the operands before it. MuPDF stops the whole stream at it +/// ("unknown keyword: '1e3'"); pdftotext goes on. +#[test] +fn scientific_notation_is_a_number_to_the_content_crate_and_an_unknown_operator_to_hayro() { + let data = b"1e3 w q Q"; + assert_eq!(content(data), [tok("w", &["1000"]), tok("q", &[]), tok("Q", &[])]); + assert_eq!(hayro(data), [tok("w", &[]), tok("q", &[]), tok("Q", &[])]); + // A number glued to letters: the content crate skips the digits and reads the letters as an + // operator; hayro reads the whole token as an operator (and clears the operands before it). + let data = b"12abc w q Q"; + assert_eq!(content(data), [tok("abc", &[]), tok("w", &[]), tok("q", &[]), tok("Q", &[])]); + assert_eq!(hayro(data), [tok("w", &[]), tok("q", &[]), tok("Q", &[])]); +} + +// ── inline images ─────────────────────────────────────────────────────────────────────────── + +/// The bytes of the first inline image each parser reads in `data`: the content crate's, then +/// hayro's raw stream data (nothing stripped). +fn inline_bytes(data: &[u8]) -> (Option>, Option>) { + let parsed = pdfcraft_content::parse(data); + let c = parsed.ops.iter().find_map(|op| op.inline.as_ref().map(|(_, d)| d.clone())); + let mut iter = UntypedIter::new(data); + let mut h = None; + while let Some(instr) = iter.next() { + if let Some(HObject::Stream(s)) = instr.operands().next() { + h = Some(s.raw_data().to_vec()); + break; + } + } + (c, h) +} + +/// ISO 32000-2 §8.9.7 (p.268): the data lies between `ID` and `EI` "excluding the white-space +/// delimiting those operators". The content crate keeps exactly that. hayro used to keep the +/// delimiter before `EI` as the last byte of every inline image. Since #3438 (f9d37c089) it +/// takes an unfiltered image's length from `/W /H /BPC /CS` and keeps exactly the samples, so +/// both read `ABCD`. A filtered image, which hayro still ends at a candidate `EI`, keeps the +/// delimiter: that part is still pinned. Decoders stop at the filter's end-of-data marker and +/// ignore the extra byte, so the other tests strip it from filtered images (see `norm_hayro`). +#[test] +fn hayro_keeps_the_delimiter_before_ei_only_in_filtered_image_data() { + let unfiltered = inline_bytes(&inline_gray(b"ABCD", b"")); + assert_eq!(unfiltered, (Some(b"ABCD".to_vec()), Some(b"ABCD".to_vec())), "unfiltered: both keep exactly the samples"); + let filtered = inline_bytes(b"q BI /W 1 /H 1 /BPC 8 /CS /G /F /AHx ID 41> EI Q"); + assert_eq!(filtered, (Some(b"41>".to_vec()), Some(b"41> ".to_vec())), "filtered: hayro keeps the space before EI"); +} + +/// Samples that end in a white-space byte (here ` EI ` as four gray samples, the last one 0x20) +/// are all image data when the length is computed from the parameters (4 × 1 × 8 bits = 4 +/// bytes, ISO 32000-2 §8.9.7). Both parsers keep all four bytes; hayro's raw data, unstripped, +/// is the same four bytes, so its unfiltered-length path (#3438) does not take a white-space +/// sample for the delimiter before `EI`. +#[test] +fn both_parsers_keep_a_last_sample_that_is_white_space() { + for samples in [b" EI ", b"\x10\x20\x30\x20", b"\x10\x20\x30\x0A"] { + let (c, h) = inline_bytes(&inline_gray(samples, b"q Q")); + assert_eq!(c.as_deref(), Some(&samples[..]), "content crate: {samples:02x?}"); + assert_eq!(h.as_deref(), Some(&samples[..]), "hayro: {samples:02x?}"); + } +} + +#[test] +fn inline_images_agree_when_ei_is_delimited() { + agree("raw samples", &inline_gray(b"ABCD", b"BT (t) Tj ET"), &["q", "BI", "Q", "BT", "Tj", "ET"]); + agree("ASCIIHex with a space before EI", b"q BI /W 1 /H 1 /BPC 8 /CS /G /F /AHx ID 41> EI Q BT (t) Tj ET", &["q", "BI", "Q", "BT", "Tj", "ET"]); + agree("ASCII85 with a space before EI", b"q BI /W 1 /H 1 /BPC 8 /CS /G /F /A85 ID 5l~> EI Q BT (t) Tj ET", &["q", "BI", "Q", "BT", "Tj", "ET"]); + agree("two images in a row", b"q BI /W 1 /H 1 /BPC 8 /CS /G ID A\nEI BI /W 1 /H 1 /BPC 8 /CS /G ID B\nEI Q", &["q", "BI", "BI", "Q"]); + agree("a comment inside the parameters", b"q BI /W 1 % w\n/H 1 /BPC 8 /CS /G ID A\nEI Q", &["q", "BI", "Q"]); + // A fake ` EI ` inside the samples with no text operator after the image: hayro's look-ahead + // keeps searching and finds the real EI, so both read the four bytes. + agree("fake EI, nothing but q/Q after", &inline_gray(b" EI ", b"q Q"), &["q", "BI", "Q", "q", "Q"]); + assert_eq!(content(&inline_gray(b" EI ", b"q Q"))[1].operands, [format!("{GRAY_4X1} data:20454920")]); +} + +/// #3107. Unfiltered samples that happen to hold ` EI ` followed by a text operator: hayro's +/// look-ahead used to accept the first `EI` because text follows it, so its image held no +/// samples and the rest of them ran as content (`EI` became an operator). Closed by #3438 +/// (f9d37c089): hayro now takes the length from `/W /H /BPC /CS`, as the content crate does, and +/// both keep all four bytes (ISO 32000-2 §8.9.7). MuPDF and pdftotext show the text after. +#[test] +fn both_parsers_keep_a_fake_ei_inside_unfiltered_samples() { + let data = inline_gray(b" EI ", b"BT (t) Tj ET"); + agree("fake EI, text after", &data, &["q", "BI", "Q", "BT", "Tj", "ET"]); + assert_eq!(content(&data)[1].operands, [format!("{GRAY_4X1} data:20454920")]); +} + +/// #3108. `EI` written straight after the filter's end-of-data marker (`>EI`, `~>EI`): hayro +/// ends the image there, and the content crate used to find no delimited `EI` and swallow the +/// rest of the stream into the image. Closed by #3259 (the content crate accepts `EI` after the +/// marker) and b72078871 (the content crate takes a filtered image's end from hayro's +/// tokenizer): both end the image at the marker and read the text after, as MuPDF and pdftotext +/// do. +#[test] +fn both_parsers_end_the_image_at_ei_glued_to_the_filters_end_marker() { + for (name, data, dict, marker) in [ + ("ASCIIHex", &b"q BI /W 1 /H 1 /BPC 8 /CS /G /F /AHx ID 41>EI Q BT (t) Tj ET"[..], "<>", "34313e"), + ("ASCII85", b"q BI /W 1 /H 1 /BPC 8 /CS /G /F /A85 ID 5l~>EI Q BT (t) Tj ET", "<>", "356c7e3e"), + ] { + agree(name, data, &["q", "BI", "Q", "BT", "Tj", "ET"]); + assert_eq!(content(data)[1].operands, [format!("{dict} data:{marker}")], "{name}"); + } +} + +/// No issue filed. `EI` glued to the next operator (`EIQ`): the content crate swallows the rest +/// of the stream into the image; hayro yields a `BI` with no image at all, skips to the next +/// delimited token and reads on, so the `Q` glued to `EI` is lost too. MuPDF ends the whole +/// stream ("syntax error after inline image"). +#[test] +fn ei_glued_to_the_next_operator_loses_the_image_on_both_sides_differently() { + let data = b"q BI /W 1 /H 1 /BPC 8 /CS /G ID A EIQ BT (t) Tj ET"; + let c = content(data); + assert_eq!(ops(&c), ["q", "BI"]); + assert_eq!(c[1].operands, [format!("{GRAY_1X1} data:{}", hex(b"A EIQ BT (t) Tj ET"))]); + assert_eq!(hayro(data), [tok("q", &[]), tok("BI", &[]), tok("BT", &[]), tok("Tj", &["(74)"]), tok("ET", &[])]); +} + +/// No issue filed. An inline image with no `EI` at all: the content crate makes the rest of the +/// stream the image's data; hayro yields a `BI` with no image and then reads the samples and +/// everything after them as operators. MuPDF ends the whole stream. +#[test] +fn an_inline_image_without_ei_is_data_to_the_content_crate_and_operators_to_hayro() { + let data = b"q BI /W 1 /H 1 /BPC 8 /CS /G ID A Q BT (t) Tj ET"; + let c = content(data); + assert_eq!(ops(&c), ["q", "BI"]); + assert_eq!(c[1].operands, [format!("{GRAY_1X1} data:{}", hex(b"A Q BT (t) Tj ET"))]); + assert_eq!(ops(&hayro(data)), ["q", "BI", "A", "Q", "BT", "Tj", "ET"]); +} + +/// No issue filed. Parameters that can't be read (`/W` with no value): the content crate skips +/// to the `EI` and goes on; hayro ends its stream. MuPDF reports the dictionary and goes on. +#[test] +fn an_inline_image_with_unreadable_parameters_is_skipped_by_the_content_crate_and_ends_hayros_stream() { + let data = b"q BI /W ID A\nEI Q"; + assert_eq!(ops(&content(data)), ["q", "Q"]); + assert_eq!(ops(&hayro(data)), ["q"]); +} + +/// One page, 612 × 792, whose content stream is `content`. +fn one_page_pdf(content: &[u8]) -> Vec { + let mut stream = format!("<< /Length {} >>\nstream\n", content.len()).into_bytes(); + stream.extend_from_slice(content); + stream.extend_from_slice(b"\nendstream"); + let objs: [&[u8]; 4] = [ + b"<< /Type /Catalog /Pages 2 0 R >>", + b"<< /Type /Pages /Kids [3 0 R] /Count 1 >>", + b"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] /Contents 4 0 R /Resources << >> >>", + &stream, + ]; + let mut out = b"%PDF-1.7\n".to_vec(); + let mut offsets = Vec::new(); + for (i, body) in objs.iter().enumerate() { + offsets.push(out.len()); + out.extend_from_slice(format!("{} 0 obj\n", i + 1).as_bytes()); + out.extend_from_slice(body); + out.extend_from_slice(b"\nendobj\n"); + } + let xref = out.len(); + out.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objs.len() + 1).as_bytes()); + for off in offsets { + out.extend_from_slice(format!("{off:010} 00000 n \n").as_bytes()); + } + out.extend_from_slice(format!("trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n", objs.len() + 1).as_bytes()); + out +} + +/// End to end: the renderer draws all four samples of an unfiltered 4 × 1 gray inline image +/// whose last sample is a white-space byte (0x20 or 0x0A), so hayro's computed length (#3438) +/// reaches the pixels and no sample is taken for the delimiter before `EI`. +#[test] +fn the_renderer_draws_a_last_sample_that_is_white_space() { + use pdfcraft_render::{PageRenderer, RenderConfig, RenderRequest, RequestKind}; + for samples in [[0x20, 0x45, 0x49, 0x20], [0x10, 0x60, 0xB0, 0x20], [0x10, 0x60, 0xB0, 0x0A]] { + // Each sample is a 100 × 100 pt block from x = 100, y = 300 (PDF space). + let mut content = b"q 400 0 0 100 100 300 cm BI /W 4 /H 1 /BPC 8 /CS /G ID ".to_vec(); + content.extend_from_slice(&samples); + content.extend_from_slice(b"\nEI Q"); + let pdf = std::sync::Arc::new(one_page_pdf(&content)); + let mut renderer = PageRenderer::new(pdf, RenderConfig::default()); + let page = renderer.render(RenderRequest { page: 0, kind: RequestKind::Pixels, tile: None, scale: 1.0, tag: 0, preview: false }); + assert!(page.error.is_none(), "{samples:02x?}: {:?}", page.error); + // Block centres: x = 150 + 100 i, raster y = 792 - 350 = 442. + let drawn: Vec = (0..4u32) + .map(|i| { + let idx = ((442 * page.width + 150 + 100 * i) * 4) as usize; + page.rgba.get(idx).copied().unwrap_or(0xFF) + }) + .collect(); + assert_eq!(drawn, samples, "{samples:02x?}: the gray of each block"); + } +} From 69571065b903e911af6bfef6986eb7defae04623 Mon Sep 17 00:00:00 2001 From: NetterSilver <133535268+Silvertree2010@users.noreply.github.com> Date: Mon, 9 Nov 2026 11:23:57 +0100 Subject: [PATCH 04/41] Split edit-text paragraphs at headings and blank lines (#3642) --- crates/edit/src/tests.rs | 14 ++++++++++++++ crates/edit/src/text_regions/grouping.rs | 14 +++++++++++++- 2 files changed, 27 insertions(+), 1 deletion(-) diff --git a/crates/edit/src/tests.rs b/crates/edit/src/tests.rs index 2640eadd5a..eb80c02c39 100644 --- a/crates/edit/src/tests.rs +++ b/crates/edit/src/tests.rs @@ -5929,6 +5929,20 @@ fn replacing_a_line_keeps_a_distinct_nearby_line() { } } +#[test] +fn text_blocks_split_at_headings_and_blank_lines() { + let page = |size: u32, gap: u32, words: &[&str], top: u32| -> String { + words.iter().enumerate().map(|(i, w)| format!("BT /F1 {size} Tf 40 {} Td ({w}) Tj ET\n", top - i as u32 * gap)).collect() + }; + let count = |content: String| text::text_blocks(&text_page(&content), 0).unwrap().len(); + let heading = page(12, 14, &["First line of text", "second line here", "third line ends"], 340) + + &page(12, 14, &["HEADING"], 340 - 28 - 24) + + &page(12, 14, &["Next paragraph one", "next two", "next three"], 340 - 28 - 24 - 24); + assert_eq!(count(heading), 3); + assert_eq!(count(page(12, 14, &["Para one a", "para one b"], 340) + &page(12, 14, &["Para two a", "para two b"], 340 - 42)), 2); + assert_eq!(count(page(24, 29, &["VISIBLE", "HIDDEN"], 340)), 1); +} + #[test] fn editing_text_keeps_an_overlapping_watermark_and_covered_paragraphs() { let listed = |doc: &Document| text::text_lines(doc, 0).unwrap().into_iter().map(|l| l.text).collect::>(); diff --git a/crates/edit/src/text_regions/grouping.rs b/crates/edit/src/text_regions/grouping.rs index 1dcb9a9373..72a516292d 100644 --- a/crates/edit/src/text_regions/grouping.rs +++ b/crates/edit/src/text_regions/grouping.rs @@ -331,7 +331,19 @@ fn group_layout(lines: &[TextLine], mixed: bool) -> Vec { let scale = gap.abs().max(ngap.abs()); if same && ngap > tol * 0.8 && scale < tol * 4.0 && (ngap - gap).abs() <= scale * 0.08 + 0.5 { Some(gap) } else { None } }); - let max_gap = own_leading.filter(|lead| *lead > tol * 0.4).or(stated).or(repeated).map_or(tol * 2.05, |lead| lead * 1.5); + // A lone row with none of those: the step to the row after it is the leading of the + // paragraph it opens, and the gap above it is a heading's or a blank line's when it + // is much wider (#3577). + let following = rows.get(row_at.saturating_add(1)).and_then(|next| { + let nfirst = next.first().and_then(|i| lines.get(*i))?; + let ngap = first.frame_origin().1 - nfirst.frame_origin().1; + (same_plane(first, nfirst) && ngap > tol * 0.8 && ngap < tol * 2.05).then_some(ngap) + }); + let max_gap = own_leading + .filter(|lead| *lead > tol * 0.4) + .or(stated) + .or(repeated) + .map_or_else(|| following.map_or(tol * 2.05, |lead| (lead * 1.35).min(tol * 2.05)), |lead| lead * 1.5); // OCR lines stay apart: the scan fixes where each one is, so they never rewrap together. (!mixed || !(invisible(prev) || invisible(first))) && (mixed || same_style(prev, first)) From 5f560dfde0d1a46bee83cf2815eca99e80e816ca Mon Sep 17 00:00:00 2001 From: mtlcl410 <2368091+mtlcl410@users.noreply.github.com> Date: Mon, 9 Nov 2026 18:24:16 +0800 Subject: [PATCH 05/41] M14: the portable copy can register as a PDF handler for the current user (#3758) (#3790) --- README.md | 2 ++ packaging/windows/portable.txt | 38 ++++++++++++++++++++++++++++++++++ 2 files changed, 40 insertions(+) diff --git a/README.md b/README.md index 74288bff8f..645347d811 100644 --- a/README.md +++ b/README.md @@ -472,6 +472,8 @@ Installers and executables are code-signed. The portable zip runs from any folder, a USB stick included. Its `portable.txt` keeps the settings, logs and crash recovery in a `PdfCraftData` folder next to `pdfcraft.exe`, so nothing is written to `%APPDATA%`; delete that file to use the normal per-user folders. +Windows doesn't offer the portable copy as a default PDF app on its own, since it registers nothing; +`portable.txt` has the PowerShell lines that register it for your user only, and how to undo them. The MSI installs for your account only by default, without administrator privileges. Choose "Everyone on this computer" on its first page, or pass `ALLUSERS=1` from an elevated terminal, to install diff --git a/packaging/windows/portable.txt b/packaging/windows/portable.txt index 9586f93060..3890323976 100644 --- a/packaging/windows/portable.txt +++ b/packaging/windows/portable.txt @@ -7,3 +7,41 @@ autosaves and new digital IDs in the PdfCraftData folder beside it, and writes n Delete this file to use the normal per-user folders (%APPDATA%\PdfCraft and %LOCALAPPDATA%\PdfCraft) instead. If PdfCraftData can't be written (for example on a read-only drive), PdfCraft says so and uses the per-user folders instead. + +Opening PDF files with this copy + +The portable copy doesn't register itself with Windows, so picking it under "Open with" may not +stick. To offer it in Settings > Apps > Default apps, open PowerShell in this folder and run: + + $exe = (Resolve-Path .\pdfcraft.exe).Path + $c = "HKCU:\Software\Classes" + $cap = "HKCU:\Software\PdfCraft.Portable\Capabilities" + foreach ($k in "$c\PdfCraft.Portable\shell\open\command", "$c\.pdf\OpenWithProgids", + "$cap\FileAssociations", "HKCU:\Software\RegisteredApplications") { + if (!(Test-Path $k)) { New-Item -Path $k -Force | Out-Null } + } + Set-Item -Path "$c\PdfCraft.Portable" -Value "PDF Document" + Set-Item -Path "$c\PdfCraft.Portable\shell\open\command" -Value "`"$exe`" `"%1`"" + New-ItemProperty -Path "$c\.pdf\OpenWithProgids" -Name PdfCraft.Portable -Value "" -PropertyType String -Force | Out-Null + New-ItemProperty -Path $cap -Name ApplicationName -Value "PdfCraft (portable)" -PropertyType String -Force | Out-Null + New-ItemProperty -Path $cap -Name ApplicationDescription -Value "Open-source PDF workbench" -PropertyType String -Force | Out-Null + New-ItemProperty -Path "$cap\FileAssociations" -Name .pdf -Value PdfCraft.Portable -PropertyType String -Force | Out-Null + New-ItemProperty -Path "HKCU:\Software\RegisteredApplications" -Name "PdfCraft (portable)" -Value "Software\PdfCraft.Portable\Capabilities" -PropertyType String -Force | Out-Null + +Then open Settings > Apps > Default apps, search for .pdf (or for PdfCraft) and choose it. This +changes only your own user's settings (HKEY_CURRENT_USER), and it doesn't change the default app by +itself. If you move or rename this folder, run it again from the new place. + +To undo, first choose another app for .pdf in Settings > Apps > Default apps (otherwise Windows +keeps pointing PDF files at the removed entry), then run: + + Remove-Item -Path "HKCU:\Software\Classes\PdfCraft.Portable", "HKCU:\Software\PdfCraft.Portable" -Recurse -ErrorAction SilentlyContinue + Remove-ItemProperty -Path "HKCU:\Software\Classes\.pdf\OpenWithProgids" -Name PdfCraft.Portable -ErrorAction SilentlyContinue + Remove-ItemProperty -Path "HKCU:\Software\RegisteredApplications" -Name "PdfCraft (portable)" -ErrorAction SilentlyContinue + foreach ($k in "HKCU:\Software\Classes\.pdf\OpenWithProgids", "HKCU:\Software\Classes\.pdf") { + $i = Get-Item -Path $k -ErrorAction SilentlyContinue + if ($i -and $i.ValueCount -eq 0 -and $i.SubKeyCount -eq 0) { Remove-Item -Path $k } + } + +The last lines delete the .pdf keys only when nothing else is left in them, so another app's entry +there stays. From 59eee050c65db1f1a7ce3873e71f8d9573339d6f Mon Sep 17 00:00:00 2001 From: mtlcl410 <2368091+mtlcl410@users.noreply.github.com> Date: Mon, 9 Nov 2026 18:24:39 +0800 Subject: [PATCH 06/41] M14: the --control file is owner-only on Windows too, and the CLI checks it (#3922) (#3956) --- AGENTS.md | 2 +- Cargo.lock | 1 + README.md | 2 +- apps/pdfcraft-cli/src/ui.rs | 87 ++- apps/pdfcraft-cli/tests/it/ui_control.rs | 18 + apps/pdfcraft/Cargo.toml | 2 + apps/pdfcraft/src/main.rs | 114 +++- apps/pdfcraft/src/tests.rs | 36 ++ crates/platform/src/lib.rs | 4 +- crates/platform/src/owner_only.rs | 736 +++++++++++++++++++++++ 10 files changed, 955 insertions(+), 47 deletions(-) create mode 100644 crates/platform/src/owner_only.rs diff --git a/AGENTS.md b/AGENTS.md index 066cad3597..6126d4887f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -150,7 +150,7 @@ If you find an asset that breaks these rules, stop and remove it from the reposi ## 3. Agent control (automation and MCP) - **Everything is reachable headlessly.** Every user-facing feature must be callable without the GUI: an engine API, then a tool in `crates/automation` (`pdfcraft-cli run` and MCP use the same table). A feature is not done until its tool exists and has an end-to-end test (`crates/automation/README.md`, "Adding a tool"). -- **The UI control channel is opt-in too.** It runs only when the app is launched with `--control `. It binds to loopback, uses a fresh random token per launch, and writes it to an owner-only file. Never enable it by default, widen the bind address, or log the token. +- **The UI control channel is opt-in too.** It runs only when the app is launched with `--control `. It binds to loopback, uses a fresh random token per launch, and writes it to an owner-only file (mode 0600 on Unix; on Windows an access list of the user and SYSTEM only, which `pdfcraft_platform::owner_only` sets and checks, and which the CLI checks again before reading the token). Never enable it by default, widen the bind address, or log the token. - **The MCP server is opt-in.** Never make the app, the CLI or an installer start it, register it with an agent, or listen on a port by default. It runs only when a user (or an agent the user configured) launches `pdfcraft-cli mcp`. Any future network transport must be off by default, bound to loopback and authenticated with a token. - **Safe by default.** Tools must never discard unsaved work silently, must write atomically, must honour `--root`, and must return errors an agent can act on (say what was wrong and what is allowed). - **Track parity honestly.** Every user-facing feature has an entry in `parity/acrobat-features.toml`. Mark it `shipped` only when it works end to end and the entry cites a passing test (`evidence`); `cargo xtask parity` (part of `xtask ci`) rejects claims whose commands, tools or tests don't exist. diff --git a/Cargo.lock b/Cargo.lock index b7ffc051f1..52333f20ff 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4268,6 +4268,7 @@ dependencies = [ "fmv-macos-events", "log", "pdfcraft-engine", + "pdfcraft-platform", "pdfcraft-ui-egui", "pollster 1.0.1", "raw-window-handle", diff --git a/README.md b/README.md index 645347d811..4b534f6e17 100644 --- a/README.md +++ b/README.md @@ -331,7 +331,7 @@ Edits stay in memory, undoable, until `doc_save`. Saving to the same file append Start the desktop app with `pdfcraft --control ~/.pdfcraft-control.json` and an agent can see and operate the real interface: the widget tree with labels and positions (from the accessibility tree), clicks, typing, keys, commands, view options and screenshots. This is also off by default. It listens only on loopback, and every connection must present the random token written to that file, which only you can read. -Keep the control file in a folder only you can write, not a shared one such as `/tmp`: another user could create the file there first and receive your commands. `pdfcraft-cli ui` refuses a control file that is a symbolic link, and on macOS, Linux and FreeBSD one that another user owns or can read or write. The app doesn't start if it can't write the file. +Keep the control file in a folder only you can write, not a shared one such as `/tmp`: another user could create the file there first and receive your commands. `pdfcraft-cli ui` refuses a control file that is a symbolic link, or one that another user owns, or that another user or a group such as Administrators can read or write: on macOS, Linux and FreeBSD by its owner and mode (the app writes it with mode 600), on Windows by its owner and access list (the app gives it one with only you and SYSTEM on it). The app doesn't start if it can't write the file. On Windows, cmd.exe doesn't expand `~`: use `%USERPROFILE%\.pdfcraft-control.json` there. ```sh pdfcraft-cli ui --control ~/.pdfcraft-control.json inspect query=rotate # find widgets diff --git a/apps/pdfcraft-cli/src/ui.rs b/apps/pdfcraft-cli/src/ui.rs index dc419171b0..0ad18c154b 100644 --- a/apps/pdfcraft-cli/src/ui.rs +++ b/apps/pdfcraft-cli/src/ui.rs @@ -50,18 +50,23 @@ pub(crate) fn ui(args: &[String]) -> Result<(), CliError> { /// Most a control file may hold. The app writes under 100 bytes. const CONTROL_FILE_MAX: u64 = 64 * 1024; +#[cfg(not(windows))] const CONTROL_FILE_ADVICE: &str = "Start the app with a control file in a folder only you can write, such as `pdfcraft --control ~/.pdfcraft-control.json`"; +// cmd.exe doesn't expand `~` (#3922). +#[cfg(windows)] +const CONTROL_FILE_ADVICE: &str = + r"Start the app with a control file in a folder only you can write, such as `pdfcraft --control %USERPROFILE%\.pdfcraft-control.json`"; /// Read the file written by `pdfcraft --control FILE`. /// /// It names the port that receives the token and every command, typed text included, so it must /// be the user's own. In a shared folder such as `/tmp`, another local user can create the file /// first; the app then can't write it, and the CLI would talk to the other user's listener. -/// Refused: a symbolic link, anything but a regular file, and on Unix a file owned by someone -/// else or open to other users (the app writes it with mode 600). +/// Refused: a symbolic link, anything but a regular file, and a file owned by someone else or +/// open to other users: on Unix by its owner and mode (the app writes it with mode 600), on +/// Windows by its owner and access list (the app gives it one of its user and SYSTEM, #3922). fn read_control_file(file: &str) -> Result { - use std::io::Read as _; let at_path = std::fs::symlink_metadata(file).map_err(|e| format!("{file}: {e}"))?; if at_path.file_type().is_symlink() { return Err(format!("{file}: is a symbolic link, which could point at another user's file. {CONTROL_FILE_ADVICE}")); @@ -69,21 +74,41 @@ fn read_control_file(file: &str) -> Result { if !at_path.is_file() { return Err(format!("{file}: not a regular file. {CONTROL_FILE_ADVICE}")); } - let f = std::fs::File::open(file).map_err(|e| format!("{file}: {e}"))?; - #[cfg(unix)] - { - use std::os::unix::fs::MetadataExt; - // Checked on the open file, so nothing swapped in after `symlink_metadata` gets through. - let opened = f.metadata().map_err(|e| format!("{file}: {e}"))?; - if (opened.dev(), opened.ino()) != (at_path.dev(), at_path.ino()) { - return Err(format!("{file}: was replaced while being opened. {CONTROL_FILE_ADVICE}")); + #[cfg(not(windows))] + let text = { + use std::io::Read as _; + let f = std::fs::File::open(file).map_err(|e| format!("{file}: {e}"))?; + #[cfg(unix)] + { + use std::os::unix::fs::MetadataExt; + // Checked on the open file, so nothing swapped in after `symlink_metadata` gets through. + let opened = f.metadata().map_err(|e| format!("{file}: {e}"))?; + if (opened.dev(), opened.ino()) != (at_path.dev(), at_path.ino()) { + return Err(format!("{file}: was replaced while being opened. {CONTROL_FILE_ADVICE}")); + } + if let Some(problem) = control_file_problem(opened.uid(), opened.mode(), current_uid()) { + return Err(format!("{file}: {problem}. {CONTROL_FILE_ADVICE}")); + } } - if let Some(problem) = control_file_problem(opened.uid(), opened.mode(), current_uid()) { + let mut text = String::new(); + f.take(CONTROL_FILE_MAX.saturating_add(1)).read_to_string(&mut text).map_err(|e| format!("{file}: {e}"))?; + text + }; + // Windows: the same owner and access check, on the security descriptor of the very handle the + // contents are read through, so nothing swapped in at the path gets through (#3922). A file + // planted by another user, or one other users can read, is refused; if the check itself can't + // run, the file isn't trusted either. + #[cfg(windows)] + let text = { + use pdfcraft_platform::owner_only; + let me = owner_only::current_user_sid().map_err(|e| format!("{file}: can't check who owns it: {e}. {CONTROL_FILE_ADVICE}"))?; + let (sddl, bytes) = owner_only::read_with_sddl(std::path::Path::new(file), CONTROL_FILE_MAX) + .map_err(|e| format!("{file}: can't check who owns it: {e}. {CONTROL_FILE_ADVICE}"))?; + if let Some(problem) = owner_only::foreign_access(&sddl, &me) { return Err(format!("{file}: {problem}. {CONTROL_FILE_ADVICE}")); } - } - let mut text = String::new(); - f.take(CONTROL_FILE_MAX.saturating_add(1)).read_to_string(&mut text).map_err(|e| format!("{file}: {e}"))?; + String::from_utf8(bytes).map_err(|e| format!("{file}: {e}"))? + }; if u64::try_from(text.len()).unwrap_or(u64::MAX) > CONTROL_FILE_MAX { return Err(format!("{file}: too large for a control file (over {} KiB)", CONTROL_FILE_MAX / 1024)); } @@ -112,6 +137,38 @@ fn current_uid() -> u32 { mod tests { use super::control_file_problem; + /// #3922: on Windows the CLI checks the control file's owner and access list. A file other + /// users can read is refused; the same file restricted to its owner is accepted. + #[cfg(windows)] + #[test] + fn on_windows_a_control_file_other_users_can_read_is_refused() { + let dir = std::env::temp_dir().join(format!("pdfcraft-cli-control-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + let users = std::process::Command::new("icacls").arg(&dir).arg("/grant").arg("*S-1-5-32-545:(OI)(CI)R").output().unwrap(); + assert!(users.status.success()); + let file = dir.join("ctl.json"); + std::fs::write(&file, r#"{"port":4242,"token":"t0ken"}"#).unwrap(); + let path = file.to_string_lossy().into_owned(); + let refused = super::read_control_file(&path).unwrap_err(); + assert!(refused.contains("another account") && refused.contains("%USERPROFILE%"), "{refused}"); + pdfcraft_platform::owner_only::restrict_to_owner(&file, &pdfcraft_platform::owner_only::current_user_sid().unwrap()).unwrap(); + assert_eq!(super::read_control_file(&path).unwrap()["token"].as_str(), Some("t0ken")); + // The contents come with the check, capped: exactly the limit is read, one byte more is refused. + let padded = |len: usize| { + let json = r#"{"port":4242,"token":"t0ken"}"#; + json.to_string() + &" ".repeat(len - json.len()) + }; + std::fs::write(&file, padded(64 * 1024)).unwrap(); + assert_eq!(super::read_control_file(&path).unwrap()["port"].as_u64(), Some(4242)); + std::fs::write(&file, padded(64 * 1024 + 1)).unwrap(); + assert!(super::read_control_file(&path).unwrap_err().contains("too large")); + // Valid JSON once the byte is replaced by U+FFFD, so only a strict UTF-8 read refuses it. + std::fs::write(&file, b"{\"port\":4242,\"token\":\"\xFF\"}").unwrap(); + assert!(super::read_control_file(&path).is_err(), "not UTF-8"); + let _ = std::fs::remove_dir_all(&dir); + } + #[test] fn a_control_file_must_be_yours_and_private() { // Regular file type bits (0o100000) don't matter, only the owner and the permission bits. diff --git a/apps/pdfcraft-cli/tests/it/ui_control.rs b/apps/pdfcraft-cli/tests/it/ui_control.rs index f49771b001..082faf3426 100644 --- a/apps/pdfcraft-cli/tests/it/ui_control.rs +++ b/apps/pdfcraft-cli/tests/it/ui_control.rs @@ -86,6 +86,24 @@ fn write_private(path: &Path, text: &str) { opts.mode(0o600); } opts.open(path).unwrap().write_all(text.as_bytes()).unwrap(); + // On Windows the file inherits its folder's access list, as the app's would before it + // restricts it (#3922). + #[cfg(windows)] + { + use pdfcraft_platform::owner_only; + owner_only::restrict_to_owner(path, &owner_only::current_user_sid().unwrap()).unwrap(); + // The file the CLI is handed must already pass its check, or a refusal below would be + // the helper's fault, not the CLI's. + let sddl = owner_only::sddl_of(path).unwrap(); + let listing = |p: &Path| Command::new("icacls").arg(p).output().map(|o| String::from_utf8_lossy(&o.stdout).into_owned()); + let folder = path.parent().map(listing); + assert_eq!( + owner_only::foreign_access(&sddl, &owner_only::current_user_sid().unwrap()), + None, + "restricted file: {sddl}; icacls: {:?}; folder: {folder:?}", + listing(path) + ); + } } fn ui(control: &Path, method: &str) -> Output { diff --git a/apps/pdfcraft/Cargo.toml b/apps/pdfcraft/Cargo.toml index 966af354d1..94e1de896c 100644 --- a/apps/pdfcraft/Cargo.toml +++ b/apps/pdfcraft/Cargo.toml @@ -14,6 +14,8 @@ workspace = true pdfcraft-ui-egui = { workspace = true } # `build_info`: the version and compile-time build label for `--version` and the log. pdfcraft-engine = { workspace = true } +# `owner_only`: the --control file is restricted to its user on Windows (#3922). +pdfcraft-platform = { workspace = true } # wgpu draws; OpenGL (glow) is the fallback when wgpu can't start on an old or unusual GPU or # driver (#461, #435, #392), and can be chosen with PDFCRAFT_RENDERER=gl. eframe = { workspace = true, features = ["default_fonts", "wgpu", "glow", "accesskit", "wayland", "x11", "persistence", "links", "clipboard"] } diff --git a/apps/pdfcraft/src/main.rs b/apps/pdfcraft/src/main.rs index 58ce6ba8ec..d777ddc269 100644 --- a/apps/pdfcraft/src/main.rs +++ b/apps/pdfcraft/src/main.rs @@ -514,7 +514,9 @@ fn open_control_channel(file: &str, endpoint: std::io::Result { - let message = format!("--control {file}: {e}. Use a control file in a folder only you can write, such as ~/.pdfcraft-control.json"); + // cmd.exe doesn't expand `~` (#3922). + let example = if cfg!(windows) { r"%USERPROFILE%\.pdfcraft-control.json" } else { "~/.pdfcraft-control.json" }; + let message = format!("--control {file}: {e}. Use a control file in a folder only you can write, such as {example}"); log::error!("{message}"); Err(message) } @@ -523,15 +525,25 @@ fn open_control_channel(file: &str, endpoint: std::io::Result std::io::Result<()> { - use std::io::{ErrorKind, Write}; + use std::io::ErrorKind; let json = serde_json::json!({ "port": port, "token": token, "pid": std::process::id() }).to_string(); let path = std::path::Path::new(path); let name = path.file_name().ok_or_else(|| std::io::Error::new(ErrorKind::InvalidInput, "not a file name"))?; + // Windows: the user's SID, read once for the private folder and for the file in it, since each + // lookup starts `whoami` (review on #3956). + #[cfg(windows)] + let me = pdfcraft_platform::owner_only::current_user_sid()?; + #[cfg(windows)] + let restrict = |file: &std::path::Path| pdfcraft_platform::owner_only::restrict_to_owner(file, &me); + #[cfg(not(windows))] + let restrict = |_: &std::path::Path| Ok::<(), std::io::Error>(()); // Unpredictable suffixes, so the name can't be planted in advance. `RandomState` is keyed from // the operating system's random source. let random = std::hash::RandomState::new(); @@ -540,35 +552,79 @@ fn write_control_file(path: &str, port: u16, token: &str) -> std::io::Result<()> staged.push(name); staged.push(format!(".{:016x}.tmp", std::hash::BuildHasher::hash_one(&random, i))); let staged = path.with_file_name(staged); - let mut opts = std::fs::OpenOptions::new(); - opts.write(true).create_new(true); - #[cfg(unix)] - { - use std::os::unix::fs::OpenOptionsExt; - opts.mode(0o600); - } - let file = match opts.open(&staged) { - Ok(f) => f, - Err(e) if e.kind() == ErrorKind::AlreadyExists => continue, - // Windows refuses `create_new` on a folder with "access denied"; the name is taken all - // the same. A folder we can't write to, with nothing at the name, still fails here. - Err(e) if e.kind() == ErrorKind::PermissionDenied && staged.symlink_metadata().is_ok() => continue, - Err(e) => return Err(e), + // Windows: a file created next to `path` would inherit that folder's access list, which may + // let other users read it, and restricting it afterwards leaves a window in which one of + // them opens a handle; Windows checks access only when a handle is opened, so a later + // change of the list doesn't revoke it. The file is created in a private folder instead, + // and that folder is removed once the file has left it. + #[cfg(windows)] + let (staging, staged) = { + match pdfcraft_platform::owner_only::create_private_dir(&staged, &me) { + Ok(()) => {} + Err(e) if e.kind() == ErrorKind::AlreadyExists => continue, + // As for a file below: something at the name we may not open still takes it. + Err(e) if e.kind() == ErrorKind::PermissionDenied && staged.symlink_metadata().is_ok() => continue, + Err(e) => return Err(e), + } + let file = staged.join(name); + (staged, file) }; - // The block closes the file before it is renamed. - let written = { - let mut file = file; - file.write_all(json.as_bytes()) - }; - let done = written.and_then(|()| std::fs::rename(&staged, path)); - if done.is_err() { - let _ = std::fs::remove_file(&staged); + let done = stage_control_file(&staged, path, &json, &restrict); + #[cfg(windows)] + let _ = std::fs::remove_dir(&staging); + match done { + Some(done) => return done, + None => continue, } - return done; } Err(std::io::Error::new(ErrorKind::AlreadyExists, "no free name for the control file's temporary copy")) } +/// Create `staged` fresh and owner-only, `restrict` it, write `json` to it and rename it to `path`. +/// `None` when something already has the name, so the caller tries another. On any other failure +/// the new file is removed and nothing is left at `path`'s side. +fn stage_control_file( + staged: &std::path::Path, + path: &std::path::Path, + json: &str, + restrict: &dyn Fn(&std::path::Path) -> std::io::Result<()>, +) -> Option> { + use std::io::{ErrorKind, Write}; + let mut opts = std::fs::OpenOptions::new(); + opts.write(true).create_new(true); + #[cfg(unix)] + { + use std::os::unix::fs::OpenOptionsExt; + opts.mode(0o600); + } + let file = match opts.open(staged) { + Ok(f) => f, + Err(e) if e.kind() == ErrorKind::AlreadyExists => return None, + // Windows refuses `create_new` on a folder with "access denied"; the name is taken all + // the same. A folder we can't write to, with nothing at the name, still fails here. + Err(e) if e.kind() == ErrorKind::PermissionDenied && staged.symlink_metadata().is_ok() => return None, + Err(e) => return Some(Err(e)), + }; + // Windows: the file inherited the private folder's list. It also gets a list of its own, so it + // stays private wherever the rename puts it, and that is checked before the token is written; + // nothing is written if that fails (#3922). Unix has mode 0600 above, and `restrict` does nothing. + if let Err(e) = restrict(staged) { + drop(file); + let _ = std::fs::remove_file(staged); + return Some(Err(e)); + } + // The block closes the file before it is renamed. + let written = { + let mut file = file; + file.write_all(json.as_bytes()) + }; + let done = written.and_then(|()| std::fs::rename(staged, path)); + if done.is_err() { + let _ = std::fs::remove_file(staged); + } + Some(done) +} + /// How wgpu finds a GPU. Each choice yields to its wgpu environment variable. /// /// - Draw on the integrated GPU unless `WGPU_POWER_PREF` says otherwise. A PDF viewer has no use diff --git a/apps/pdfcraft/src/tests.rs b/apps/pdfcraft/src/tests.rs index 3512f46b36..ebd13dccc8 100644 --- a/apps/pdfcraft/src/tests.rs +++ b/apps/pdfcraft/src/tests.rs @@ -654,6 +654,42 @@ fn the_control_file_replaces_the_one_a_previous_run_left() { let _ = std::fs::remove_dir_all(&dir); } +/// #3922: a control file created in a folder that lets every user read what's in it is still +/// readable by its user and SYSTEM only, like mode 0600 on Unix. +#[cfg(windows)] +#[test] +fn the_control_file_is_owner_only_in_a_folder_other_users_can_read() { + use pdfcraft_platform::owner_only; + let dir = scratch(); + let users = std::process::Command::new("icacls").arg(&dir).arg("/grant").arg("*S-1-5-32-545:(OI)(CI)R").output().unwrap(); + assert!(users.status.success()); + // What the folder hands down, for comparison: a plain file there is readable by Users. + std::fs::write(dir.join("plain.txt"), "x").unwrap(); + let me = owner_only::current_user_sid().unwrap(); + assert!(owner_only::foreign_access(&owner_only::sddl_of(&dir.join("plain.txt")).unwrap(), &me).is_some()); + super::write_control_file(&dir.join("ctl.json").to_string_lossy(), 4242, "t0ken").unwrap(); + let sddl = owner_only::sddl_of(&dir.join("ctl.json")).unwrap(); + assert_eq!(owner_only::foreign_access(&sddl, &me), None, "{sddl}"); + let (json, names) = written(&dir); + assert_eq!(json["token"].as_str(), Some("t0ken")); + assert_eq!(names, ["ctl.json", "plain.txt"], "no private staging folder left behind"); + let _ = std::fs::remove_dir_all(&dir); +} + +/// Review on #3956: writing the control file reads the user's SID once, for both the private +/// folder and the file in it, rather than starting `whoami` for each. +#[cfg(windows)] +#[test] +fn writing_the_control_file_looks_up_the_users_sid_once() { + use pdfcraft_platform::owner_only::sid_lookups_on_this_thread; + let dir = scratch(); + let before = sid_lookups_on_this_thread(); + super::write_control_file(&dir.join("ctl.json").to_string_lossy(), 4242, "t0ken").unwrap(); + assert_eq!(sid_lookups_on_this_thread().saturating_sub(before), 1, "whoami runs once per control file"); + assert_eq!(written(&dir).0["token"].as_str(), Some("t0ken")); + let _ = std::fs::remove_dir_all(&dir); +} + /// X11 startup notification and docks match a window to its launcher by `StartupWMClass`, /// so it has to be the class the window carries: the app id, on Wayland and X11 alike. #[test] diff --git a/crates/platform/src/lib.rs b/crates/platform/src/lib.rs index ba8f8ab50a..7763cb8102 100644 --- a/crates/platform/src/lib.rs +++ b/crates/platform/src/lib.rs @@ -1,8 +1,10 @@ //! pdfcraft-platform — L7 operating-system services shared by the desktop app (`ui-egui`) and the -//! automation tools (`automation`), which sit on the same layer and can't depend on each other. +//! automation tools (`automation`), which sit on the same layer and can't depend on each other, +//! and by the two executables (`owner_only`, for the `--control` file). #![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] pub mod desktop_theme; +pub mod owner_only; pub mod staging; pub mod zone; diff --git a/crates/platform/src/owner_only.rs b/crates/platform/src/owner_only.rs new file mode 100644 index 0000000000..7c181dab48 --- /dev/null +++ b/crates/platform/src/owner_only.rs @@ -0,0 +1,736 @@ +//! Owner-only files on Windows (#3922): the `--control` token file must be readable by its user +//! alone, as mode 0600 makes it on Unix. +//! +//! A file created in a folder inherits the folder's access list, and folders such as a new folder +//! at a drive root, `C:\Users\Public` or `C:\ProgramData` let every signed-in user read what is +//! created in them. [`restrict_to_owner`] replaces the whole list with the current user and +//! SYSTEM only, makes that user the owner, and checks the result. [`create_private_dir`] does the +//! same for a new folder whose list the files created in it inherit, so a file can be private from +//! the moment it exists. [`read_with_sddl`] reads a file and +//! its security descriptor through one handle. [`foreign_access`] reads the descriptor in SDDL +//! form, which names accounts by SID and so reads the same in every Windows language, and says why +//! another user could have written or could read the file. They run the tools Windows ships rather +//! than calling the security API directly, which would need `unsafe`. + +/// Why the file whose security descriptor is `sddl` (`O:` owner and `D:` access list, in SDDL or +/// as [`read_with_sddl`] writes it) can't be trusted as user `me`'s own: it is owned by someone else, it has no +/// access list (everyone may use it), or it lets an account other than `me` or SYSTEM use it. +/// Accounts are compared by SID; `None` means it is `me`'s alone. +/// +/// Stricter than OpenSSH's rule for private keys, which also lets Administrators in: an entry for +/// the Administrators group would let another administrator read the token without taking the +/// file over. [`restrict_to_owner`] never grants it, so the app's own file always passes; a file +/// that inherited it from a user's folder is refused. +pub fn foreign_access(sddl: &str, me: &str) -> Option { + let me = me.trim(); + let owner = section(sddl, "O:"); + match owner { + Some(o) if same_account(o, me) => {} + Some(o) => return Some(format!("it is owned by {}", another_account(o, ""))), + None => return Some("its owner can't be read".into()), + } + let Some(dacl) = section(sddl, "D:") else { + return Some("it has no access list, so every user may read it".into()); + }; + if dacl.starts_with("NO_ACCESS_CONTROL") { + return Some("it has no access control, so every user may read it".into()); + } + for ace in dacl.split('(').skip(1) { + let ace = ace.split(')').next().unwrap_or_default(); + let parts: Vec<&str> = ace.split(';').collect(); + // `type;flags;rights;object guid;inherited object guid;account SID[;(condition)]`. + let (Some(kind), Some(flags)) = (parts.first(), parts.get(1)) else { continue }; + // Deny entries take access away, and inherit-only entries apply to children, not the file. + let allows = matches!(*kind, "A" | "OA" | "XA" | "ZA"); + if !allows || flags.contains("IO") { + continue; + } + // An entry that grants access but names no account can't be shown to be the user's. + let Some(sid) = parts.get(5).filter(|s| !s.is_empty()) else { + return Some(format!("its access list has an entry that can't be read ({ace})")); + }; + if !(same_account(sid, me) || matches!(*sid, "SY" | "S-1-5-18" | "OW" | "S-1-3-4")) { + return Some(another_account(sid, "has access to it")); + } + } + None +} + +/// `another account () `, with the account's name before its SID when it is one of the +/// well-known accounts a control file can carry, such as `another account, Administrators +/// (S-1-5-32-544), has access to it` (review on #3956). The SID stays, so the message reads the +/// same in every Windows language and can still be looked up. +fn another_account(sid: &str, rest: &str) -> String { + match (well_known_name(sid), rest.is_empty()) { + (Some(name), true) => format!("another account, {name} ({sid})"), + (Some(name), false) => format!("another account, {name} ({sid}), {rest}"), + (None, true) => format!("another account ({sid})"), + (None, false) => format!("another account ({sid}) {rest}"), + } +} + +/// The English name Windows gives a well-known SID that can be on a file's access list or own it: +/// those a folder hands down at a drive root, in `C:\Users\Public` and in a user's profile, and +/// Everyone. The SIDs and names are Microsoft's public list of well-known SIDs +/// (); the names are +/// the ones `icacls` shows, without the `BUILTIN\` or `NT AUTHORITY\` prefix. Any other SID, +/// a user's or a domain group's, has no fixed name and gets `None`. +fn well_known_name(sid: &str) -> Option<&'static str> { + const NAMES: [(&str, &str); 8] = [ + ("S-1-1-0", "Everyone"), + ("S-1-5-3", "BATCH"), + ("S-1-5-4", "INTERACTIVE"), + ("S-1-5-6", "SERVICE"), + ("S-1-5-11", "Authenticated Users"), + ("S-1-5-18", "SYSTEM"), + ("S-1-5-32-544", "Administrators"), + ("S-1-5-32-545", "Users"), + ]; + NAMES.iter().find(|(known, _)| known.eq_ignore_ascii_case(sid)).map(|&(_, name)| name) +} + +/// `section(sddl, "O:")` is the owner, `"D:"` the access list: the text after the tag up to the +/// next top-level tag. +fn section<'a>(sddl: &'a str, tag: &str) -> Option<&'a str> { + let start = sddl.find(tag)?.checked_add(tag.len())?; + let rest = sddl.get(start..)?; + // The owner and group are bare SIDs; the access lists run until the next `X:` tag outside + // parentheses. + let mut depth = 0usize; + let mut end = rest.len(); + for (i, c) in rest.char_indices() { + match c { + '(' => depth = depth.saturating_add(1), + ')' => depth = depth.saturating_sub(1), + ':' if depth == 0 && i > 0 => { + // The tag is the one letter before this colon. + end = rest.get(..i).and_then(|s| s.char_indices().last()).map_or(i, |(j, _)| j); + break; + } + _ => {} + } + } + rest.get(..end).map(str::trim) +} + +/// Whether account `a` names user SID `me`. `OW` (owner rights) and `CO` are not users. An alias +/// is never taken for the user: [`read_with_sddl`] gives every account as a full SID, because +/// Windows' own SDDL writes some users by alias (the built-in Administrator as `LA`, whose SID +/// depends on the machine). +fn same_account(a: &str, me: &str) -> bool { + a.eq_ignore_ascii_case(me) +} + +/// The tools Windows ships that these checks run, by their path under `%SystemRoot%\System32`. +#[cfg(windows)] +const WHOAMI: &str = "whoami.exe"; +#[cfg(windows)] +const POWERSHELL: &str = r"WindowsPowerShell\v1.0\powershell.exe"; +#[cfg(windows)] +const ICACLS: &str = "icacls.exe"; + +/// A tool Windows ships, started by its full path (review on #3956). Started by bare name, a +/// `whoami.exe`, `powershell.exe` or `icacls.exe` found first (next to the app, or earlier on the +/// search path) would answer instead, and `whoami`'s answer is what every check here trusts. +#[cfg(windows)] +fn system_tool(relative: &str) -> std::process::Command { + let root = std::env::var_os("SystemRoot").map_or_else(|| std::path::PathBuf::from(r"C:\Windows"), std::path::PathBuf::from); + std::process::Command::new(root.join("System32").join(relative)) +} + +#[cfg(windows)] +thread_local! { + /// How many times [`current_user_sid`] ran on this thread, for [`sid_lookups_on_this_thread`]. + static SID_LOOKUPS: std::cell::Cell = const { std::cell::Cell::new(0) }; +} + +/// How many times [`current_user_sid`] has started `whoami` on the calling thread. Each lookup +/// starts a process, so a test can check that a caller reads the SID once and passes it on +/// (review on #3956). Per thread, so tests running in parallel don't count each other's. +#[cfg(windows)] +#[doc(hidden)] +pub fn sid_lookups_on_this_thread() -> usize { + SID_LOOKUPS.with(std::cell::Cell::get) +} + +/// The current user's SID (`whoami /user`). It starts a process, so a caller that needs it for +/// several steps reads it once and passes it to each. +#[cfg(windows)] +pub fn current_user_sid() -> std::io::Result { + SID_LOOKUPS.with(|n| n.set(n.get().saturating_add(1))); + let out = system_tool(WHOAMI).args(["/user", "/fo", "csv", "/nh"]).output()?; + if !out.status.success() { + return Err(std::io::Error::other("whoami /user failed")); + } + sid_from_whoami(&String::from_utf8_lossy(&out.stdout)).ok_or_else(|| std::io::Error::other("whoami /user printed no SID")) +} + +#[cfg(any(test, windows))] +/// The SID in `whoami /user /fo csv /nh` output, `"DOMAIN\user","S-1-5-21-…"`: the last quoted +/// field, when it is one. Anything else (an error message, nothing) gives `None`. +fn sid_from_whoami(text: &str) -> Option { + let last = text.trim().rsplit(',').next()?.trim().trim_matches('"'); + let digits = last.strip_prefix("S-1-")?; + // Every part after `S-1-` is a number; an empty remainder is one empty part, so it fails too. + digits.split('-').all(|part| !part.is_empty() && part.bytes().all(|b| b.is_ascii_digit())).then(|| last.to_string()) +} + +/// Replace `path`'s access list with one that only user `me` (the current user's SID, from +/// [`current_user_sid`], which the caller reads once for every step) and SYSTEM are on, then make +/// that user its owner (`icacls /setowner`), then read the file's descriptor back and check it with +/// [`foreign_access`]. The whole list is replaced, not just what was inherited: on the +/// windows-latest runner a new file in the temp folder carries an Administrators entry that `icacls +/// /inheritance:r` leaves in place. The owner is set because a file an elevated administrator creates +/// is owned by the Administrators group, not by the user. The list comes first: its owner may always +/// replace it, while setting the owner needs the Full control it grants (a folder may give Modify). +#[cfg(windows)] +pub fn restrict_to_owner(path: &std::path::Path, me: &str) -> std::io::Result<()> { + set_access_list(path, me, Kind::File)?; + icacls(path, &["/setowner".into(), format!("*{me}")])?; + still_foreign(&sddl_of(path)?, me) +} + +/// Create the folder `dir`, which must not exist yet, and give it an access list of user `me` (as +/// for [`restrict_to_owner`]) and SYSTEM only, which every file created in it inherits; then make +/// that user its owner and check the result as [`restrict_to_owner`] does. A file created in it is +/// private from the moment it exists. Restricting a file after creating it leaves a window in which another user +/// whom the parent folder lets read it can open a handle, and Windows checks access only when a +/// handle is opened, so a later change of the list doesn't revoke it (review on #3956). A handle +/// on the folder itself, opened during the folder's own window, gives no access to the files +/// created in it later. The folder is removed again when it can't be restricted. +#[cfg(windows)] +pub fn create_private_dir(dir: &std::path::Path, me: &str) -> std::io::Result<()> { + std::fs::create_dir(dir)?; + fn restrict(dir: &std::path::Path, me: &str) -> std::io::Result<()> { + set_access_list(dir, me, Kind::Folder)?; + icacls(dir, &["/setowner".into(), format!("*{me}")])?; + still_foreign(&sddl_of(dir)?, me) + } + let restricted = restrict(dir, me); + if restricted.is_err() { + let _ = std::fs::remove_dir(dir); + } + restricted +} + +/// `Err` naming the problem when the descriptor read back after restricting still isn't `me`'s +/// alone, so a file that only looks restricted is never used as one. +#[cfg(any(test, windows))] +fn still_foreign(sddl: &str, me: &str) -> std::io::Result<()> { + match foreign_access(sddl, me) { + None => Ok(()), + Some(problem) => Err(std::io::Error::other(format!("the file could not be restricted to its owner: {problem}"))), + } +} + +/// What [`set_access_list`] restricts: a file, or a folder whose entries the files and folders +/// created in it inherit. +#[cfg(windows)] +#[derive(Clone, Copy)] +enum Kind { + File, + Folder, +} + +/// Set `path`'s access list to Full control for `me` and SYSTEM and nothing else, protected from +/// inheritance; on a folder, what is created in it inherits the two entries. Only the list is +/// written, not the owner. `me` and the path reach PowerShell through environment variables, never +/// as script text. +#[cfg(windows)] +fn set_access_list(path: &std::path::Path, me: &str, kind: Kind) -> std::io::Result<()> { + // No double quotes: the command line would strip them. + const FILE: &str = "$s = New-Object Security.AccessControl.FileSecurity; \ + $s.SetSecurityDescriptorSddlForm('D:P(A;;FA;;;' + $env:PDFCRAFT_ACL_USER + ')(A;;FA;;;SY)', 'Access'); \ + [IO.File]::SetAccessControl($env:PDFCRAFT_ACL_PATH, $s)"; + const FOLDER: &str = "$s = New-Object Security.AccessControl.DirectorySecurity; \ + $s.SetSecurityDescriptorSddlForm('D:P(A;OICI;FA;;;' + $env:PDFCRAFT_ACL_USER + ')(A;OICI;FA;;;SY)', 'Access'); \ + [IO.Directory]::SetAccessControl($env:PDFCRAFT_ACL_PATH, $s)"; + let script = match kind { + Kind::File => FILE, + Kind::Folder => FOLDER, + }; + let out = system_tool(POWERSHELL) + .args(["-NoProfile", "-NonInteractive", "-Command", script]) + .env("PDFCRAFT_ACL_PATH", path) + .env("PDFCRAFT_ACL_USER", me) + .output()?; + if out.status.success() { + Ok(()) + } else { + Err(std::io::Error::other(format!("PowerShell could not restrict the file to its owner: {}", String::from_utf8_lossy(&out.stderr).trim()))) + } +} + +#[cfg(windows)] +fn icacls(path: &std::path::Path, args: &[String]) -> std::io::Result<()> { + let out = system_tool(ICACLS).arg(path).args(args).output()?; + if out.status.success() { + Ok(()) + } else { + Err(std::io::Error::other(format!("icacls could not restrict the file to its owner: {}", String::from_utf8_lossy(&out.stderr).trim()))) + } +} + +/// PowerShell that turns `$raw`, a `RawSecurityDescriptor`, into `$sddl` as [`read_with_sddl`] +/// describes it. No double quotes: the command line would strip them. An entry with no account (a +/// custom entry) is written with none, and `foreign_access` refuses it. +#[cfg(windows)] +macro_rules! describe_raw { + () => { + "$sddl = 'O:' + $raw.Owner.Value; if ($raw.DiscretionaryAcl -ne $null) { $sddl += 'D:'; foreach ($a in $raw.DiscretionaryAcl) { $t = 'A'; if ([string]$a.AceType -like 'AccessDenied*') { $t = 'D' }; $f = ''; if ($a.AceFlags.HasFlag([Security.AccessControl.AceFlags]::InheritOnly)) { $f = 'IO' }; $sid = ''; if ($a.SecurityIdentifier) { $sid = $a.SecurityIdentifier.Value }; $sddl += '(' + $t + ';' + $f + ';' + $a.AccessMask + ';;;' + $sid + ')' } }; " + }; +} + +/// Open `path` shared for reading only, so that while it is open it can't be replaced, renamed or +/// written, and read through that one handle both its security descriptor (owner and access list) +/// and its first `max + 1` bytes. The descriptor checked is the one of the handle the bytes come +/// from, so there is no moment at which the path could name a different file (a time-of-check to +/// time-of-use race). The path reaches PowerShell through an environment variable, never as script +/// text. +/// +/// The descriptor is written in SDDL's shape but with every account as a full SID, read from the +/// binary descriptor (`RawSecurityDescriptor`): `O:D:(;;;;;)…`, +/// where the type is `D` for a deny entry and `A` for any other, and the flags are `IO` for an +/// inherit-only entry. Windows' own SDDL writes some accounts by alias, and the built-in +/// Administrator's alias `LA` stands for a SID that depends on the machine. +#[cfg(windows)] +pub fn read_with_sddl(path: &std::path::Path, max: u64) -> std::io::Result<(String, Vec)> { + const SCRIPT: &str = concat!( + "$fs = [IO.File]::Open($env:PDFCRAFT_ACL_PATH, 'Open', 'Read', 'Read'); try { $raw = New-Object Security.AccessControl.RawSecurityDescriptor -ArgumentList @($fs.GetAccessControl().GetSecurityDescriptorBinaryForm(), 0); ", + describe_raw!(), + "$max = [int]$env:PDFCRAFT_ACL_MAX + 1; $buf = New-Object byte[] $max; $total = 0; while ($total -lt $max) { $n = $fs.Read($buf, $total, $max - $total); if ($n -le 0) { break }; $total += $n }; [Console]::Out.Write($sddl + [char]10 + [BitConverter]::ToString($buf, 0, $total)) } finally { $fs.Dispose() }" + ); + // Bounded so the script's `[int]` and buffer stay small; a control file is far below this. + let max = max.min(1 << 20); + let out = system_tool(POWERSHELL) + .args(["-NoProfile", "-NonInteractive", "-Command", SCRIPT]) + .env("PDFCRAFT_ACL_PATH", path) + .env("PDFCRAFT_ACL_MAX", max.to_string()) + .output()?; + let read = if out.status.success() { sddl_and_bytes(&String::from_utf8_lossy(&out.stdout)) } else { None }; + read.ok_or_else(|| std::io::Error::other("PowerShell could not read the file with its owner and access list")) +} + +/// The security descriptor of `path` as [`read_with_sddl`] writes it, read by name without opening +/// the file's contents, so it works on a file that is open for writing (the app restricts its +/// control file before writing the token through the handle it created). +#[cfg(windows)] +pub fn sddl_of(path: &std::path::Path) -> std::io::Result { + const SCRIPT: &str = concat!( + // Stop: a failed read (a missing file) must end the script with an error, not print `O:`. + "$ErrorActionPreference = 'Stop'; $raw = New-Object Security.AccessControl.RawSecurityDescriptor -ArgumentList @([IO.File]::GetAccessControl($env:PDFCRAFT_ACL_PATH).GetSecurityDescriptorBinaryForm(), 0); ", + describe_raw!(), + "[Console]::Out.Write($sddl + [char]10)" + ); + let out = system_tool(POWERSHELL).args(["-NoProfile", "-NonInteractive", "-Command", SCRIPT]).env("PDFCRAFT_ACL_PATH", path).output()?; + let read = if out.status.success() { sddl_and_bytes(&String::from_utf8_lossy(&out.stdout)) } else { None }; + read.map(|(sddl, _)| sddl).ok_or_else(|| std::io::Error::other("PowerShell could not read the file's owner and access list")) +} + +/// [`read_with_sddl`]'s output: the SDDL, a newline, then the bytes as `[BitConverter]::ToString` +/// prints them (`7B-22-…`, empty for none). Anything else gives `None`. +#[cfg(any(test, windows))] +fn sddl_and_bytes(text: &str) -> Option<(String, Vec)> { + let (sddl, hex) = text.split_once('\n')?; + let sddl = sddl.trim(); + if !sddl.contains("O:") { + return None; + } + let hex = hex.trim(); + if hex.is_empty() { + return Some((sddl.to_string(), Vec::new())); + } + // Two hex digits each; `from_str_radix` alone would also take a sign, as in `+7`. + let byte = |pair: &str| (pair.len() == 2 && pair.bytes().all(|b| b.is_ascii_hexdigit())).then(|| u8::from_str_radix(pair, 16).ok()).flatten(); + let bytes = hex.split('-').map(byte).collect::>>()?; + Some((sddl.to_string(), bytes)) +} + +#[cfg(test)] +mod tests { + use super::*; + + const ME: &str = "S-1-5-21-1-2-3-1001"; + + /// The SID of the user running the tests, as `write_control_file` reads it once and passes on. + #[cfg(windows)] + fn user() -> String { + current_user_sid().unwrap() + } + + #[test] + fn the_users_own_file_with_system_is_trusted_and_administrators_are_not() { + assert_eq!(foreign_access(&format!("O:{ME}G:{ME}D:P(A;;FA;;;{ME})(A;;FA;;;SY)"), ME), None); + assert_eq!(foreign_access(&format!("O:{ME}D:(A;;2032127;;;S-1-5-18)(A;;2032127;;;{ME})"), ME), None); + // Another administrator could read it through the Administrators group, by alias or by SID. + for admins in ["BA", "S-1-5-32-544"] { + let sddl = format!("O:{ME}D:PAI(A;;FA;;;SY)(A;;FR;;;{admins})(A;;FA;;;{ME})"); + assert!(foreign_access(&sddl, ME).is_some_and(|p| p.contains(admins)), "{sddl}"); + } + // Inherit-only entries and deny entries don't grant access to the file itself. + assert_eq!(foreign_access(&format!("O:{ME}D:(A;OICIIO;GA;;;CO)(D;;FA;;;BU)(A;;FA;;;{ME})"), ME), None); + } + + /// Review on #3956: each tool is started by its full path in System32, never looked up by + /// name. + #[cfg(windows)] + #[test] + fn the_tools_are_started_from_system32() { + for tool in [WHOAMI, POWERSHELL, ICACLS] { + let command = system_tool(tool); + let path = std::path::Path::new(command.get_program()); + assert!(path.is_absolute() && path.is_file(), "{path:?}"); + assert!(path.to_string_lossy().to_ascii_lowercase().contains(r"\system32\"), "{path:?}"); + } + } + + #[test] + fn other_owners_readers_and_missing_lists_are_refused() { + let other = "S-1-5-21-1-2-3-1002"; + assert!(foreign_access(&format!("O:{other}D:(A;;FA;;;{ME})"), ME).unwrap().contains("owned by another account")); + // Users (BU) and Authenticated Users (AU), as a folder at a drive root passes on. + assert!(foreign_access(&format!("O:{ME}D:AI(A;ID;FA;;;{ME})(A;ID;0x1301bf;;;AU)"), ME).unwrap().contains("AU")); + assert!(foreign_access(&format!("O:{ME}D:(A;;FR;;;BU)(A;;FA;;;{ME})"), ME).unwrap().contains("BU")); + assert!(foreign_access(&format!("O:{ME}D:(A;;FA;;;{other})"), ME).is_some()); + assert!(foreign_access(&format!("O:{ME}G:SY"), ME).unwrap().contains("no access list")); + assert!(foreign_access(&format!("O:{ME}D:NO_ACCESS_CONTROL"), ME).is_some()); + assert!(foreign_access("D:(A;;FA;;;SY)", ME).unwrap().contains("owner")); + } + + /// A conditional entry can hold a `:` in a string; the access list doesn't end there, so the + /// entry after it is still checked. + #[test] + fn a_colon_inside_an_entry_does_not_end_the_access_list() { + let sddl = format!(r#"O:{ME}D:(XA;;FA;;;{ME};(@User.dept == "a:b"))(A;;FR;;;BU)"#); + assert!(foreign_access(&sddl, ME).is_some_and(|p| p.contains("BU")), "{sddl}"); + } + + /// A conditional entry for the user is the user's; one that grants access but whose account + /// can't be read is refused rather than skipped. + #[test] + fn conditional_and_malformed_entries() { + assert_eq!(foreign_access(&format!(r#"O:{ME}D:(XA;;FA;;;{ME};(@User.dept == "x"))"#), ME), None); + assert!(foreign_access(&format!("O:{ME}D:(A;;FA;;;{ME})(A;;FR)"), ME).is_some_and(|p| p.contains("can't be read"))); + assert!(foreign_access(&format!("O:{ME}D:(A;;FA;;;)"), ME).is_some_and(|p| p.contains("can't be read"))); + // A conditional entry grants access like a plain one: another account on it is refused. + let other = format!(r#"O:{ME}D:(A;;FA;;;{ME})(XA;;FR;;;BU;(@User.dept == "x"))"#); + assert!(foreign_access(&other, ME).is_some_and(|p| p.contains("BU")), "{other}"); + } + + #[test] + fn the_sid_is_read_from_whoamis_output_and_nothing_else() { + assert_eq!(sid_from_whoami("\"desk\\ada\",\"S-1-5-21-1-2-3-1001\"\r\n").as_deref(), Some("S-1-5-21-1-2-3-1001")); + for bad in [ + "", + "ERROR: Access is denied.", + "\"desk\\ada\",\"\"", + "\"desk\\ada\",\"S-1-\"", + "\"S-1-5-x\"", + "\"desk\\ada\"", + "\"desk\\ada\",\"1-5-21-7\"", + ] { + assert_eq!(sid_from_whoami(bad), None, "{bad:?}"); + } + } + + /// The real tools: a file created in a folder that lets Users read what's in it inherits that + /// access; after `restrict_to_owner` only the current user and SYSTEM are left. + #[cfg(windows)] + #[test] + fn a_file_in_a_shared_folder_is_restricted_to_its_owner() { + let dir = std::env::temp_dir().join(format!("pdfcraft-owner-only-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let ok = std::process::Command::new("icacls").arg(&dir).arg("/grant").arg("*S-1-5-32-545:(OI)(CI)R").output().unwrap(); + assert!(ok.status.success(), "{}", String::from_utf8_lossy(&ok.stderr)); + let file = dir.join("ctl.json"); + std::fs::write(&file, b"{}").unwrap(); + let me = current_user_sid().unwrap(); + // Users inherit read access, so the file is refused. (Run elevated, the file is also owned + // by Administrators, which is the reason given first.) + let before = sddl_of(&file).unwrap(); + assert!(before.contains(";BU)") || before.contains(";S-1-5-32-545)"), "Users inherit read access: {before}"); + assert!(foreign_access(&before, &me).is_some(), "{before}"); + restrict_to_owner(&file, &user()).unwrap(); + let sddl = sddl_of(&file).unwrap(); + assert_eq!(section(&sddl, "O:"), Some(me.as_str()), "{sddl}"); + assert_eq!(foreign_access(&sddl, &me), None, "{sddl}"); + let _ = std::fs::remove_dir_all(&dir); + } + + /// Review on #3956: a file created in a private folder inside a folder that lets Users read + /// what's in it is the user's alone from the moment it exists, before anything restricts it. + #[cfg(windows)] + #[test] + fn a_file_created_in_a_private_folder_is_private_from_the_start() { + let dir = std::env::temp_dir().join(format!("pdfcraft-owner-only-private-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + let ok = std::process::Command::new("icacls").arg(&dir).arg("/grant").arg("*S-1-5-32-545:(OI)(CI)R").output().unwrap(); + assert!(ok.status.success(), "{}", String::from_utf8_lossy(&ok.stderr)); + let private = dir.join("staging"); + create_private_dir(&private, &user()).unwrap(); + let me = current_user_sid().unwrap(); + let folder = sddl_of(&private).unwrap(); + assert_eq!((section(&folder, "O:"), foreign_access(&folder, &me)), (Some(me.as_str()), None), "{folder}"); + let file = private.join("ctl.json"); + std::fs::OpenOptions::new().write(true).create_new(true).open(&file).unwrap(); + let sddl = sddl_of(&file).unwrap(); + assert!(!sddl.contains(";BU)") && !sddl.contains(";S-1-5-32-545)"), "Users must not inherit read access: {sddl}"); + assert_eq!(foreign_access(&sddl, &me), None, "{sddl}"); + // The name is taken: the folder isn't reused. + assert!(create_private_dir(&private, &user()).is_err()); + let _ = std::fs::remove_dir_all(&dir); + } + + /// A folder may give its user Modify but not Full control, so a file created there doesn't let + /// its owner change the owner (no `WRITE_OWNER`). An owner can always replace the access list, + /// though, and the user's Full control on it then allows setting the owner. + #[cfg(windows)] + #[test] + fn a_file_in_a_folder_granting_only_modify_is_still_restricted() { + let dir = std::env::temp_dir().join(format!("pdfcraft-owner-only-modify-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + let me = current_user_sid().unwrap(); + let modify = std::process::Command::new("icacls") + .arg(&dir) + .args(["/inheritance:r", "/grant:r"]) + .arg(format!("*{me}:(OI)(CI)M")) + .arg("*S-1-5-18:(OI)(CI)F") + .output() + .unwrap(); + assert!(modify.status.success(), "{}", String::from_utf8_lossy(&modify.stderr)); + let file = dir.join("ctl.json"); + std::fs::write(&file, b"{}").unwrap(); + restrict_to_owner(&file, &user()).unwrap(); + let sddl = sddl_of(&file).unwrap(); + assert_eq!((section(&sddl, "O:"), foreign_access(&sddl, &me)), (Some(me.as_str()), None), "{sddl}"); + let _ = std::fs::remove_dir_all(&dir); + } + + /// A null access list (`NO_ACCESS_CONTROL`) lets every account use the file, so it is refused. + #[cfg(windows)] + #[test] + fn a_file_with_no_access_list_is_refused() { + let dir = std::env::temp_dir().join(format!("pdfcraft-owner-only-null-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + let file = dir.join("ctl.json"); + std::fs::write(&file, b"{}").unwrap(); + restrict_to_owner(&file, &user()).unwrap(); + // A null access list lets everyone in; the user, as owner, may set one. + let null = std::process::Command::new("powershell") + .args([ + "-NoProfile", + "-NonInteractive", + "-Command", + "$s = New-Object Security.AccessControl.FileSecurity; $s.SetSecurityDescriptorSddlForm('D:NO_ACCESS_CONTROL', 'Access'); [IO.File]::SetAccessControl($env:PDFCRAFT_NULL_PATH, $s)", + ]) + .env("PDFCRAFT_NULL_PATH", &file) + .output() + .unwrap(); + assert!(null.status.success(), "{}", String::from_utf8_lossy(&null.stderr)); + let (sddl, _) = read_with_sddl(&file, 64).unwrap(); + let me = current_user_sid().unwrap(); + assert!(foreign_access(&sddl, &me).is_some_and(|p| p.contains("no access list")), "{sddl}"); + let _ = std::fs::remove_dir_all(&dir); + } + + /// An `OWNER RIGHTS` entry replaces what an owner may do implicitly. Granting only *write + /// owner* that way, the access list can't be replaced although the owner can still be set: the + /// file isn't taken as restricted. + #[cfg(windows)] + #[test] + fn a_file_whose_access_list_cant_be_replaced_is_not_restricted() { + let dir = std::env::temp_dir().join(format!("pdfcraft-owner-only-ow-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + let me = current_user_sid().unwrap(); + let file = dir.join("ctl.json"); + std::fs::write(&file, b"{}").unwrap(); + // The whole list is written, as `restrict_to_owner` does: `icacls /inheritance:r` leaves + // the Administrators entry the windows-latest runner gives a new file, and with it an + // elevated administrator could replace the list after all. + let rights = std::process::Command::new("powershell") + .args([ + "-NoProfile", + "-NonInteractive", + "-Command", + "$s = New-Object Security.AccessControl.FileSecurity; $s.SetSecurityDescriptorSddlForm('D:P(A;;FR;;;' + $env:PDFCRAFT_OW_USER + ')(A;;WO;;;OW)', 'Access'); [IO.File]::SetAccessControl($env:PDFCRAFT_OW_PATH, $s)", + ]) + .env("PDFCRAFT_OW_PATH", &file) + .env("PDFCRAFT_OW_USER", &me) + .output() + .unwrap(); + assert!(rights.status.success(), "{}", String::from_utf8_lossy(&rights.stderr)); + // Nothing but the two entries: no Administrators entry for the elevated runner to use. + let sddl = sddl_of(&file).unwrap(); + assert_eq!(section(&sddl, "D:"), Some(format!("(A;;524288;;;S-1-3-4)(A;;1179785;;;{me})").as_str()), "{sddl}"); + assert!(restrict_to_owner(&file, &user()).is_err(), "the access list could not be replaced"); + let _ = std::fs::remove_dir_all(&dir); + } + + /// When the tools fail, the functions say so: nothing is taken as restricted or as checked. + #[cfg(windows)] + #[test] + fn tool_failures_are_errors() { + let missing = std::env::temp_dir().join(format!("pdfcraft-owner-only-missing-{}", std::process::id())).join("none.json"); + assert!(restrict_to_owner(&missing, &user()).is_err(), "icacls on a missing file"); + assert!(sddl_of(&missing).is_err(), "Get-Acl on a missing file"); + let me = current_user_sid().unwrap(); + assert!(me.starts_with("S-1-5-") && me.split('-').count() >= 4, "{me}"); + assert!(!me.contains('"') && !me.contains('\\'), "the SID alone, not the account name: {me}"); + } + + #[test] + fn the_descriptor_and_bytes_are_read_from_the_scripts_output_and_nothing_else() { + let sddl = format!("O:{ME}D:P(A;;FA;;;{ME})"); + assert_eq!(sddl_and_bytes(&format!("{sddl}\n7B-0A-ff\r\n")), Some((sddl.clone(), vec![0x7B, 0x0A, 0xFF]))); + assert_eq!(sddl_and_bytes(&format!("{sddl}\n")), Some((sddl.clone(), Vec::new()))); + for bad in [ + String::new(), + sddl.clone(), + "D:P(A;;FA;;;SY)\n7B".into(), + format!("{sddl}\n7B-2"), + format!("{sddl}\n7B--22"), + format!("{sddl}\n7G"), + format!("{sddl}\n+7-22"), + ] { + assert_eq!(sddl_and_bytes(&bad), None, "{bad:?}"); + } + } + + /// The bytes come with the descriptor of the same handle, capped at `max + 1` so an oversized + /// file shows, and the restricted file is owned by the user. + #[cfg(windows)] + #[test] + fn the_file_is_read_with_its_descriptor_through_one_handle() { + let dir = std::env::temp_dir().join(format!("pdfcraft-owner-only-read-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let file = dir.join("ctl.json"); + std::fs::write(&file, br#"{"port":1}"#).unwrap(); + restrict_to_owner(&file, &user()).unwrap(); + let me = current_user_sid().unwrap(); + let (sddl, bytes) = read_with_sddl(&file, 64).unwrap(); + assert_eq!(section(&sddl, "O:"), Some(me.as_str()), "{sddl}"); + assert_eq!((foreign_access(&sddl, &me), bytes.as_slice()), (None, &br#"{"port":1}"#[..])); + assert_eq!(read_with_sddl(&file, 3).unwrap().1, br#"{"po"#); + // A limit past what the script's `[int]` holds is capped, not handed to it. + assert_eq!(read_with_sddl(&file, u64::MAX).unwrap().1, br#"{"port":1}"#); + assert!(read_with_sddl(&dir.join("none.json"), 64).is_err()); + // A deny entry is read as one: denying Guests takes nothing from the user. + let guests = std::process::Command::new("icacls").arg(&file).arg("/deny").arg("*S-1-5-32-546:(R)").output().unwrap(); + assert!(guests.status.success(), "{}", String::from_utf8_lossy(&guests.stderr)); + let (sddl, _) = read_with_sddl(&file, 64).unwrap(); + assert!(sddl.contains(";;;S-1-5-32-546)") && sddl.contains("(D;"), "{sddl}"); + assert_eq!(foreign_access(&sddl, &me), None, "{sddl}"); + // The handle shares the file for reading only: a file someone has open for writing (or + // could rename or delete) isn't read, and so can't change between the check and the read. + let writer = std::fs::OpenOptions::new().write(true).open(&file).unwrap(); + assert!(read_with_sddl(&file, 64).is_err(), "a file open for writing is not read"); + // Restricting reads the descriptor alone, so it works on the file the app holds open to + // write the token through. + restrict_to_owner(&file, &user()).unwrap(); + assert_eq!(foreign_access(&sddl_of(&file).unwrap(), &me), None); + drop(writer); + assert!(read_with_sddl(&file, 64).is_ok()); + let _ = std::fs::remove_dir_all(&dir); + } + + /// Entries of the file's own, not inherited, are replaced too: an Administrators or Users + /// entry (as the windows-latest runner's temp folder leaves on a new file, #3922) and a deny + /// that would keep the user from setting the owner. + #[cfg(windows)] + #[test] + fn entries_of_the_files_own_are_replaced() { + let dir = std::env::temp_dir().join(format!("pdfcraft-owner-only-own-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + let me = current_user_sid().unwrap(); + let mine = format!("*{me}:(R)"); + for (name, entries) in [ + ("others.json", ["/grant", "*S-1-5-32-544:(F)", "/grant", "*S-1-5-32-545:(R)"]), + ("deny.json", ["/deny", "*S-1-1-0:(WO)", "/grant", "*S-1-5-32-544:(F)"]), + ] { + let file = dir.join(name); + std::fs::write(&file, b"{}").unwrap(); + // The user keeps read access, so the descriptor can be read before restricting. + let own = std::process::Command::new("icacls").arg(&file).arg("/inheritance:r").args(entries).args(["/grant", &mine]).output().unwrap(); + assert!(own.status.success(), "{}", String::from_utf8_lossy(&own.stderr)); + assert!(foreign_access(&sddl_of(&file).unwrap(), &me).is_some(), "{name}"); + restrict_to_owner(&file, &user()).unwrap(); + let sddl = sddl_of(&file).unwrap(); + assert_eq!((section(&sddl, "O:"), foreign_access(&sddl, &me)), (Some(me.as_str()), None), "{name}: {sddl}"); + assert!(!sddl.contains("(D;"), "{name}: the deny is gone: {sddl}"); + } + let _ = std::fs::remove_dir_all(&dir); + } + + #[test] + fn a_descriptor_still_open_to_others_after_restricting_is_an_error() { + assert!(still_foreign(&format!("O:{ME}D:P(A;;FA;;;SY)(A;;FA;;;{ME})"), ME).is_ok()); + for bad in [format!("O:{ME}D:P(A;;FA;;;S-1-5-32-544)(A;;FA;;;{ME})"), format!("O:S-1-5-32-544D:P(A;;FA;;;{ME})"), format!("O:{ME}")] { + assert!(still_foreign(&bad, ME).is_err_and(|e| e.to_string().contains("could not be restricted")), "{bad}"); + } + } + + /// [`read_with_sddl`] gives accounts as full SIDs (rights as a number, no list flags), the way a + /// CI runner's built-in Administrator reads its own restricted file. An alias such as `LA` + /// is never taken for the user, whatever the user's RID: on a domain-joined machine a domain + /// account can share the local Administrator's RID 500. + #[test] + fn descriptors_with_full_sids_and_aliases_that_are_never_the_user() { + let admin = "S-1-5-21-1-2-3-500"; + let full = format!("O:{admin}D:(A;;2032127;;;S-1-5-18)(A;;2032127;;;{admin})"); + assert_eq!(foreign_access(&full, admin), None); + // Owner rights by SID, a deny by SID, an inherit-only entry for Creator Owner. + let more = format!("O:{ME}D:(D;;1179785;;;S-1-5-32-546)(A;IO;268435456;;;S-1-3-0)(A;;2032127;;;S-1-3-4)(A;;2032127;;;{ME})"); + assert_eq!(foreign_access(&more, ME), None); + assert!(foreign_access(&format!("O:{ME}D:(A;;1179785;;;S-1-5-32-545)(A;;2032127;;;{ME})"), ME).is_some_and(|p| p.contains("S-1-5-32-545"))); + for me in [admin, "S-1-5-21-1-2-3-501"] { + assert!(foreign_access("O:LAD:PAI(A;;FA;;;SY)(A;;FA;;;LA)", me).is_some_and(|p| p.contains("owned by another account (LA)")), "{me}"); + assert!(foreign_access("O:LGD:P(A;;FA;;;LG)", me).is_some(), "{me}"); + } + assert!(foreign_access(&format!("O:{admin}D:P(A;;FA;;;{admin})(A;;FR;;;LA)"), admin).is_some_and(|p| p.contains("LA"))); + } + + /// Review on #3956: a refusal names a well-known account as well as giving its SID, so + /// `S-1-5-32-544` reads as Administrators without a lookup; any other SID is given alone. + #[test] + fn refusals_name_well_known_accounts_and_keep_their_sid() { + for (sid, name) in [ + ("S-1-5-32-544", "Administrators"), + ("S-1-5-32-545", "Users"), + ("S-1-5-11", "Authenticated Users"), + ("S-1-5-4", "INTERACTIVE"), + ("S-1-1-0", "Everyone"), + ("S-1-5-6", "SERVICE"), + ("S-1-5-3", "BATCH"), + ] { + let sddl = format!("O:{ME}D:PAI(A;;FA;;;{ME})(A;ID;1179817;;;{sid})"); + assert_eq!(foreign_access(&sddl, ME), Some(format!("another account, {name} ({sid}), has access to it")), "{sddl}"); + } + // SYSTEM may have access, but a file it owns isn't the user's. + assert_eq!(foreign_access(&format!("O:S-1-5-18D:P(A;;FA;;;{ME})"), ME).as_deref(), Some("it is owned by another account, SYSTEM (S-1-5-18)")); + assert_eq!( + foreign_access(&format!("O:S-1-5-32-544D:P(A;;FA;;;{ME})"), ME).as_deref(), + Some("it is owned by another account, Administrators (S-1-5-32-544)") + ); + // Another user, a lookalike and an alias have no fixed name: the account is given as it was. + for sid in ["S-1-5-21-1-2-3-1002", "S-1-5-32-5440", "S-1-5-1", "BA"] { + let sddl = format!("O:{ME}D:(A;;FA;;;{ME})(A;;FR;;;{sid})"); + assert_eq!(foreign_access(&sddl, ME), Some(format!("another account ({sid}) has access to it")), "{sddl}"); + } + assert_eq!( + foreign_access("O:S-1-5-21-1-2-3-1002D:(A;;FA;;;SY)", ME).as_deref(), + Some("it is owned by another account (S-1-5-21-1-2-3-1002)") + ); + } + + #[test] + fn sections_stop_at_the_next_tag() { + assert_eq!(section("O:S-1-5-18G:S-1-5-32-544D:(A;;FA;;;SY)", "O:"), Some("S-1-5-18")); + assert_eq!(section("O:BAG:SYD:P(A;;FA;;;SY)S:(AU;SA;FA;;;WD)", "D:"), Some("P(A;;FA;;;SY)")); + assert_eq!(section("O:BA", "D:"), None); + } +} From 0bb77ce5e6a14278b275e9c9db6fb2062d58257d Mon Sep 17 00:00:00 2001 From: mtlcl410 <2368091+mtlcl410@users.noreply.github.com> Date: Mon, 9 Nov 2026 18:24:59 +0800 Subject: [PATCH 07/41] M5: review statuses are hidden, chain per reviewer and show per reviewer, by /IRT reference (#4122) (#4597) --- crates/annot/src/info.rs | 8 +- crates/annot/src/modify.rs | 67 ++- crates/annot/src/tests.rs | 116 +++++ crates/automation/src/comments.rs | 30 +- crates/automation/tests/it/automation.rs | 92 ++++ crates/render/src/inspect.rs | 2 +- crates/render/src/inspect/annotations.rs | 6 +- crates/render/src/inspect/model.rs | 451 ++++++++++++++++++-- crates/render/src/lib.rs | 3 +- crates/ui-egui/src/dialogs/comment_props.rs | 8 +- crates/ui-egui/src/panels/comments_panel.rs | 91 ++-- crates/ui-egui/src/tools/comments/menu.rs | 7 +- crates/ui-egui/tests/it/comments.rs | 90 +++- parity/acrobat-features.toml | 4 +- 14 files changed, 883 insertions(+), 92 deletions(-) diff --git a/crates/annot/src/info.rs b/crates/annot/src/info.rs index c86b9f8f78..71a4fbfa32 100644 --- a/crates/annot/src/info.rs +++ b/crates/annot/src/info.rs @@ -154,7 +154,13 @@ pub fn summaries(doc: &Document) -> Vec { grouped: d.name(b"RT") == Some(b"Group"), rect, color, - state: text_value(doc, d, b"State"), + // A reply that gives only `/StateModel` has the model's default state (ISO 32000-2 + // Table 175), as `last_state_reply` and the inspector read it. + state: text_value(doc, d, b"State").or_else(|| match text_value(doc, d, b"StateModel").as_deref() { + Some("Marked") => Some("Unmarked".into()), + Some("Review") => Some("None".into()), + _ => None, + }), quads, locked: annotation_flags(doc, d) & FLAG_LOCKED != 0, intent: d.get(b"IT").and_then(|o| doc.resolve(o).as_name().map(|n| String::from_utf8_lossy(n).into_owned())), diff --git a/crates/annot/src/modify.rs b/crates/annot/src/modify.rs index 78200f5c16..2156ad045a 100644 --- a/crates/annot/src/modify.rs +++ b/crates/annot/src/modify.rs @@ -260,8 +260,9 @@ pub fn add_reply(doc: &mut Document, page: usize, index: usize, text: &str, auth reply(doc, page, index, text, author, meta, None) } -/// Set a comment's review status (Acrobat: "Set status ▸ Accepted"…). Like Acrobat this adds a -/// state reply (`/State`, `/StateModel /Review`) by `author`; the latest one wins. +/// Set a comment's review status ("Set status ▸ Accepted"…). This adds a hidden state reply +/// (`/State`, `/StateModel /Review`) by `author`, in reply to `author`'s previous one when there is +/// one; each reviewer's latest is their status. pub fn set_review_state(doc: &mut Document, page: usize, index: usize, state: ReviewState, author: &str, meta: &Meta) -> Result { let text = format!("{} set by {}", state.name(), if author.is_empty() { "unknown" } else { author }); reply(doc, page, index, &text, author, meta, Some(state)) @@ -294,6 +295,11 @@ fn reply( d.set(b"State".to_vec(), PdfString::text(s.name())); d.set(b"StateModel".to_vec(), PdfString::text("Review")); d.set(b"Subj".to_vec(), PdfString::text("Status")); + // Hidden (Table 167, bit 2): the state "is not specified in the annotation itself but in a + // separate text annotation" (ISO 32000-2 §12.5.6.3), so this reply is a state record, not + // a note, and is neither drawn nor listed as one. + d.set(b"F".to_vec(), Object::Int(FLAG_HIDDEN | FLAG_PRINT | FLAG_NO_ZOOM | FLAG_NO_ROTATE)); + d.set(b"IRT".to_vec(), Object::Ref(last_state_reply(doc, p, parent, author, "Review").unwrap_or(parent))); } // An empty form: nothing is drawn for the reply itself. let mut fd = Dict::new(); @@ -330,8 +336,9 @@ pub fn set_locked(doc: &mut Document, page: usize, index: usize, locked: bool) - Ok(()) } -/// Mark or unmark a comment with a checkmark. Like Acrobat this adds a hidden state reply with -/// `/StateModel /Marked` by `author`; the latest one wins. It's private bookkeeping, not a status. +/// Mark or unmark a comment with a checkmark. This adds a hidden state reply with `/StateModel +/// /Marked` by `author`, in reply to `author`'s previous one when there is one; their latest one +/// wins. It's each reviewer's private bookkeeping, not a status. pub fn set_marked(doc: &mut Document, page: usize, index: usize, marked: bool, author: &str, meta: &Meta) -> Result { let (p, parent) = annot_ref(doc, page, index)?; let pd = annot_dict(doc, parent); @@ -342,7 +349,7 @@ pub fn set_marked(doc: &mut Document, page: usize, index: usize, marked: bool, a let text = format!("{state} set by {}", if author.is_empty() { "unknown" } else { author }); let mut d = base_dict("Text", [0.0; 4], p, &text, author, meta); d.set(b"Rect".to_vec(), pd.get(b"Rect").cloned().unwrap_or_else(|| num_array(&[0.0; 4]))); - d.set(b"IRT".to_vec(), Object::Ref(parent)); + d.set(b"IRT".to_vec(), Object::Ref(last_state_reply(doc, p, parent, author, "Marked").unwrap_or(parent))); d.set(b"F".to_vec(), Object::Int(FLAG_HIDDEN | FLAG_PRINT | FLAG_NO_ZOOM | FLAG_NO_ROTATE)); d.set(b"State".to_vec(), PdfString::text(state)); d.set(b"StateModel".to_vec(), PdfString::text("Marked")); @@ -354,6 +361,56 @@ pub fn set_marked(doc: &mut Document, page: usize, index: usize, marked: bool, a Ok(i) } +/// `author`'s latest `model` state reply about `target` on page `p`, for the next change to reply +/// to: ISO 32000-2 §12.5.6.3, "Additional state changes shall be made by adding text annotations +/// in reply to the previous reply for a given user". A state reply is about the +/// annotation its `/IRT` chain reaches past other state replies; a loop reaches nothing. Each +/// reply's answer is kept, so a long history is walked once. +fn last_state_reply(doc: &Document, p: ObjRef, target: ObjRef, author: &str, model: &str) -> Option { + use std::collections::{HashMap, HashSet}; + // As the readers see it: a readable `/State` (not null, empty or another type), or a known + // `/StateModel` whose default state applies. + let is_state = |r: ObjRef| { + doc.get(r).as_dict().is_some_and(|d| { + text_value(doc, d, b"State").is_some() || matches!(text_value(doc, d, b"StateModel").as_deref(), Some("Marked" | "Review")) + }) + }; + let irt = |r: ObjRef| doc.get(r).as_dict().and_then(|d| d.get(b"IRT").and_then(Object::as_ref)); + let mut reaches: HashMap> = HashMap::new(); + let mut mine = Vec::new(); + for r in annots(doc, p).iter().filter_map(Object::as_ref).filter(|&r| r != target) { + let d = annot_dict(doc, r); + if text_value(doc, &d, b"StateModel").as_deref() != Some(model) || text_value(doc, &d, b"T").unwrap_or_default() != author.trim() { + continue; + } + let (mut path, mut seen, mut at) = (vec![r], HashSet::from([r]), irt(r)); + let about = loop { + let Some(up) = at else { break None }; + if let Some(&known) = reaches.get(&up) { + break known; + } + if !is_state(up) { + break Some(up); + } + if !seen.insert(up) { + break None; + } + path.push(up); + at = irt(up); + }; + for x in path { + reaches.insert(x, about); + } + if about == Some(target) { + mine.push(r); + } + } + // The end of the chain, whatever order the page lists it in: a reply none of the others + // replies to; among several (changes that don't chain), the last listed. + let answered: HashSet = mine.iter().filter_map(|&r| irt(r)).collect(); + mine.iter().rev().copied().find(|r| !answered.contains(r)) +} + /// Move a comment (and its pop-up) by `(dx, dy)` points. The appearance moves with `/Rect`. pub fn move_annotation(doc: &mut Document, page: usize, index: usize, dx: f64, dy: f64, meta: &Meta) -> Result<(), AnnotError> { if !finite(&[dx, dy]) { diff --git a/crates/annot/src/tests.rs b/crates/annot/src/tests.rs index 526cd02770..9d9a71dd34 100644 --- a/crates/annot/src/tests.rs +++ b/crates/annot/src/tests.rs @@ -213,6 +213,122 @@ fn replies_and_status_thread_onto_the_parent() { assert_eq!(text(&all[2], b"Contents"), "Accepted set by Bob"); } +/// A state reply records a state, not a note (ISO 32000-2 §12.5.6.3: the state is kept "in a +/// separate text annotation"), so it is written Hidden; a reviewer's next change replies to their +/// previous state reply ("Additional state changes shall be made by adding text annotations in +/// reply to the previous reply for a given user"). Another reviewer's state, and another state +/// model, start their own chain on the comment. +#[test] +fn status_and_checkmark_replies_are_hidden_and_chain_per_reviewer() { + let mut doc = fixture(); + let first = set_review_state(&mut doc, 0, 0, ReviewState::Accepted, "Bob", &meta("s1")).unwrap(); + let carol = set_review_state(&mut doc, 0, 0, ReviewState::Completed, "Carol", &meta("s2")).unwrap(); + let second = set_review_state(&mut doc, 0, 0, ReviewState::Rejected, "Bob", &meta("s3")).unwrap(); + let mark = set_marked(&mut doc, 0, 0, true, "Bob", &meta("m1")).unwrap(); + let unmark = set_marked(&mut doc, 0, 0, false, "Bob", &meta("m2")).unwrap(); + let doc = reopen(&doc); + let p = page_refs(&doc).unwrap()[0]; + let entries = annots(&doc, p); + let at = |i: usize| entries[i].as_ref().unwrap(); + let all = list(&doc, 0); + for i in [first, carol, second, mark, unmark] { + assert_eq!(all[i].int(b"F"), Some(30), "hidden, printable, no zoom, no rotate: {i}"); + } + assert_eq!(all[first].reference(b"IRT"), Some(at(0))); + assert_eq!(all[carol].reference(b"IRT"), Some(at(0)), "another reviewer starts on the comment"); + assert_eq!(all[second].reference(b"IRT"), Some(at(first)), "Bob's change replies to Bob's last status"); + assert_eq!(all[mark].reference(b"IRT"), Some(at(0)), "a checkmark is its own chain"); + assert_eq!(all[unmark].reference(b"IRT"), Some(at(mark))); + // A reply that isn't a state stays visible. + let mut doc = doc; + let r = add_reply(&mut doc, 0, 0, "Agreed", "Bob", &meta("r")).unwrap(); + assert_eq!(list(&doc, 0)[r].int(b"F"), Some(28)); +} + +/// Review on #4126: a history has no length limit. The 70th change still replies to the 69th, +/// and a reviewer whose name was typed with spaces continues their own chain. +#[test] +fn a_long_status_history_keeps_chaining_to_the_latest() { + let mut doc = fixture(); + let mut prev: Option = None; + for i in 0..70 { + let state = if i % 2 == 0 { ReviewState::Accepted } else { ReviewState::Rejected }; + let at = set_review_state(&mut doc, 0, 0, state, "Bob", &meta(&format!("s{i}"))).unwrap(); + if let Some(p) = prev { + let entries = annots(&doc, page_refs(&doc).unwrap()[0]); + assert_eq!(list(&doc, 0)[at].reference(b"IRT"), entries[p].as_ref(), "change {i}"); + } + prev = Some(at); + } + let padded = set_review_state(&mut doc, 0, 0, ReviewState::Completed, " Bob ", &meta("pad")).unwrap(); + let entries = annots(&doc, page_refs(&doc).unwrap()[0]); + assert_eq!(list(&doc, 0)[padded].reference(b"IRT"), prev.and_then(|p| entries[p].as_ref())); + // Listed newest first, the first reply read walks the whole chain at once. + let latest = entries[padded].as_ref(); + let p = page_refs(&doc).unwrap()[0]; + let mut reversed = entries.clone(); + reversed.reverse(); + set_annots(&mut doc, p, reversed).unwrap(); + let next = set_review_state(&mut doc, 0, 71, ReviewState::Accepted, "Bob", &meta("next")).unwrap(); + assert_eq!(list(&doc, 0)[next].reference(b"IRT"), latest); +} + +/// Review on #4126: a reply with `/StateModel` but no `/State` has the model's default state +/// (ISO 32000-2 Table 175), for the reader as for the writer: it is Bob's latest status, None, +/// and his next change replies to it. +#[test] +fn a_state_model_without_a_state_is_the_default_state() { + let mut doc = fixture(); + let bare = add_reply(&mut doc, 0, 0, "", "Bob", &meta("bare")).unwrap(); + let p = page_refs(&doc).unwrap()[0]; + let r = annots(&doc, p)[bare].as_ref().unwrap(); + doc.update_dict(r, |d| { + d.set(b"StateModel".to_vec(), PdfString::text("Review")); + d.set(b"F".to_vec(), Object::Int(30)); + }) + .unwrap(); + let summary = summaries(&doc).into_iter().find(|s| s.index == bare).unwrap(); + assert_eq!(summary.state.as_deref(), Some("None")); + let next = set_review_state(&mut doc, 0, 0, ReviewState::Accepted, "Bob", &meta("next")).unwrap(); + assert_eq!(list(&doc, 0)[next].reference(b"IRT"), Some(r)); + + // A model PdfCraft doesn't know isn't a state: a status replying to such a reply is about + // that reply, not about the comment, so Bob's next change on the comment starts afresh. + let mut doc = fixture(); + let other = add_reply(&mut doc, 0, 0, "", "Ann", &meta("other")).unwrap(); + let p = page_refs(&doc).unwrap()[0]; + let o = annots(&doc, p)[other].as_ref().unwrap(); + // Nor is a `/State` the readers can't read, such as a number (review on #4126). + doc.update_dict(o, |d| { + d.set(b"StateModel".to_vec(), PdfString::text("Other")); + d.set(b"State".to_vec(), Object::Int(5)); + }) + .unwrap(); + assert_eq!(summaries(&doc).into_iter().find(|s| s.index == other).unwrap().state, None); + set_review_state(&mut doc, 0, other, ReviewState::Rejected, "Bob", &meta("on-other")).unwrap(); + let fresh = set_review_state(&mut doc, 0, 0, ReviewState::Completed, "Bob", &meta("fresh")).unwrap(); + assert_eq!(list(&doc, 0)[fresh].reference(b"IRT"), annots(&doc, p)[0].as_ref()); +} + +/// A third change replies to the second, not the first; and Bob's status on another comment +/// is never the one his next change on this comment replies to. +#[test] +fn a_reviewers_chain_continues_from_their_latest_status_on_that_comment() { + let mut doc = fixture(); + let note = add_annotation(&mut doc, &new(0, Shape::Note { at: [100.0, 100.0], icon: NoteIcon::Comment }), &meta("n")).unwrap(); + let first = set_review_state(&mut doc, 0, 0, ReviewState::Accepted, "Bob", &meta("s1")).unwrap(); + let elsewhere = set_review_state(&mut doc, 0, note, ReviewState::Cancelled, "Bob", &meta("s2")).unwrap(); + let second = set_review_state(&mut doc, 0, 0, ReviewState::Rejected, "Bob", &meta("s3")).unwrap(); + let third = set_review_state(&mut doc, 0, 0, ReviewState::Completed, "Bob", &meta("s4")).unwrap(); + let doc = reopen(&doc); + let entries = annots(&doc, page_refs(&doc).unwrap()[0]); + let at = |i: usize| entries[i].as_ref().unwrap(); + let all = list(&doc, 0); + assert_eq!(all[elsewhere].reference(b"IRT"), Some(at(note))); + assert_eq!(all[second].reference(b"IRT"), Some(at(first)), "not Bob's status on the note"); + assert_eq!(all[third].reference(b"IRT"), Some(at(second)), "the latest, not the first"); +} + #[test] fn delete_takes_popup_and_reply_chain() { let mut doc = fixture(); diff --git a/crates/automation/src/comments.rs b/crates/automation/src/comments.rs index 390e3b4d1a..3174e68dbd 100644 --- a/crates/automation/src/comments.rs +++ b/crates/automation/src/comments.rs @@ -224,24 +224,27 @@ impl Automation { }; // Every reply under the comment its thread starts from, at any depth, in document order: // a reply to a reply keeps its `/IRT` on the direct parent, so following one level would - // drop it. Parents are found by reference, as `/NM` need not be unique. A status or - // checkmark belongs to the annotation its `/IRT` names (ISO 32000-2 §12.5.6.2), as in the - // Comments panel: one set on a reply is not the comment's. + // drop it. Parents are found by reference, as `/NM` need not be unique. Status and + // checkmark replies are read through `review` instead. let tops = pdfcraft_render::thread_tops(&all); let mut threads: HashMap<(usize, usize), Vec<&Annotation>> = HashMap::new(); - for r in &all { + for r in all.iter().filter(|r| r.state.is_none()) { let Some(&top) = tops.get(&(r.page, r.index)) else { continue }; - let key = match r.parent { - Some(parent) if r.state.is_some() => parent, - _ => top, - }; - threads.entry(key).or_default().push(r); + threads.entry(top).or_default().push(r); } - // An annotation's review status and checkmark: the latest of each set on it. + // An annotation's review states. A state reply's `/IRT` refers to the annotation whose + // state it sets, and a reviewer's later change replies to their previous one (ISO 32000-2 + // §12.5.6.3), so a status on a reply is that reply's, as in the Comments panel. `status` + // is the latest set by anyone; `statuses` has each reviewer's latest (a state is "for the + // specified user"). Checkmarks are each reviewer's own too: `marked_by` names who has one + // on, and `marked` whether anyone has. + let review = pdfcraft_render::ReviewStates::new(&all); let states = |c: &Annotation, v: &mut Value| { - let set = threads.get(&(c.page, c.index)).map(Vec::as_slice).unwrap_or(&[]); - v["status"] = json!(set.iter().rev().filter(|r| !r.is_mark()).find_map(|r| r.state.clone())); - v["marked"] = json!(set.iter().rev().find(|r| r.is_mark()).is_some_and(|r| r.state.as_deref() == Some("Marked"))); + v["status"] = json!(review.latest_status(c)); + v["statuses"] = json!(review.statuses(c).iter().map(|(who, state)| json!({ "author": who, "status": state })).collect::>()); + let marked_by = review.marked(c); + v["marked"] = json!(!marked_by.is_empty()); + v["marked_by"] = json!(marked_by); }; let roots: Vec = all .iter() @@ -253,7 +256,6 @@ impl Automation { let thread = threads.get(&(c.page, c.index)).map(Vec::as_slice).unwrap_or(&[]); v["replies"] = thread .iter() - .filter(|r| r.state.is_none()) .map(|r| { let mut reply = view(r); states(r, &mut reply); diff --git a/crates/automation/tests/it/automation.rs b/crates/automation/tests/it/automation.rs index 19a172214e..046179ffaf 100644 --- a/crates/automation/tests/it/automation.rs +++ b/crates/automation/tests/it/automation.rs @@ -2311,6 +2311,98 @@ fn numbering_pages_through_tools() { assert!(matches!(a.call("page_number", &json!({ "doc": doc, "from": 1, "to": 1, "style": "klingon" })), Err(ToolError::InvalidArgs(_)))); } +/// ISO 32000-2 §12.5.6.3: a state is "for the specified user" (`/T`), so there is one status per +/// reviewer (their latest), and a checkmark ("marked by the user", Table 174) belongs only to the +/// reviewer who set it. `comment_list` reports each reviewer's status and who has a checkmark on; +/// the replies written are hidden (a state reply records a state, not a note) and a reviewer's +/// later change replies to their earlier one. +#[test] +fn review_statuses_are_per_reviewer_through_tools() { + let dir = workdir("statuses"); + let mut a = auto(&dir); + let doc = ok(&mut a, "doc_open", json!({ "path": "a.pdf" }))["doc"].as_u64().unwrap(); + let id = + ok(&mut a, "comment_add", json!({ "doc": doc, "page": 1, "type": "note", "at": [20, 20], "contents": "Check", "author": "Ada" }))["comment"] + ["id"] + .as_str() + .unwrap() + .to_string(); + for (status, author) in [("accepted", "Bob"), ("completed", "Carol"), ("rejected", "Bob")] { + ok(&mut a, "comment_set_status", json!({ "doc": doc, "id": id, "status": status, "author": author })); + } + ok(&mut a, "comment_mark", json!({ "doc": doc, "id": id, "author": "Bob" })); + ok(&mut a, "comment_mark", json!({ "doc": doc, "id": id, "author": "Carol" })); + ok(&mut a, "comment_mark", json!({ "doc": doc, "id": id, "author": "Carol", "marked": false })); + ok(&mut a, "doc_save", json!({ "doc": doc, "path": "statuses.pdf" })); + let doc = ok(&mut a, "doc_open", json!({ "path": "statuses.pdf" }))["doc"].as_u64().unwrap(); + let list = ok(&mut a, "comment_list", json!({ "doc": doc })); + assert_eq!(list["count"], 1, "status replies aren't comments: {list}"); + let c = &list["comments"][0]; + assert_eq!(c["statuses"], json!([{ "author": "Bob", "status": "Rejected" }, { "author": "Carol", "status": "Completed" }])); + assert_eq!(c["status"], "Rejected", "the latest set by anyone"); + assert_eq!((c["marked"].clone(), c["marked_by"].clone()), (json!(true), json!(["Bob"]))); + assert_eq!(c["replies"], json!([])); + + let bytes = std::fs::read(dir.join("statuses.pdf")).unwrap(); + let saved = pdfcraft_cos::Document::open(std::sync::Arc::new(bytes)).unwrap(); + let replies: Vec = saved + .object_numbers() + .into_iter() + .filter_map(|n| saved.get(pdfcraft_cos::ObjRef { num: n, generation: 0 }).as_dict().cloned()) + .filter(|d| d.contains(b"StateModel")) + .collect(); + assert_eq!(replies.len(), 6); + assert!(replies.iter().all(|d| d.int(b"F") == Some(30)), "every state reply is hidden: {replies:?}"); +} + +/// ISO 32000-2 §12.5.6.3: a state reply's `/IRT` "shall refer to the original annotation", which +/// for a status set on a reply is that reply. The reviewer's next change replies to their previous +/// state reply, and the chain, found by reference, stays the reply's: it is reported on the reply +/// and never on its comment. +#[test] +fn statuses_set_on_a_reply_chain_on_that_reply_through_tools() { + let dir = workdir("reply-statuses"); + let mut a = auto(&dir); + let doc = ok(&mut a, "doc_open", json!({ "path": "a.pdf" }))["doc"].as_u64().unwrap(); + let id = + ok(&mut a, "comment_add", json!({ "doc": doc, "page": 1, "type": "note", "at": [20, 20], "contents": "Check", "author": "Ada" }))["comment"] + ["id"] + .as_str() + .unwrap() + .to_string(); + ok(&mut a, "comment_reply", json!({ "doc": doc, "id": id, "text": "ANSWER", "author": "Bob" })); + let listed = ok(&mut a, "comment_list", json!({ "doc": doc })); + let reply_id = listed["comments"][0]["replies"][0]["id"].as_str().unwrap().to_string(); + for (status, author) in [("accepted", "Carol"), ("completed", "Dan"), ("rejected", "Carol")] { + ok(&mut a, "comment_set_status", json!({ "doc": doc, "id": reply_id, "status": status, "author": author })); + } + ok(&mut a, "doc_save", json!({ "doc": doc, "path": "reply-statuses.pdf" })); + let doc = ok(&mut a, "doc_open", json!({ "path": "reply-statuses.pdf" }))["doc"].as_u64().unwrap(); + let list = ok(&mut a, "comment_list", json!({ "doc": doc })); + let c = &list["comments"][0]; + assert_eq!((&c["status"], &c["statuses"]), (&Value::Null, &json!([])), "not the comment's: {c}"); + assert_eq!(c["replies"].as_array().map(Vec::len), Some(1), "{c}"); + let reply = &c["replies"][0]; + assert_eq!(reply["statuses"], json!([{ "author": "Carol", "status": "Rejected" }, { "author": "Dan", "status": "Completed" }]), "{reply}"); + + // Carol's second status replies to her first, which replies to the reply. + let bytes = std::fs::read(dir.join("reply-statuses.pdf")).unwrap(); + let saved = pdfcraft_cos::Document::open(std::sync::Arc::new(bytes)).unwrap(); + let dicts: Vec<(pdfcraft_cos::ObjRef, pdfcraft_cos::Dict)> = saved + .object_numbers() + .into_iter() + .map(|n| pdfcraft_cos::ObjRef { num: n, generation: 0 }) + .filter_map(|r| Some((r, saved.get(r).as_dict()?.clone()))) + .collect(); + let find = |text: &str| -> pdfcraft_cos::ObjRef { + dicts.iter().find(|(_, d)| d.get(b"Contents").and_then(|o| o.as_string()).is_some_and(|s| s.to_text().contains(text))).unwrap().0 + }; + let irt = |r: pdfcraft_cos::ObjRef| dicts.iter().find(|(x, _)| *x == r).and_then(|(_, d)| d.get(b"IRT").and_then(|o| o.as_ref())); + let (answer, first, second) = (find("ANSWER"), find("Accepted set by Carol"), find("Rejected set by Carol")); + assert_eq!(irt(first), Some(answer)); + assert_eq!(irt(second), Some(first)); +} + /// Typed text and text boxes take an oval or a circle for their border through the tools; /// comment_list reports it (a rectangle is not reported). Only typed text, dates and text boxes /// have one, and only the three shapes. diff --git a/crates/render/src/inspect.rs b/crates/render/src/inspect.rs index 0cf287b3f1..0079ba6f37 100644 --- a/crates/render/src/inspect.rs +++ b/crates/render/src/inspect.rs @@ -37,7 +37,7 @@ pub use core::{check_pages, inspect, pretty_date}; use core::{inspect_structure, spend}; pub use model::{ Annotation, Attachment, AttachmentSource, DestView, Field, FieldKind, FontInfo, Layer, LayerOp, Link, LinkTarget, OutlineItem, PageInfo, - PageTransition, RemoteDestination, TransitionStyle, Xfa, link_replies, thread_tops, + PageTransition, RemoteDestination, ReviewStates, TransitionStyle, Xfa, link_replies, thread_tops, }; #[cfg(test)] use navigation::{OUTLINE_DEPTH, OUTLINE_WINDOW}; diff --git a/crates/render/src/inspect/annotations.rs b/crates/render/src/inspect/annotations.rs index 1cc7a6eae1..bf5e8ebb93 100644 --- a/crates/render/src/inspect/annotations.rs +++ b/crates/render/src/inspect/annotations.rs @@ -1,5 +1,7 @@ use super::core::{limited_pdf_string, pretty_date, spend}; -use super::model::{Annotation, AttachmentSource, DestView, Field, FieldKind, LayerOp, Link, LinkTarget, RemoteDestination, link_replies}; +use super::model::{ + Annotation, AttachmentSource, DestView, Field, FieldKind, LayerOp, Link, LinkTarget, RemoteDestination, link_replies, state_or_default, +}; use super::navigation::text_string; use super::{Dictionary, DocInfo, Entry, HashMap, HashSet, Inspector, Object, ObjectId}; @@ -215,7 +217,7 @@ impl<'a> Inspector<'a> { rect, color, index, - state: self.text(d, b"State"), + state: state_or_default(self.text(d, b"State"), self.text(d, b"StateModel").as_deref()), quads, locked: d.get(b"F").ok().and_then(|f| self.resolve(f).as_i64().ok()).unwrap_or(0) & 128 != 0, intent: self.name(d, b"IT"), diff --git a/crates/render/src/inspect/model.rs b/crates/render/src/inspect/model.rs index 981de248ae..dc54296c83 100644 --- a/crates/render/src/inspect/model.rs +++ b/crates/render/src/inspect/model.rs @@ -1,4 +1,4 @@ -use super::HashMap; +use super::{HashMap, HashSet}; /// What kind of XFA form a document has (`/AcroForm /XFA`). #[derive(Clone, Copy, Debug, PartialEq, Eq)] @@ -425,10 +425,12 @@ fn rect_from_upright(shown: [f64; 4], rotation: u16) -> [f64; 4] { } /// Settle the reply links of a document's annotations: a reply keeps its `parent` only when -/// following the links reaches a comment of its own within 64 steps. An `/IRT` to an unlisted -/// object, a pop-up, the annotation itself or a loop leaves it a comment of its own, so it stays -/// in view instead of hanging off nothing. Both comment readers (inspect, and the engine's list -/// after an edit) run this. +/// following the links reaches a comment of its own. An `/IRT` to an unlisted object, a pop-up, +/// the annotation itself or a loop leaves it a comment of its own, so it stays in view instead of +/// hanging off nothing. A chain of any length settles: each reviewer's state changes reply to +/// their previous one (ISO 32000-2 §12.5.6.3), so a long review history is a long chain. Each +/// annotation is walked once, with a seen-set against loops. Both comment readers (inspect, and +/// the engine's list after an edit) run this. /// /// Only comments can be parents: both readers leave links, form widgets, pop-ups and other /// non-markup annotations out of `annotations` (`pdfcraft_annot::is_comment_subtype`), so an @@ -439,38 +441,181 @@ pub fn link_replies(annotations: &mut [Annotation]) { annotations.iter().enumerate().filter(|(_, a)| a.subtype != "Popup").map(|(i, a)| ((a.page, a.index), i)).collect(); let links: Vec> = annotations.iter().enumerate().map(|(i, a)| a.parent.and_then(|p| at.get(&p).copied()).filter(|&j| j != i)).collect(); - for (i, a) in annotations.iter_mut().enumerate() { - let mut up = links.get(i).copied().flatten(); - let mut settled = false; - for _ in 0..64 { - let Some(j) = up else { break }; - up = links.get(j).copied().flatten(); - if up.is_none() { - settled = true; - break; + // Whether each annotation's chain reaches a comment of its own, filled in along each walk. + let mut settled: Vec> = vec![None; annotations.len()]; + for start in 0..annotations.len() { + let (mut path, mut seen, mut at) = (Vec::new(), HashSet::new(), start); + let ends_well = loop { + match settled.get(at).copied().flatten() { + Some(known) => break known, + None if !seen.insert(at) => break false, + None => {} + } + path.push(at); + match links.get(at).copied().flatten() { + Some(up) => at = up, + None => break true, + } + }; + for i in path { + if let Some(s) = settled.get_mut(i) { + *s = Some(ends_well); } } - if !settled { + } + // A reply keeps its parent when it has a listed one and its chain reaches a comment; one whose + // `/IRT` leads nowhere listed (itself, an unlisted object) is a comment of its own. + for ((a, ok), link) in annotations.iter_mut().zip(settled).zip(links) { + if link.is_none() || ok != Some(true) { a.parent = None; } } } /// The comment each reply's thread starts from, by the reply's page and `/Annots` position: a -/// reply to a reply files under that comment too. Expects links [`link_replies`] settled. +/// reply to a reply files under that comment too. Expects links [`link_replies`] settled; a loop +/// in an unsettled list ends where it comes back round. pub fn thread_tops(annotations: &[Annotation]) -> HashMap<(usize, usize), (usize, usize)> { let parents: HashMap<(usize, usize), (usize, usize)> = annotations.iter().filter_map(|a| Some(((a.page, a.index), a.parent?))).collect(); - parents - .iter() - .map(|(&reply, &first)| { - let mut top = first; - for _ in 0..64 { - let Some(&up) = parents.get(&top) else { break }; - top = up; + let mut tops: HashMap<(usize, usize), (usize, usize)> = HashMap::new(); + for &reply in parents.keys() { + let (mut path, mut seen, mut at) = (Vec::new(), HashSet::new(), reply); + let top = loop { + if let Some(&known) = tops.get(&at) { + break known; } - (reply, top) - }) - .collect() + let Some(&up) = parents.get(&at) else { break at }; + if !seen.insert(at) { + break up; + } + path.push(at); + at = up; + }; + for r in path { + tops.insert(r, top); + } + } + tops +} + +/// The status and checkmark replies of a document's annotations, by the annotation whose state +/// each one sets, found by object reference (the settled `parent` links of [`link_replies`]). +/// +/// ISO 32000-2 §12.5.6.3: a state change is a text annotation whose `/IRT` "shall refer to the +/// original annotation" and whose `/State` and `/StateModel` "update the state of the original +/// annotation for the specified user" (`/T`); "Additional state changes shall be made by adding +/// text annotations in reply to the previous reply for a given user". So a state reply sets the +/// state of the first annotation up its `/IRT` chain that is not itself a state reply: the +/// comment, or the reply the state was set on (a reply is an annotation with a state of its own, +/// and is never folded into its thread's comment). There is one status per reviewer, and a +/// checkmark only for the reviewer who set it. +pub struct ReviewStates<'a> { + by_target: HashMap<(usize, usize), Vec<&'a Annotation>>, +} + +impl<'a> ReviewStates<'a> { + pub fn new(all: &'a [Annotation]) -> Self { + let at: HashMap<(usize, usize), &Annotation> = all.iter().filter(|a| a.subtype != "Popup").map(|a| ((a.page, a.index), a)).collect(); + // The annotation each state reply's chain reaches past other state replies, `None` for a + // loop or a reference to nothing listed; filled in along each walk, so a long history + // costs one walk, not one per reply. + let mut reaches: HashMap<(usize, usize), Option<(usize, usize)>> = HashMap::new(); + let mut by_target: HashMap<(usize, usize), Vec<&Annotation>> = HashMap::new(); + for r in all.iter().filter(|a| a.state.is_some()) { + let (mut path, mut seen, mut up) = (vec![(r.page, r.index)], HashSet::from([(r.page, r.index)]), r.parent); + let target = loop { + let Some(key) = up else { break None }; + if let Some(&known) = reaches.get(&key) { + break known; + } + match at.get(&key) { + None => break None, + Some(p) if p.state.is_none() => break Some(key), + Some(_) if !seen.insert(key) => break None, + Some(p) => { + path.push(key); + up = p.parent; + } + } + }; + for key in path { + reaches.insert(key, target); + } + if let Some(t) = target { + by_target.entry(t).or_default().push(r); + } + } + Self { by_target } + } + + /// Every status and checkmark reply about `target`, in document order. + pub fn history(&self, target: &Annotation) -> &[&'a Annotation] { + self.by_target.get(&(target.page, target.index)).map(Vec::as_slice).unwrap_or(&[]) + } + + /// Each reviewer's review status on `target`: their latest, in the order they first set one. + /// Empty when nobody set one; a status set back to "None" stays listed, since None is a state + /// (ISO 32000-2 Table 174: "The user has indicated nothing about the change"). + pub fn statuses(&self, target: &Annotation) -> Vec<(&'a str, &'a str)> { + self.by_reviewer(target, false).into_iter().filter_map(|(who, mine)| Some((who, latest(&mine)?.state.as_deref()?))).collect() + } + + /// The latest review status set on `target` by anyone, for callers that show one. + pub fn latest_status(&self, target: &Annotation) -> Option<&'a str> { + self.history(target).iter().rev().filter(|r| !r.is_mark()).find_map(|r| r.state.as_deref()) + } + + /// Whether `author`'s latest checkmark on `target` is "Marked". Checkmarks are each + /// reviewer's own (ISO 32000-2 Table 174: "The annotation has been marked by the user"). + pub fn marked_by(&self, target: &Annotation, author: &str) -> bool { + let mine: Vec<&Annotation> = self.history(target).iter().copied().filter(|r| r.is_mark() && reviewer(r) == author.trim()).collect(); + latest(&mine).is_some_and(|r| r.state.as_deref() == Some("Marked")) + } + + /// The reviewers whose latest checkmark on `target` is "Marked". + pub fn marked(&self, target: &Annotation) -> Vec<&'a str> { + self.by_reviewer(target, true) + .into_iter() + .filter(|(_, mine)| latest(mine).is_some_and(|r| r.state.as_deref() == Some("Marked"))) + .map(|(who, _)| who) + .collect() + } + + /// The checkmark (`marks`) or status replies about `target`, by reviewer, in the order each + /// reviewer first set one. + fn by_reviewer(&self, target: &Annotation, marks: bool) -> Vec<(&'a str, Vec<&'a Annotation>)> { + let mut out: Vec<(&str, Vec<&Annotation>)> = Vec::new(); + for &r in self.history(target).iter().filter(|r| r.is_mark() == marks) { + match out.iter_mut().find(|(who, _)| *who == reviewer(r)) { + Some((_, mine)) => mine.push(r), + None => out.push((reviewer(r), vec![r])), + } + } + out + } +} + +/// Of one reviewer's replies in one state model, the latest: the end of their chain, a reply none +/// of the others replies to (by reference), whatever order the file lists them in. Among several +/// such (changes that don't chain), the last listed. +fn latest<'b>(mine: &[&'b Annotation]) -> Option<&'b Annotation> { + let answered: HashSet<(usize, usize)> = mine.iter().filter_map(|r| r.parent).collect(); + mine.iter().rev().find(|r| !answered.contains(&(r.page, r.index))).copied() +} + +/// A reply's state: `/State`, or for a reply that gives only `/StateModel` the model's default +/// (ISO 32000-2 Table 175: "Unmarked if StateModel is Marked; None if StateModel is Review"). +pub(super) fn state_or_default(state: Option, model: Option<&str>) -> Option { + state.or_else(|| match model? { + "Marked" => Some("Unmarked".into()), + "Review" => Some("None".into()), + _ => None, + }) +} + +/// Who set a state: `/T`, trimmed (an author setting typed with spaces is the same reviewer). +fn reviewer(r: &Annotation) -> &str { + r.author.as_deref().unwrap_or("").trim() } #[derive(Clone, Copy, Debug, PartialEq, Eq)] @@ -542,3 +687,257 @@ pub struct FontInfo { /// the document's own glyphs are being shown. pub substitution: Option, } + +/// The review states and reply links of synthetic annotation lists. +#[cfg(test)] +mod review_state_tests { + use super::{Annotation, ReviewStates, link_replies, thread_tops}; + + /// One annotation of a synthetic page: `key` is its `/NM`, and `irt` the key of the + /// annotation its `/IRT` references, resolved by position as the readers resolve references. + type Spec<'s> = (&'s str, Option<&'s str>, &'s str, Option<&'s str>); + + /// The annotations of `specs` on page 0, in order, with their reply links settled as the + /// readers settle them. An `irt` that names no key references nothing listed. + fn listed(specs: &[Spec<'_>]) -> Vec { + let mut all: Vec = specs + .iter() + .enumerate() + .map(|(index, &(nm, irt, author, state))| Annotation { + page: 0, + subtype: "Text".into(), + author: Some(author.into()), + contents: None, + modified: None, + name: Some(nm.into()), + in_reply_to: irt.map(Into::into), + parent: irt.map(|k| (0, specs.iter().position(|s| s.0 == k).unwrap_or(usize::MAX))), + grouped: false, + rect: [0.0; 4], + color: None, + index, + state: state.map(Into::into), + quads: Vec::new(), + locked: false, + intent: None, + fill_sign: false, + no_rotate: false, + points: Vec::new(), + }) + .collect(); + link_replies(&mut all); + all + } + + fn by_name<'a>(all: &'a [Annotation], nm: &str) -> &'a Annotation { + all.iter().find(|a| a.name.as_deref() == Some(nm)).expect("listed") + } + + /// The states these replies set (ISO 32000-2 §12.5.6.3, with synthetic files): one status per + /// reviewer, their latest, whether or not their changes chain; "None" stays listed; a + /// checkmark only for the reviewer who set it. + #[test] + fn review_states_are_each_reviewers_latest_through_chains() { + let all = listed(&[ + ("c", None, "Alice", None), + ("b1", Some("c"), "Bob", Some("Accepted")), + ("k1", Some("c"), "Carol", Some("Completed")), + ("b2", Some("b1"), "Bob", Some("Rejected")), // chained, per §12.5.6.3 + ("r", Some("c"), "Dan", None), // a reply, not a state + ("rs", Some("r"), "Dan", Some("Cancelled")), // a state on the reply, not on c + ("m1", Some("c"), "Bob", Some("Marked")), + ("m2", Some("m1"), "Bob", Some("Unmarked")), + ("m3", Some("c"), "Carol", Some("Marked")), + ("n", None, "Alice", None), + ("n1", Some("n"), "Bob", Some("Completed")), + ("n2", Some("n"), "Bob", Some("None")), // unchained, as PdfCraft wrote it before + // A loop of state replies belongs to nothing. + ("x", Some("y"), "Eve", Some("Accepted")), + ("y", Some("x"), "Eve", Some("Accepted")), + ]); + let states = ReviewStates::new(&all); + let (c, r, n, x) = (by_name(&all, "c"), by_name(&all, "r"), by_name(&all, "n"), by_name(&all, "x")); + assert_eq!(states.statuses(c), [("Bob", "Rejected"), ("Carol", "Completed")]); + assert_eq!(states.latest_status(c), Some("Rejected")); + assert_eq!(states.history(c).len(), 6, "every change, the checkmarks included"); + assert_eq!(states.statuses(r), [("Dan", "Cancelled")]); + assert!(!states.marked_by(c, "Bob"), "Bob took his off"); + assert!(states.marked_by(c, "Carol")); + assert!(!states.marked_by(c, "Alice"), "nobody else's checkmark shows"); + assert_eq!(states.marked(c), ["Carol"]); + assert_eq!(states.statuses(n), [("Bob", "None")], "a status set back to None stays listed"); + assert!(states.statuses(x).is_empty() && states.history(x).is_empty()); + } + + /// §12.5.6.3: a state reply's `/IRT` "shall refer to the original annotation", and a reply is + /// an annotation of its own. A status set on a reply, and the changes that chain from it, are + /// that reply's and never its thread's comment's, at any depth of the thread. + #[test] + fn a_status_on_a_reply_and_its_chain_stay_the_replys() { + let all = listed(&[ + ("c", None, "Alice", None), + ("r", Some("c"), "Bob", None), + ("rr", Some("r"), "Carol", None), + ("s1", Some("rr"), "Dan", Some("Accepted")), + ("s2", Some("s1"), "Dan", Some("Rejected")), + ("e1", Some("s2"), "Eve", Some("Completed")), // Eve answering Dan's state reply + ("m1", Some("r"), "Dan", Some("Marked")), + ]); + let states = ReviewStates::new(&all); + let (c, r, rr) = (by_name(&all, "c"), by_name(&all, "r"), by_name(&all, "rr")); + assert!(states.history(c).is_empty() && states.statuses(c).is_empty(), "not folded to the comment"); + assert!(states.statuses(r).is_empty() && states.marked_by(r, "Dan")); + assert_eq!(states.statuses(rr), [("Dan", "Rejected"), ("Eve", "Completed")]); + assert!(states.history(by_name(&all, "s1")).is_empty(), "a state reply has no states of its own"); + } + + /// States follow object references, not `/NM`: two comments with the same `/NM` (some writers + /// name the first comment on every page alike) each keep their own statuses. + #[test] + fn states_follow_references_not_repeated_names() { + let mut all = listed(&[ + ("a", None, "Alice", None), + ("b", None, "Alice", None), + ("sa", Some("a"), "Bob", Some("Accepted")), + ("sb", Some("b"), "Bob", Some("Rejected")), + ("sb2", Some("sb"), "Bob", Some("Completed")), + ]); + for a in all.iter_mut().take(2) { + a.name = Some("dup".into()); + } + for s in all.iter_mut().skip(2) { + if s.in_reply_to.as_deref().is_some_and(|k| k == "a" || k == "b") { + s.in_reply_to = Some("dup".into()); + } + } + let states = ReviewStates::new(&all); + assert_eq!(states.statuses(&all[0]), [("Bob", "Accepted")]); + assert_eq!(states.statuses(&all[1]), [("Bob", "Completed")]); + } + + /// A state reply whose `/IRT` references nothing listed, or nothing at all, sets no + /// annotation's state, and a loop that never reaches an annotation without a state belongs to + /// nothing even in a list whose links were not settled. + #[test] + fn a_state_reply_without_a_listed_target_belongs_to_nothing() { + let all = listed(&[ + ("c", None, "Alice", None), + ("gone", Some("deleted"), "Bob", Some("Accepted")), + ("bare", None, "Bob", Some("Rejected")), + ("next", Some("gone"), "Bob", Some("Completed")), + ]); + let states = ReviewStates::new(&all); + assert!(all.iter().all(|a| states.history(a).is_empty())); + // Unsettled: the loop's links are still in place. + let mut raw = listed(&[("c", None, "Alice", None)]); + let mut x = raw[0].clone(); + (x.index, x.name, x.state, x.parent) = (1, Some("x".into()), Some("Accepted".into()), Some((0, 2))); + let mut y = x.clone(); + (y.index, y.name, y.parent) = (2, Some("y".into()), Some((0, 1))); + raw.extend([x, y]); + let states = ReviewStates::new(&raw); + assert!(raw.iter().all(|a| states.history(a).is_empty())); + } + + /// A long reply chain, as a reviewer's long status history makes (§12.5.6.3: each change + /// replies to the previous), settles, and its replies file under the comment; a loop, an `/IRT` + /// to itself and one to nothing listed leave an annotation a comment of its own. + #[test] + fn reply_links_settle_at_any_depth_but_not_in_a_loop() { + let keys: Vec = (0..200).map(|i| format!("r{i}")).collect(); + let mut specs: Vec> = vec![("c", None, "Alice", None)]; + for (i, k) in keys.iter().enumerate() { + specs.push((k, Some(if i == 0 { "c" } else { keys[i - 1].as_str() }), "Bob", None)); + } + specs.extend([ + ("x", Some("y"), "Eve", None), + ("y", Some("x"), "Eve", None), + ("z", Some("x"), "Eve", None), + ("me", Some("me"), "Eve", None), + ("lost", Some("unlisted"), "Eve", None), + ]); + // In file order and newest first: the first reply read then walks the whole chain at once. + for newest_first in [false, true] { + if newest_first { + specs.reverse(); + } + let all = listed(&specs); + let replies: Vec<&Annotation> = all.iter().filter(|a| a.name.as_deref().is_some_and(|n| n.starts_with('r'))).collect(); + assert!(replies.len() == 200 && replies.iter().all(|a| a.is_reply()), "{newest_first}"); + let nowhere = all.iter().filter(|a| matches!(a.name.as_deref(), Some("x" | "y" | "z" | "me" | "lost"))); + assert!(nowhere.clone().count() == 5 && nowhere.clone().all(|a| !a.is_reply()), "a loop, a chain into one, itself or nothing listed"); + let c = by_name(&all, "c"); + let tops = thread_tops(&all); + assert_eq!(tops.len(), 200, "{newest_first}"); + assert!(tops.values().all(|&t| t == (c.page, c.index)), "{newest_first}"); + } + } + + /// Review on #4126: a history has no length limit, and a reviewer's name is compared without + /// surrounding spaces. + #[test] + fn a_long_chain_still_reaches_its_comment_and_names_are_trimmed() { + let keys: Vec = (0..200).map(|i| format!("s{i}")).collect(); + let mut specs: Vec> = vec![("c", None, "Alice", None)]; + for (i, k) in keys.iter().enumerate() { + let parent = if i == 0 { "c" } else { keys[i - 1].as_str() }; + specs.push((k, Some(parent), "Bob", Some(if i == 199 { "Completed" } else { "Accepted" }))); + } + specs.push(("m", Some("c"), " Carol ", Some("Marked"))); + let all = listed(&specs); + let states = ReviewStates::new(&all); + assert_eq!(states.history(&all[0]).len(), 201); + assert_eq!(states.statuses(&all[0]), [("Bob", "Completed")]); + assert!(states.marked_by(&all[0], "Carol") && states.marked_by(&all[0], "Carol ")); + assert_eq!(states.marked(&all[0]), ["Carol"]); + // Listed newest first, the first reply read walks the whole chain at once. + specs.reverse(); + let all = listed(&specs); + let states = ReviewStates::new(&all); + let c = by_name(&all, "c"); + assert_eq!(states.statuses(c), [("Bob", "Completed")]); + assert_eq!(states.history(c).len(), 201); + } + + /// The inspector reads a `/StateModel`-only reply as the model's default state, so it counts + /// as Bob's status (None) on the note. + #[test] + fn the_inspector_reads_a_state_model_alone_as_its_default() { + let objs: Vec<&[u8]> = vec![ + b"<< /Type /Catalog /Pages 2 0 R >>", + b"<< /Type /Pages /Kids [3 0 R] /Count 1 >>", + b"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] /Annots [4 0 R 5 0 R] >>", + b"<< /Type /Annot /Subtype /Text /Rect [10 10 30 30] /NM (note) /T (Ann) >>", + b"<< /Type /Annot /Subtype /Text /Rect [10 10 30 30] /NM (bare) /T (Bob) /IRT 4 0 R /F 30 /StateModel (Review) >>", + ]; + let mut out = b"%PDF-1.7\n".to_vec(); + let mut offsets = Vec::new(); + for (i, o) in objs.iter().enumerate() { + offsets.push(out.len()); + out.extend_from_slice(format!("{} 0 obj\n", i + 1).as_bytes()); + out.extend_from_slice(o); + out.extend_from_slice(b"\nendobj\n"); + } + let xref = out.len(); + out.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objs.len() + 1).as_bytes()); + for o in offsets { + out.extend_from_slice(format!("{o:010} 00000 n \n").as_bytes()); + } + out.extend_from_slice(format!("trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n", objs.len() + 1).as_bytes()); + let info = crate::inspect(std::sync::Arc::new(out), None).expect("inspects"); + let note = by_name(&info.annotations, "note"); + assert_eq!(ReviewStates::new(&info.annotations).statuses(note), [("Bob", "None")]); + } + + /// ISO 32000-2 Table 175: a missing `/State` is "Unmarked if StateModel is Marked; None if + /// StateModel is Review". + #[test] + fn a_state_model_alone_gives_its_default_state() { + use super::state_or_default; + assert_eq!(state_or_default(None, Some("Review")).as_deref(), Some("None")); + assert_eq!(state_or_default(None, Some("Marked")).as_deref(), Some("Unmarked")); + assert_eq!(state_or_default(None, Some("Other")), None); + assert_eq!(state_or_default(None, None), None); + assert_eq!(state_or_default(Some("Accepted".into()), Some("Review")).as_deref(), Some("Accepted")); + } +} diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs index 6f9a3ac319..245f3a53f3 100644 --- a/crates/render/src/lib.rs +++ b/crates/render/src/lib.rs @@ -19,7 +19,8 @@ pub mod transition; pub use inspect::{ Annotation, Attachment, AttachmentSource, DestView, DocInfo, Field, FieldKind, FontInfo, Layer, LayerOp, Link, LinkTarget, OutlineItem, PageInfo, - PageTransition, RemoteDestination, TransitionStyle, Xfa, attachment_data, check_pages, inspect, link_replies, pretty_date, thread_tops, + PageTransition, RemoteDestination, ReviewStates, TransitionStyle, Xfa, attachment_data, check_pages, inspect, link_replies, pretty_date, + thread_tops, }; pub use pixels::Pixels; pub use raster::{ diff --git a/crates/ui-egui/src/dialogs/comment_props.rs b/crates/ui-egui/src/dialogs/comment_props.rs index 7b404c2f76..2309e7e600 100644 --- a/crates/ui-egui/src/dialogs/comment_props.rs +++ b/crates/ui-egui/src/dialogs/comment_props.rs @@ -312,10 +312,12 @@ pub(crate) fn body(ui: &mut egui::Ui, app: &mut PdfCraftApp, t: &Tokens) -> (boo .zip(app.comment_props.as_ref()) .map(|(doc, d)| { let me = doc.info.annotations.iter().find(|a| a.page == d.page && a.index == d.index && !a.is_reply()); - doc.info - .annotations + // Every state change set on it, including a reviewer's later ones, which reply to + // their earlier (ISO 32000-2 §12.5.6.3). + let states = pdfcraft_render::ReviewStates::new(&doc.info.annotations); + me.map(|a| states.history(a)) + .unwrap_or_default() .iter() - .filter(|r| r.state.is_some() && me.is_some_and(|a| r.replies_to(a))) .map(|r| (r.state.clone().unwrap_or_default(), r.author.clone().unwrap_or_default(), r.modified.clone().unwrap_or_default())) .collect() }) diff --git a/crates/ui-egui/src/panels/comments_panel.rs b/crates/ui-egui/src/panels/comments_panel.rs index f6b0043637..cc328db5ab 100644 --- a/crates/ui-egui/src/panels/comments_panel.rs +++ b/crates/ui-egui/src/panels/comments_panel.rs @@ -1,16 +1,16 @@ //! The Comments panel (Acrobat's right-hand comment list; 11-visual-spec §3, audit `a15-*`). //! -//! Header "Comments N" (comments, as Acrobat counts them) with "N replies" under it and search; -//! an "Add a comment" box that posts a sticky note on the current page; cards grouped by page. -//! Selecting a card selects the comment on the page and opens its reply box and "…" menu (Edit, -//! Set status, Delete), and gives each reply a "…" of its own (Edit, Copy text, Delete). Status -//! replies show as a badge. +//! Header "Comments N" (comments) with "N replies" under it and search; an "Add a comment" box +//! that posts a sticky note on the current page; cards grouped by page. Selecting a card selects +//! the comment on the page and opens its reply box and "…" menu (Edit, Set status, Delete), and +//! gives each reply a "…" of its own (Edit, Copy text, Delete). Status replies show as one badge +//! per reviewer. use std::collections::HashMap; use egui::{Align, Color32, CornerRadius, Layout, Sense, Stroke, vec2}; use pdfcraft_engine::{Edit, NoteIcon, ReviewState, Shape}; -use pdfcraft_render::{Annotation, DocInfo}; +use pdfcraft_render::{Annotation, DocInfo, ReviewStates}; use crate::canvas::DocView; use crate::comments::{CommentPrefs, CommentTool, SortBy, color32, status_badge}; @@ -84,13 +84,7 @@ fn check_label(marked: bool) -> &'static str { if marked { "Checked" } else { "Unchecked" } } -/// The latest checkmark reply in a thread says "Marked". -pub fn is_marked(thread: &[&Annotation]) -> bool { - thread.iter().rev().find(|r| r.is_mark()).is_some_and(|r| r.state.as_deref() == Some("Marked")) -} - -/// The number shown in the header: comments, not their replies or status changes (as Acrobat -/// counts them). +/// The number shown in the header: comments, not their replies or status changes. pub fn count(info: &DocInfo) -> usize { info.annotations.iter().filter(|a| a.is_comment()).count() } @@ -178,15 +172,18 @@ pub(crate) fn show( let mut page_counts: HashMap = HashMap::new(); // Replies find their parent by reference (`/NM` need not be unique) and keep source order. // A reply at any depth joins the thread of the comment it starts from, as comment_list lists - // it. A status or checkmark stays with the annotation it was set on (its `/IRT` names it, - // ISO 32000-2 §12.5.6.2): one set on a reply, at any depth, is that reply's and not the - // comment's, so the card's badge and checkmark leave it out (comment_list reports it on the - // reply). Page badges count roots before filtering. + // it. Status and checkmark replies are read through `states`: each sets the state of the + // annotation its `/IRT` chain refers to past earlier state replies (ISO 32000-2 §12.5.6.3), + // so one set on a reply, at any depth, is that reply's and not the comment's, and the card's + // badges and checkmark leave it out (comment_list reports it on the reply). Page badges count + // roots before filtering. let tops = pdfcraft_render::thread_tops(&info.annotations); + let states = ReviewStates::new(&info.annotations); for a in &info.annotations { if let Some(parent) = a.parent { - let key = if a.state.is_none() { tops.get(&(a.page, a.index)).copied().unwrap_or(parent) } else { parent }; - replies.entry(key).or_default().push(a); + if a.state.is_none() { + replies.entry(tops.get(&(a.page, a.index)).copied().unwrap_or(parent)).or_default().push(a); + } } else if a.is_comment() { let count = page_counts.entry(a.page).or_default(); *count = count.saturating_add(1); @@ -194,18 +191,31 @@ pub(crate) fn show( } let replies_of = |a: &Annotation| -> &[&Annotation] { replies.get(&(a.page, a.index)).map(Vec::as_slice).unwrap_or(&[]) }; let cv = &view.comments; - let status_of = - |a: &Annotation| replies_of(a).iter().rev().filter(|r| !r.is_mark()).find_map(|r| r.state.clone()).unwrap_or_else(|| "None".into()); + // Hidden by the status filter when every reviewer's status is hidden ("None" when nobody set one). + let status_hidden = |a: &Annotation| { + let all = states.statuses(a); + if all.is_empty() { + cv.hidden_statuses.iter().any(|s| s == "None") + } else { + all.iter().all(|(_, s)| cv.hidden_statuses.iter().any(|h| h == s)) + } + }; let mut roots: Vec<&Annotation> = info .annotations .iter() .filter(|a| a.is_comment()) - .filter(|a| query.as_deref().is_none_or(|q| matches(a, q) || replies_of(a).iter().any(|r| matches(r, q)))) + // Search finds a comment by its text, its replies' and the status replies set on any of + // them: one set on a reply is that reply's (`states`), but it belongs to this thread. + .filter(|a| { + query.as_deref().is_none_or(|q| { + std::iter::once(*a).chain(replies_of(a).iter().copied()).any(|x| matches(x, q) || states.history(x).iter().any(|r| matches(r, q))) + }) + }) .filter(|a| !cv.hidden_types.iter().any(|t| t == kind_label(a))) .filter(|a| !cv.hidden_authors.contains(&a.author.clone().unwrap_or_default())) - .filter(|a| !cv.hidden_statuses.contains(&status_of(a))) + .filter(|a| !status_hidden(a)) .filter(|a| !cv.hidden_colors.contains(&color_key(a))) - .filter(|a| !cv.hidden_checks.contains(&check_label(is_marked(replies_of(a))).to_string())) + .filter(|a| !cv.hidden_checks.contains(&check_label(states.marked_by(a, &prefs.author)).to_string())) .collect(); let sort = cv.sort; match sort { @@ -282,7 +292,7 @@ pub(crate) fn show( // A card with an editor open on it or on a reply is always drawn too (CodeRabbit on // #5583): a kept height would show it at its size before the editor opened. let editing = view.comments.editing.as_ref().is_some_and(|(p, i, _)| (*p, *i) == key || thread.iter().any(|r| (r.page, r.index) == (*p, *i))); - let id = ui.scope_id().with(("comment-card", view.id, key, card_signature(a, thread), selected, editing)); + let id = ui.scope_id().with(("comment-card", view.id, key, card_signature(a, thread, &states), selected, editing)); // The selected card is always drawn, so it can scroll itself into view and keep its menu // and editor. if virtual_list && !selected && !editing && skip_if_hidden(ui, id) { @@ -290,7 +300,9 @@ pub(crate) fn show( } let rotation = info.pages.get(a.page).map_or(0, |p| p.rotation); let top = ui.cursor().min.y; - if let Some(e) = card(ui, view, CardContext { theme: t, annotation: a, rotation, thread, order: &order, doc, prefs, allowed, nav }) { + if let Some(e) = + card(ui, view, CardContext { theme: t, annotation: a, rotation, thread, order: &order, states: &states, doc, prefs, allowed, nav }) + { edit = Some(e); } if virtual_list { @@ -332,14 +344,18 @@ fn page_header(ui: &mut egui::Ui, t: &Tokens, info: &DocInfo, page: usize, n: us } /// What a card's height depends on in the document: the comment and its replies as shown (their -/// kind, author, text, date, status, colour and lock). Two cards with the same signature lay out +/// kind, author, text, date, status, colour and lock), and the status and checkmark replies set on +/// each, which the thread leaves out and `states` reads. Two cards with the same signature lay out /// alike at one width. -fn card_signature(a: &Annotation, thread: &[&Annotation]) -> u64 { +fn card_signature(a: &Annotation, thread: &[&Annotation], states: &ReviewStates) -> u64 { use std::hash::{Hash, Hasher}; let mut h = std::collections::hash_map::DefaultHasher::new(); for x in std::iter::once(a).chain(thread.iter().copied()) { (&x.subtype, &x.author, &x.contents, &x.modified, &x.state, x.locked).hash(&mut h); x.color.map(|c| c.map(f32::to_bits)).hash(&mut h); + for r in states.history(x) { + (&r.subtype, &r.author, &r.modified, &r.state).hash(&mut h); + } } h.finish() } @@ -381,6 +397,7 @@ struct CardContext<'a> { thread: &'a [&'a Annotation], /// The cards in the order shown, for a ⇧-click range. order: &'a [(usize, usize)], + states: &'a ReviewStates<'a>, doc: &'a pdfcraft_engine::Document, prefs: &'a CommentPrefs, allowed: bool, @@ -388,7 +405,7 @@ struct CardContext<'a> { } fn card(ui: &mut egui::Ui, view: &mut DocView, context: CardContext<'_>) -> Option { - let CardContext { theme: t, annotation: a, rotation, thread, order, doc, prefs, allowed, nav } = context; + let CardContext { theme: t, annotation: a, rotation, thread, order, states, doc, prefs, allowed, nav } = context; let info = &doc.info; let mut edit = None; let key = (a.page, a.index); @@ -397,9 +414,11 @@ fn card(ui: &mut egui::Ui, view: &mut DocView, context: CardContext<'_>) -> Opti // the handles. let primary = view.comments.selected == Some(key); let color = a.color.map(|c| color32(c.map(f64::from))).unwrap_or(t.accent); - let status = thread.iter().rev().filter(|r| !r.is_mark()).find_map(|r| r.state.as_deref()); - let marked = is_marked(thread); - let replies: Vec<&&Annotation> = thread.iter().filter(|r| r.state.is_none()).collect(); + // Each reviewer's status, and the checkmark of the reviewer using this copy (prefs.author): + // checkmarks are each reviewer's own (ISO 32000-2 Table 174, "marked by the user"). + let statuses = states.statuses(a); + let marked = states.marked_by(a, &prefs.author); + let replies: Vec<&&Annotation> = thread.iter().collect(); let editing = view.comments.editing.as_ref().is_some_and(|(p, i, _)| (*p, *i) == key); let fill = if selected { t.hover.gamma_multiply(0.7) } else { Color32::TRANSPARENT }; let mut open_menu = false; @@ -460,11 +479,15 @@ fn card(ui: &mut egui::Ui, view: &mut DocView, context: CardContext<'_>) -> Opti widgets::wrapped_text(ui, body, theme::regular(13.0), t.text, !selected); } } - if let Some((icon, label, c)) = status.and_then(status_badge) { + // One badge per reviewer, with their name: a state is "for the specified user" + // (ISO 32000-2 §12.5.6.3). + for (who, state) in &statuses { + let Some((icon, label, c)) = status_badge(state) else { continue }; ui.add_space(4.0); ui.horizontal(|ui| { ui.add(icons::image(icon, 13.0, c)); - ui.label(egui::RichText::new(tl!(label)).font(theme::medium(11.5)).color(c)); + let text = if who.is_empty() { tl!(label).to_string() } else { format!("{who}: {}", tl!(label)) }; + ui.label(egui::RichText::new(text).font(theme::medium(11.5)).color(c)); }); } }); diff --git a/crates/ui-egui/src/tools/comments/menu.rs b/crates/ui-egui/src/tools/comments/menu.rs index a2c38b2628..9a599b7796 100644 --- a/crates/ui-egui/src/tools/comments/menu.rs +++ b/crates/ui-egui/src/tools/comments/menu.rs @@ -118,8 +118,9 @@ pub(crate) fn context_menu( } }); }); - let thread: Vec<&pdfcraft_render::Annotation> = info.annotations.iter().filter(|r| r.replies_to(a)).collect(); - let marked = crate::comments_panel::is_marked(&thread); + // The checkmark of the reviewer using this copy: checkmarks are each reviewer's own + // (ISO 32000-2 Table 174: "The annotation has been marked by the user"). + let marked = pdfcraft_render::ReviewStates::new(&info.annotations).marked_by(a, &prefs.author); if ui.add_enabled(allowed, egui::Button::new(tl!(if marked { "Remove checkmark" } else { "Mark with checkmark" }))).clicked() { action = Some(CanvasAction::Edit(Box::new(Edit::MarkAnnotation { page, index, marked: !marked, author: prefs.author.clone() }))); ui.close(); @@ -460,6 +461,8 @@ pub fn status_badge(state: &str) -> Option<(&'static str, &'static str, Color32) "Rejected" => Some(("thumbs-down", "Rejected", Color32::from_rgb(0xD3, 0x2F, 0x2F))), "Cancelled" => Some(("circle-x", "Cancelled", Color32::from_rgb(0x8E, 0x8E, 0x8E))), "Completed" => Some(("check", "Completed", Color32::from_rgb(0x2D, 0x9D, 0x5B))), + // A status set back to None stays shown: None is a state (ISO 32000-2 Table 174). + "None" => Some(("minus", "None", Color32::from_rgb(0x8E, 0x8E, 0x8E))), _ => None, } } diff --git a/crates/ui-egui/tests/it/comments.rs b/crates/ui-egui/tests/it/comments.rs index c9ab522ecd..599f1863e1 100644 --- a/crates/ui-egui/tests/it/comments.rs +++ b/crates/ui-egui/tests/it/comments.rs @@ -1627,6 +1627,32 @@ fn the_panel_preserves_threads_source_order_and_unfiltered_page_counts() { assert_eq!(h.query_all_by_label("Needle first reply").count(), 0, "nothing replies to the unnamed root"); } +// Contributor-original PDF. A status is set on a reply (its /IRT names the reply, ISO 32000-2 +// §12.5.6.3), not on the comment; the status reply's text is found only there. +const REPLY_STATUS_FIXTURE: &[u8] = b"%PDF-1.7 +1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj +2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 >> endobj +3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 300 200] /Annots [5 0 R 6 0 R 7 0 R 8 0 R] >> endobj +5 0 obj << /Type /Annot /Subtype /Text /Rect [20 140 40 160] /T (Alpha) /Contents (Plain root) >> endobj +6 0 obj << /Type /Annot /Subtype /Text /Rect [60 140 80 160] /T (Beta) /Contents (Other root) >> endobj +7 0 obj << /Type /Annot /Subtype /Text /Rect [20 100 40 120] /IRT 5 0 R /T (Reader) /Contents (Ordinary reply) >> endobj +8 0 obj << /Type /Annot /Subtype /Text /Rect [20 100 40 120] /IRT 7 0 R /T (Carol) /StateModel /Review /State /Accepted /Contents (Reply-status token) >> endobj +trailer << /Root 1 0 R >> +%%EOF"; + +#[test] +fn search_finds_a_comment_by_the_status_set_on_one_of_its_replies() { + let mut h = harness_with_bytes(REPLY_STATUS_FIXTURE.to_vec(), |app| { + app.set_option("panel", "comments").unwrap(); + app.views[0].comments.search = Some("reply-status token".into()); + }); + h.run_steps(2); + h.get_by_label("Plain root"); + h.get_by_label("Ordinary reply"); + assert_eq!(h.query_all_by_label("Other root").count(), 0, "the other comment has no such status"); + assert_eq!(h.query_all_by_label("No comments match.").count(), 0); +} + // Contributor-original PDF, shaped like PyMuPDF output: every page's first comment is named // `fitz-A0`, so only the `/IRT` reference tells which comment a reply belongs to. const PER_PAGE_NAMES: &[u8] = b"%PDF-1.7 @@ -1706,7 +1732,7 @@ fn one_reply_is_deleted_from_its_menu_and_the_thread_stays() { assert_eq!(h.query_all_by_label("Wrong reply").count(), 0); assert_eq!(h.query_all_by_label("Answer to the wrong reply").count(), 0); h.get_by_label("Right reply"); - h.get_by_label("Accepted"); + h.get_by_label("Ben: Accepted"); // One badge per reviewer. assert_eq!(h.state().session.get(id).unwrap().can_undo(), Some("Delete comment"), "one undo step"); // Copy text copies the reply's text. @@ -2333,6 +2359,68 @@ fn the_panel_filters_by_colour_and_checkmark() { assert_eq!(cards(&h), before); } +/// ISO 32000-2 §12.5.6.3 and Table 174: a comment shows each reviewer's latest status, with +/// their name, and a status set back to None stays shown; a checkmark shows only for the +/// reviewer who set it. +#[test] +fn the_panel_shows_each_reviewers_status_and_only_my_checkmark() { + let mut h = harness(|app| { + app.set_option("panel", "comments").unwrap(); + app.set_option("quick", "square").unwrap(); + }); + drag_pt(&mut h, (40.0, 100.0), (140.0, 40.0)); + h.state_mut().set_option("quick", "select").unwrap(); + let me = h.state().comment_prefs.author.clone(); + let other = format!("{me} (other)"); + for (state, author) in [ + (pdfcraft_engine::ReviewState::Accepted, "Bob"), + (pdfcraft_engine::ReviewState::Completed, "Carol"), + (pdfcraft_engine::ReviewState::Rejected, "Bob"), + (pdfcraft_engine::ReviewState::Completed, "Dan"), + (pdfcraft_engine::ReviewState::None, "Dan"), + ] { + h.state_mut().apply_edit(pdfcraft_engine::Edit::SetAnnotationStatus { page: 0, index: 0, state, author: author.into() }); + } + h.state_mut().apply_edit(pdfcraft_engine::Edit::MarkAnnotation { page: 0, index: 0, marked: true, author: other.clone() }); + h.state_mut().views[0].comments.selected = Some((0, 0)); + h.run_steps(2); + h.get_by_label("Bob: Rejected"); + h.get_by_label("Carol: Completed"); + h.get_by_label("Dan: None"); + assert_eq!(h.query_all_by_label("Bob: Accepted").count(), 0, "only Bob's latest"); + assert!(h.query_all_by_label("Remove checkmark").next().is_none(), "another reviewer's checkmark isn't mine"); + h.get_by_label("Mark with checkmark"); + + // Filters: a comment stays while any reviewer's status is shown; one nobody set a status on + // counts as None; the checkmark filter goes by my checkmark only. + h.state_mut().set_option("quick", "circle").unwrap(); + drag_pt(&mut h, (160.0, 100.0), (260.0, 40.0)); + h.state_mut().set_option("quick", "select").unwrap(); + h.state_mut().views[0].comments.selected = None; + h.run_steps(2); + let cards = |h: &Harness<'static, PdfCraftApp>| (h.query_all_by_label("Rectangle").count(), h.query_all_by_label("Oval").count()); + let shown = cards(&h); + h.state_mut().views[0].comments.hidden_statuses = vec!["Rejected".into()]; + h.run_steps(2); + assert_eq!(cards(&h), shown, "Carol's and Dan's statuses still show the rectangle"); + h.state_mut().views[0].comments.hidden_statuses = vec!["None".into()]; + h.run_steps(2); + assert_eq!(cards(&h), (shown.0, shown.1 - 1), "the oval has no status, so it counts as None"); + h.state_mut().views[0].comments.hidden_statuses = vec!["Rejected".into(), "Completed".into(), "None".into()]; + h.run_steps(2); + assert_eq!(cards(&h), (shown.0 - 1, shown.1 - 1), "every status hidden"); + h.state_mut().views[0].comments.hidden_statuses.clear(); + h.state_mut().views[0].comments.hidden_checks = vec!["Checked".into()]; + h.run_steps(2); + assert_eq!(cards(&h), shown, "another reviewer's checkmark isn't mine"); + + h.state_mut().views[0].comments.hidden_checks.clear(); + h.state_mut().views[0].comments.selected = Some((0, 0)); + h.state_mut().comment_prefs.author = other; + h.run_steps(2); + h.get_by_label("Remove checkmark"); +} + #[test] fn make_current_properties_default() { let mut h = harness(|app| { diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index 084b06dbae..714ba4814f 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -4082,8 +4082,8 @@ milestone = "M5" status = "shipped" commands = ["comment.list"] tools = ["comment_set_status"] -evidence = ["crates/annot/src/tests.rs::replies_and_status_thread_onto_the_parent", "crates/engine/src/tests.rs::comments_are_added_drawn_threaded_and_undone"] -notes = "status replies (/State, /StateModel Review), shown as a badge" +evidence = ["crates/annot/src/tests.rs::replies_and_status_thread_onto_the_parent", "crates/engine/src/tests.rs::comments_are_added_drawn_threaded_and_undone", "crates/annot/src/tests.rs::status_and_checkmark_replies_are_hidden_and_chain_per_reviewer", "crates/render/src/inspect/model.rs::review_states_are_each_reviewers_latest_through_chains", "crates/render/src/inspect/model.rs::a_status_on_a_reply_and_its_chain_stay_the_replys", "crates/render/src/inspect/model.rs::states_follow_references_not_repeated_names", "crates/automation/tests/it/automation.rs::review_statuses_are_per_reviewer_through_tools", "crates/automation/tests/it/automation.rs::statuses_set_on_a_reply_chain_on_that_reply_through_tools", "crates/ui-egui/tests/it/comments.rs::the_panel_shows_each_reviewers_status_and_only_my_checkmark"] +notes = "Status replies (/State, /StateModel Review) are written Hidden because a state reply records a state, not a note (ISO 32000-2 12.5.6.3: the state is kept 'in a separate text annotation'), and a reviewer's next change replies to their previous one (12.5.6.3: 'in reply to the previous reply for a given user'). Each reviewer's latest status shows as its own badge with their name (a state is 'for the specified user'), a status set back to None stays shown (None is a state, Table 174), and checkmarks (/StateModel Marked, 'marked by the user', Table 174) show only for the current author; comment_list reports statuses and marked_by. Tested with synthetic files. States are found by /IRT object reference, not /NM: a state reply sets the state of the first annotation up its /IRT chain that is not itself a state reply (12.5.6.3: /IRT 'shall refer to the original annotation'), so a status set on a reply stays that reply's and comment_list reports it there. Still missing: the shared-review Resolve and Mark as Unread (Document Cloud)." [[feature]] id = "comment.checkmark" From 3006212ba12ad4f96e9ac039f0c8964868ff984b Mon Sep 17 00:00:00 2001 From: Hitesh Date: Mon, 9 Nov 2026 15:55:24 +0530 Subject: [PATCH 08/41] fix(edit): catch paragraph edges and frames without aiming for the hairline (#4624) --- crates/ui-egui/src/tools/edit_text_ui.rs | 59 +++++++++++++++++++---- crates/ui-egui/tests/it/editing.rs | 61 ++++++++++++++++++++++++ 2 files changed, 110 insertions(+), 10 deletions(-) diff --git a/crates/ui-egui/src/tools/edit_text_ui.rs b/crates/ui-egui/src/tools/edit_text_ui.rs index 7abee76dd9..942b39674c 100644 --- a/crates/ui-egui/src/tools/edit_text_ui.rs +++ b/crates/ui-egui/src/tools/edit_text_ui.rs @@ -536,11 +536,12 @@ pub struct BlockDrag { grip: Grip, } -/// How far an edge grip reaches either side of a box's edge (screen pixels). -const EDGE_REACH: f32 = 6.0; +/// How far an edge grip reaches either side of a box's edge (screen pixels). Wide enough to +/// catch without aiming for the hairline (#2203); a narrow box still keeps its middle for moving. +const EDGE_REACH: f32 = 8.0; /// How far outside an open paragraph's frame the pointer still means the frame (screen pixels): /// the frame's Move cursor shows, and a press there takes hold of the box (`object_ui`). -pub(crate) const FRAME_REACH: f32 = 6.0; +pub(crate) const FRAME_REACH: f32 = 8.0; /// The rewrap handles drawn on a paragraph box's left and right edges (screen). fn width_handles(b: Rect) -> [Rect; 2] { @@ -564,12 +565,25 @@ fn edge_at(b: Rect, p: Pos2) -> Option { } } -/// The topmost box under `p` and what it grabs there; edges only on an upright page. +/// The box under `p` and what it grabs there; edges only on an upright page. An edge reaches a +/// little above and below its box, so where that reach overlaps another box the one `p` is level +/// with wins: a press beside one paragraph's edge doesn't rewrap the paragraph just under it. +/// Among boxes equally near, the topmost. fn grab_at(boxes: &[Rect], p: Pos2, upright: bool) -> Option<(usize, Grip)> { - boxes.iter().enumerate().rev().find_map(|(i, b)| match upright.then(|| edge_at(*b, p)).flatten() { - Some(g) => Some((i, g)), - None => b.contains(p).then_some((i, Grip::Move)), - }) + let mut best: Option<(f32, usize, Grip)> = None; + for (i, b) in boxes.iter().enumerate().rev() { + let grip = match upright.then(|| edge_at(*b, p)).flatten() { + Some(g) => g, + None if b.contains(p) => Grip::Move, + None => continue, + }; + // How far above or below the box `p` is: 0 within its height. + let off = (b.top() - p.y).max(p.y - b.bottom()).max(0.0); + if best.is_none_or(|(o, ..)| off < o) { + best = Some((off, i, grip)); + } + } + best.map(|(_, i, g)| (i, g)) } /// A paragraph's box on screen: its text rect (user space), with a little room around it. @@ -942,8 +956,12 @@ pub(crate) fn overlay(ctx: &egui::Context, view: &mut DocView, info: &DocInfo) - let live = draft_rect.map_or(visible, |bounds| bounds.union(visible)); ui.painter().rect_stroke(live.expand(2.0), CornerRadius::ZERO, Stroke::new(1.5, ACCENT), egui::StrokeKind::Outside); // On the frame, a press takes hold of the box (a click selects it, a drag moves it): - // the pointer says so there, and reads as text inside. - if let Some(p) = ui.input(|i| i.pointer.hover_pos()) + // the pointer says so there, and reads as text inside. Once the text is changed, a + // press there applies the typing like any click off the text instead of taking the + // box (`object_ui` would drop the typing), so the pointer doesn't offer it then + // (review on #4624). + if !ed.has_changes() + && let Some(p) = ui.input(|i| i.pointer.hover_pos()) && ui.clip_rect().contains(p) && live.expand(2.0 + FRAME_REACH).contains(p) && !live.expand(2.0).contains(p) @@ -1082,6 +1100,27 @@ pub(crate) fn lost_focus_once(r: &egui::Response, had: &mut bool) -> bool { mod tests { use super::*; + /// Where an edge's reach above or below its box overlaps the next paragraph's edge, the + /// paragraph the pointer is level with takes the press, whichever is drawn on top (the play + /// session "resize grown neighbor" at 50% zoom). + #[test] + fn an_edge_goes_to_the_box_the_pointer_is_level_with() { + let upper = Rect::from_min_max(Pos2::new(100.0, 100.0), Pos2::new(200.0, 140.0)); + let lower = Rect::from_min_max(Pos2::new(100.0, 146.0), Pos2::new(201.0, 160.0)); + // Inside the upper box's height, 6 px above the lower one: within both edges' reach. + let p = Pos2::new(199.0, 140.0); + assert_eq!(edge_at(lower, p), Some(Grip::Right), "the lower edge reaches up to p"); + assert_eq!(grab_at(&[upper, lower], p, true), Some((0, Grip::Right))); + assert_eq!(grab_at(&[lower, upper], p, true), Some((1, Grip::Right))); + // Level with neither, 4 px below one and 2 px above the other: the nearer box. + assert_eq!(grab_at(&[lower, upper], Pos2::new(199.0, 144.0), true), Some((0, Grip::Right))); + // Overlapping boxes both level with p: the topmost, as before. + assert_eq!(grab_at(&[upper, upper.translate(egui::vec2(5.0, 0.0))], upper.center(), true), Some((1, Grip::Move))); + // A body under p beats an edge reaching over from a box above or below it. + let body = Rect::from_min_max(Pos2::new(150.0, 146.0), Pos2::new(260.0, 160.0)); + assert_eq!(grab_at(&[body, upper], Pos2::new(205.0, 147.0), true), Some((0, Grip::Move))); + } + /// Formatting applied restarts the rows' alignments by text with the box's history: a Redo /// past the format can't bring back an alignment from before it (FredApps on bfc6f8b1). #[test] diff --git a/crates/ui-egui/tests/it/editing.rs b/crates/ui-egui/tests/it/editing.rs index 9752f0ff24..8b0bf25973 100644 --- a/crates/ui-egui/tests/it/editing.rs +++ b/crates/ui-egui/tests/it/editing.rs @@ -5426,6 +5426,67 @@ fn alt_arrows_navigate_instead_of_nudging_a_selected_image() { assert!((moved[0] - before[0] - 1.0).abs() < 0.01, "a plain → nudged a point: {before:?} → {moved:?}"); } +/// A paragraph's edge and an open paragraph's frame are caught a few pixels either side of +/// their hairline, and the cursor shows exactly where a press takes hold (#2203). +#[test] +fn paragraph_edges_and_frames_are_caught_without_aiming_for_the_hairline() { + let start = |h: &mut Harness<'static, PdfCraftApp>| { + assert!(h.state_mut().execute("edit.edit_text")); + h.run_steps(2); + let id = h.state().views[0].id; + let b = h.state().session.get(id).unwrap().text_blocks(0)[0].clone(); + let r = h.state().views[0].page_screen_rect(0).expect("on screen"); + let k = r.width() / 200.0; + // The drawn box: the text on screen, 2 px larger each side. + let bx = egui::Rect::from_min_max( + egui::pos2(r.left() + b.rect[0] as f32 * k, r.top() + (300.0 - b.rect[3] as f32) * k), + egui::pos2(r.left() + b.rect[2] as f32 * k, r.top() + (300.0 - b.rect[1] as f32) * k), + ) + .expand(2.0); + (id, bx) + }; + // A closed paragraph's right edge, 7 px inside and 7 px outside: the rewrap arrow, and a + // drag from there changes the width rather than moving the box. + for dx in [-7.0f32, 7.0] { + let mut h = harness(1, |_| {}); + let (id, bx) = start(&mut h); + let p = egui::pos2(bx.right() + dx, bx.center().y); + h.hover_at(p); + h.run_steps(2); + assert_eq!(h.output().platform_output.cursor_icon, egui::CursorIcon::ResizeHorizontal, "{dx} px from the edge"); + drag(&mut h, p, p + egui::vec2(40.0, 0.0)); + assert_eq!(h.state().session.get(id).unwrap().can_undo(), Some("Edit text"), "{dx} px from the edge rewraps"); + } + // An open paragraph's frame, 7 px beyond its drawn line: the move arrow, and a press there + // selects the box instead of closing the paragraph. + let mut h = harness(1, |_| {}); + let (_, bx) = start(&mut h); + click_at(&mut h, bx.center()); + assert!(h.state().views[0].line_editor.is_some(), "a click on the text opens it for typing"); + let p = egui::pos2(bx.center().x, bx.top() - 7.0); + h.hover_at(p); + h.run_steps(2); + assert_eq!(h.output().platform_output.cursor_icon, egui::CursorIcon::Move, "the pointer shows the frame takes hold"); + click_at(&mut h, p); + assert!(h.state().views[0].line_editor.is_none()); + h.get_by_label("1 object selected"); + // Once the text is changed, a press on the frame doesn't take the box (selecting it would + // drop the typing): it applies the typing like any click off the text, so the pointer doesn't + // offer the move arrow there either. + let mut h = harness(1, |_| {}); + let (_, bx) = start(&mut h); + click_at(&mut h, bx.center()); + h.state_mut().views[0].line_editor.as_mut().expect("open for typing").text = "Page one".into(); + h.run_steps(2); + h.hover_at(p); + h.run_steps(2); + assert_ne!(h.output().platform_output.cursor_icon, egui::CursorIcon::Move, "no move arrow where a press can't take the box"); + click_at(&mut h, p); + let id = h.state().views[0].id; + assert_eq!(h.state().session.get(id).unwrap().text_blocks(0)[0].text, "Page one", "the click applies the typing, as any click off the text does"); + assert!(h.query_all_by_label_contains("object selected").next().is_none(), "and doesn't take the box"); +} + #[test] fn an_open_paragraph_moves_by_its_frame_and_by_the_arrow_keys() { let mut h = harness(2, |_| {}); From e24780c7bd733c3d294070309dccaedeab959d67 Mon Sep 17 00:00:00 2001 From: Livio Oswald <125505497+Lcoding15@users.noreply.github.com> Date: Mon, 9 Nov 2026 11:25:41 +0100 Subject: [PATCH 09/41] packaging: Flatpak may talk to the accessibility bus (#4612) (#4641) --- packaging/linux/flatpak/ai.storyteller.pdfcraft.bundle.yml | 4 ++++ packaging/linux/flatpak/ai.storyteller.pdfcraft.yml | 4 ++++ 2 files changed, 8 insertions(+) diff --git a/packaging/linux/flatpak/ai.storyteller.pdfcraft.bundle.yml b/packaging/linux/flatpak/ai.storyteller.pdfcraft.bundle.yml index 6e9f9fa607..d781ad6c7c 100644 --- a/packaging/linux/flatpak/ai.storyteller.pdfcraft.bundle.yml +++ b/packaging/linux/flatpak/ai.storyteller.pdfcraft.bundle.yml @@ -25,6 +25,10 @@ finish-args: - --share=ipc # GPU access for the wgpu renderer (Vulkan, or GL as a fallback). - --device=dri + # Screen readers: AccessKit asks the accessibility bus (org.a11y.Bus) whether one is running + # and publishes the window there over AT-SPI. Without it the sandbox hides the bus and the + # app is invisible to Orca and other assistive technology (#4612). + - --talk-name=org.a11y.Bus # Printing: lpstat/lp (the cups-client module) reach the host's CUPS through its socket and # find driverless network printers through Avahi (`lpstat -e`). Without these the Print # dialog lists no printers. diff --git a/packaging/linux/flatpak/ai.storyteller.pdfcraft.yml b/packaging/linux/flatpak/ai.storyteller.pdfcraft.yml index c9086ab077..fba62fe382 100644 --- a/packaging/linux/flatpak/ai.storyteller.pdfcraft.yml +++ b/packaging/linux/flatpak/ai.storyteller.pdfcraft.yml @@ -26,6 +26,10 @@ finish-args: - --share=ipc # GPU access for the wgpu renderer (Vulkan, or GL as a fallback). - --device=dri + # Screen readers: AccessKit asks the accessibility bus (org.a11y.Bus) whether one is running + # and publishes the window there over AT-SPI. Without it the sandbox hides the bus and the + # app is invisible to Orca and other assistive technology (#4612). + - --talk-name=org.a11y.Bus # Printing: lpstat/lp (the cups-client module) reach the host's CUPS through its socket and # find driverless network printers through Avahi (`lpstat -e`). Without these the Print # dialog lists no printers. From d4828fe541efb65a88e2018875d95440c51b2482 Mon Sep 17 00:00:00 2001 From: jonathanwxh-cell Date: Mon, 9 Nov 2026 18:26:07 +0800 Subject: [PATCH 10/41] fix(app): set Windows taskbar and Alt+Tab big icon (ICON_BIG) (#3818) (#4681) --- apps/pdfcraft/Cargo.toml | 4 +++ apps/pdfcraft/src/main.rs | 53 ++++++++++++++++++++++++++++++++++++++ apps/pdfcraft/src/tests.rs | 25 ++++++++++++++++++ 3 files changed, 82 insertions(+) diff --git a/apps/pdfcraft/Cargo.toml b/apps/pdfcraft/Cargo.toml index 94e1de896c..5e4010fcad 100644 --- a/apps/pdfcraft/Cargo.toml +++ b/apps/pdfcraft/Cargo.toml @@ -64,6 +64,10 @@ windows = { version = "0.62", default-features = false, features = ["std", "UI_V # socket. `std` has no Unix sockets on Windows, and calling Winsock directly needs unsafe, which # our workspace forbids. Already in the lockfile through zbus; pinned exactly, review before bumping. uds_windows = "=1.2.1" +# Full-resolution taskbar and Alt+Tab icon (`ICON_BIG`, issue #3818). Set both window icons +# directly so eframe cannot replace ICON_BIG with a system-sized copy. Already in the tree +# through eframe and covered by the workspace's winit patch. +winit = { version = "0.30", default-features = false } [build-dependencies] # Icon + VERSIONINFO for pdfcraft.exe; build.rs only uses it when targeting Windows. diff --git a/apps/pdfcraft/src/main.rs b/apps/pdfcraft/src/main.rs index d777ddc269..837a803e22 100644 --- a/apps/pdfcraft/src/main.rs +++ b/apps/pdfcraft/src/main.rs @@ -340,7 +340,15 @@ fn native_options(integrated: bool, app_frame: bool, renderer: eframe::Renderer) .with_drag_and_drop(true) // Wayland app id: matches packaging/linux/ai.storyteller.pdfcraft.desktop. .with_app_id(APP_ID); + // eframe replaces ICON_BIG with a system-sized icon on its first active frame. + // An explicitly empty icon disables that setter (None selects eframe's default). + // Windows installs both sizes directly through winit in app_creator instead. + #[cfg(target_os = "windows")] + { + viewport = viewport.with_icon(egui::IconData::default()); + } // Dock, taskbar, Alt-Tab and launcher icon when running unbundled. + #[cfg(not(target_os = "windows"))] match eframe::icon_data::from_png_bytes(APP_ICON_PNG) { Ok(icon) => viewport = viewport.with_icon(icon), Err(e) => log::warn!("app icon: {e}"), @@ -391,6 +399,48 @@ fn native_wayland(_: &eframe::CreationContext<'_>) -> bool { false } +/// Decode `png` as a [`winit::window::Icon`] for the Windows taskbar and Alt+Tab (`ICON_BIG`). +#[cfg(target_os = "windows")] +fn taskbar_icon(png: &[u8]) -> Option { + let icon = match eframe::icon_data::from_png_bytes(png) { + Ok(icon) => icon, + Err(e) => { + log::warn!("taskbar icon: {e}"); + return None; + } + }; + match winit::window::Icon::from_rgba(icon.rgba, icon.width, icon.height) { + Ok(icon) => Some(icon), + Err(e) => { + log::warn!("taskbar icon: {e}"); + None + } + } +} + +/// Install the decoded [`winit::window::Icon`] from `png` via `set` when decoding succeeds. +#[cfg(target_os = "windows")] +fn install_taskbar_icon(png: &[u8], set: impl FnOnce(winit::window::Icon)) { + if let Some(icon) = taskbar_icon(png) { + set(icon); + } +} + +/// Install both window icons before the first frame. eframe's icon setter is disabled in +/// `native_options`, so it cannot replace the full-resolution taskbar/Alt+Tab icon (#3818). +#[cfg(target_os = "windows")] +fn set_taskbar_icon(window: &winit::window::Window) { + use winit::platform::windows::{IconExtWindows as _, WindowExtWindows as _}; + install_taskbar_icon(APP_ICON_PNG, |icon| window.set_taskbar_icon(Some(icon))); + // The title bar's icon: the small image drawn for this scale in the exe's own icon (build.rs). + let side = (16.0 * window.scale_factor()).round().clamp(16.0, 256.0) as u32; + match winit::window::Icon::from_resource(1, Some(winit::dpi::PhysicalSize::new(side, side))) { + Ok(icon) => window.set_window_icon(Some(icon)), + Err(e) => log::warn!("window icon: {e}"), + } +} + +/// Build the [`eframe::AppCreator`] closure that initializes [`PdfCraftApp`] once the window and renderer are ready. fn app_creator<'a>( launch: Launch, started: Rc>, @@ -418,6 +468,9 @@ fn app_creator<'a>( // it is set to On or Off. #[cfg(target_os = "windows")] { + if let Some(window) = cc.winit_window() { + set_taskbar_icon(window); + } app.motion.source = Some(motion::source()); } app.set_startup_update_check(true); diff --git a/apps/pdfcraft/src/tests.rs b/apps/pdfcraft/src/tests.rs index ebd13dccc8..7c1f111b96 100644 --- a/apps/pdfcraft/src/tests.rs +++ b/apps/pdfcraft/src/tests.rs @@ -137,6 +137,31 @@ fn the_desktop_frame_is_turned_off_only_when_the_app_draws_one() { assert_eq!(native_options(false, true, eframe::Renderer::Glow).viewport.decorations, Some(false)); } +#[test] +fn the_window_and_windows_taskbar_icons_load_from_the_bundled_png() { + let icon = eframe::icon_data::from_png_bytes(super::APP_ICON_PNG).expect("the bundled PNG decodes as the window icon"); + assert!(icon.width > 0 && icon.height > 0); + assert_eq!(icon.rgba.len(), (icon.width as usize) * (icon.height as usize) * 4); + #[cfg(not(target_os = "windows"))] + assert_eq!(super::native_options(false, false, eframe::Renderer::Glow).viewport.icon.as_deref(), Some(&icon)); + #[cfg(target_os = "windows")] + { + // None would load eframe's default icon; only the explicit empty sentinel prevents + // its first-frame setter from overwriting our full-resolution ICON_BIG. + for renderer in [eframe::Renderer::Glow, eframe::Renderer::Wgpu] { + let viewport = super::native_options(false, false, renderer).viewport; + assert_eq!(viewport.icon.as_deref(), Some(&egui::IconData::default())); + } + assert_eq!((icon.width, icon.height), (256, 256)); + let mut installed = None; + super::install_taskbar_icon(super::APP_ICON_PNG, |icon| installed = Some(icon)); + assert!(installed.is_some(), "issue #3818: ICON_BIG loads and installs from APP_ICON_PNG"); + let mut bad = None; + super::install_taskbar_icon(b"not a png", |icon| bad = Some(icon)); + assert!(bad.is_none()); + } +} + #[test] fn a_software_renderer_is_shown_to_the_user() { use eframe::wgpu::DeviceType; From 2baf95fcefd42d4e1051afc5f63ee626d1d8d476 Mon Sep 17 00:00:00 2001 From: ".CooCoo" Date: Mon, 9 Nov 2026 02:26:23 -0800 Subject: [PATCH 11/41] Tests: highlighting an area off the text waits up to 30 s for the page text (#4683) --- crates/ui-egui/tests/it/comments.rs | 17 +++++++---------- 1 file changed, 7 insertions(+), 10 deletions(-) diff --git a/crates/ui-egui/tests/it/comments.rs b/crates/ui-egui/tests/it/comments.rs index 599f1863e1..67ea715fc2 100644 --- a/crates/ui-egui/tests/it/comments.rs +++ b/crates/ui-egui/tests/it/comments.rs @@ -2668,17 +2668,14 @@ fn highlighting_an_area_off_the_text() { let q = c[0].quads[0]; assert!((q[0] - 40.0).abs() < 3.0 && (q[1] - 100.0).abs() < 3.0, "{q:?}"); // Dragging over text still highlights the text (once the text layer is in). - let mut glyphs = None; - for _ in 0..200 { - h.run_steps(1); + support::wait_until(&mut h, "the page's text layer", |app| { + let v = &app.views[0]; + v.glyph_screen_pos(0, 4).is_some() && v.glyph_screen_pos(0, 14).is_some() + }); + let (a, b) = { let v = &h.state().views[0]; - if let (Some(a), Some(b)) = (v.glyph_screen_pos(0, 4), v.glyph_screen_pos(0, 14)) { - glyphs = Some((a, b)); - break; - } - std::thread::sleep(std::time::Duration::from_millis(5)); - } - let (a, b) = glyphs.expect("text layer"); + (v.glyph_screen_pos(0, 4).expect("text layer"), v.glyph_screen_pos(0, 14).expect("glyph")) + }; drag(&mut h, a, b); assert_eq!(comments(&h).len(), 2); } From b04823573b6893e500f33637c2acabda157ae718 Mon Sep 17 00:00:00 2001 From: jonathanwxh-cell Date: Mon, 9 Nov 2026 18:26:53 +0800 Subject: [PATCH 12/41] fix(optimize): merge side-by-side and 2D grid image fragments (#4818) --- crates/optimize/src/fragments.rs | 423 +++++++++++++++++++++++++----- crates/optimize/tests/optimize.rs | 46 ++++ 2 files changed, 407 insertions(+), 62 deletions(-) diff --git a/crates/optimize/src/fragments.rs b/crates/optimize/src/fragments.rs index 34c8f9ca76..aa52c20750 100644 --- a/crates/optimize/src/fragments.rs +++ b/crates/optimize/src/fragments.rs @@ -9,8 +9,9 @@ //! between them, each drawing an image upright (no rotation, skew or flip); //! - the images are 8-bit grey or RGB in the same colour space, without masks or a decode array //! that changes anything, in a codec PdfCraft decodes; -//! - they share their left and right edges and are stacked edge to edge with no gap or overlap -//! (in any drawing order), at least [`MIN_FRAGMENTS`] of them. +//! - they tile one rectangle edge to edge with no gap or overlap — stacked top to bottom, placed +//! side by side, or arranged in a grid of rows and columns (in any drawing order), at least +//! [`MIN_FRAGMENTS`] of them. //! //! The merged image keeps the finest resolution of its fragments both ways, so nothing is lost //! until the image settings downsample it; it is stored losslessly. A page whose content @@ -89,53 +90,152 @@ fn fragment_image(doc: &Document, xobjects: &Dict, name: &[u8]) -> Option<(ObjRe (w > 0 && h > 0).then_some((r, w, h, cs, n)) } -/// Whether `run` tiles one rectangle top to bottom: same left and right edges, edge to edge. -/// Sorts it from the top down. -fn stacked(run: &mut [Fragment]) -> bool { - let Some(first) = run.first() else { return false }; - let [x0, _, x1, _] = first.rect; - let width = x1 - x0; - if !(width.is_finite() && width > 0.0) { - return false; - } - let tol = 1e-3 * width.max(1.0); - if run.iter().any(|f| { - (f.rect[0] - x0).abs() > tol || (f.rect[2] - x1).abs() > tol || f.rect[3].partial_cmp(&f.rect[1]) != Some(std::cmp::Ordering::Greater) - }) { - return false; - } - run.sort_by(|a, b| b.rect[3].total_cmp(&a.rect[3])); - run.windows(2).all(|w| match w { - [a, b] => (a.rect[1] - b.rect[3]).abs() <= 0.01 * (a.rect[3] - a.rect[1]).min(b.rect[3] - b.rect[1]), - _ => true, - }) +/// Whether `a` and `b` share their top and bottom edges within row tolerance. +fn same_y(a: &Fragment, b: &Fragment) -> bool { + let tol = 1e-3 * (a.rect[3] - a.rect[1]).min(b.rect[3] - b.rect[1]); + (a.rect[1] - b.rect[1]).abs() <= tol && (a.rect[3] - b.rect[3]).abs() <= tol } -/// The fragment groups from `i` on while each is drawn right under (or right over) the one -/// before it, in the same direction: a picture drawn row by row, up to a gap or a jump. -fn drawn_in_sequence(ops: &[Op], i: usize, xobjects: &Dict, doc: &Document) -> Vec { +/// Whether `run` tiles one rectangle edge to edge (stacked top to bottom, placed side by side, +/// or arranged in a grid of rows and columns). Sorts it from the top row down, and left to right +/// within each row. Returns that exact row partition: tolerance-based membership is not +/// transitive, so assembly must not regroup after the horizontal sort changes each row's anchor. +fn stacked(run: &mut [Fragment]) -> Option>> { + if run.is_empty() + || run.iter().any(|f| { + let [x0, y0, x1, y1] = f.rect; + let (w, h) = (x1 - x0, y1 - y0); + !(x0.is_finite() && y0.is_finite() && x1.is_finite() && y1.is_finite() && w.is_finite() && w > 0.0 && h.is_finite() && h > 0.0) + }) + { + return None; + } + run.sort_by(|a, b| b.rect[3].total_cmp(&a.rect[3])); + let mut row_bounds: Vec<[f64; 4]> = Vec::new(); + let mut rows = Vec::new(); + let mut start = 0; + while start < run.len() { + let mut end = start + 1; + while end < run.len() && same_y(&run[start], &run[end]) { + end += 1; + } + let row = &mut run[start..end]; + row.sort_by(|a, b| a.rect[0].total_cmp(&b.rect[0])); + if !row.windows(2).all(|w| match w { + [a, b] => (a.rect[2] - b.rect[0]).abs() <= 0.01 * (a.rect[2] - a.rect[0]).min(b.rect[2] - b.rect[0]), + _ => true, + }) { + return None; + } + let (Some(first), Some(last)) = (row.first(), row.last()) else { return None }; + row_bounds.push([first.rect[0], first.rect[1], last.rect[2], first.rect[3]]); + rows.push(start..end); + start = end; + } + let (Some(&[x0, _, x1, y1]), Some(&[_, y0, _, _])) = (row_bounds.first(), row_bounds.last()) else { return None }; + let (width, height) = (x1 - x0, y1 - y0); + if !(width.is_finite() && width > 0.0 && height.is_finite() && height > 0.0) { + return None; + } + let tol_x = 1e-3 * width.max(1.0); + if row_bounds.iter().any(|r| (r[0] - x0).abs() > tol_x || (r[2] - x1).abs() > tol_x) { + return None; + } + row_bounds + .windows(2) + .all(|w| match w { + [a, b] => (a[1] - b[3]).abs() <= 0.01 * (a[3] - a[1]).min(b[3] - b[1]), + _ => true, + }) + .then_some(rows) +} + +/// The fragment groups from `i` on (at most `limit`) while they form a picture drawn row by row +/// or strip by strip in one direction, up to a gap or a jump. +fn drawn_in_sequence(ops: &[Op], i: usize, limit: usize, xobjects: &Dict, doc: &Document) -> Vec { let mut run: Vec = Vec::new(); + let mut row_x = [0.0f64, 0.0]; + let mut cur_x = [0.0f64, 0.0]; + let mut cur_y = [0.0f64, 0.0]; + let mut first_row = true; + let mut right: Option = None; let mut down: Option = None; + let mut complete = 0usize; let mut j = i; - while let Some((next, m, name)) = group_at(ops, j) { + while run.len() < limit { + let Some((next, m, name)) = group_at(ops, j) else { break }; let Some((image, width, height, ..)) = fragment_image(doc, xobjects, &name) else { break }; let [a, _, _, d, e, f] = m.0; let f2 = Fragment { image, rect: [e, f, e + a, f + d], width, height }; if let Some(prev) = run.last() { - let tol = 0.01 * (prev.rect[3] - prev.rect[1]).min(f2.rect[3] - f2.rect[1]); - let below = (prev.rect[1] - f2.rect[3]).abs() <= tol; - let above = (f2.rect[1] - prev.rect[3]).abs() <= tol; - let same_x = (prev.rect[0] - f2.rect[0]).abs() <= 1e-3 * a.abs().max(1.0) && (prev.rect[2] - f2.rect[2]).abs() <= 1e-3 * a.abs().max(1.0); - match (down, below, above) { - _ if !same_x => break, - (None | Some(true), true, _) => down = Some(true), - (None | Some(false), _, true) => down = Some(false), - _ => break, + let tol_y = 1e-3 * (cur_y[1] - cur_y[0]).min(f2.rect[3] - f2.rect[1]); + let same_row = (cur_y[0] - f2.rect[1]).abs() <= tol_y && (cur_y[1] - f2.rect[3]).abs() <= tol_y; + if same_row { + let tol_x = 0.01 * (prev.rect[2] - prev.rect[0]).min(f2.rect[2] - f2.rect[0]); + let step_right = (f2.rect[0] - prev.rect[2]).abs() <= tol_x; + let step_left = (prev.rect[0] - f2.rect[2]).abs() <= tol_x; + match (right, step_right, step_left) { + (None | Some(true), true, _) => { + right = Some(true); + cur_x[1] = f2.rect[2]; + } + (None | Some(false), _, true) => { + right = Some(false); + cur_x[0] = f2.rect[0]; + } + _ => break, + } + if first_row { + row_x = cur_x; + complete = run.len() + 1; + } else { + let tol_span = 1e-3 * (row_x[1] - row_x[0]).max(1.0); + if cur_x[0] < row_x[0] - tol_span || cur_x[1] > row_x[1] + tol_span { + break; + } + if (cur_x[0] - row_x[0]).abs() <= tol_span && (cur_x[1] - row_x[1]).abs() <= tol_span { + complete = run.len() + 1; + } + } + } else { + let tol_span = 1e-3 * (row_x[1] - row_x[0]).max(1.0); + if (cur_x[0] - row_x[0]).abs() > tol_span || (cur_x[1] - row_x[1]).abs() > tol_span { + break; + } + let tol_v = 0.01 * (cur_y[1] - cur_y[0]).min(f2.rect[3] - f2.rect[1]); + let below = (cur_y[0] - f2.rect[3]).abs() <= tol_v; + let above = (f2.rect[1] - cur_y[1]).abs() <= tol_v; + match (down, below, above) { + (None | Some(true), true, _) => down = Some(true), + (None | Some(false), _, true) => down = Some(false), + _ => break, + } + let starts_left = (f2.rect[0] - row_x[0]).abs() <= tol_span && f2.rect[2] <= row_x[1] + tol_span; + let starts_right = (f2.rect[2] - row_x[1]).abs() <= tol_span && f2.rect[0] >= row_x[0] - tol_span; + if starts_left && starts_right { + right = None; + complete = run.len() + 1; + } else if starts_left { + right = Some(true); + } else if starts_right { + right = Some(false); + } else { + break; + } + first_row = false; + cur_x = [f2.rect[0], f2.rect[2]]; + cur_y = [f2.rect[1], f2.rect[3]]; } + } else { + row_x = [f2.rect[0], f2.rect[2]]; + cur_x = row_x; + cur_y = [f2.rect[1], f2.rect[3]]; + complete = 1; } run.push(f2); j = next; } + run.truncate(complete); run } @@ -152,38 +252,61 @@ fn run_end(ops: &[Op], i: usize, count: usize) -> usize { } /// The run's pixels as one image, top row first: `(pixels, width, height)`. -fn merge(doc: &Document, run: &[Fragment], n: usize, budget: &mut ImageBudget) -> Option<(Vec, u32, u32)> { - let (top, bottom) = (run.first()?.rect[3], run.last()?.rect[1]); - let width = run.iter().map(|f| f.width).max()?; - // Pixel rows per unit of height: the finest fragment's. - let density = run.iter().map(|f| f.height as f64 / (f.rect[3] - f.rect[1])).fold(0.0, f64::max); - let height = ((top - bottom) * density).round(); +fn merge(doc: &Document, run: &[Fragment], partition: &[std::ops::Range], n: usize, budget: &mut ImageBudget) -> Option<(Vec, u32, u32)> { + let (first, last) = (run.first()?, run.last()?); + let (left, right, top, bottom) = (first.rect[0], last.rect[2], first.rect[3], last.rect[1]); + let rows: Vec<&[Fragment]> = partition.iter().map(|range| run.get(range.clone())).collect::>()?; + // Pixels per unit along each axis: the finest fragment's. + let density_x = run.iter().map(|f| f.width as f64 / (f.rect[2] - f.rect[0])).fold(0.0, f64::max); + let density_y = run.iter().map(|f| f.height as f64 / (f.rect[3] - f.rect[1])).fold(0.0, f64::max); + let width = if rows.len() == run.len() { f64::from(run.iter().map(|f| f.width).max()?) } else { ((right - left) * density_x).round() }; + let height = if rows.len() == 1 { f64::from(run.iter().map(|f| f.height).max()?) } else { ((top - bottom) * density_y).round() }; + if !(width.is_finite() && width >= 1.0 && width <= f64::from(u32::MAX)) { + return None; + } if !(height.is_finite() && height >= 1.0 && height <= f64::from(u32::MAX)) { return None; } - // In range and whole, so the cast is exact. - let height = height as u32; + // In range and whole, so the casts are exact. + let (width, height) = (width as u32, height as u32); if u64::from(width) * u64::from(height) > MAX_PIXELS { return None; } - let row = (width as usize).checked_mul(n)?; + let stride = (width as usize).checked_mul(n)?; // What this builds isn't what its strips cost to read: four small ones can ask for all of it. - let bytes = row.checked_mul(height as usize)?; + let bytes = stride.checked_mul(height as usize)?; if !budget.pay_pixels(u64::try_from(bytes).ok()?) { return None; } let mut out = vec![255u8; bytes]; - for f in run { - let r0 = (((top - f.rect[3]) * density).round().max(0.0) as u32).min(height); - let r1 = (((top - f.rect[1]) * density).round().max(0.0) as u32).min(height); + let mut r0 = 0u32; + for (row_idx, row) in rows.iter().enumerate() { + let r1 = if row_idx + 1 == rows.len() { height } else { (((top - row.first()?.rect[1]) * density_y).round().max(0.0) as u32).min(height) }; if r1 <= r0 { - continue; + return None; } - let obj = doc.get(f.image); - let Object::Stream(s) = &*obj else { return None }; - let px = pixels(doc, s, n, budget)?; - let px = if (f.width, f.height) == (width, r1 - r0) { px } else { resample(&px, n, f.width, f.height, width, r1 - r0)? }; - out.get_mut(r0 as usize * row..r1 as usize * row)?.copy_from_slice(px.get(..(r1 - r0) as usize * row)?); + let th = r1 - r0; + let mut c0 = 0u32; + for (col_idx, f) in row.iter().enumerate() { + let c1 = if col_idx + 1 == row.len() { width } else { (((f.rect[2] - left) * density_x).round().max(0.0) as u32).min(width) }; + if c1 <= c0 { + return None; + } + let tw = c1 - c0; + let obj = doc.get(f.image); + let Object::Stream(s) = &*obj else { return None }; + let px = pixels(doc, s, n, budget)?; + let px = if (f.width, f.height) == (tw, th) { px } else { resample(&px, n, f.width, f.height, tw, th)? }; + let tile_row = (tw as usize).checked_mul(n)?; + let col_off = (c0 as usize).checked_mul(n)?; + for r in 0..th as usize { + let dst = (r0 as usize + r) * stride + col_off; + let src = r * tile_row; + out.get_mut(dst..dst + tile_row)?.copy_from_slice(px.get(src..src + tile_row)?); + } + c0 = c1; + } + r0 = r1; } Some((out, width, height)) } @@ -288,13 +411,15 @@ fn merge_page( j = next; } scanned = scanned.saturating_add(run.len()).saturating_add(1); - // Not one picture as a whole: the rows drawn one under the other from the start may be. - if run.len() >= MIN_FRAGMENTS && !stacked(&mut run) { - run = drawn_in_sequence(&ops, i, &xobjects, doc); + // Not one picture as a whole: a prefix drawn row by row or strip by strip may be. + let mut rows = if run.len() >= MIN_FRAGMENTS { stacked(&mut run) } else { None }; + if run.len() >= MIN_FRAGMENTS && rows.is_none() { + run = drawn_in_sequence(&ops, i, run.len(), &xobjects, doc); j = run_end(&ops, i, run.len()); scanned = scanned.saturating_add(run.len().saturating_mul(2)).saturating_add(2); + rows = stacked(&mut run); } - let Some((cs, n)) = kind.filter(|_| run.len() >= MIN_FRAGMENTS && stacked(&mut run)) else { + let Some(((cs, n), rows)) = kind.zip(rows).filter(|_| run.len() >= MIN_FRAGMENTS) else { // Not a run: keep this op and look again from the next one. if let Some(op) = ops.get(i) { out.push(op.clone()); @@ -305,7 +430,7 @@ fn merge_page( if !progress(Stage::Fragments) { return Err(OptimizeError::Cancelled); } - let Some((px, w, h)) = merge(doc, &run, n, images) else { + let Some((px, w, h)) = merge(doc, &run, &rows, n, images) else { out.extend(ops.get(i..j).unwrap_or_default().iter().cloned()); i = j; continue; @@ -322,7 +447,7 @@ fn merge_page( xobjects.set(name.clone(), Object::Ref(image)); add_xobject(doc, page, &name, image)?; let (Some(first), Some(last)) = (run.first(), run.last()) else { break }; - let [x0, y0, x1, y1] = [first.rect[0], last.rect[1], first.rect[2], first.rect[3]]; + let [x0, y0, x1, y1] = [first.rect[0], last.rect[1], last.rect[2], first.rect[3]]; let num = Object::Real; out.push(Op::new("q", Vec::new())); out.push(Op::new("cm", vec![num(x1 - x0), Object::Int(0), Object::Int(0), num(y1 - y0), num(x0), num(y0)])); @@ -339,6 +464,7 @@ fn merge_page( Ok(merged) } +/// Merge consecutive tiled image fragments across `pages`. pub(crate) fn run( doc: &mut Document, pages: &[ObjRef], @@ -361,17 +487,45 @@ mod tests { use super::*; + /// Merged dimensions exceeding `u32::MAX` are rejected before allocating pixel buffers. #[test] fn a_merged_height_beyond_u32_is_refused_before_allocating() { // Two fragments, one pixel row each, the top one 2^-48 high: the merged image would be // 2^16 × 2^48 pixels, whose product wraps to 0 in u64. let image = ObjRef { num: 1, generation: 0 }; let edge = 1.0 - (-48f64).exp2(); - let run = [ + let vertical = [ Fragment { image, rect: [0.0, edge, 1.0, 1.0], width: 1 << 16, height: 1 }, Fragment { image, rect: [0.0, 0.0, 1.0, edge], width: 1 << 16, height: 1 }, ]; - assert!(merge(&Document::new_empty(), &run, 3, &mut ImageBudget::with_work(u64::MAX)).is_none()); + assert!(merge(&Document::new_empty(), &vertical, &[0..1, 1..2], 3, &mut ImageBudget::with_work(u64::MAX)).is_none()); + let horizontal = [ + Fragment { image, rect: [0.0, 0.0, edge, 1.0], width: 1, height: 1 << 16 }, + Fragment { image, rect: [edge, 0.0, 1.0, 1.0], width: 1, height: 1 << 16 }, + ]; + assert!(merge(&Document::new_empty(), &horizontal, std::slice::from_ref(&(0..2)), 3, &mut ImageBudget::with_work(u64::MAX)).is_none()); + } + + /// Even an inconsistent partition must not silently omit a row or tile from its report. + #[test] + fn assembly_refuses_collapsed_output_rows_and_tiles() { + let mut doc = Document::new_empty(); + let mut dict = Dict::new(); + dict.set(b"Width".to_vec(), Object::Int(10)); + dict.set(b"Height".to_vec(), Object::Int(10)); + dict.set(b"BitsPerComponent".to_vec(), Object::Int(8)); + dict.set(b"ColorSpace".to_vec(), Object::name("DeviceGray")); + let image = doc.add(Object::Stream(Stream::flate(dict, &[80; 100]))); + let mut run: Vec<_> = (0..4) + .map(|col| Fragment { image, rect: [10.0 * f64::from(col), 0.0, 10.0 * f64::from(col + 1), 10.0], width: 10, height: 10 }) + .collect(); + // The first supposed row consumes the entire height; the second cannot be discarded. + assert!(merge(&doc, &run, &[0..2, 2..4], 1, &mut ImageBudget::with_work(u64::MAX)).is_none()); + // Likewise, a tile whose right edge does not advance cannot simply vanish. + if let Some(tile) = run.get_mut(1) { + tile.rect = [0.0, 0.0, 10.0, 10.0]; + } + assert!(merge(&doc, &run, std::slice::from_ref(&(0..4)), 1, &mut ImageBudget::with_work(u64::MAX)).is_none()); } /// Merging reads the images the pass after it reads, from the same allowance: with nothing @@ -474,4 +628,149 @@ mod tests { let reads = GROUP_READS.with(|c| c.get()); assert!(reads <= 40 * 4 * groups, "{reads} group reads"); } + + /// Side-by-side vertical strips and 2D tile grids merge into one image XObject while invalid + /// or separated runs are handled per run. + #[test] + fn side_by_side_and_grid_fragments_merge_into_one_image() { + let tile = |doc: &mut Document, w: u32, h: u32, px: Vec| { + let mut d = Dict::new(); + d.set(b"Type".to_vec(), Object::name("XObject")); + d.set(b"Subtype".to_vec(), Object::name("Image")); + d.set(b"Width".to_vec(), Object::Int(i64::from(w))); + d.set(b"Height".to_vec(), Object::Int(i64::from(h))); + d.set(b"BitsPerComponent".to_vec(), Object::Int(8)); + d.set(b"ColorSpace".to_vec(), Object::name("DeviceGray")); + doc.add(Object::Stream(Stream::flate(d, &px))) + }; + let build_page = |doc: &mut Document, xobjects: Dict, ops: &str| { + let content = doc.add(Object::Stream(Stream::flate(Dict::new(), ops.as_bytes()))); + let mut resources = Dict::new(); + resources.set(b"XObject".to_vec(), Object::Dict(xobjects)); + let mut page = Dict::new(); + page.set(b"Contents".to_vec(), Object::Ref(content)); + page.set(b"Resources".to_vec(), Object::Dict(resources)); + doc.add(Object::Dict(page)) + }; + + // Four vertical strips placed side by side (drawn out of order), alternating 1×3 and 2×3 + // pixels so the merged image keeps the finest horizontal resolution (8×3). + let mut doc = Document::new_empty(); + let mut xobjects = Dict::new(); + for c in 0..4u8 { + let w = if c % 2 == 0 { 1 } else { 2 }; + let im = tile(&mut doc, w, 3, vec![c * 50; (w * 3) as usize]); + xobjects.set(format!("C{c}").into_bytes(), Object::Ref(im)); + } + let ops = [2usize, 0, 3, 1].into_iter().map(|c| format!("q 10 0 0 30 {} 100 cm /C{c} Do Q\n", 15.0 + c as f64 * 10.0)).collect::(); + let page = build_page(&mut doc, xobjects, &ops); + let mut report = Report::default(); + let merged = merge_page(&mut doc, page, &mut report, &mut |_| true, &mut ImageBudget::with_work(u64::MAX)).unwrap(); + assert_eq!((merged, report.merged_images), (4, 1)); + let xo = doc + .get(page) + .as_dict() + .and_then(|d| d.get(b"Resources")) + .and_then(Object::as_dict) + .and_then(|d| d.get(b"XObject")) + .and_then(Object::as_dict) + .cloned() + .unwrap(); + let merged_ref = xo.get(b"PCMerged1").and_then(Object::as_ref).unwrap(); + let Object::Stream(s) = &*doc.get(merged_ref) else { panic!("expected stream") }; + assert_eq!((s.dict.int(b"Width"), s.dict.int(b"Height")), (Some(8), Some(3))); + assert_eq!(s.decoded().unwrap(), [0, 0, 50, 50, 100, 100, 150, 150].repeat(3)); + let c_ref = doc.get(page).as_dict().and_then(|d| d.reference(b"Contents")).unwrap(); + let Object::Stream(cs) = &*doc.get(c_ref) else { panic!("expected contents") }; + let parsed = parse(&cs.decoded().unwrap()); + let cm = parsed.ops.iter().find(|o| o.is("cm")).and_then(|o| Matrix::from_operands(&o.operands)).unwrap(); + assert_eq!(cm.0, [40.0, 0.0, 0.0, 30.0, 15.0, 100.0]); + + // A 2×3 grid of tiles (2 rows, 3 columns), each 2×2 pixels, drawn in mixed order. + let mut doc = Document::new_empty(); + let mut xobjects = Dict::new(); + for r in 0..2u8 { + for c in 0..3u8 { + let v = r * 100 + c * 20; + let im = tile(&mut doc, 2, 2, vec![v; 4]); + xobjects.set(format!("T{r}{c}").into_bytes(), Object::Ref(im)); + } + } + let ops = [(1u8, 2u8), (0, 1), (1, 0), (0, 2), (0, 0), (1, 1)] + .into_iter() + .map(|(r, c)| { + let x = 5.0 + f64::from(c) * 10.0; + let y = 50.0 + f64::from(1 - r) * 10.0; + format!("q 10 0 0 10 {x} {y} cm /T{r}{c} Do Q\n") + }) + .collect::(); + let page = build_page(&mut doc, xobjects, &ops); + let mut report = Report::default(); + let merged = merge_page(&mut doc, page, &mut report, &mut |_| true, &mut ImageBudget::with_work(u64::MAX)).unwrap(); + assert_eq!((merged, report.merged_images), (6, 1)); + let xo = doc + .get(page) + .as_dict() + .and_then(|d| d.get(b"Resources")) + .and_then(Object::as_dict) + .and_then(|d| d.get(b"XObject")) + .and_then(Object::as_dict) + .cloned() + .unwrap(); + let Object::Stream(s) = &*doc.get(xo.get(b"PCMerged1").and_then(Object::as_ref).unwrap()) else { panic!("expected stream") }; + assert_eq!((s.dict.int(b"Width"), s.dict.int(b"Height")), (Some(6), Some(4))); + let mut expected = Vec::new(); + expected.extend([0, 0, 20, 20, 40, 40].repeat(2)); + expected.extend([100, 100, 120, 120, 140, 140].repeat(2)); + assert_eq!(s.decoded().unwrap(), expected); + + // Two side-by-side runs of 4 strips separated by a horizontal gap merge into 2 images, + // whether drawn left to right or right to left. + for right_to_left in [false, true] { + let mut doc = Document::new_empty(); + let mut xobjects = Dict::new(); + let im = tile(&mut doc, 2, 2, vec![80; 4]); + xobjects.set(b"S".to_vec(), Object::Ref(im)); + let xs: Vec = if right_to_left { + vec![110.0, 100.0, 90.0, 80.0, 30.0, 20.0, 10.0, 0.0] + } else { + vec![0.0, 10.0, 20.0, 30.0, 80.0, 90.0, 100.0, 110.0] + }; + let ops = xs.into_iter().map(|x| format!("q 10 0 0 20 {x} 0 cm /S Do Q\n")).collect::(); + let page = build_page(&mut doc, xobjects, &ops); + let mut report = Report::default(); + let merged = merge_page(&mut doc, page, &mut report, &mut |_| true, &mut ImageBudget::with_work(u64::MAX)).unwrap(); + assert_eq!((merged, report.merged_images), (8, 2), "right_to_left={right_to_left}"); + } + + // A 2×2 grid followed immediately by a lone fifth tile below only the first column merges + // the 4 grid tiles and leaves the fifth tile alone. + let mut doc = Document::new_empty(); + let mut xobjects = Dict::new(); + let im = tile(&mut doc, 2, 2, vec![90; 4]); + xobjects.set(b"G".to_vec(), Object::Ref(im)); + let ops = [(0.0, 20.0), (10.0, 20.0), (0.0, 10.0), (10.0, 10.0), (0.0, 0.0)] + .into_iter() + .map(|(x, y)| format!("q 10 0 0 10 {x} {y} cm /G Do Q\n")) + .collect::(); + let page = build_page(&mut doc, xobjects, &ops); + let mut report = Report::default(); + let merged = merge_page(&mut doc, page, &mut report, &mut |_| true, &mut ImageBudget::with_work(u64::MAX)).unwrap(); + assert_eq!((merged, report.merged_images), (4, 1)); + + // Overlapping side-by-side strips or rows of different widths are left alone. + for bad_ops in [ + "q 10 0 0 20 0 0 cm /G Do Q\nq 10 0 0 20 5 0 cm /G Do Q\nq 10 0 0 20 15 0 cm /G Do Q\nq 10 0 0 20 25 0 cm /G Do Q\n", + "q 10 0 0 10 0 10 cm /G Do Q\nq 10 0 0 10 10 10 cm /G Do Q\nq 10 0 0 10 0 0 cm /G Do Q\nq 15 0 0 10 10 0 cm /G Do Q\n", + ] { + let mut doc = Document::new_empty(); + let mut xobjects = Dict::new(); + let im = tile(&mut doc, 2, 2, vec![90; 4]); + xobjects.set(b"G".to_vec(), Object::Ref(im)); + let page = build_page(&mut doc, xobjects, bad_ops); + let mut report = Report::default(); + let merged = merge_page(&mut doc, page, &mut report, &mut |_| true, &mut ImageBudget::with_work(u64::MAX)).unwrap(); + assert_eq!((merged, report.merged_images), (0, 0)); + } + } } diff --git a/crates/optimize/tests/optimize.rs b/crates/optimize/tests/optimize.rs index c4fb865198..2b8f513a24 100644 --- a/crates/optimize/tests/optimize.rs +++ b/crates/optimize/tests/optimize.rs @@ -3096,6 +3096,52 @@ fn drawn_images(doc: &Document) -> Vec { .collect() } +/// A tolerated row must not be regrouped after its highest tile moves during X sorting. +#[test] +fn fragment_row_tolerance_preserves_every_tiles_pixels() { + for aligned in [true, false] { + for merge_fragments in [false, true] { + let mut doc = Document::new_empty(); + let mut refs = Vec::new(); + for gray in [20u8, 80, 140, 220] { + let mut d = Dict::new(); + d.set(b"Type".to_vec(), Object::name("XObject")); + d.set(b"Subtype".to_vec(), Object::name("Image")); + d.set(b"Width".to_vec(), Object::Int(10)); + d.set(b"Height".to_vec(), Object::Int(10)); + d.set(b"BitsPerComponent".to_vec(), Object::Int(8)); + d.set(b"ColorSpace".to_vec(), Object::name("DeviceGray")); + refs.push(doc.add(Object::Stream(Stream::flate(d, &[gray; 100])))); + } + let content = if aligned { + "q 10 0 0 10 0 0 cm /T0 Do Q q 10 0 0 10 10 0 cm /T1 Do Q q 10 0 0 10 20 0 cm /T2 Do Q q 10 0 0 10 30 0 cm /T3 Do Q" + } else { + "q 10 0 0 10 0 0 cm /T0 Do Q q 10 0 0 9.992 10 0.009 cm /T1 Do Q q 10 0 0 9.982 20 0.018 cm /T2 Do Q q 10 0 0 9.991 30 0.009 cm /T3 Do Q" + }; + page(&mut doc, &[("T0", refs[0]), ("T1", refs[1]), ("T2", refs[2]), ("T3", refs[3])], content); + let off = ImageSettings { downsample: false, compression: Compression::Retain, ..ImageSettings::REDUCE }; + let report = optimize(&mut doc, &Settings { merge_fragments, color: off, gray: off, ..Settings::default() }).unwrap(); + // Save and reopen, then inspect only images still drawn by the optimized page. + let doc = Document::open(Arc::new(write_full(&doc, &SaveOptions::default()).unwrap())).unwrap(); + let drawn = drawn_images(&doc); + if report.fragments_merged == 0 { + assert_eq!(report.merged_images, 0); + assert_eq!(drawn.len(), 4); + for (image, gray) in drawn.iter().zip([20u8, 80, 140, 220]) { + assert_eq!(image.decoded().unwrap(), [gray; 100]); + } + } else { + assert!(merge_fragments); + assert_eq!((report.fragments_merged, report.merged_images, drawn.len()), (4, 1, 1)); + let image = &drawn[0]; + assert_eq!((image.dict.int(b"Width"), image.dict.int(b"Height")), (Some(40), Some(10))); + let row: Vec<_> = [20u8, 80, 140, 220].into_iter().flat_map(|gray| [gray; 10]).collect(); + assert_eq!(image.decoded().unwrap(), row.repeat(10), "aligned={aligned}"); + } + } + } +} + #[test] fn a_page_picture_printed_as_one_pixel_strips_is_merged_into_one_image() { // 120 strips, every other one JPEG and half as wide (as one PDF printer writes them). From f4e195215b896ec5150979d6cb75abadc9847320 Mon Sep 17 00:00:00 2001 From: Eric Kaden <608341+EricKaden@users.noreply.github.com> Date: Mon, 9 Nov 2026 05:27:09 -0500 Subject: [PATCH 13/41] M7: the Preferences language test opens the app menu on macOS (#4882) --- crates/ui-egui/tests/it/control.rs | 31 ++++++++++++++++++++++++++---- 1 file changed, 27 insertions(+), 4 deletions(-) diff --git a/crates/ui-egui/tests/it/control.rs b/crates/ui-egui/tests/it/control.rs index 764d4c6d24..5bbc26c991 100644 --- a/crates/ui-egui/tests/it/control.rs +++ b/crates/ui-egui/tests/it/control.rs @@ -953,6 +953,8 @@ fn inverted_middle_drag_moves_the_view_the_way_the_pointer_goes() { assert!(!h.state().session.get(h.state().views[0].id).unwrap().dirty); } +/// The control channel reports the open document, its page count and current page, and its +/// view options can be set: the page, and a right-hand panel by name (an unknown one is refused). #[test] fn state_and_view_options() { let (mut h, c) = harness(); @@ -969,6 +971,9 @@ fn state_and_view_options() { assert!(call(&mut h, &c, "ui.set", json!({ "key": "panel", "value": "nonsense" })).is_err()); } +/// In a Japanese interface, the mode tabs and Preferences show Japanese labels, the command +/// palette finds commands by their Japanese or English names or their ids, and switching back to +/// English keeps the tabs working. #[test] fn japanese_controls_and_search_keep_command_ids() { if crate::support::skip_without_catalogs("japanese_controls_and_search_keep_command_ids") { @@ -1004,6 +1009,9 @@ fn japanese_controls_and_search_keep_command_ids() { assert_eq!(ok(&mut h, &c, "ui.state", json!({}))["mode"], "AllTools"); } +/// In a German interface, the mode tabs and Preferences show German labels, the language +/// selector switches to English and back, and the command palette finds Split document by its +/// German name, its English name or its command id. #[test] fn german_preferences_and_search_keep_command_ids() { if crate::support::skip_without_catalogs("german_preferences_and_search_keep_command_ids") { @@ -1039,6 +1047,9 @@ fn german_preferences_and_search_keep_command_ids() { assert_eq!(ok(&mut h, &c, "ui.state", json!({}))["documents"][0]["name"], "doc.pdf"); } +/// In a Ukrainian interface, Preferences and dialogs show Ukrainian labels, the command palette +/// finds Split document by its Ukrainian name, its English name or its command id, and a refused +/// edit's notice names the command in Ukrainian. #[test] fn ukrainian_preferences_and_search_keep_command_ids() { if crate::support::skip_without_catalogs("ukrainian_preferences_and_search_keep_command_ids") { @@ -1075,6 +1086,10 @@ fn ukrainian_preferences_and_search_keep_command_ids() { assert_eq!(state["documents"][0]["name"], "doc.pdf"); } +/// In a Japanese interface, Preferences opened from the menu bar switches the language to English, +/// Ukrainian and back, and opens again with Cmd+,; the shortcuts list then reads in Japanese and +/// the document stays open. Preferences is in Edit on Windows and Linux, and in the app menu (as +/// Settings…) on macOS. #[test] fn preferences_menu_and_shortcut_allow_switching_interface_languages() { if crate::support::skip_without_catalogs("preferences_menu_and_shortcut_allow_switching_interface_languages") { @@ -1085,16 +1100,24 @@ fn preferences_menu_and_shortcut_allow_switching_interface_languages() { } let (mut h, c) = harness(); ok(&mut h, &c, "ui.set", json!({ "key": "language", "value": "ja" })); - // The menu bar's Edit menu holds Preferences. Titles and rows carry their mnemonic - // ("編集(E)", "環境設定(P)…"), so match by the start of the label. + // The menu bar's Edit menu holds Preferences on Windows and Linux. On macOS the in-window bar + // takes the Mac layout (`Target::current()`), which moves it to the app menu as Settings…. + // Titles and rows carry their mnemonic ("編集(E)", "環境設定(P)…"), so match by the start of + // the label. + let (menu, preferences) = if cfg!(target_os = "macos") { + let ja = pdfcraft_ui_egui::i18n::Lang::from_code("ja").unwrap(); + ("PdfCraft", pdfcraft_ui_egui::i18n::tr(ja, "Settings…").trim_end_matches('…')) + } else { + ("編集", "環境設定") + }; let menu_row = |text: &'static str| { move |n: &egui_kittest::kittest::AccessKitNode<'_>| { n.role() == egui::accesskit::Role::MenuItem && n.label().is_some_and(|l| l.starts_with(text)) } }; - h.get_by(menu_row("編集")).click(); + h.get_by(menu_row(menu)).click(); h.run_steps(2); - h.get_by(menu_row("環境設定")).click(); + h.get_by(menu_row(preferences)).click(); h.run_steps(2); for (current, next, code) in [("日本語", "English", "en"), ("English", "Українська", "uk"), ("Українська", "日本語", "ja")] { From 1bffa8d72dafb85801500a967ab1e86907a26e96 Mon Sep 17 00:00:00 2001 From: Harrison Booth <73434116+Harrison-Booth@users.noreply.github.com> Date: Mon, 9 Nov 2026 04:27:25 -0600 Subject: [PATCH 14/41] Tests: a retained PDF version is the later of header and catalog (#3227 follow-up) (#4933) --- crates/automation/tests/it/automation.rs | 29 ++++++++++++++++++++++++ 1 file changed, 29 insertions(+) diff --git a/crates/automation/tests/it/automation.rs b/crates/automation/tests/it/automation.rs index 046179ffaf..de9e996fcd 100644 --- a/crates/automation/tests/it/automation.rs +++ b/crates/automation/tests/it/automation.rs @@ -7677,6 +7677,35 @@ fn retained_pdf_version_reports_the_header_raised_for_object_streams() { assert_eq!(r["pdf_version"].as_str(), Some("1.5"), "{r}"); } +/// #3227: retaining the version, the later of the written header and the catalog `/Version` is +/// reported: a catalog below the header cannot hide it, and equal versions are reported as is. +#[test] +fn retained_pdf_version_is_the_later_of_header_and_catalog() { + let dir = workdir("optimize-retain-version-later"); + let mut a = auto(&dir); + let blank = ok(&mut a, "doc_create", json!({ "from": "blank", "width": 300, "height": 400 }))["doc"].as_u64().unwrap(); + ok(&mut a, "doc_optimize", json!({ "doc": blank, "path": "v14.pdf", "compatibility": "1.4" })); + let mut cos = pdfcraft_cos::Document::open(std::sync::Arc::new(std::fs::read(dir.join("v14.pdf")).unwrap())).unwrap(); + let root = cos.root().unwrap(); + for (label, header, catalog, saved, reported) in [ + ("higher-header", "1.7", "1.4", "1.7", "1.7"), + // PDF versions compare as major/minor numbers: 2.0 is newer than 1.7. + // Retain keeps the header. The catalog /Version only sets the reported version. + ("higher-catalog", "1.7", "2.0", "1.7", "2.0"), + ("equal", "1.4", "1.4", "1.4", "1.4"), + ] { + cos.update_dict(root, |c| c.set(b"Version".to_vec(), pdfcraft_cos::Object::name(catalog))).unwrap(); + cos.set_version(header); + let input = pdfcraft_cos::write_full(&cos, &pdfcraft_cos::SaveOptions { object_streams: false, ..Default::default() }).unwrap(); + std::fs::write(dir.join(format!("{label}-input.pdf")), input).unwrap(); + let doc = ok(&mut a, "doc_open", json!({ "path": format!("{label}-input.pdf") }))["doc"].as_u64().unwrap(); + let out = format!("{label}-output.pdf"); + let r = ok(&mut a, "doc_optimize", json!({ "doc": doc, "path": out, "compatibility": "retain", "object_streams": false })); + assert!(std::fs::read(dir.join(&out)).unwrap().starts_with(format!("%PDF-{saved}\n").as_bytes()), "{label}: written header"); + assert_eq!(r["pdf_version"].as_str(), Some(reported), "{label}: {r}"); + } +} + /// `data` as LZW with one 9-bit literal code per byte (clear-table first, EOD last): valid for /// fewer than 250 bytes, before the code width grows. fn lzw_literals(data: &[u8]) -> Vec { From b44257b92b0c3bb71e58954cfbf0e5e22c6a4e87 Mon Sep 17 00:00:00 2001 From: Harrison Booth <73434116+Harrison-Booth@users.noreply.github.com> Date: Mon, 9 Nov 2026 04:27:50 -0600 Subject: [PATCH 15/41] Test preservation of PDF 2.0 structure metadata (#5073) --- crates/cos/tests/it/writer.rs | 71 ++++++++++++++++++++++++++++++ crates/organize/src/tests.rs | 81 +++++++++++++++++++++++++++++++++++ 2 files changed, 152 insertions(+) diff --git a/crates/cos/tests/it/writer.rs b/crates/cos/tests/it/writer.rs index 2716b9242e..1dc6275b5f 100644 --- a/crates/cos/tests/it/writer.rs +++ b/crates/cos/tests/it/writer.rs @@ -544,3 +544,74 @@ fn incremental_saves_of_a_file_whose_offsets_count_from_its_first_byte_keep_coun // No qpdf check here: qpdf counts every offset from the header in a file with bytes before // it, so it already reconstructs this input before anything is appended. } + +/// One page whose catalog carries a PDF 2.0 document part tree (`/DPartRoot`, ISO 32000-2 +/// 14.12) and whose structure tree root lists structure namespaces (`/Namespaces`, 14.7.4), +/// both through indirect objects that nothing else references. +fn pdf2_fixture() -> Vec { + Pdf::new() + .version("2.0") + .obj("<< /Type /Catalog /Pages 2 0 R /DPartRoot 6 0 R /StructTreeRoot 8 0 R >>") + .obj("<< /Type /Pages /Kids [4 0 R] /Count 1 /MediaBox [0 0 200 300] >>") + .obj("<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>") + .obj("<< /Type /Page /Parent 2 0 R /Contents 5 0 R /Resources << /Font << /F1 3 0 R >> >> >>") + .stream("", "BT /F1 24 Tf 20 150 Td (Part) Tj ET") + .obj("<< /Type /DPartRoot /DPartRootNode 7 0 R /NodeNameList [/Chapter] >>") + .obj("<< /Type /DPart /Parent 6 0 R /Start 4 0 R /End 4 0 R /DPM << /Kind (cover) >> >>") + .obj("<< /Type /StructTreeRoot /K [] /Namespaces [9 0 R] >>") + .obj("<< /Type /Namespace /NS (http://iso.org/pdf2/ssn) /Schema << /Type /Filespec /F (ssn.rnc) >> >>") + .build() +} + +/// What `pdf2_fixture` declares, read back through the catalog. +fn assert_dpart_and_namespaces(doc: &Document) { + let catalog = doc.get(doc.root().unwrap()); + let catalog = catalog.as_dict().unwrap(); + let root = doc.resolve(catalog.get(b"DPartRoot").expect("catalog keeps /DPartRoot")); + let root = root.as_dict().unwrap(); + let node = doc.resolve(root.get(b"DPartRootNode").expect("/DPartRootNode kept")); + let node = node.as_dict().unwrap(); + assert_eq!(node.name(b"Type"), Some(&b"DPart"[..])); + // Both ends of the part are still the one page, found through /Pages (references are + // renumbered by a full save, so they are compared inside this document). + let pages = doc.resolve(catalog.get(b"Pages").unwrap()); + let kids = pages.as_dict().unwrap().get(b"Kids").unwrap().as_array().unwrap(); + let page = kids.first().and_then(|k| k.as_ref()).expect("the page is still the first kid"); + assert_eq!(node.reference(b"Start"), Some(page), "the part still starts at its page"); + assert_eq!(node.reference(b"End"), Some(page), "the part still ends at its page"); + assert!(doc.resolve(node.get(b"DPM").unwrap()).as_dict().unwrap().get(b"Kind").is_some()); + let tree = doc.resolve(catalog.get(b"StructTreeRoot").unwrap()); + let namespaces = doc.resolve(tree.as_dict().unwrap().get(b"Namespaces").expect("/Namespaces kept")); + let namespace = doc.resolve(&namespaces.as_array().unwrap()[0]); + let namespace = namespace.as_dict().unwrap(); + assert_eq!(namespace.get(b"NS").unwrap().as_string().unwrap().bytes, b"http://iso.org/pdf2/ssn"); + assert!(namespace.get(b"Schema").is_some(), "/Schema is retained"); +} + +#[test] +fn document_parts_and_structure_namespaces_survive_full_and_incremental_saves() { + let original = Arc::new(pdf2_fixture()); + let doc = Document::open(original.clone()).unwrap(); + assert_dpart_and_namespaces(&doc); + + for object_streams in [true, false] { + let opts = SaveOptions { object_streams, ..SaveOptions::default() }; + let full = write_full(&doc, &opts).unwrap(); + let back = Document::open(Arc::new(full.clone())).unwrap(); + assert!(back.repair_log().is_empty(), "{:?}", back.repair_log()); + assert_dpart_and_namespaces(&back); + // A second generation, rewritten from the rewritten file. + let second = Document::open(Arc::new(write_full(&back, &opts).unwrap())).unwrap(); + assert!(second.repair_log().is_empty(), "{:?}", second.repair_log()); + assert_dpart_and_namespaces(&second); + } + + // An incremental save of an unrelated edit leaves the file's bytes, and so both, alone. + let mut edited = Document::open(original.clone()).unwrap(); + edited.add(Object::Int(1)); + let updated = write_incremental(&edited, &SaveOptions::default()).unwrap(); + assert!(updated.starts_with(&original)); + let reopened = Document::open(Arc::new(updated)).unwrap(); + assert!(reopened.repair_log().is_empty(), "{:?}", reopened.repair_log()); + assert_dpart_and_namespaces(&reopened); +} diff --git a/crates/organize/src/tests.rs b/crates/organize/src/tests.rs index 0aa6d149d9..451a6dc397 100644 --- a/crates/organize/src/tests.rs +++ b/crates/organize/src/tests.rs @@ -4505,6 +4505,87 @@ fn bookmark_style_and_colour_are_set_as_the_table_says() { assert_eq!(got, [(Some([0.0, 0.0, 1.0]), true, true), (None, false, false)]); } +/// Gives the fixture a PDF 2.0 document part tree (`/DPartRoot`, ISO 32000-2 14.12) that starts +/// at its first page, and a structure tree root listing a structure namespace (14.7.4). +fn with_document_parts_and_namespaces() -> Document { + // PDF 2.0, the version these structures belong to. + let mut bytes = fixture(); + let header = bytes.get_mut(..8).expect("the fixture starts with its header"); + assert!(header.starts_with(b"%PDF-1.")); + header.copy_from_slice(b"%PDF-2.0"); + let mut doc = open(bytes); + let first = pages(&doc).unwrap()[0].obj; + let mut namespace = pdfcraft_cos::Dict::new(); + namespace.set(b"Type".to_vec(), Object::name("Namespace")); + namespace.set(b"NS".to_vec(), Object::String(pdfcraft_cos::PdfString { bytes: b"http://iso.org/pdf2/ssn".to_vec(), hex: false })); + let namespace = doc.add(Object::Dict(namespace)); + let mut tree = pdfcraft_cos::Dict::new(); + tree.set(b"Type".to_vec(), Object::name("StructTreeRoot")); + tree.set(b"Namespaces".to_vec(), Object::Array(vec![Object::Ref(namespace)])); + let tree = doc.add(Object::Dict(tree)); + let mut part = pdfcraft_cos::Dict::new(); + part.set(b"Type".to_vec(), Object::name("DPart")); + part.set(b"Start".to_vec(), Object::Ref(first)); + part.set(b"End".to_vec(), Object::Ref(first)); + let part = doc.add(Object::Dict(part)); + let mut root = pdfcraft_cos::Dict::new(); + root.set(b"Type".to_vec(), Object::name("DPartRoot")); + root.set(b"DPartRootNode".to_vec(), Object::Ref(part)); + let root = doc.add(Object::Dict(root)); + doc.update_dict(doc.root().unwrap(), |c| { + c.set(b"DPartRoot".to_vec(), Object::Ref(root)); + c.set(b"StructTreeRoot".to_vec(), Object::Ref(tree)); + }) + .unwrap(); + doc +} + +fn assert_document_parts_and_namespaces_kept(doc: &Document, what: &str) { + let catalog = doc.get(doc.root().unwrap()); + let catalog = catalog.as_dict().unwrap(); + let root = doc.resolve(catalog.get(b"DPartRoot").unwrap_or_else(|| panic!("{what}: /DPartRoot dropped"))); + let node = doc.resolve(root.as_dict().unwrap().get(b"DPartRootNode").unwrap_or_else(|| panic!("{what}: /DPartRootNode dropped"))); + let part = node.as_dict().unwrap(); + assert_eq!(part.name(b"Type"), Some(&b"DPart"[..]), "{what}"); + assert!(part.get(b"Start").is_some(), "{what}: /Start dropped"); + assert!(part.get(b"End").is_some(), "{what}: /End dropped"); + let tree = doc.resolve(catalog.get(b"StructTreeRoot").unwrap_or_else(|| panic!("{what}: /StructTreeRoot dropped"))); + let namespaces = doc.resolve(tree.as_dict().unwrap().get(b"Namespaces").unwrap_or_else(|| panic!("{what}: /Namespaces dropped"))); + let namespace = doc.resolve(&namespaces.as_array().unwrap()[0]); + assert_eq!(namespace.as_dict().unwrap().name(b"Type"), Some(&b"Namespace"[..]), "{what}"); + assert_eq!(namespace.as_dict().unwrap().get(b"NS").unwrap().as_string().unwrap().bytes, b"http://iso.org/pdf2/ssn", "{what}: /NS changed"); + assert_eq!(doc.version(), "2.0", "{what}: the PDF 2.0 version is kept"); +} + +#[test] +fn page_edits_keep_document_parts_and_structure_namespaces() { + type Edit = fn(&mut Document); + let edits: [(&str, Edit); 6] = [ + ("rotate", |d| rotate_pages(d, &[0], 90).unwrap()), + ("move", |d| move_pages(d, &[0], 2).unwrap()), + ("reverse", |d| reverse_pages(d, &[0, 1, 2]).unwrap()), + ("duplicate", |d| duplicate_pages(d, &[0], None).unwrap()), + ("insert blank", |d| { + let _ = insert_blank_page(d, 0, 100.0, 100.0).unwrap(); + }), + // The part's start page goes: the keys stay, the page reference is the part's to repair. + ("delete", |d| delete_pages(d, &[0]).unwrap()), + ]; + for (what, edit) in edits { + let mut doc = with_document_parts_and_namespaces(); + let base = write_full(&doc, &SaveOptions::default()).unwrap(); + assert_document_parts_and_namespaces_kept(&open(base.clone()), "setup"); + doc = open(base); + edit(&mut doc); + assert_document_parts_and_namespaces_kept(&doc, what); + assert_document_parts_and_namespaces_kept( + &open(write_incremental(&doc, &SaveOptions::default()).unwrap()), + &format!("{what}, incremental save"), + ); + assert_document_parts_and_namespaces_kept(&open(write_full(&doc, &SaveOptions::default()).unwrap()), &format!("{what}, full save")); + } +} + /// #3502: a `/Contents` array's pieces are joined with a space, as the renderer joins them, so a /// `%` comment at the end of a piece without a line break continues through the first line of the /// next piece in both resource pruning (`extract_pages`) and `page_as_form`. From 048386eea156393a1449448983c85187c9d740c1 Mon Sep 17 00:00:00 2001 From: Eric Kaden <608341+EricKaden@users.noreply.github.com> Date: Mon, 9 Nov 2026 05:28:14 -0500 Subject: [PATCH 16/41] M12: XFA date pictures read a two-digit year with the century split of 30 (#5074) --- crates/js/src/formcalc/dates.rs | 7 ++++++- crates/js/src/tests.rs | 10 ++++++++++ crates/xfa/src/data/dates.rs | 9 +++++++-- crates/xfa/src/tests.rs | 23 ++++++++++++++++++++--- parity/acrobat-features.toml | 6 +++--- 5 files changed, 46 insertions(+), 9 deletions(-) diff --git a/crates/js/src/formcalc/dates.rs b/crates/js/src/formcalc/dates.rs index bfb6383cdd..4d9befeab2 100644 --- a/crates/js/src/formcalc/dates.rs +++ b/crates/js/src/formcalc/dates.rs @@ -49,6 +49,11 @@ pub(super) fn civil_from_days(n: i64) -> (i64, u32, u32) { /// Day numbers (and years) beyond this are refused rather than computed. pub(super) const MAX_DAYS: f64 = 1e8; +/// The century split of a two-digit year (`YY`), XFA picture clauses' default: `00` to `29` are +/// 2000 to 2029 and `30` to `99` are 1930 to 1999 (XFA 3.3, chapter 26, Picture Clause Reference, +/// p. 1171, which makes 30 the default and lets the century split be set otherwise). +const CENTURY_SPLIT: i64 = 30; + const MONTHS: [&str; 12] = ["January", "February", "March", "April", "May", "June", "July", "August", "September", "October", "November", "December"]; /// Parse `text` as a date by `picture` (`YYYY-MM-DD`, `MM/DD/YYYY`, `MMM D, YYYY`, …). @@ -89,7 +94,7 @@ pub(super) fn parse_date(text: &str, picture: &str) -> Option<(i64, u32, u32)> { } let v: i64 = digits.parse().ok()?; match c { - 'Y' => y = Some(if run <= 2 { if v < 50 { 2000 + v } else { 1900 + v } } else { v }), + 'Y' => y = Some(if run <= 2 { if v < CENTURY_SPLIT { 2000 + v } else { 1900 + v } } else { v }), 'M' => m = Some(v as u32), _ => d = Some(v as u32), } diff --git a/crates/js/src/tests.rs b/crates/js/src/tests.rs index a12b78dcb6..46316f90ad 100644 --- a/crates/js/src/tests.rs +++ b/crates/js/src/tests.rs @@ -1346,6 +1346,16 @@ mod formcalc { assert_eq!(calc(r#"Date2Num("2024-12-31") <> 0"#), "1"); } + /// A `YY` year in a date picture splits its century at 30, the XFA picture clause default: + /// `00` to `29` are 2000 to 2029 and `30` to `99` are 1930 to 1999. + #[test] + fn a_two_digit_year_splits_its_century_at_30() { + for (text, iso) in [("12/31/29", "2029-12-31"), ("01/01/30", "1930-01-01"), ("12/31/49", "1949-12-31"), ("01/01/50", "1950-01-01")] { + assert_eq!(calc(&format!(r#"Num2Date(Date2Num("{text}", "MM/DD/YY"), "YYYY-MM-DD")"#)), iso, "{text}"); + assert_eq!(calc(&format!(r#"Parse("MM/DD/YY", "{text}")"#)), iso, "{text}"); + } + } + #[test] fn control_flow_variables_and_functions_work() { assert_eq!(calc("var x = 3\nif (x > 2) then 10 elseif (x > 1) then 20 else 30 endif"), "10"); diff --git a/crates/xfa/src/data/dates.rs b/crates/xfa/src/data/dates.rs index 565c44117d..e1c30f3f5a 100644 --- a/crates/xfa/src/data/dates.rs +++ b/crates/xfa/src/data/dates.rs @@ -37,7 +37,7 @@ pub(crate) fn data_to_iso(value: &str, data_pattern: Option<&str>) -> String { } /// An ISO date as the data should hold it: in `data_pattern` when the field has one and the -/// date reads back from it unchanged (a two-digit year holds only 1950 to 2049), else as ISO. +/// date reads back from it unchanged (a two-digit year holds only 1930 to 2029), else as ISO. pub(crate) fn iso_to_data(iso: String, data_pattern: Option<&str>) -> String { match data_pattern { Some(p) => { @@ -53,6 +53,11 @@ pub(super) fn data_date_pattern(tpl: Option<&Template>, som: &str) -> Option Option { rest = &rest[digits.len()..]; match t.as_str() { "yyyy" => y = Some(v), - "yy" => y = Some(if v < 50 { 2000 + v } else { 1900 + v }), + "yy" => y = Some(if v < CENTURY_SPLIT { 2000 + v } else { 1900 + v }), "mm" | "m" => m = Some(v), _ => d = Some(v), } diff --git a/crates/xfa/src/tests.rs b/crates/xfa/src/tests.rs index 632f6a3f0d..af3cb094bd 100644 --- a/crates/xfa/src/tests.rs +++ b/crates/xfa/src/tests.rs @@ -1192,19 +1192,34 @@ fn node_at<'a>(live: &'a crate::script::FormNode, som: &str) -> Option<&'a crate live.children.iter().find_map(|c| node_at(c, som)) } -/// A date its data picture can't hold (2050 in a two-digit year) is written as ISO and reads +/// A date its data picture can't hold (2030 in a two-digit year) is written as ISO and reads /// back as written. #[test] fn a_date_its_data_picture_cant_hold_is_written_as_iso() { use crate::data::{data_to_iso, iso_to_data}; assert_eq!(iso_to_data("2026-01-02".into(), Some("yymmdd")), "260102"); - // A two-digit year reads back as 1950 to 2049: 2050 and 1949 keep ISO, and read back as written. - for iso in ["2050-01-02", "1949-12-31"] { + // A two-digit year reads back as 1930 to 2029, so those years are written in it. + for (iso, data) in [("2029-12-31", "291231"), ("1930-01-01", "300101"), ("1949-12-31", "491231"), ("1950-01-01", "500101")] { + assert_eq!(iso_to_data(iso.into(), Some("yymmdd")), data, "{iso}"); + } + // 2030 and 1929 keep ISO, and read back as written. + for iso in ["2030-01-01", "1929-12-31", "2050-01-02"] { assert_eq!(iso_to_data(iso.into(), Some("yymmdd")), iso); assert_eq!(data_to_iso(iso, Some("yymmdd")), iso); } } +/// A two-digit year splits its century at 30, the XFA picture clause default: `00` to `29` are +/// 2000 to 2029 and `30` to `99` are 1930 to 1999, typed and in the data alike. +#[test] +fn a_two_digit_year_splits_its_century_at_30() { + use crate::data::data_to_iso; + for (text, iso) in [("291231", "2029-12-31"), ("300101", "1930-01-01"), ("491231", "1949-12-31"), ("500101", "1950-01-01")] { + assert_eq!(pattern_to_iso(text, "yymmdd").as_deref(), Some(iso), "{text}"); + assert_eq!(data_to_iso(text, Some("yymmdd")), iso, "{text}"); + } +} + /// The datasets fill the fields and add the rows of repeating subforms. #[test] fn month_and_weekday_names_in_date_patterns() { @@ -4221,6 +4236,8 @@ fn a_typed_date_is_rewritten_only_by_date_clauses_into_a_first_pattern_that_keep let short = "form[0].short[0]"; assert_eq!(typed(short, "20261018").as_deref(), Some("261018"), "a year the first pattern keeps"); assert_eq!(typed(short, "19011018"), None, "1901 as 01 would read back as 2001"); + assert_eq!(typed(short, "19451018").as_deref(), Some("451018"), "45 reads back as 1945"); + assert_eq!(typed(short, "20301018"), None, "2030 as 30 would read back as 1930"); // DDMMYYYY reads 20011203 as 1203-01-20, which YYMMDD can't keep; YYYYMMDD reads 2001-12-03. assert_eq!(typed("form[0].pick[0]", "20011203").as_deref(), Some("011203"), "the next pattern that the first keeps"); } diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index 714ba4814f..761a0a44d7 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -5459,7 +5459,7 @@ milestone = "M12" status = "partial" commands = ["file.open"] tools = ["doc_open", "form_fill", "js_run"] -evidence = ["crates/js/src/tests.rs::arithmetic_strings_and_comparisons_follow_formcalc_rules", "crates/js/src/tests.rs::built_in_functions_cover_the_common_set", "crates/js/src/tests.rs::control_flow_variables_and_functions_work", "crates/js/src/tests.rs::form_objects_resolve_assign_and_record_effects", "crates/js/src/tests.rs::hostile_formcalc_scripts_end_with_an_error_and_bounded_output", "crates/js/src/tests.rs::formcalc_values_results_and_errors_stay_bounded", "crates/engine/src/tests.rs::dynamic_xfa_forms_run_formcalc_scripts", "crates/engine/src/tests.rs::hostile_formcalc_scripts_cannot_hang_or_bloat_the_document", "crates/engine/src/tests.rs::a_formcalc_calculate_result_is_cut_before_it_is_saved", "crates/js/src/tests.rs::formcalc_message_box_keeps_its_title_icon_and_buttons"] +evidence = ["crates/js/src/tests.rs::arithmetic_strings_and_comparisons_follow_formcalc_rules", "crates/js/src/tests.rs::built_in_functions_cover_the_common_set", "crates/js/src/tests.rs::control_flow_variables_and_functions_work", "crates/js/src/tests.rs::form_objects_resolve_assign_and_record_effects", "crates/js/src/tests.rs::hostile_formcalc_scripts_end_with_an_error_and_bounded_output", "crates/js/src/tests.rs::formcalc_values_results_and_errors_stay_bounded", "crates/js/src/tests.rs::a_two_digit_year_splits_its_century_at_30", "crates/engine/src/tests.rs::dynamic_xfa_forms_run_formcalc_scripts", "crates/engine/src/tests.rs::hostile_formcalc_scripts_cannot_hang_or_bloat_the_document", "crates/engine/src/tests.rs::a_formcalc_calculate_result_is_cut_before_it_is_saved", "crates/js/src/tests.rs::formcalc_message_box_keeps_its_title_icon_and_buttons"] notes = "a native FormCalc interpreter (crates/js/src/formcalc.rs) over the same form table, effects and budgets as the XFA JavaScript model: expressions and FormCalc coercions, var, if/while/for/foreach/func, accessors ($, $host, $form, $record, $layout, $event, xfa., .., [n], [*], _name instance managers) and the arithmetic, logical, string, date/time, financial and unit built-ins. Bounded: steps and loop iterations, statement and expression nesting, operator/accessor chain length, strings (1 MiB; messages and thrown errors clipped to 4 KiB, field values and calculate results to 64 KiB, noted; copying and comparing long strings is charged to the step budget, so it ends on wasm, which has no clock), lists (each object once), clamped dates, the instance cap, and a deadline the script itself honours after the engine stops waiting. Missing: locale-aware pictures beyond simple number and date ones, Format/Parse time and text pictures, Get/Post/Put (refused), $template/$connectionSet, user-defined functions across scripts, a cap on a script's total memory (each string and list is capped, but many variables can each hold one), real Uuid() values (deterministic, from the step count) and the wall clock on wasm (Date() reads 1970-01-01 and Time() midnight there), and checking against Acrobat on real Designer forms beyond the fixtures. $host.messageBox keeps its title, icon and buttons; it answers OK (1) for now (#4291)." [[feature]] @@ -5471,8 +5471,8 @@ milestone = "M12" status = "partial" commands = ["file.open", "file.save", "form.export_data", "form.import_data"] tools = ["doc_open", "form_fill", "doc_save", "doc_export_data", "doc_import_data", "form_set_image"] -evidence = ["crates/xfa/src/tests.rs::the_datasets_packet_is_written_from_the_fields_and_read_back", "crates/xfa/src/tests.rs::a_date_is_read_from_the_data_in_its_data_picture", "crates/xfa/src/tests.rs::scripts_read_a_date_as_iso_whatever_its_data_picture_writes", "crates/engine/src/tests.rs::xfa_dates_are_written_and_read_back_in_their_data_picture", "crates/engine/src/tests.rs::each_xfa_date_field_is_written_in_its_own_data_picture_and_a_partial_one_keeps_iso", "crates/xfa/src/tests.rs::som_paths_name_the_template_field_their_numbers_count_to", "crates/xfa/src/tests.rs::a_refreshed_field_takes_its_value_from_its_own_template_field", "crates/xfa/src/tests.rs::a_date_its_data_picture_cant_hold_is_written_as_iso", "crates/xfa/src/tests.rs::data_fills_the_fields_and_adds_rows", "crates/engine/src/tests.rs::dynamic_xfa_forms_are_laid_out_on_open_filled_and_saved_incrementally", "crates/xfa/src/tests.rs::data_paths_leave_areas_out", "crates/engine/src/tests.rs::xfa_rows_in_areas_are_laid_out_read_calculated_and_written_where_adobe_binds_them", "crates/xfa/src/tests.rs::data_exports_as_datasets_xml_or_an_xdp_and_imports_from_any_of_them", "crates/xfa/src/tests.rs::a_picture_chosen_for_an_image_field_is_kept_in_the_data_with_its_content_type", "crates/engine/src/tests.rs::xfa_data_exports_imports_and_pictures_round_trip_through_the_session", "crates/engine/src/tests.rs::xfa_data_import_adds_and_removes_rows_of_a_dynamic_form", "crates/automation/tests/it/automation.rs::xfa_data_files_export_and_import_through_tools"] -notes = "the datasets packet inside the PDF: read on open (values, dates in ISO form, or in the field's data picture (``, such as date{YYYYMMDD}) when it has one holding a whole date (one without a day, such as date{YYYYMM}, keeps ISO, as does a date the picture can't hold, such as 2050 in a two-digit year), each field its own even beside same-named siblings, which saving writes them in too, while scripts still read the ISO date as rawValue, as another viewer gives it, check and radio states, extra rows for repeating subforms) and rewritten after every form edit from the fields' SOM paths (/PCSom), with areas left out of data paths as Adobe binds them. Export Data writes it as XML (the data alone, rooted at the form's root data node, as Acrobat does) or an XDP with the whole datasets and a reference to the PDF; Import Data takes an XDP, datasets or data XML file (or a bare data document) as the form's whole data, keeping data descriptions, lays a dynamic form out again from it (rows added and removed, more than 50 rows reported) and runs its calculations, undoable; a static form's fields take the data and the rest are emptied; the file's data is re-serialized with the packet's own prefix and every packet rewrite is parsed back before it is written; XFDF/FDF/CSV still go the AcroForm way. Files are UTF-8, UTF-16 or ISO-8859-1, at most 16 MB and 64 levels deep; pictures at most 8 MB (PNG, JPEG, GIF, BMP, TIFF). Pictures chosen for image fields are kept in the data as base64 with xfa:contentType, as Designer saves them. Missing: explicit bind refs, data descriptions are kept but not checked, no data connection (WSDL/ODBC)" +evidence = ["crates/xfa/src/tests.rs::the_datasets_packet_is_written_from_the_fields_and_read_back", "crates/xfa/src/tests.rs::a_date_is_read_from_the_data_in_its_data_picture", "crates/xfa/src/tests.rs::scripts_read_a_date_as_iso_whatever_its_data_picture_writes", "crates/engine/src/tests.rs::xfa_dates_are_written_and_read_back_in_their_data_picture", "crates/engine/src/tests.rs::each_xfa_date_field_is_written_in_its_own_data_picture_and_a_partial_one_keeps_iso", "crates/xfa/src/tests.rs::som_paths_name_the_template_field_their_numbers_count_to", "crates/xfa/src/tests.rs::a_refreshed_field_takes_its_value_from_its_own_template_field", "crates/xfa/src/tests.rs::a_date_its_data_picture_cant_hold_is_written_as_iso", "crates/xfa/src/tests.rs::a_two_digit_year_splits_its_century_at_30", "crates/xfa/src/tests.rs::data_fills_the_fields_and_adds_rows", "crates/engine/src/tests.rs::dynamic_xfa_forms_are_laid_out_on_open_filled_and_saved_incrementally", "crates/xfa/src/tests.rs::data_paths_leave_areas_out", "crates/engine/src/tests.rs::xfa_rows_in_areas_are_laid_out_read_calculated_and_written_where_adobe_binds_them", "crates/xfa/src/tests.rs::data_exports_as_datasets_xml_or_an_xdp_and_imports_from_any_of_them", "crates/xfa/src/tests.rs::a_picture_chosen_for_an_image_field_is_kept_in_the_data_with_its_content_type", "crates/engine/src/tests.rs::xfa_data_exports_imports_and_pictures_round_trip_through_the_session", "crates/engine/src/tests.rs::xfa_data_import_adds_and_removes_rows_of_a_dynamic_form", "crates/automation/tests/it/automation.rs::xfa_data_files_export_and_import_through_tools"] +notes = "the datasets packet inside the PDF: read on open (values, dates in ISO form, or in the field's data picture (``, such as date{YYYYMMDD}) when it has one holding a whole date (one without a day, such as date{YYYYMM}, keeps ISO, as does a date the picture can't hold, such as 2030 in a two-digit year, which holds 1930 to 2029 as XFA pictures split the century at 30), each field its own even beside same-named siblings, which saving writes them in too, while scripts still read the ISO date as rawValue, as another viewer gives it, check and radio states, extra rows for repeating subforms) and rewritten after every form edit from the fields' SOM paths (/PCSom), with areas left out of data paths as Adobe binds them. Export Data writes it as XML (the data alone, rooted at the form's root data node, as Acrobat does) or an XDP with the whole datasets and a reference to the PDF; Import Data takes an XDP, datasets or data XML file (or a bare data document) as the form's whole data, keeping data descriptions, lays a dynamic form out again from it (rows added and removed, more than 50 rows reported) and runs its calculations, undoable; a static form's fields take the data and the rest are emptied; the file's data is re-serialized with the packet's own prefix and every packet rewrite is parsed back before it is written; XFDF/FDF/CSV still go the AcroForm way. Files are UTF-8, UTF-16 or ISO-8859-1, at most 16 MB and 64 levels deep; pictures at most 8 MB (PNG, JPEG, GIF, BMP, TIFF). Pictures chosen for image fields are kept in the data as base64 with xfa:contentType, as Designer saves them. Missing: explicit bind refs, data descriptions are kept but not checked, no data connection (WSDL/ODBC)" [[feature]] id = "form.xfa-flatten" From 423cc239a143c3cd62dba094995c4e2cc7035d97 Mon Sep 17 00:00:00 2001 From: mtlcl410 <2368091+mtlcl410@users.noreply.github.com> Date: Mon, 9 Nov 2026 18:28:53 +0800 Subject: [PATCH 17/41] M12: an XFA breakBefore or breakAfter without a targetType starts no new page (form.xfa-dynamic) (#5145) --- crates/xfa/src/parse.rs | 4 ++- crates/xfa/src/tests.rs | 61 +++++++++++++++++++++++++++++++++++++++++ 2 files changed, 64 insertions(+), 1 deletion(-) diff --git a/crates/xfa/src/parse.rs b/crates/xfa/src/parse.rs index cec433f469..7f8a080be4 100644 --- a/crates/xfa/src/parse.rs +++ b/crates/xfa/src/parse.rs @@ -662,7 +662,9 @@ fn parse_subform(n: XmlNode, warnings: &mut Vec, depth: usize) -> Subfor } let column_widths: Vec = n.attribute("columnWidths").map(|s| s.split_whitespace().filter_map(measure).map(|w| w.max(0.0)).take(256).collect()).unwrap_or_default(); - let break_to_page = |name: &str| children(n, name).any(|b| b.attribute("targetType").is_none_or(|t| t == "pageArea" || t == "contentArea")); + // A break to a page or content area. With no targetType, the default is `auto`: "layout + // continues using the current layout container" (XFA 3.3 p. 638), so no break. + let break_to_page = |name: &str| children(n, name).any(|b| b.attribute("targetType").is_some_and(|t| t == "pageArea" || t == "contentArea")); Subform { common, layout: Layout::parse(n.attribute("layout")), diff --git a/crates/xfa/src/tests.rs b/crates/xfa/src/tests.rs index af3cb094bd..35a25077f0 100644 --- a/crates/xfa/src/tests.rs +++ b/crates/xfa/src/tests.rs @@ -4525,6 +4525,67 @@ fn clear_password_values_clears_static_xfa_password_fields() { assert!(!packets.xdp.contains("hunter2"), "{}", packets.xdp); } +/// The pages of `form[0].last[0].f[0]` and of the field `after` that follows `last`, when `last` +/// holds `before` ahead of `f` and `after` behind it. A breakAfter acts after its subform, so the +/// page of `after` shows whether it broke. +fn pages_around_break(before: &str, after: &str) -> (Option, Option) { + let xml = format!( + r#""# + ); + let t = crate::parse(&xml).unwrap().0; + let form = crate::layout::layout(&t, None).unwrap(); + let page_of = |som: &str| form.pages.iter().position(|p| widgets(p).iter().any(|w| w.som == som)); + (page_of("form[0].last[0].f[0]"), page_of("form[0].after[0]")) +} + +/// A `` without a targetType breaks nothing: its default is `auto`, layout goes on +/// in the current container (XFA 3.3 p. 638), as a public form's last section expects. Written +/// out as `auto` it breaks nothing either. One to a page area, or a content area, still starts a +/// new page; so does a `breakAfter` to one. +#[test] +fn a_break_before_without_a_target_type_does_not_start_a_page() { + let page_before = |brk: &str| pages_around_break(brk, "").0; + assert_eq!(page_before(""), Some(0), "auto: no break"); + assert_eq!(page_before(r#""#), Some(0)); + assert_eq!(page_before(r#""#), Some(1)); + assert_eq!(page_before(r#""#), Some(1)); + let page_after = |brk: &str| pages_around_break("", brk).1; + assert_eq!(page_after(""), Some(0), "auto: no break"); + assert_eq!(page_after(r#""#), Some(0)); + assert_eq!(page_after(r#""#), Some(1)); + assert_eq!(page_after(r#""#), Some(1)); +} + +/// targetType is one of `auto`, `contentArea` and `pageArea` (XFA 3.3 pp. 636-638). An empty or +/// unknown value, in either direction, names neither a page nor a content area, so it breaks +/// nothing; the names are case-sensitive. +#[test] +fn a_break_with_an_empty_or_unknown_target_type_does_not_start_a_page() { + for value in ["", " ", "page", "pagearea", "PageArea", "next"] { + let before = format!(r#""#); + assert_eq!(pages_around_break(&before, "").0, Some(0), "breakBefore targetType={value:?}"); + let after = format!(r#""#); + assert_eq!(pages_around_break("", &after).1, Some(0), "breakAfter targetType={value:?}"); + } +} + +/// The deprecated `` form (XFA 3.3 pp. 633-634): `auto`, its default, forces +/// no transition; `pageArea` and `contentArea` start a new page; an empty or unknown value breaks +/// nothing. +#[test] +fn the_deprecated_break_before_form_breaks_only_to_a_page_or_content_area() { + let page = |brk: &str| pages_around_break(brk, "").0; + assert_eq!(page(""), Some(0), "auto: no break"); + assert_eq!(page(r#""#), Some(0)); + assert_eq!(page(r#""#), Some(0)); + assert_eq!(page(r#""#), Some(0)); + assert_eq!(page(r#""#), Some(1)); + assert_eq!(page(r#""#), Some(1)); +} + /// A form with `draws` in a subform placed by position, on a letter page whose content area is a /// quarter inch in, with `master` on the page itself. fn form_of_draws(master: &str, draws: &str) -> String { From 4f0b926894f9861e7178e3729b2972dd7f6722d0 Mon Sep 17 00:00:00 2001 From: jonathanwxh-cell Date: Mon, 9 Nov 2026 18:29:31 +0800 Subject: [PATCH 18/41] fix(measure): resolve indirect scale objects and validate closed area edges (#5270) --- crates/measure/src/lib.rs | 46 +++++++++++++++----- crates/measure/src/tests.rs | 83 +++++++++++++++++++++++++++++++++++++ 2 files changed, 118 insertions(+), 11 deletions(-) diff --git a/crates/measure/src/lib.rs b/crates/measure/src/lib.rs index 7ff8bfdcf1..bd60a192c1 100644 --- a/crates/measure/src/lib.rs +++ b/crates/measure/src/lib.rs @@ -177,7 +177,7 @@ impl Scale { pub fn read(doc: &Document, object: &Object) -> Result { let o = doc.resolve(object); let d = o.as_dict().ok_or_else(|| invalid("invalid measure dictionary"))?; - if d.name(b"Subtype").is_some_and(|n| n != b"RL") { + if d.get(b"Subtype").map(|o| doc.resolve(o)).is_some_and(|o| !matches!(*o, Object::Null) && o.as_name() != Some(b"RL")) { return Err(invalid("only rectilinear measurement scales are supported")); } let format = |key: &[u8]| -> Result<(String, f64, u8)> { @@ -188,20 +188,25 @@ impl Scale { } let o = doc.resolve(a.first().ok_or_else(|| invalid("empty number format"))?); let f = o.as_dict().ok_or_else(|| invalid("invalid number format dictionary"))?; - let precision = match f.name(b"F") { + let fmt = f.get(b"F").map(|o| doc.resolve(o)); + let precision = match fmt.as_deref().and_then(Object::as_name) { Some(b"R") => 0, None | Some(b"D") => { - let denom = f.int(b"D").unwrap_or(100); + let denom = f.get(b"D").and_then(|o| doc.resolve(o).as_int()).unwrap_or(100); (0..=6).find(|p| 10_i64.pow(*p) == denom).ok_or_else(|| invalid("unsupported measurement precision"))? } _ => return Err(invalid("fractional measurement formats are not supported yet")), }; - Ok((text(f, b"U"), f.get(b"C").and_then(Object::as_f64).ok_or_else(|| invalid("missing conversion factor"))?, precision as u8)) + Ok(( + text(doc, f, b"U"), + f.get(b"C").and_then(|o| doc.resolve(o).as_f64()).ok_or_else(|| invalid("missing conversion factor"))?, + precision as u8, + )) }; let (unit, x, _) = format(b"X")?; let y = if d.contains(b"Y") { let (_, y, _) = format(b"Y")?; - y * d.get(b"CYX").and_then(Object::as_f64).ok_or_else(|| invalid("Y scale requires CYX for lengths and areas"))? + y * d.get(b"CYX").and_then(|o| doc.resolve(o).as_f64()).ok_or_else(|| invalid("Y scale requires CYX for lengths and areas"))? } else { x }; @@ -210,7 +215,7 @@ impl Scale { return Err(invalid("different coordinate and distance units are not supported yet")); } let (area_unit, area_factor, _) = format(b"A")?; - let out = Self { x, y, unit, distance_factor, area_factor, area_unit, precision, ratio: text(d, b"R") }; + let out = Self { x, y, unit, distance_factor, area_factor, area_unit, precision, ratio: text(doc, d, b"R") }; out.validate()?; Ok(out) } @@ -279,12 +284,19 @@ pub struct Reading { pub fn reading(kind: Kind, points: &[Point], scale: &Scale) -> Result { check_points(points)?; scale.validate()?; + let points = if kind == Kind::Area { open_polygon(points) } else { points }; let mut length = 0.0; for w in points.windows(2) { if let [a, b] = w { length += ((b[0] - a[0]) * scale.x).hypot((b[1] - a[1]) * scale.y); } } + if kind == Kind::Area + && points.len() >= 3 + && let (Some(a), Some(b)) = (points.last(), points.first()) + { + length += ((b[0] - a[0]) * scale.x).hypot((b[1] - a[1]) * scale.y); + } let area = if points.len() >= 3 { let origin = points.first().copied().unwrap_or_default(); points @@ -340,7 +352,9 @@ fn validate_geometry(kind: Kind, points: &[Point]) -> Result<()> { Kind::Area if points.len() < 3 => return Err(invalid("area needs at least three points")), _ => {} } - if points.windows(2).any(|p| matches!(p,[a,b] if distance(*a,*b)<1e-6)) { + if points.windows(2).any(|p| matches!(p, [a, b] if distance(*a, *b) < 1e-6)) + || (kind == Kind::Area && matches!((points.first(), points.last()), (Some(a), Some(b)) if distance(*a, *b) < 1e-6)) + { return Err(invalid("adjacent measurement vertices must be distinct")); } if kind == Kind::Area { @@ -357,8 +371,8 @@ fn validate_geometry(kind: Kind, points: &[Point]) -> Result<()> { } Ok(()) } -fn text(d: &Dict, k: &[u8]) -> String { - d.get(k).and_then(Object::as_string).map(PdfString::to_text).unwrap_or_default() +fn text(doc: &Document, d: &Dict, k: &[u8]) -> String { + d.get(k).and_then(|o| doc.resolve(o).as_string().map(PdfString::to_text)).unwrap_or_default() } fn nums(v: impl IntoIterator) -> Object { Object::Array(v.into_iter().map(Object::Real).collect()) @@ -491,7 +505,16 @@ fn read_measurement(doc: &Document, d: &Dict, kind: Kind, page: usize, index: us let points: Vec = v.as_chunks::<2>().0.to_vec(); validate_geometry(kind, &points)?; let reading = reading(kind, &points, &scale)?; - Ok(Measurement { page, index, id: text(d, b"NM"), author: text(d, b"T"), label: text(d, b"PCMeasureLabel"), points, scale, reading }) + Ok(Measurement { + page, + index, + id: text(doc, d, b"NM"), + author: text(doc, d, b"T"), + label: text(doc, d, b"PCMeasureLabel"), + points, + scale, + reading, + }) } /// Every measurement annotation. One that can't be read is reported in `unsupported` /// rather than failing the whole list. @@ -520,7 +543,8 @@ pub fn list(doc: &Document) -> Listing { } let o = doc.resolve(a); let Some(d) = o.as_dict() else { continue }; - let kind = match d.name(b"IT") { + let it = d.get(b"IT").map(|o| doc.resolve(o)); + let kind = match it.as_deref().and_then(Object::as_name) { Some(b"LineDimension") => Kind::Distance, Some(b"PolyLineDimension") => Kind::Perimeter, Some(b"PolygonDimension") => Kind::Area, diff --git a/crates/measure/src/tests.rs b/crates/measure/src/tests.rs index b039f7c57f..80d30fdefe 100644 --- a/crates/measure/src/tests.rs +++ b/crates/measure/src/tests.rs @@ -549,3 +549,86 @@ fn perimeters_are_charged_linear_work() { assert!(!l.truncated, "{} listed", l.measurements.len()); assert_eq!(l.measurements.len() + l.unsupported.len(), lines, "{:?}", l.unsupported.first()); } + +#[test] +fn indirect_scale_fields_and_closed_area_edges_are_handled() { + let mut d = fixture("", "", &[]); + let mut m = new(Kind::Area, vec![[0.0, 0.0], [3.0, 0.0], [3.0, 4.0], [0.0, 4.0]]); + m.scale = Scale { y: 3.0, ..Scale::new(2.0, "m", 2).unwrap() }; + add(&mut d, &m, &Meta { date: None, id: "area-1".into() }).unwrap(); + + // Open and closed vertex lists for the same rectangle produce identical readings (including perimeter length). + let open = reading(Kind::Area, &[[0.0, 0.0], [3.0, 0.0], [3.0, 4.0], [0.0, 4.0]], &m.scale).unwrap(); + let closed = reading(Kind::Area, &[[0.0, 0.0], [3.0, 0.0], [3.0, 4.0], [0.0, 4.0], [0.0, 0.0]], &m.scale).unwrap(); + close(open.length, 36.0); + close(closed.length, 36.0); + + // A doubled closing vertex leaves a zero-length closing edge after stripping one closing duplicate, + // and an area with fewer than 3 distinct vertices or a NaN coordinate is also rejected. + assert!(add(&mut d, &new(Kind::Area, vec![[0.0, 0.0], [3.0, 0.0], [3.0, 4.0], [0.0, 0.0], [0.0, 0.0]]), &Meta::default()).is_err()); + assert!(add(&mut d, &new(Kind::Area, vec![[0.0, 0.0], [3.0, 0.0], [0.0, 0.0]]), &Meta::default()).is_err()); + assert!(add(&mut d, &new(Kind::Area, vec![[0.0, 0.0], [3.0, 0.0], [f64::NAN, 4.0]]), &Meta::default()).is_err()); + assert!(reading(Kind::Area, &[[0.0, 0.0], [3.0, 0.0], [f64::NAN, 4.0]], &m.scale).is_err()); + + // Indirect PDF objects inside /Measure, /NumberFormat, and the annotation dictionary still resolve cleanly. + let (r, mut annot_dict) = annot(&d, 0, 0).unwrap(); + let it_ref = d.add(Object::name("PolygonDimension")); + let nm_ref = d.add(PdfString::text("area-indirect")); + let author_ref = d.add(PdfString::text("Architect")); + let label_ref = d.add(PdfString::text("Floor Plan")); + let rl_ref = d.add(Object::name("RL")); + let ratio_ref = d.add(PdfString::text("1 pt = 2 m")); + let cyx_ref = d.add(Object::Real(1.5)); + let unit_ref = d.add(PdfString::text("m")); + let area_unit_ref = d.add(PdfString::text("m^2")); + let factor_x_ref = d.add(Object::Real(2.0)); + let factor_y_ref = d.add(Object::Real(2.0)); + let factor_d_ref = d.add(Object::Real(1.0)); + let factor_a_ref = d.add(Object::Real(1.0)); + let fmt_d_ref = d.add(Object::name("D")); + let denom_ref = d.add(Object::Int(1000)); + + let nf = |u: ObjRef, c: ObjRef| { + let mut f = Dict::new(); + f.set(b"Type".to_vec(), Object::name("NumberFormat")); + f.set(b"U".to_vec(), Object::Ref(u)); + f.set(b"C".to_vec(), Object::Ref(c)); + f.set(b"F".to_vec(), Object::Ref(fmt_d_ref)); + f.set(b"D".to_vec(), Object::Ref(denom_ref)); + Object::Array(vec![Object::Dict(f)]) + }; + let mut measure = Dict::new(); + measure.set(b"Type".to_vec(), Object::name("Measure")); + measure.set(b"Subtype".to_vec(), Object::Ref(rl_ref)); + measure.set(b"R".to_vec(), Object::Ref(ratio_ref)); + measure.set(b"CYX".to_vec(), Object::Ref(cyx_ref)); + measure.set(b"X".to_vec(), nf(unit_ref, factor_x_ref)); + measure.set(b"Y".to_vec(), nf(unit_ref, factor_y_ref)); + measure.set(b"D".to_vec(), nf(unit_ref, factor_d_ref)); + measure.set(b"A".to_vec(), nf(area_unit_ref, factor_a_ref)); + + annot_dict.set(b"IT".to_vec(), Object::Ref(it_ref)); + annot_dict.set(b"NM".to_vec(), Object::Ref(nm_ref)); + annot_dict.set(b"T".to_vec(), Object::Ref(author_ref)); + annot_dict.set(b"PCMeasureLabel".to_vec(), Object::Ref(label_ref)); + annot_dict.set(b"Measure".to_vec(), Object::Dict(measure.clone())); + d.update_dict(r, |target| *target = annot_dict).unwrap(); + + // Non-RL indirect subtypes and numeric subtypes are rejected by Scale::read. + let geo_ref = d.add(Object::name("GEO")); + let mut bad_geo = measure.clone(); + bad_geo.set(b"Subtype".to_vec(), Object::Ref(geo_ref)); + assert!(Scale::read(&d, &Object::Dict(bad_geo)).is_err()); + let mut bad_num = measure; + bad_num.set(b"Subtype".to_vec(), Object::Int(42)); + assert!(Scale::read(&d, &Object::Dict(bad_num)).is_err()); + + let listing = list(&d); + assert!(listing.unsupported.is_empty(), "{:?}", listing.unsupported); + assert_eq!(listing.measurements.len(), 1); + let item = &listing.measurements[0]; + assert_eq!(item.id, "area-indirect"); + assert_eq!(item.author, "Architect"); + assert_eq!(item.label, "Floor Plan"); + assert_eq!(item.scale, Scale { y: 3.0, ..Scale::new(2.0, "m", 3).unwrap() }); +} From 6764809929106da908277c9d317fa62397b0640a Mon Sep 17 00:00:00 2001 From: jonathanwxh-cell Date: Mon, 9 Nov 2026 18:29:47 +0800 Subject: [PATCH 19/41] fix(geom): normalize quad ordering for perspective foreshortening and scale (#5273) --- crates/geom/src/perspective.rs | 75 ++++++++++++++++++++++++++++------ 1 file changed, 63 insertions(+), 12 deletions(-) diff --git a/crates/geom/src/perspective.rs b/crates/geom/src/perspective.rs index 96aa663e25..726d58326b 100644 --- a/crates/geom/src/perspective.rs +++ b/crates/geom/src/perspective.rs @@ -81,29 +81,49 @@ fn solve8(mut m: [[f64; 9]; 8]) -> Option<[f64; 8]> { Some(out) } +/// Axis-aligned `[min_x, min_y, max_x, max_y]` bounding box of finite `points`. +fn bounds(points: &[Point]) -> Option<[f64; 4]> { + let &[first, ref rest @ ..] = points else { return None }; + if points.iter().flatten().any(|v| !v.is_finite()) { + return None; + } + Some(rest.iter().fold([first[0], first[1], first[0], first[1]], |[x0, y0, x1, y1], &[x, y]| [x0.min(x), y0.min(y), x1.max(x), y1.max(y)])) +} + /// The four corners in reading order for a y-down space (view or raster pixels): top-left, /// top-right, bottom-right, bottom-left, whatever order they were given in. `None` unless they /// form a convex quadrilateral with a non-negligible area. pub fn order_quad(points: [Point; 4]) -> Option<[Point; 4]> { - if points.iter().flatten().any(|v| !v.is_finite()) { + let [min_x, min_y, max_x, max_y] = bounds(&points)?; + let span = max_x - min_x; + let rise = max_y - min_y; + let size = span.max(rise); + if span <= 0.0 || rise <= 0.0 || !size.is_finite() { return None; } - let cx = points.iter().map(|p| p[0]).sum::() / 4.0; - let cy = points.iter().map(|p| p[1]).sum::() / 4.0; + let norm = |p: Point| [(p[0] - min_x) / size, (p[1] - min_y) / size]; + let cx = points.iter().map(|p| norm(*p)[0]).sum::() / 4.0; + let cy = points.iter().map(|p| norm(*p)[1]).sum::() / 4.0; let mut q = points; // Ascending angle about the centre is clockwise on screen (y down): top, right, bottom, left. - q.sort_by(|a, b| (a[1] - cy).atan2(a[0] - cx).total_cmp(&(b[1] - cy).atan2(b[0] - cx))); - let first = (0..4).min_by(|&a, &b| (q[a][0] + q[a][1]).total_cmp(&(q[b][0] + q[b][1])))?; + q.sort_by(|a, b| { + let (na, nb) = (norm(*a), norm(*b)); + (na[1] - cy).atan2(na[0] - cx).total_cmp(&(nb[1] - cy).atan2(nb[0] - cx)) + }); + // Score each candidate rotation by its normalised left-edge x and top-edge y. + let score = |i: usize| { + let (tl, tr, bl) = (q[i], q[(i + 1) % 4], q[(i + 3) % 4]); + ((tl[0] - min_x) + (bl[0] - min_x)) / span + ((tl[1] - min_y) + (tr[1] - min_y)) / rise + }; + let first = (0..4).min_by(|&a, &b| score(a).total_cmp(&score(b)))?; q.rotate_left(first); // Convex, with every turn the same way, and not a sliver. + let nq = q.map(norm); let cross = |o: Point, a: Point, b: Point| (a[0] - o[0]) * (b[1] - o[1]) - (a[1] - o[1]) * (b[0] - o[0]); - let turns: Vec = (0..4).map(|i| cross(q[i], q[(i + 1) % 4], q[(i + 2) % 4])).collect(); + let turns: [f64; 4] = std::array::from_fn(|i| cross(nq[i], nq[(i + 1) % 4], nq[(i + 2) % 4])); let area = turns.iter().sum::() / 2.0; - let span = q.iter().map(|p| p[0]).fold(f64::MIN, f64::max) - q.iter().map(|p| p[0]).fold(f64::MAX, f64::min); - let rise = q.iter().map(|p| p[1]).fold(f64::MIN, f64::max) - q.iter().map(|p| p[1]).fold(f64::MAX, f64::min); - let size = span.max(rise); - let convex = turns.iter().all(|&t| t > 1e-9 * size * size); - (convex && area > 1e-6 * size * size).then_some(q) + let convex = turns.iter().all(|&t| t > 1e-9); + (convex && area > 1e-6).then_some(q) } #[cfg(test)] @@ -129,6 +149,10 @@ mod tests { let r = [[0.0, 0.0], [10.0, 0.0], [10.0, 20.0], [0.0, 20.0]]; let h = Homography::from_points(r, r).unwrap(); assert!(close(h.apply([3.0, 7.0]).unwrap(), [3.0, 7.0])); + + let narrow = [[0.0, 0.0], [1e10, 0.0], [1e10, 1.0], [0.0, 1.0]]; + let h_narrow = Homography::from_points(narrow, narrow).unwrap(); + assert!(close(h_narrow.apply([5e9, 0.5]).unwrap(), [5e9, 0.5])); } #[test] @@ -144,6 +168,8 @@ mod tests { fn collinear_or_non_finite_points_have_no_transform() { let to = [[0.0, 0.0], [1.0, 0.0], [1.0, 1.0], [0.0, 1.0]]; assert!(Homography::from_points([[0.0, 0.0], [1.0, 1.0], [2.0, 2.0], [3.0, 3.0]], to).is_none()); + assert!(Homography::from_points(to, [[0.0, 0.0], [1.0, 1.0], [2.75, 2.75], [3.0, 3.0]]).is_none()); + assert!(Homography::from_points(to, [[0.0, 0.0], [1.0, 0.0], [2.0, 0.0], [0.0, 1.0]]).is_none()); assert!(Homography::from_points([[0.0, 0.0]; 4], to).is_none()); assert!(Homography::from_points([[f64::NAN, 0.0], [1.0, 0.0], [1.0, 1.0], [0.0, 1.0]], to).is_none()); assert!(Homography::from_points([[f64::INFINITY, 0.0], [1.0, 0.0], [1.0, 1.0], [0.0, 1.0]], to).is_none()); @@ -155,7 +181,7 @@ mod tests { let tr = [200.0, 5.0]; let br = [210.0, 300.0]; let bl = [0.0, 290.0]; - for q in [[br, tl, bl, tr], [bl, br, tr, tl], [tl, tr, br, bl], [tr, bl, tl, br]] { + for q in [[br, tl, bl, tr], [bl, br, tr, tl], [tl, tr, br, bl], [tl, bl, br, tr], [tr, bl, tl, br]] { assert_eq!(order_quad(q), Some([tl, tr, br, bl]), "{q:?}"); } } @@ -164,8 +190,33 @@ mod tests { fn concave_degenerate_or_non_finite_quads_are_refused() { // A dart: one corner inside the triangle of the other three. assert_eq!(order_quad([[0.0, 0.0], [100.0, 0.0], [20.0, 20.0], [0.0, 100.0]]), None); + assert_eq!(order_quad([[0.0, 0.0], [50.0, 0.0], [100.0, 0.0], [0.0, 100.0]]), None); + assert_eq!(order_quad([[0.0, 0.0], [100.0, 0.0], [100.0, 0.0], [0.0, 100.0]]), None); assert_eq!(order_quad([[0.0, 0.0], [10.0, 0.0], [20.0, 0.0], [30.0, 0.0]]), None); assert_eq!(order_quad([[5.0, 5.0]; 4]), None); assert_eq!(order_quad([[f64::NAN, 0.0], [1.0, 0.0], [1.0, 1.0], [0.0, 1.0]]), None); } + + #[test] + fn foreshortened_tilted_and_large_scale_quads_order_correctly() { + // A landscape page photographed from below has a foreshortened top edge where + // bl[0] + bl[1] (500) < tl[0] + tl[1] (550), yet tl is still top-left. + let (tl, tr, br, bl) = ([250.0, 300.0], [550.0, 300.0], [800.0, 500.0], [0.0, 500.0]); + assert_eq!(order_quad([br, tl, bl, tr]), Some([tl, tr, br, bl])); + + // A narrow portrait strip tilted counter-clockwise has tr[0] + tr[1] (90) < tl[0] + tl[1] (120), + // yet tl is still top-left. + let (tl, tr, br, bl) = ([20.0, 100.0], [80.0, 10.0], [140.0, 400.0], [80.0, 490.0]); + assert_eq!(order_quad([tr, bl, tl, br]), Some([tl, tr, br, bl])); + + // A foreshortened portrait quad where bl sits at x = 0 and y = 60 while tl sits at [40, 0] + // still orders [40, 0] as top-left. + let (tl, tr, br, bl) = ([40.0, 0.0], [100.0, 10.0], [100.0, 200.0], [0.0, 60.0]); + assert_eq!(order_quad([bl, br, tr, tl]), Some([tl, tr, br, bl])); + + // Large finite coordinates where `size * size` would overflow f64 still order cleanly. + let s = 1e160; + let (tl, tr, br, bl) = ([0.0, 0.0], [s, 0.0], [s, s], [0.0, s]); + assert_eq!(order_quad([bl, tr, br, tl]), Some([tl, tr, br, bl])); + } } From 6d2bd31b2399d49054c87d8eb4b784284759a34f Mon Sep 17 00:00:00 2001 From: Livio Oswald <125505497+Lcoding15@users.noreply.github.com> Date: Mon, 9 Nov 2026 11:30:10 +0100 Subject: [PATCH 20/41] M3: a pinned folder on Home collapses to its heading, and stays collapsed (#5295) (#5341) --- crates/ui-egui/src/app/control.rs | 1 + crates/ui-egui/src/app/prefs.rs | 2 + crates/ui-egui/src/control.rs | 2 + crates/ui-egui/src/control/host.rs | 2 + crates/ui-egui/src/home/folders_ui.rs | 73 ++++++++++++++++++++++++++- crates/ui-egui/tests/it/control.rs | 41 +++++++++++++++ crates/ui-egui/tests/it/folders.rs | 65 ++++++++++++++++++++++++ parity/acrobat-features.toml | 4 +- 8 files changed, 187 insertions(+), 3 deletions(-) diff --git a/crates/ui-egui/src/app/control.rs b/crates/ui-egui/src/app/control.rs index 065bd7a228..aaf5c009f8 100644 --- a/crates/ui-egui/src/app/control.rs +++ b/crates/ui-egui/src/app/control.rs @@ -357,6 +357,7 @@ impl PdfCraftApp { }; } ("author", _) => self.comment_prefs.author = value.to_string(), + ("folder-collapsed", _) => self.set_folder_collapsed(value)?, ("comment-tool-pinned", _) => { self.comment_prefs.pinned = match value { "true" => true, diff --git a/crates/ui-egui/src/app/prefs.rs b/crates/ui-egui/src/app/prefs.rs index 1ec33884fe..6a6452d463 100644 --- a/crates/ui-egui/src/app/prefs.rs +++ b/crates/ui-egui/src/app/prefs.rs @@ -87,6 +87,7 @@ impl PdfCraftApp { "reload_changed_files": self.reload_changed_files, "system_title_bar": self.system_title_bar, "pinned_folders": self.pinned.folders, + "pinned_folders_collapsed": self.pinned.collapsed, "theme": self.theme_preference, "scroll_speed": self.scroll_speed, "invert_middle_drag": self.invert_middle_drag, @@ -252,6 +253,7 @@ impl PdfCraftApp { self.text_links = on; } self.pinned.restore(&v["pinned_folders"]); + self.pinned.restore_collapsed(&v["pinned_folders_collapsed"]); if let Some(items) = v.get("pinned_tools").and_then(serde_json::Value::as_array) { let mut tools = Vec::new(); for item in items { diff --git a/crates/ui-egui/src/control.rs b/crates/ui-egui/src/control.rs index 44e5481a11..59fb97d7d1 100644 --- a/crates/ui-egui/src/control.rs +++ b/crates/ui-egui/src/control.rs @@ -45,6 +45,8 @@ //! `hotbar-size=24..64` controls its button size in logical pixels; `ui.state.hotbar` //! reports its tools, relative position, orientation and strip preference. These options persist across restarts. //! `ui.state.comment_tool_pinned` reports the same preference. +//! `folder-collapsed==on|off` folds a pinned folder on Home down to its heading or +//! opens it again; `ui.state.pinned_folders` lists each pinned folder and whether it is. //! - `ui.open {path}`: open a file. A file whose tab is already open focuses it //! (`"focused": true`) instead of adding a tab; native opens report `pending: true` until //! they complete and are listed in `ui.state.pending_opens`; only a failed open replies `Err`. diff --git a/crates/ui-egui/src/control/host.rs b/crates/ui-egui/src/control/host.rs index acccf5ead3..cf8e5e7fcc 100644 --- a/crates/ui-egui/src/control/host.rs +++ b/crates/ui-egui/src/control/host.rs @@ -146,6 +146,8 @@ impl Host for crate::PdfCraftApp { "default_mode": self.default_mode, "hotbar": self.hotbar, "comment_tool_pinned": self.comment_prefs.pinned, + // Home's pinned folders, in order, and whether each is folded down to its heading. + "pinned_folders": self.pinned.folders.iter().map(|f| json!({ "folder": f, "collapsed": self.pinned.collapsed.contains(f) })).collect::>(), "left_panel": if self.left_open { json!(format!("{:?}", self.left)) } else { Value::Null }, "tools_side": if self.side_panels.tools_on_right() { "right" } else { "left" }, "rail_side": if self.side_panels.rail_on_right() { "right" } else { "left" }, diff --git a/crates/ui-egui/src/home/folders_ui.rs b/crates/ui-egui/src/home/folders_ui.rs index fe9b2c3706..60e130420b 100644 --- a/crates/ui-egui/src/home/folders_ui.rs +++ b/crates/ui-egui/src/home/folders_ui.rs @@ -55,6 +55,8 @@ pub struct PinnedFolders { pub listings: Vec, /// Folders whose "Show more" is open. pub expanded: Vec, + /// Folders folded down to their heading (#5295); kept in settings. + pub collapsed: Vec, /// A listing in progress: the worker fills the slot. scan: Option>>>>, /// When (egui time) the last listing started. @@ -86,8 +88,34 @@ impl PinnedFolders { self.folders = folders; self.listings.clear(); self.expanded.clear(); + self.collapsed.clear(); self.stale = true; } + + /// Restore which folders are collapsed, after [`Self::restore`]: only folders that are + /// pinned, each once, so untrusted settings can't grow the list. + pub(crate) fn restore_collapsed(&mut self, value: &serde_json::Value) { + let Some(items) = value.as_array() else { return }; + self.collapsed.clear(); + for folder in items.iter().filter_map(serde_json::Value::as_str) { + if self.folders.iter().any(|f| f == folder) && !self.collapsed.iter().any(|f| f == folder) { + self.collapsed.push(folder.to_string()); + } + } + } + + /// Fold pinned `folder` down to its heading (`collapsed`) or open it again. Returns false, + /// changing nothing, when `folder` isn't pinned. + pub(crate) fn set_collapsed(&mut self, folder: &str, collapsed: bool) -> bool { + if !self.folders.iter().any(|f| f == folder) { + return false; + } + self.collapsed.retain(|f| f != folder); + if collapsed { + self.collapsed.push(folder.to_string()); + } + true + } } /// `path` as a folder to pin: trimmed, without a trailing separator, and absolute; `None` if it @@ -221,6 +249,25 @@ impl PdfCraftApp { self.pinned.folders.retain(|f| f != folder); self.pinned.listings.retain(|l| l.folder != folder); self.pinned.expanded.retain(|f| f != folder); + self.pinned.collapsed.retain(|f| f != folder); + } + + /// `ui.set folder-collapsed =on|off` (CodeRabbit on #5341): the control channel's + /// way to fold a pinned folder down to its heading or open it again, as its chevron does. + /// `ui.state` lists the pinned folders and which are collapsed. The folder is named by its + /// path as pinned; the last `=` splits it from the state, so a path may hold `=` too. + pub(crate) fn set_folder_collapsed(&mut self, value: &str) -> Result<(), String> { + let (folder, state) = value.rsplit_once('=').ok_or("folder-collapsed must be =on or =off")?; + let collapsed = match state { + "on" => true, + "off" => false, + _ => return Err("folder-collapsed must be =on or =off".into()), + }; + if self.pinned.set_collapsed(folder, collapsed) { + Ok(()) + } else { + Err(format!("folder-collapsed: {folder} is not a pinned folder (ui.state lists them)")) + } } /// List the pinned folders again if the last listing is older than a few seconds or the @@ -308,10 +355,27 @@ pub(crate) fn section(app: &mut PdfCraftApp, ui: &mut egui::Ui) { let mut open = None; let mut unpin = None; let mut toggle = None; + let mut fold = None; for folder in &app.pinned.folders { let name = folder_name(folder); + // Buttons name their folder; two pinned folders of the same name add where each is. + let shared = app.pinned.folders.iter().any(|f| f != folder && folder_name(f) == name); + let named = if shared { format!("{name} ({})", location(folder)) } else { name.clone() }; + let collapsed = app.pinned.collapsed.contains(folder); ui.add_space(6.0); ui.horizontal(|ui| { + let (chevron, tip, label) = if collapsed { + ("chevron-right", tl!("Expand"), tl!("Expand {folder}")) + } else { + ("chevron-down", tl!("Collapse"), tl!("Collapse {folder}")) + }; + let resp = icons::button(ui, chevron, 22.0, false, tip); + // Every folder has one: say which folder it folds. + let label = crate::i18n::fmt(label, &[("folder", &named)]); + resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, true, &label)); + if resp.clicked() { + fold = Some(folder.clone()); + } let (icon, _) = ui.allocate_exact_size(vec2(22.0, 22.0), Sense::hover()); icons::paint(ui, icon, "folder", 20.0, t.icon); // Folder names and paths are file/path chrome: explicitly left to right, so an @@ -327,13 +391,16 @@ pub(crate) fn section(app: &mut PdfCraftApp, ui: &mut egui::Ui) { ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { let resp = widgets::ghost_button(ui, "x", tl!("Unpin")); // Every folder has an Unpin button: say which one it is. - let label = crate::i18n::fmt(tl!("Unpin {folder}"), &[("folder", &name)]); + let label = crate::i18n::fmt(tl!("Unpin {folder}"), &[("folder", &named)]); resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, true, &label)); if resp.clicked() { unpin = Some(folder.clone()); } }); }); + if collapsed { + continue; + } let note = |ui: &mut egui::Ui, text: &str| { ui.add_space(2.0); ui.label(egui::RichText::new(text).color(t.text_muted)); @@ -405,6 +472,10 @@ pub(crate) fn section(app: &mut PdfCraftApp, ui: &mut egui::Ui) { app.pinned.expanded.push(folder); } } + if let Some(folder) = fold { + let collapsed = app.pinned.collapsed.contains(&folder); + app.pinned.set_collapsed(&folder, !collapsed); + } if let Some(path) = open { app.open_recent(&path); } diff --git a/crates/ui-egui/tests/it/control.rs b/crates/ui-egui/tests/it/control.rs index 5bbc26c991..8e397c803b 100644 --- a/crates/ui-egui/tests/it/control.rs +++ b/crates/ui-egui/tests/it/control.rs @@ -2176,6 +2176,47 @@ fn routine_continuous_scrolling_does_not_add_navigation_history() { assert!(h.state().views[0].forward.is_empty()); } +/// CodeRabbit on #5341: the control channel folds a pinned folder on Home down to its heading +/// and opens it again, as its chevron does, and `ui.state` says which are folded. Folders that +/// aren't pinned, and states other than on and off, are refused with the reason. +#[test] +fn control_collapses_and_expands_a_pinned_folder() { + let dir = std::env::temp_dir().join(format!("pdfcraft-ui-fold-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + std::fs::write(dir.join("inside.pdf"), fixture(1)).unwrap(); + let folder = dir.to_string_lossy().into_owned(); + let slot: Arc>> = Arc::default(); + let (s, pin) = (slot.clone(), folder.clone()); + let mut h = Harness::builder().with_size(egui::vec2(1200.0, 900.0)).build_eframe(move |cc| { + let mut app = PdfCraftApp::new(); + app.set_option("language", "en").unwrap(); + app.run_inline = true; + app.pin_folder(&pin); + *s.lock().unwrap() = Some(app.attach_control(&cc.egui_ctx)); + app + }); + h.run_steps(4); + let c = slot.lock().unwrap().take().unwrap(); + let folded = |h: &mut Harness<'static, PdfCraftApp>| ok(h, &c, "ui.state", json!({}))["pinned_folders"].clone(); + assert_eq!(folded(&mut h), json!([{ "folder": folder, "collapsed": false }])); + h.get_by_label("inside.pdf"); + ok(&mut h, &c, "ui.set", json!({ "key": "folder-collapsed", "value": format!("{folder}=on") })); + h.run_steps(2); + assert_eq!(folded(&mut h), json!([{ "folder": folder, "collapsed": true }])); + assert!(h.query_by_label("inside.pdf").is_none(), "a collapsed folder lists nothing"); + let unpinned = call(&mut h, &c, "ui.set", json!({ "key": "folder-collapsed", "value": "/not/pinned=on" })).unwrap_err(); + assert!(unpinned.contains("not a pinned folder"), "{unpinned}"); + let bad = call(&mut h, &c, "ui.set", json!({ "key": "folder-collapsed", "value": format!("{folder}=yes") })).unwrap_err(); + assert!(bad.contains("=on or"), "{bad}"); + assert_eq!(folded(&mut h), json!([{ "folder": folder, "collapsed": true }]), "refusals change nothing"); + ok(&mut h, &c, "ui.set", json!({ "key": "folder-collapsed", "value": format!("{folder}=off") })); + h.run_steps(2); + assert_eq!(folded(&mut h), json!([{ "folder": folder, "collapsed": false }])); + h.get_by_label("inside.pdf"); + let _ = std::fs::remove_dir_all(&dir); +} + #[test] fn accessible_labels_on_format_text_and_pin_dialog() { let (mut h, c) = harness(); diff --git a/crates/ui-egui/tests/it/folders.rs b/crates/ui-egui/tests/it/folders.rs index 583cc35eaa..a3e954b6a2 100644 --- a/crates/ui-egui/tests/it/folders.rs +++ b/crates/ui-egui/tests/it/folders.rs @@ -140,6 +140,71 @@ fn unpinning_removes_the_folder_but_not_its_files() { assert!(path.exists(), "unpinning never touches the files"); } +/// #5295: a pinned folder folds down to its heading and back, the other folders stay as they +/// are, and the folded state is kept across a restart. +#[test] +fn a_pinned_folder_collapses_to_its_heading_and_stays_collapsed_after_a_restart() { + let (first, second) = (TempFolder::new("fold-a"), TempFolder::new("fold-b")); + first.file("alpha.pdf", FIXTURE, Duration::from_secs(10)); + second.file("beta.pdf", FIXTURE, Duration::from_secs(10)); + let (a, b) = (first.path(), second.path()); + let pins = (a.clone(), b.clone()); + let mut h = harness(move |app| { + app.pin_folder(&pins.0); + app.pin_folder(&pins.1); + }); + h.run_steps(2); + let name = pdfcraft_ui_egui::folders_ui::folder_name(&a); + h.get_by_label(&format!("Collapse {name}")).click(); + h.run_steps(2); + assert!(h.query_by_label("alpha.pdf").is_none(), "a collapsed folder lists nothing"); + h.get_by_label("beta.pdf"); + h.get_by_label(&format!("Unpin {name}")); + assert_eq!(h.state().pinned.collapsed, std::slice::from_ref(&a)); + + let mut restarted = PdfCraftApp::new(); + restarted.restore(&h.state().persist()); + assert_eq!(restarted.pinned.collapsed, std::slice::from_ref(&a)); + + h.get_by_label(&format!("Expand {name}")).click(); + h.run_steps(2); + h.get_by_label("alpha.pdf"); + assert!(h.state().pinned.collapsed.is_empty()); + + // Settings are untrusted: only pinned folders, each once. + let settings = serde_json::json!({ "pinned_folders": [a, b], "pinned_folders_collapsed": [b, b, "/not/pinned", 5] }); + restarted.restore(&settings.to_string()); + assert_eq!(restarted.pinned.collapsed, std::slice::from_ref(&b)); + restarted.unpin_folder(&b); + assert!(restarted.pinned.collapsed.is_empty(), "unpinning forgets the folded state"); +} + +/// Two pinned folders of the same name: their buttons say where each one is, so a screen reader +/// can tell them apart. +#[test] +fn folders_of_the_same_name_have_buttons_that_tell_them_apart() { + let root = TempFolder::new("same-name"); + let (a, b) = (root.0.join("work").join("Scans"), root.0.join("home").join("Scans")); + std::fs::create_dir_all(&a).unwrap(); + std::fs::create_dir_all(&b).unwrap(); + let (a, b) = (a.to_string_lossy().into_owned(), b.to_string_lossy().into_owned()); + let pins = (a.clone(), b.clone()); + let mut h = harness(move |app| { + app.pin_folder(&pins.0); + app.pin_folder(&pins.1); + }); + h.run_steps(2); + for (folder, other) in [(&a, &b), (&b, &a)] { + let named = format!("Scans ({})", pdfcraft_ui_egui::folders_ui::location(folder)); + assert_ne!(named, format!("Scans ({})", pdfcraft_ui_egui::folders_ui::location(other))); + h.get_by_label(&format!("Collapse {named}")); + h.get_by_label(&format!("Unpin {named}")); + } + h.get_by_label(&format!("Collapse Scans ({})", pdfcraft_ui_egui::folders_ui::location(&a))).click(); + h.run_steps(2); + assert_eq!(h.state().pinned.collapsed, std::slice::from_ref(&a), "the button folds its own folder"); +} + #[test] fn pinned_folders_survive_a_restart() { let dir = TempFolder::new("restart"); diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index 761a0a44d7..d7a6a406eb 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -1706,8 +1706,8 @@ milestone = "M3" status = "shipped" commands = ["file.pin_folder"] tools = [] -evidence = ["crates/ui-egui/tests/it/ui.rs::home_shows_welcome_and_tools", "crates/ui-egui/tests/it/folders.rs::a_pinned_folder_lists_its_newest_pdfs_on_home", "crates/ui-egui/tests/it/folders.rs::clicking_a_pinned_pdf_opens_it_and_adds_it_to_recent", "crates/ui-egui/tests/it/folders.rs::pinned_folders_survive_a_restart"] -notes = "Desktop: folders pinned to Home (File ▸ Pin folder to Home…, up to 8) list their newest PDFs, read on a worker thread and refreshed every few seconds while Home shows; a folder that can't be read (moved, offline synced drive) stays pinned and says so. Pinned folders are local paths, so a cloud drive works through its synced folder (iCloud Drive, OneDrive, Dropbox). Not yet: subfolders, search within a pinned folder, the web build." +evidence = ["crates/ui-egui/tests/it/ui.rs::home_shows_welcome_and_tools", "crates/ui-egui/tests/it/folders.rs::a_pinned_folder_lists_its_newest_pdfs_on_home", "crates/ui-egui/tests/it/folders.rs::clicking_a_pinned_pdf_opens_it_and_adds_it_to_recent", "crates/ui-egui/tests/it/folders.rs::pinned_folders_survive_a_restart", "crates/ui-egui/tests/it/folders.rs::a_pinned_folder_collapses_to_its_heading_and_stays_collapsed_after_a_restart", "crates/ui-egui/tests/it/control.rs::control_collapses_and_expands_a_pinned_folder"] +notes = "Desktop: folders pinned to Home (File ▸ Pin folder to Home…, up to 8) list their newest PDFs, read on a worker thread and refreshed every few seconds while Home shows; a folder that can't be read (moved, offline synced drive) stays pinned and says so. Each folder collapses to its heading, and stays collapsed across restarts. Pinned folders are Home-view settings, not a document's, so the engine tools have none; the control channel folds them (`ui.set folder-collapsed =on|off`) and `ui.state` lists them. Pinned folders are local paths, so a cloud drive works through its synced folder (iCloud Drive, OneDrive, Dropbox). Not yet: subfolders, search within a pinned folder, the web build." [[feature]] id = "view.all-tools" From 008b035326488d20b9f45e98f6432cfdd14a7167 Mon Sep 17 00:00:00 2001 From: jonathanwxh-cell Date: Mon, 9 Nov 2026 18:30:27 +0800 Subject: [PATCH 21/41] fix(optimize): validate named destination targets and ignore orphaned refs (#5362) --- crates/optimize/src/audit.rs | 2 +- crates/optimize/src/links.rs | 107 +++++++++++++++++------------- crates/optimize/tests/optimize.rs | 101 ++++++++++++++++++++++++++++ 3 files changed, 163 insertions(+), 47 deletions(-) diff --git a/crates/optimize/src/audit.rs b/crates/optimize/src/audit.rs index afa95f555e..f5da17c45d 100644 --- a/crates/optimize/src/audit.rs +++ b/crates/optimize/src/audit.rs @@ -262,7 +262,7 @@ pub fn audit_space(doc: &Document, file_len: u64) -> Vec { a.claim(o, SpaceCategory::Bookmarks); } let names = a.resolve_dict(cat.get(b"Names")); - if let Some(d) = names.get(b"Dests").or_else(|| cat.get(b"Dests")) { + for d in [names.get(b"Dests"), cat.get(b"Dests")].into_iter().flatten() { a.claim(d, SpaceCategory::NamedDestinations); } if let Some(e) = names.get(b"EmbeddedFiles") { diff --git a/crates/optimize/src/links.rs b/crates/optimize/src/links.rs index cbd64eb928..5b74ad7023 100644 --- a/crates/optimize/src/links.rs +++ b/crates/optimize/src/links.rs @@ -5,16 +5,19 @@ use std::collections::{HashSet, VecDeque}; use pdfcraft_cos::{Dict, Document, ObjRef, Object, PdfString}; -/// Named destinations: the `/Names /Dests` tree (entries) and the old `/Dests` dictionary. -fn named_dests(doc: &Document) -> HashSet> { +/// Named destinations whose target page exists: the `/Names /Dests` tree and the old `/Dests` +/// dictionary. +fn named_dests(doc: &Document, pages: &HashSet, count: usize) -> HashSet> { let mut out = HashSet::new(); let Some(cat) = doc.root().and_then(|r| doc.get(r).as_dict().cloned()) else { return out }; if let Some(d) = cat.get(b"Dests").map(|d| doc.resolve(d)).and_then(|d| d.as_dict().cloned()) { - out.extend(d.iter().map(|(k, _)| k.clone())); + out.extend(d.iter().filter(|(_, v)| valid_explicit(doc, v, pages, count)).map(|(k, _)| k.clone())); } if let Some(tree) = names_tree(doc, &cat) { - for (k, _) in tree_entries(doc, &tree) { - out.insert(k); + for (k, v) in tree_entries(doc, &tree) { + if valid_explicit(doc, &v, pages, count) { + out.insert(k); + } } } out @@ -74,22 +77,33 @@ fn dest_name(o: &Object) -> Option> { } } -fn valid(o: &Object, pages: &HashSet, count: usize, names: &HashSet>) -> bool { - match o { - Object::Array(a) => match a.first() { - Some(Object::Ref(r)) => pages.contains(r), - Some(Object::Int(i)) => *i >= 0 && (*i as usize) < count, - _ => false, - }, - other => dest_name(other).is_some_and(|n| names.contains(&n)), +/// The `[page /Fit …]` array of an explicit destination (an array or a `<< /D […] >>` dictionary). +fn explicit_array(doc: &Document, o: &Object) -> Option> { + match doc.resolve(o).as_ref() { + Object::Array(a) => Some(a.clone()), + Object::Dict(d) => d.get(b"D").and_then(|x| doc.resolve(x).as_array().cloned()), + _ => None, } } +/// Whether an explicit destination targets one of the document's pages. +fn valid_explicit(doc: &Document, o: &Object, pages: &HashSet, count: usize) -> bool { + explicit_array(doc, o).is_some_and(|a| match a.first() { + Some(Object::Ref(r)) => pages.contains(r), + Some(Object::Int(i)) => usize::try_from(*i).is_ok_and(|i| i < count), + _ => false, + }) +} + +fn valid(doc: &Document, o: &Object, pages: &HashSet, count: usize, names: &HashSet>) -> bool { + valid_explicit(doc, o, pages, count) || dest_name(o).is_some_and(|n| names.contains(&n)) +} + /// Remove links whose destination is missing, and clear the destination of such bookmarks /// (they stay, so their children do too). Returns (links, bookmarks). pub(crate) fn remove_invalid(doc: &mut Document, pages: &[ObjRef]) -> Result<(usize, usize), pdfcraft_cos::CosError> { let set: HashSet = pages.iter().copied().collect(); - let names = named_dests(doc); + let names = named_dests(doc, &set, pages.len()); let mut links = 0; for p in pages { let Some(pd) = doc.get(*p).as_dict().cloned() else { continue }; @@ -101,7 +115,7 @@ pub(crate) fn remove_invalid(doc: &mut Document, pages: &[ObjRef]) -> Result<(us if d.name(b"Subtype") != Some(b"Link") { return true; } - destination(doc, &d).is_none_or(|dest| valid(&dest, &set, pages.len(), &names)) + destination(doc, &d).is_none_or(|dest| valid(doc, &dest, &set, pages.len(), &names)) }) .cloned() .collect(); @@ -130,7 +144,7 @@ pub(crate) fn remove_invalid(doc: &mut Document, pages: &[ObjRef]) -> Result<(us stack.push(f); } if let Some(dest) = destination(doc, &d) - && !valid(&dest, &set, pages.len(), &names) + && !valid(doc, &dest, &set, pages.len(), &names) { doc.update_dict(r, |d| { d.remove(b"Dest"); @@ -142,39 +156,40 @@ pub(crate) fn remove_invalid(doc: &mut Document, pages: &[ObjRef]) -> Result<(us Ok((links, bookmarks)) } -/// Every named destination some object refers to (links, bookmarks, actions, open action). +/// Every named destination some reachable object refers to (links, bookmarks, actions, open action). fn referenced_names(doc: &Document) -> HashSet> { let mut out = HashSet::new(); - let mut visit = |o: &Object| { - let mut stack = vec![o.clone()]; - while let Some(o) = stack.pop() { - match o { - Object::Dict(d) => { - // Resolve first: /Dest and a GoTo's /D may be an indirect reference to the name - // string, and an unresolved ref has no name, so the destination it uses would - // look unreferenced and be removed. A GoToR or GoToE /D names a destination in - // another document, so it does not count as a use of this one's. - if let Some(n) = d.get(b"Dest").and_then(|x| dest_name(doc.resolve(x).as_ref())) { - out.insert(n); - } - if d.name(b"S") == Some(b"GoTo") - && let Some(n) = d.get(b"D").and_then(|x| dest_name(doc.resolve(x).as_ref())) - { - out.insert(n); - } - stack.extend(d.iter().map(|(_, v)| v.clone())); - } - Object::Array(a) => stack.extend(a), - Object::Stream(s) => stack.push(Object::Dict(s.dict.clone())), - _ => {} - } - } - }; - for num in doc.object_numbers() { - visit(&doc.get(ObjRef::new(num, doc.generation(num)))); - } + let mut seen = HashSet::new(); + let mut stack = vec![Object::Dict(doc.trailer().clone())]; if let Some(r) = doc.root() { - visit(&doc.get(r)); + stack.push(Object::Ref(r)); + } + while let Some(o) = stack.pop() { + match o { + Object::Ref(r) => { + if seen.insert(r) { + stack.push(doc.get(r).as_ref().clone()); + } + } + Object::Dict(d) => { + // Resolve first: /Dest and a GoTo's /D may be an indirect reference to the name + // string, and an unresolved ref has no name, so the destination it uses would + // look unreferenced and be removed. A GoToR or GoToE /D names a destination in + // another document, so it does not count as a use of this one's. + if let Some(n) = d.get(b"Dest").and_then(|x| dest_name(doc.resolve(x).as_ref())) { + out.insert(n); + } + if d.name(b"S") == Some(b"GoTo") + && let Some(n) = d.get(b"D").and_then(|x| dest_name(doc.resolve(x).as_ref())) + { + out.insert(n); + } + stack.extend(d.iter().map(|(_, v)| v.clone())); + } + Object::Array(a) => stack.extend(a), + Object::Stream(s) => stack.push(Object::Dict(s.dict)), + _ => {} + } } out } diff --git a/crates/optimize/tests/optimize.rs b/crates/optimize/tests/optimize.rs index 2b8f513a24..b6fe624082 100644 --- a/crates/optimize/tests/optimize.rs +++ b/crates/optimize/tests/optimize.rs @@ -4242,6 +4242,107 @@ fn mixed_text_and_small_halftone_regions_keep_their_tones_with_wide_white_margin } } +#[test] +fn dictionary_destinations_broken_named_destinations_and_both_dest_tables() { + let mut doc = Document::new_empty(); + let p = page(&mut doc, &[], "BT ET"); + let s = |t: &str| Object::String(pdfcraft_cos::PdfString::literal(t.as_bytes().to_vec())); + let dict_dest = |target: ObjRef| { + let mut d = Dict::new(); + d.set(b"D".to_vec(), Object::Array(vec![Object::Ref(target), Object::name("Fit")])); + Object::Dict(d) + }; + let link = |doc: &mut Document, dest: Object| { + let mut d = Dict::new(); + d.set(b"Type".to_vec(), Object::name("Annot")); + d.set(b"Subtype".to_vec(), Object::name("Link")); + d.set(b"Rect".to_vec(), Object::Array(vec![0.into(), 0.into(), 10.into(), 10.into()])); + d.set(b"Dest".to_vec(), dest); + Object::Ref(doc.add(Object::Dict(d))) + }; + let missing = ObjRef::new(999, 0); + // Links: (1) explicit dictionary destination to `p` (valid), (2) explicit dictionary + // destination to `missing` (invalid), (3) named destination "dict_ok" whose value is + // `<< /D [p /Fit] >>` (valid), (4) named destination "broken_tree" whose target page is + // `missing` (invalid). + let annots = vec![ + link(&mut doc, dict_dest(p)), + link(&mut doc, dict_dest(missing)), + link(&mut doc, Object::Array(vec![Object::Int(i64::from(u32::MAX) + 1), Object::name("Fit")])), + link(&mut doc, s("dict_ok")), + link(&mut doc, s("broken_tree")), + ]; + doc.update_dict(p, |d| d.set(b"Annots".to_vec(), Object::Array(annots))).unwrap(); + + // Bookmark with an indirect GoTo action pointing to "broken_old" in catalog /Dests (whose + // page is `missing`), and a second bookmark pointing to "old_ok" in catalog /Dests. + let outlines = doc.add(Object::Null); + let mut goto_broken = Dict::new(); + goto_broken.set(b"S".to_vec(), Object::name("GoTo")); + goto_broken.set(b"D".to_vec(), Object::name("broken_old")); + let goto_broken = doc.add(Object::Dict(goto_broken)); + let mut bm_broken = Dict::new(); + bm_broken.set(b"Title".to_vec(), s("Broken")); + bm_broken.set(b"Parent".to_vec(), Object::Ref(outlines)); + bm_broken.set(b"A".to_vec(), Object::Ref(goto_broken)); + let bm_broken = doc.add(Object::Dict(bm_broken)); + let mut bm_ok = Dict::new(); + bm_ok.set(b"Title".to_vec(), s("Ok")); + bm_ok.set(b"Parent".to_vec(), Object::Ref(outlines)); + bm_ok.set(b"Dest".to_vec(), Object::name("old_ok")); + let bm_ok = doc.add(Object::Dict(bm_ok)); + doc.update_dict(bm_broken, |d| d.set(b"Next".to_vec(), Object::Ref(bm_ok))).unwrap(); + let mut o = Dict::new(); + o.set(b"First".to_vec(), Object::Ref(bm_broken)); + o.set(b"Last".to_vec(), Object::Ref(bm_ok)); + doc.set(outlines, Object::Dict(o)); + + // /Names /Dests has "broken_tree" (pointing to `missing`) and "dict_ok" (`<< /D [p /Fit] >>`). + let broken_dest = doc.add(dict_dest(missing)); + let ok_dest = doc.add(dict_dest(p)); + let mut tree = Dict::new(); + tree.set(b"Names".to_vec(), Object::Array(vec![s("broken_tree"), Object::Ref(broken_dest), s("dict_ok"), Object::Ref(ok_dest)])); + let tree = doc.add(Object::Dict(tree)); + let mut names = Dict::new(); + names.set(b"Dests".to_vec(), Object::Ref(tree)); + + // Catalog /Dests has "broken_old" (pointing to `missing`) and "old_ok" (`<< /D [p /Fit] >>`). + let mut old_dests = Dict::new(); + old_dests.set(b"broken_old".to_vec(), Object::Array(vec![Object::Ref(missing), Object::name("Fit")])); + old_dests.set(b"old_ok".to_vec(), dict_dest(p)); + let old_dests = doc.add(Object::Dict(old_dests)); + + let root = doc.root().unwrap(); + doc.update_dict(root, |c| { + c.set(b"Names".to_vec(), Object::Dict(names)); + c.set(b"Dests".to_vec(), Object::Ref(old_dests)); + c.set(b"Outlines".to_vec(), Object::Ref(outlines)); + }) + .unwrap(); + + // Space audit claims both /Names /Dests and catalog /Dests under NamedDestinations. + let only_tree = { + let mut d = doc.clone(); + d.update_dict(root, |c| { + c.remove(b"Dests"); + }) + .unwrap(); + audit_space(&d, 0).into_iter().find(|u| u.category == SpaceCategory::NamedDestinations).unwrap().bytes + }; + let both_tables = audit_space(&doc, 0).into_iter().find(|u| u.category == SpaceCategory::NamedDestinations).unwrap().bytes; + assert!(both_tables > only_tree, "catalog /Dests is counted alongside /Names /Dests: {both_tables} vs {only_tree}"); + + let report = optimize(&mut doc, &Settings::default()).unwrap(); + assert_eq!((report.invalid_links, report.invalid_bookmarks, report.unreferenced_dests), (3, 1, 2), "{report:?}"); + let left_links = doc.get(p).as_dict().unwrap().get(b"Annots").unwrap().as_array().unwrap().len(); + assert_eq!(left_links, 2, "the two valid dictionary/named links remain"); + assert!(doc.get(bm_broken).as_dict().unwrap().get(b"A").is_none(), "broken bookmark action is cleared"); + assert!(doc.get(bm_ok).as_dict().unwrap().get(b"Dest").is_some(), "valid bookmark destination stays"); + assert_eq!(named_dest_keys(&doc), vec![b"dict_ok".to_vec()]); + let left_old = doc.get(old_dests).as_dict().cloned().unwrap(); + assert!(left_old.contains(b"old_ok") && !left_old.contains(b"broken_old"), "{left_old:?}"); +} + /// A one-bit stencil of `w` × `h`, solid but for a hole in its middle. fn stencil(doc: &mut Document, w: u32, h: u32) -> ObjRef { let row = (w as usize).div_ceil(8); From 61bc0871a5f97c9de4fe76d94fcee20fbee25569 Mon Sep 17 00:00:00 2001 From: Tiago Kiel <186439951+TiagoCK@users.noreply.github.com> Date: Mon, 9 Nov 2026 05:31:04 -0500 Subject: [PATCH 22/41] fix(ui): selecting in an unchanged paragraph lands on the words the page shows (refs #5185) (#5390) --- crates/ui-egui/src/tools/edit_text_ui.rs | 9 ++ .../ui-egui/src/tools/edit_text_ui/layout.rs | 132 +++++++++++++++++- crates/ui-egui/tests/it/editing.rs | 100 +++++++++++++ 3 files changed, 239 insertions(+), 2 deletions(-) diff --git a/crates/ui-egui/src/tools/edit_text_ui.rs b/crates/ui-egui/src/tools/edit_text_ui.rs index 942b39674c..fc2cf29471 100644 --- a/crates/ui-egui/src/tools/edit_text_ui.rs +++ b/crates/ui-egui/src/tools/edit_text_ui.rs @@ -986,6 +986,15 @@ pub(crate) fn overlay(ctx: &egui::Context, view: &mut DocView, info: &DocInfo) - } else { caret_color_on(fill) }; + // An unchanged paragraph's galley is unseen over the page's own text. Selecting + // part of it must not paint its stand-in glyphs over that text in the selection + // colour (a serif paragraph turned sans when highlighted), nor hide it under an + // opaque fill: the selection is a tint the page shows through. + if unchanged { + let selection = &mut ui.visuals_mut().selection; + selection.stroke.color = Color32::TRANSPARENT; + selection.bg_fill = selection.bg_fill.gamma_multiply(0.45); + } let rows = 1; let snapshot = ed.clone(); let mut layouter = |ui: &egui::Ui, text: &dyn egui::TextBuffer, wrap_width: f32| { diff --git a/crates/ui-egui/src/tools/edit_text_ui/layout.rs b/crates/ui-egui/src/tools/edit_text_ui/layout.rs index 9acca88022..50a514f90e 100644 --- a/crates/ui-egui/src/tools/edit_text_ui/layout.rs +++ b/crates/ui-egui/src/tools/edit_text_ui/layout.rs @@ -231,8 +231,6 @@ pub(super) fn editor_galley(ui: &egui::Ui, ed: &LineEditor, text: &str, scale: f sources.windows(2).map(|r| (r[0].0 - r[1].0).abs()).rfind(|v| *v > 0.0).unwrap_or(fallback) }; let g = Arc::make_mut(&mut galley); - let mut bounds = Rect::NOTHING; - let mut meshes = Rect::NOTHING; // Which line of the text (between `\n`s) each row is on: rows aligned on their own go where // the rewrite will put them, in the paragraph's width. let mut line = 0usize; @@ -274,6 +272,14 @@ pub(super) fn editor_galley(ui: &egui::Ui, ed: &LineEditor, text: &str, scale: f } } row.size.y = (ascent + descent).max(1.0); + } + if text == ed.original && ed.look == ed.look0 && ed.extras == ed.extras0 && block.rotation.abs() < 0.5 { + place_on_source_words(g, block, lines, text, scale); + } + let mut bounds = Rect::NOTHING; + let mut meshes = Rect::NOTHING; + for placed in &mut g.rows { + let row = Arc::make_mut(&mut placed.row); row.visuals.mesh_bounds = row.visuals.mesh.vertices.iter().fold(Rect::NOTHING, |r, v| r.union(Rect::from_min_max(v.pos, v.pos))); bounds = bounds.union(Rect::from_min_size(placed.pos, row.size)); meshes = meshes.union(row.visuals.mesh_bounds.translate(placed.pos.to_vec2())); @@ -283,6 +289,128 @@ pub(super) fn editor_galley(ui: &egui::Ui, ed: &LineEditor, text: &str, scale: f galley } +/// While a paragraph is unchanged the page shows its own text, and the editor's galley is drawn +/// unseen: it only places the caret and the selection. Put each source word's glyphs across that +/// word's box on the page, so a click or a drag lands on the word under the pointer. The editor's +/// face is often a stand-in whose advances differ from the PDF's (a font program it can't load, or +/// an OCR layer whose every word has its own size), and stretching each run to its width still let +/// every word boundary drift, so a drag over one word selected another two words along. +/// +/// A word takes its `ink` box when the run's words and boxes pair up, else the run's box spans all +/// its words. Within a word the stand-in's proportions are kept; spaces fill the gaps between +/// words. A row with a character this can't place, or with boxes out of reading order, is left as +/// laid out. +fn place_on_source_words(g: &mut egui::Galley, block: &pdfcraft_engine::TextBlock, lines: &[pdfcraft_engine::TextLine], text: &str, scale: f32) { + let starts: Vec = text.char_indices().map(|(at, _)| at).collect(); + let chars: Vec = text.chars().collect(); + let x = |user: f64| ((user - block.rect[0]) * f64::from(scale)) as f32; + // Each character's glyph advance, in the galley's character order (a newline has no glyph). + let mut advance = vec![0.0f32; chars.len()]; + let mut at = 0usize; + for placed in &g.rows { + for (glyph, slot) in placed.row.glyphs.iter().zip(advance.iter_mut().skip(at)) { + *slot = glyph.advance_width.max(0.0); + } + at = at.saturating_add(placed.row.glyphs.len()).saturating_add(usize::from(placed.ends_with_newline)); + } + if at != chars.len() { + return; + } + // Where each character goes: its left edge and width, in galley coordinates. + let mut target: Vec> = vec![None; chars.len()]; + let place = |range: std::ops::Range, left: f32, right: f32, target: &mut [Option<(f32, f32)>]| { + let natural: f32 = advance.get(range.clone()).map_or(0.0, |a| a.iter().sum()); + let count = range.len().max(1) as f32; + let mut pos = left; + for i in range { + let share = if natural > 0.0 { advance.get(i).copied().unwrap_or(0.0) / natural } else { 1.0 / count }; + let width = ((right - left) * share).max(0.0); + if let Some(slot) = target.get_mut(i) { + *slot = Some((pos, width)); + } + pos += width; + } + }; + for (range, index) in &block.spans { + let Some(line) = lines.get(*index) else { continue }; + let (first, end) = (starts.partition_point(|b| *b < range.start), starts.partition_point(|b| *b < range.end)); + let mut words = Vec::new(); + let mut i = first; + while i < end { + if chars.get(i).is_some_and(|c| c.is_whitespace()) { + i += 1; + continue; + } + let from = i; + while i < end && chars.get(i).is_some_and(|c| !c.is_whitespace()) { + i += 1; + } + words.push(from..i); + } + let (Some(head), Some(tail)) = (words.first().cloned(), words.last().cloned()) else { continue }; + if line.ink.len() == words.len() { + for (word, ink) in words.iter().zip(&line.ink) { + place(word.clone(), x(ink[0]), x(ink[2]), &mut target); + } + } else { + place(head.start..tail.end, x(line.rect[0]), x(line.rect[2]), &mut target); + } + } + let mut first = 0usize; + for placed in &mut g.rows { + let row = Arc::make_mut(&mut placed.row); + let range = first..first.saturating_add(row.glyphs.len()); + first = range.end.saturating_add(usize::from(placed.ends_with_newline)); + let Some(mut spots) = target.get(range.clone()).map(<[_]>::to_vec) else { continue }; + let row_chars = chars.get(range).unwrap_or_default(); + // Spaces between two placed characters share the gap between them; at the row's ends + // they sit against the nearest placed character. + let mut i = 0; + while i < spots.len() { + if spots.get(i).is_some_and(Option::is_some) { + i += 1; + continue; + } + let from = i; + while i < spots.len() && spots.get(i).is_some_and(Option::is_none) { + i += 1; + } + if row_chars.get(from..i).is_none_or(|c| !c.iter().all(|c| c.is_whitespace())) { + break; + } + let left = from.checked_sub(1).and_then(|p| spots.get(p).copied().flatten()).map(|(at, w)| at + w); + let right = spots.get(i).copied().flatten().map(|(at, _)| at); + let (left, right) = match (left, right) { + (Some(l), Some(r)) => (l, r.max(l)), + (Some(l), None) => (l, l), + (None, Some(r)) => (r, r), + (None, None) => break, + }; + let width = (right - left) / (i - from) as f32; + for (k, spot) in spots.iter_mut().enumerate().take(i).skip(from) { + *spot = Some((left + width * (k - from) as f32, width)); + } + } + let placed_all: Option> = spots.into_iter().collect(); + let Some(spots) = placed_all else { continue }; + if spots.iter().any(|(at, w)| !at.is_finite() || !w.is_finite()) || spots.windows(2).any(|p| p[1].0 + 0.01 < p[0].0) { + continue; + } + for (glyph, (at, width)) in row.glyphs.iter_mut().zip(spots) { + let new_x = at - placed.pos.x; + let dx = new_x - glyph.pos.x; + glyph.pos.x = new_x; + glyph.advance_width = width; + if !glyph.uv_rect.is_nothing() { + for vertex in row.visuals.mesh.vertices.iter_mut().skip(glyph.first_vertex as usize).take(4) { + vertex.pos.x += dx; + } + } + } + row.size.x = row.glyphs.iter().map(|g| g.pos.x + g.advance_width).fold(row.size.x.min(0.0), f32::max); + } +} + /// Caret colour that stays visible on a field filled with `fill`: black on light fills, white on dark ones. pub(crate) fn caret_color_on(fill: Color32) -> Color32 { let luma = 299 * u32::from(fill.r()) + 587 * u32::from(fill.g()) + 114 * u32::from(fill.b()); diff --git a/crates/ui-egui/tests/it/editing.rs b/crates/ui-egui/tests/it/editing.rs index 8b0bf25973..40a2da9b44 100644 --- a/crates/ui-egui/tests/it/editing.rs +++ b/crates/ui-egui/tests/it/editing.rs @@ -6473,6 +6473,106 @@ fn the_caret_on_a_small_row_is_as_tall_as_that_row() { assert!(caret > row_height * 0.8 && caret < row_height + 4.0, "caret {caret} px on a {row_height} px row"); } +/// One paragraph whose every word is its own run at its own size, in a font the editor can't load +/// (not embedded, and not a standard font), with glyphs much wider than its stand-in face's: the +/// shape of an OCR layer, and of a Type 1 paragraph from LaTeX. Returns the PDF and its words. +fn stand_in_words_fixture() -> (Vec, Vec<&'static str>) { + let words: Vec<(&str, f64)> = vec![ + ("In", 10.0), + ("poker", 10.3), + ("a", 15.1), + ("full", 11.7), + ("house", 10.7), + ("is", 9.7), + ("a", 15.1), + ("set", 9.0), + ("of", 10.4), + ("cards", 10.0), + ("that", 9.4), + ("make", 9.0), + ("a", 12.4), + ("pair", 11.7), + ]; + let mut content = String::new(); + let mut x = 20.0; + for (word, size) in &words { + content.push_str(&format!("BT /F1 {size} Tf {x:.2} 200 Td ({word}) Tj ET\n")); + x += word.len() as f64 * 0.7 * size + 4.0; + } + let widths = vec!["700"; 95].join(" "); + let objs: Vec = vec![ + "<< /Type /Catalog /Pages 2 0 R >>".into(), + "<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 700 300] >>".into(), + "<< /Type /Page /Parent 2 0 R /Contents 4 0 R /Resources << /Font << /F1 5 0 R >> >> >>".into(), + format!("<< /Length {} >>\nstream\n{content}\nendstream", content.len()), + format!("<< /Type /Font /Subtype /Type1 /BaseFont /CMR10 /FirstChar 32 /LastChar 126 /Widths [{widths}] >>"), + ]; + let mut out = b"%PDF-1.7\n".to_vec(); + let mut offsets = Vec::new(); + for (i, o) in objs.iter().enumerate() { + offsets.push(out.len()); + out.extend_from_slice(format!("{} 0 obj\n{o}\nendobj\n", i + 1).as_bytes()); + } + let xref = out.len(); + out.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objs.len() + 1).as_bytes()); + for o in offsets { + out.extend_from_slice(format!("{o:010} 00000 n \n").as_bytes()); + } + out.extend_from_slice(format!("trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n", objs.len() + 1).as_bytes()); + (out, words.into_iter().map(|(w, _)| w).collect()) +} + +/// A drag over a word of an unchanged paragraph selects that word. The editor lays the paragraph +/// out unseen in a stand-in face, and stretching each run to its width let every word boundary +/// drift: over an OCR layer, dragging across "kind" selected "of a", two words before it. +#[test] +fn dragging_over_a_word_selects_that_word_when_the_editor_uses_a_stand_in_face() { + let (bytes, words) = stand_in_words_fixture(); + let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).build_eframe(move |_cc| { + let mut app = PdfCraftApp::new(); + app.set_option("language", "en").unwrap(); + app.open_bytes("ocr-like.pdf", None, bytes).unwrap(); + app + }); + h.run_steps(4); + assert!(h.state_mut().execute("edit.edit_text")); + h.run_steps(2); + let page = h.state().views[0].page_screen_rect(0).unwrap(); + let k = page.width() / 700.0; + let screen = |x: f64, y: f64| egui::pos2(page.left() + x as f32 * k, page.top() + (300.0 - y) as f32 * k); + let lines = h.state().session.get(h.state().views[0].id).unwrap().text_lines(0); + assert_eq!(lines.len(), words.len(), "one run per word: {:?}", lines.iter().map(|l| &l.text).collect::>()); + let first = lines[0].rect; + let at = screen((first[0] + first[2]) / 2.0, (first[1] + first[3]) / 2.0); + h.hover_at(at); + h.run_steps(1); + h.drag_at(at); + h.run_steps(1); + h.drop_at(at); + h.run_steps(4); + let opened = h.state().views[0].line_editor.as_ref().map(|e| e.text.clone()); + assert_eq!(opened.as_deref(), Some(words.join(" ").as_str()), "the words open as one paragraph"); + // Drag across each of the last words, just inside its box, as a reader aims at what the page shows. + for target in [words.len() - 1, words.len() - 4, 9] { + let r = lines[target].rect; + let middle = (r[1] + r[3]) / 2.0; + let (from, to) = (screen(r[0] + 0.5, middle), screen(r[2] - 0.5, middle)); + h.hover_at(from); + h.run_steps(1); + h.drag_at(from); + h.run_steps(1); + for step in 1..=6 { + h.hover_at(from + (to - from) * (step as f32 / 6.0)); + h.run_steps(1); + } + h.drop_at(to); + h.run_steps(3); + let ed = h.state().views[0].line_editor.as_ref().expect("the paragraph stays open"); + let selected = ed.selection.clone().and_then(|s| ed.text.get(s)).unwrap_or_default(); + assert_eq!(selected, words[target], "dragging over word {target} ({:?})", words[target]); + } +} + /// Opt-in private-document regression. This never captures images or saves the source file. /// Run with PDFCRAFT_TEXT_REGRESSION_PDF=/path/to/document.pdf and --ignored. #[test] From 12a9b233942fc48e3584bd14269b114cf54f75fe Mon Sep 17 00:00:00 2001 From: oracola91 <118899664+oracola91@users.noreply.github.com> Date: Mon, 9 Nov 2026 12:31:29 +0200 Subject: [PATCH 23/41] Create PDF from text takes Cyrillic and Greek, and reports no longer escape them (#5403) --- crates/automation/src/assemble.rs | 5 +- crates/automation/src/tools.rs | 2 +- crates/create/README.md | 8 ++- crates/create/src/lib.rs | 53 ++++++++------ crates/create/src/tests.rs | 95 ++++++++++++++++++++++++- crates/create/src/text_font.rs | 108 +++++++++++++++++++++++++++++ crates/engine/src/session/files.rs | 8 ++- crates/engine/src/tests.rs | 29 ++++++++ parity/acrobat-features.toml | 4 +- 9 files changed, 278 insertions(+), 34 deletions(-) create mode 100644 crates/create/src/text_font.rs diff --git a/crates/automation/src/assemble.rs b/crates/automation/src/assemble.rs index deb2efa18a..5fd9d9b0be 100644 --- a/crates/automation/src/assemble.rs +++ b/crates/automation/src/assemble.rs @@ -36,8 +36,9 @@ impl Automation { } (None, None) => return Err(ToolError::InvalidArgs("text needs `text` or `path`".into())), }; - // The page font is Helvetica/WinAnsi: text it would turn into `?` is refused (with - // its line) by `pdfcraft_create::from_text`, as for every other caller. + // The page font is Helvetica/WinAnsi, or the bundled Inter face for Latin, Greek and + // Cyrillic letters WinAnsi lacks: text neither can draw is refused (with its line) + // by `pdfcraft_create::from_text`, as for every other caller. let bytes = self.session.create_from_text(&title, &text).map_err(|e| match e { pdfcraft_engine::EditError::Create(pdfcraft_engine::CreateError::Invalid(m)) => ToolError::InvalidArgs(m), e => failed(e), diff --git a/crates/automation/src/tools.rs b/crates/automation/src/tools.rs index eaf6117447..81be15b3c9 100644 --- a/crates/automation/src/tools.rs +++ b/crates/automation/src/tools.rs @@ -1543,7 +1543,7 @@ pub fn tools() -> Vec { t( "doc_create", "Create a PDF", - "Create a new, unsaved document and return it like doc_open: `blank` (pages, width, height in points; default 1 US Letter page), `images` (paths of PNG, JPEG, TIFF (every page), GIF or BMP files, one page each at the image's resolution) or `text` (a .txt path, or `text` directly; Western European (WinAnsi) characters only, others are refused). Save it with doc_save and a path.", + "Create a new, unsaved document and return it like doc_open: `blank` (pages, width, height in points; default 1 US Letter page), `images` (paths of PNG, JPEG, TIFF (every page), GIF or BMP files, one page each at the image's resolution) or `text` (a .txt path, or `text` directly; set in Helvetica, or in the bundled Inter face when it has Latin, Greek or Cyrillic letters outside Western European (WinAnsi); other characters are refused). Save it with doc_save and a path.", ) .with(schema( json!({ diff --git a/crates/create/README.md b/crates/create/README.md index f72f70aa26..95d47d27a9 100644 --- a/crates/create/README.md +++ b/crates/create/README.md @@ -19,10 +19,12 @@ Layer L4. Create a PDF (Acrobat's Create a PDF tool), execution plan M10.2: for one point per pixel, or `ImageResolution::Embedded` for the same behavior as `from_images`. The largest page side is still capped at 14,400 points; - `from_text(text, …)`: plain text set in Helvetica, wrapped and paginated. Text Helvetica - can't draw is refused, naming the line and the character, never set as `?`. + can't draw is set in the bundled Inter face instead (Latin, Greek and Cyrillic letters), with + the same page, margins, wrapping and form feeds; text Inter can't draw either is refused, + naming the line and the character, never set as `?`. - `from_text_escaped(text, …)`: the same for reports PdfCraft generates from documents - (comment summaries, compare reports): such characters are written as `[U+XXXX]` and the first - line says so (`ESCAPED_NOTE`). + (comment summaries, compare reports): text Inter draws is set in it; otherwise the characters + Helvetica can't draw are written as `[U+XXXX]` and the first line says so (`ESCAPED_NOTE`). Every function returns a `pdfcraft_cos::Document`; the caller writes it. diff --git a/crates/create/src/lib.rs b/crates/create/src/lib.rs index ef46ccb713..389173d848 100644 --- a/crates/create/src/lib.rs +++ b/crates/create/src/lib.rs @@ -5,9 +5,11 @@ use pdfcraft_cos::{Dict, Document, ObjRef, Object, PdfString, Stream, serialize}; mod extract; +mod text_font; mod upright_image; pub use extract::{ExtractedImage, ImageExport, extract_images, image_file, image_file_within}; -use pdfcraft_fonts::{literal, win_ansi, wrap}; +use pdfcraft_fonts::wrap_with; +use text_font::TextFont; #[derive(Debug, thiserror::Error, PartialEq)] pub enum CreateError { @@ -912,14 +914,20 @@ fn turkish_code_page(bytes: &[u8]) -> bool { } /// Plain text set in Helvetica on pages of `page` size with 1-inch margins. Text Helvetica can't -/// draw (outside Western European) is refused with [`CreateError::Invalid`] naming the line and -/// the character; text PdfCraft generates goes through [`from_text_escaped`] instead. +/// draw (outside Western European) is set in the bundled Inter face instead, whole, when Inter +/// draws all of it (Latin, Greek and Cyrillic letters); anything else is refused with +/// [`CreateError::Invalid`] naming the line and the first character neither face draws. Text +/// PdfCraft generates goes through [`from_text_escaped`] instead. pub fn from_text(title: &str, text: &str, page: (f64, f64), font_size: f64) -> Result { - // Helvetica in WinAnsiEncoding: text it can't draw is refused with where it is, not set as `?`. - if let Some((line, c)) = first_undrawable_line(text) { - return Err(CreateError::Invalid(pdfcraft_fonts::undrawable_message(&format!("the text (line {line})"), c))); + // Helvetica in WinAnsiEncoding draws it: set as it always was. + if first_undrawable_line(text).is_none() { + return set_text(title, text, page, font_size, false); } - set_text(title, text, page, font_size) + // Otherwise the bundled face, or a refusal with where it is, never `?`. + if let Some((line, c)) = text_font::first_missing(text) { + return Err(CreateError::Invalid(text_font::undrawable_message(line, c))); + } + set_text(title, &text.chars().map(text_font::drawn_as).collect::(), page, font_size, true) } /// The line (1-based) and character of the first one Helvetica can't draw. Line breaks, tabs and @@ -933,10 +941,15 @@ fn first_undrawable_line(text: &str) -> Option<(usize, char)> { pub const ESCAPED_NOTE: &str = "Characters Helvetica can't draw are written as [U+XXXX] code points."; /// [`from_text`] for text PdfCraft generates from documents (a comment summary, a compare -/// report), which may quote any language. Each character Helvetica can't draw is written as -/// `[U+XXXX]` instead of being refused or set as `?`, so nothing is lost, and the first line says -/// so ([`ESCAPED_NOTE`]). Returns the document and how many characters were written that way. +/// report), which may quote any language. Text the bundled Inter face draws all of is set as +/// [`from_text`] sets it, nothing escaped. Otherwise each character Helvetica can't draw is +/// written as `[U+XXXX]` instead of being refused or set as `?`, so nothing is lost, and the first +/// line says so ([`ESCAPED_NOTE`]). Returns the document and how many characters were written +/// that way. pub fn from_text_escaped(title: &str, text: &str, page: (f64, f64), font_size: f64) -> Result<(Document, usize), CreateError> { + if first_undrawable_line(text).is_some() && text_font::first_missing(text).is_none() { + return Ok((set_text(title, &text.chars().map(text_font::drawn_as).collect::(), page, font_size, true)?, 0)); + } let mut escaped = 0usize; let mut out = String::with_capacity(text.len()); for c in text.chars() { @@ -951,29 +964,25 @@ pub fn from_text_escaped(title: &str, text: &str, page: (f64, f64), font_size: f out = format!("{ESCAPED_NOTE}\n\n{out}"); } // The title is a text string in /Info, which holds any character. - Ok((set_text(title, &out, page, font_size)?, escaped)) + Ok((set_text(title, &out, page, font_size, false)?, escaped)) } -fn set_text(title: &str, text: &str, page: (f64, f64), font_size: f64) -> Result { +/// `text` set on pages of `page` size, in Helvetica or, with `inter`, the bundled Inter face. +fn set_text(title: &str, text: &str, page: (f64, f64), font_size: f64, inter: bool) -> Result { let size = if font_size.is_finite() && font_size > 0.0 { font_size.clamp(4.0, 72.0) } else { 11.0 }; let (w, h) = page; let margin = 72.0; let line_h = size * 1.25; let width = (w - 2.0 * margin).max(36.0); + let mut doc = Document::new_empty(); + let font = if inter { TextFont::inter(&mut doc)? } else { TextFont::helvetica(&mut doc) }; let lines: Vec = text .replace("\r\n", "\n") .replace('\t', " ") .split('\u{c}') - .flat_map(|p| wrap(p, size, width).into_iter().chain(std::iter::once("\u{c}".to_string()))) + .flat_map(|p| wrap_with(p, size, width, |s, size| font.width(s, size)).into_iter().chain(std::iter::once("\u{c}".to_string()))) .collect(); let per_page = (((h - 2.0 * margin) / line_h).floor() as usize).max(1); - let mut doc = Document::new_empty(); - let mut font = Dict::new(); - font.set(b"Type".to_vec(), Object::name("Font")); - font.set(b"Subtype".to_vec(), Object::name("Type1")); - font.set(b"BaseFont".to_vec(), Object::name("Helvetica")); - font.set(b"Encoding".to_vec(), Object::name("WinAnsiEncoding")); - let fr = doc.add(font); let mut tree = Pages::of(&doc)?; let mut page_lines: Vec> = vec![Vec::new()]; for l in lines.iter().take(lines.len().saturating_sub(1)) { @@ -990,12 +999,12 @@ fn set_text(title: &str, text: &str, page: (f64, f64), font_size: f64) -> Result for lines in page_lines { let mut c = format!("BT /F1 {size} Tf {line_h:.3} TL {margin} {:.3} Td\n", h - margin - size).into_bytes(); for l in lines { - c.extend(literal(&win_ansi(&l))); + c.extend(font.show(&l)?); c.extend_from_slice(b" Tj T*\n"); } c.extend_from_slice(b"ET\n"); let mut fonts = Dict::new(); - fonts.set(b"F1".to_vec(), Object::Ref(fr)); + fonts.set(b"F1".to_vec(), Object::Ref(font.font())); let mut res = Dict::new(); res.set(b"Font".to_vec(), Object::Dict(fonts)); tree.add(&mut doc, w, h, res, Some(c)); diff --git a/crates/create/src/tests.rs b/crates/create/src/tests.rs index 584b013503..d2a8c143e7 100644 --- a/crates/create/src/tests.rs +++ b/crates/create/src/tests.rs @@ -163,7 +163,7 @@ fn text_keeps_leading_spaces_and_tabs() { fn text_helvetica_cannot_draw_is_refused_or_escaped() { let err = from_text("notes", "Café —\tok\r\n\u{c}第二页 notes", LETTER, 11.0).unwrap_err(); let CreateError::Invalid(msg) = &err else { panic!("{err:?}") }; - assert!(msg.contains("(line 2)") && msg.contains("\"第\" (U+7B2C)") && msg.contains("Western European"), "{msg}"); + assert!(msg.contains("(line 2)") && msg.contains("\"第\" (U+7B2C)") && msg.contains("Cyrillic letters"), "{msg}"); // Western European text, tabs, CRLF and form feeds included, still converts. from_text("notes", "Café —\tok\r\n\u{c}Seite zwei: Grüße", LETTER, 11.0).unwrap(); let (doc, escaped) = from_text_escaped("Summary of Comments on 报告.pdf", "Comment: 签名 ok\nPage 2", LETTER, 11.0).unwrap(); @@ -183,6 +183,99 @@ fn text_helvetica_cannot_draw_is_refused_or_escaped() { assert!(!String::from_utf8_lossy(&s.decoded().unwrap()).contains("U+XXXX")); } +/// The decoded content of each page. +fn page_contents(doc: &Document) -> Vec { + pages(doc) + .iter() + .map(|p| { + let Object::Stream(s) = &*doc.resolve(p.get(b"Contents").unwrap()) else { panic!("no content stream") }; + String::from_utf8_lossy(&s.decoded().unwrap()).into_owned() + }) + .collect() +} + +/// The text each page shows, read back through its font's `ToUnicode`, one line per `Tj`. +fn page_text(doc: &Document, page: &Dict) -> Vec { + let res = doc.resolve(page.get(b"Resources").unwrap()).as_dict().cloned().unwrap(); + let fonts = doc.resolve(res.get(b"Font").unwrap()).as_dict().cloned().unwrap(); + let font = doc.resolve(fonts.get(b"F1").unwrap()).as_dict().cloned().unwrap(); + let metrics = pdfcraft_fonts::pdf::Metrics::from_dict(doc, &font); + let Object::Stream(s) = &*doc.resolve(page.get(b"Contents").unwrap()) else { panic!("no content stream") }; + let content = String::from_utf8_lossy(&s.decoded().unwrap()).into_owned(); + content + .split("> Tj") + .filter_map(|part| part.rsplit_once('<').map(|(_, hex)| hex)) + .map(|hex| { + let bytes: Vec = (0..hex.len()).step_by(2).map(|i| u8::from_str_radix(&hex[i..i + 2], 16).unwrap()).collect(); + metrics.decode(&bytes) + }) + .collect() +} + +const PANGRAM: &str = "Жълтата дюля беше щастлива, че пухът, който цъфна, замръзна като гьон."; + +/// Cyrillic text was refused (Helvetica can't draw it). Text with letters Helvetica lacks is set +/// in the bundled Inter face when that draws all of it, its Latin lines too, with Helvetica's +/// page, margins, line height, wrapping, tabs and form feeds; it reads back as typed. +#[test] +fn text_with_cyrillic_is_set_in_the_bundled_face() { + let long: String = (0..120).map(|i| format!("Ред {i}: {PANGRAM}\n")).collect(); + let text = format!("{long}\tТаб, „кавички“, № 5 и Ελληνικά\u{c}Нова страница"); + let doc = reopen(&from_text("бележки", &text, LETTER, 11.0).unwrap()); + let fonts: Vec<_> = doc.scan_objects().filter_map(|(_, o)| o.as_dict().and_then(|d| d.name(b"BaseFont").map(<[u8]>::to_vec))).collect(); + assert!(fonts.iter().any(|f| f == b"Inter") && !fonts.iter().any(|f| f == b"Helvetica"), "{fonts:?}"); + let programs = doc.scan_objects().filter(|(_, o)| matches!(o.as_ref(), Object::Stream(s) if s.dict.get(b"PCFullFont").is_some())).count(); + assert_eq!(programs, 1, "one copy of the face for every page"); + let p = pages(&doc); + let contents = page_contents(&doc); + assert!(p.len() >= 4, "{} pages", p.len()); + assert_eq!(media(&p[0]), [0.0, 0.0, 612.0, 792.0]); + // The same page set-up as Helvetica's: 11 pt, 1.25 line height, 1-inch margins. + assert!(contents[0].starts_with("BT /F1 11 Tf 13.750 TL 72 709.000 Td\n<"), "{}", contents[0]); + assert!(contents.iter().all(|c| !c.contains('?') && !c.contains("[U+"))); + // Each line fits between the margins in Inter's widths, and long lines wrap. + let lines: Vec = p.iter().flat_map(|page| page_text(&doc, page)).collect(); + assert!(lines.len() > 120, "{} lines", lines.len()); + assert!(lines.iter().all(|l| pdfcraft_fonts::inter_width(l, 11.0) <= 612.0 - 144.0 + 1e-6), "{lines:?}"); + let joined = lines.join(" "); + assert!(joined.contains("Ред 0: Жълтата дюля") && joined.contains("Ред 119:"), "{joined}"); + assert!(joined.contains(" Таб, „кавички“, № 5 и Ελληνικά"), "a tab is four spaces: {joined}"); + assert_eq!(page_text(&doc, p.last().unwrap()), ["Нова страница"], "a form feed starts a page"); + // As many lines on a page as in Helvetica. + let latin: String = (0..120).map(|i| format!("Row {i}\n")).collect(); + let cyrillic: String = (0..120).map(|i| format!("Ред {i}\n")).collect(); + let per_page = + |text: &str| page_contents(&from_text("t", text, LETTER, 11.0).unwrap()).iter().map(|c| c.matches("Tj T*").count()).collect::>(); + assert_eq!(per_page(&cyrillic), per_page(&latin)); +} + +/// Text the bundled face can't draw all of keeps the Helvetica way: refused, naming the first +/// character neither face draws, or, for text PdfCraft generates, escaped as before. Text it draws +/// all of is set in it with nothing escaped, and Western European text stays in Helvetica. +#[test] +fn text_the_bundled_face_cannot_draw_is_refused_or_escaped_as_before() { + let err = from_text("t", "Бележка\nПодпис: 签名", LETTER, 11.0).unwrap_err(); + let CreateError::Invalid(msg) = &err else { panic!("{err:?}") }; + assert!(msg.contains("(line 2)") && msg.contains("\"签\" (U+7B7E)"), "{msg}"); + let (doc, escaped) = from_text_escaped("r", "Бележка: 签名", LETTER, 11.0).unwrap(); + assert_eq!(escaped, 9, "every character Helvetica can't draw, as before"); + let shown = page_contents(&reopen(&doc)).join("\n"); + assert!(shown.contains(&format!("({ESCAPED_NOTE}) Tj")) && shown.contains("([U+0411][U+0435]"), "{shown}"); + let (doc, escaped) = from_text_escaped("Обобщение на коментарите", &format!("Коментар: {PANGRAM}\nPage 2"), LETTER, 11.0).unwrap(); + assert_eq!(escaped, 0); + let doc = reopen(&doc); + let shown = page_contents(&doc).join("\n"); + assert!(!shown.contains("[U+") && !shown.contains(ESCAPED_NOTE), "{shown}"); + assert_eq!(page_text(&doc, &pages(&doc)[0]), [format!("Коментар: {PANGRAM}"), "Page 2".to_string()]); + // A soft hyphen, which Inter lacks, is drawn as the hyphen Helvetica draws for it. + let doc = reopen(&from_text("t", "при\u{ad}мер", LETTER, 11.0).unwrap()); + assert_eq!(page_text(&doc, &pages(&doc)[0]), ["при-мер"]); + // Western European text is still set in Helvetica, exactly as before. + let doc = from_text("t", "Café 5€ ™", LETTER, 11.0).unwrap(); + let fonts: Vec<_> = doc.scan_objects().filter_map(|(_, o)| o.as_dict().and_then(|d| d.name(b"BaseFont").map(<[u8]>::to_vec))).collect(); + assert_eq!(fonts, [b"Helvetica".to_vec()]); +} + fn image_of(doc: &Document, page: usize) -> Dict { let p = &pages(doc)[page]; let res = doc.resolve(p.get(b"Resources").unwrap()).as_dict().cloned().unwrap(); diff --git a/crates/create/src/text_font.rs b/crates/create/src/text_font.rs new file mode 100644 index 0000000000..555ed3be73 --- /dev/null +++ b/crates/create/src/text_font.rs @@ -0,0 +1,108 @@ +//! The font a text document is set in: standard Helvetica, or, for text Helvetica can't draw, the +//! bundled Inter face (`pdfcraft_fonts::document_latin_fonts`), which every build has and which +//! carries the Latin, Greek and Cyrillic letters WinAnsiEncoding lacks. Text Helvetica draws is +//! set in Helvetica exactly as before. + +use pdfcraft_cos::{Dict, Document, ObjRef, Object}; +use pdfcraft_fonts::pdf::Metrics; +use pdfcraft_fonts::{helvetica_width, literal, win_ansi}; + +use crate::CreateError; + +/// The bundled face, Regular. +fn face() -> Option<&'static pdfcraft_fonts::CraftFont> { + pdfcraft_fonts::document_latin_fonts(false).into_iter().next() +} + +/// Line breaks, tabs and form feeds lay the text out; they are not drawn. +fn layout(c: char) -> bool { + matches!(c, '\n' | '\r' | '\t' | '\u{c}') +} + +/// The character the bundled face draws for `c`: a soft hyphen as a hyphen, as Helvetica draws it +/// (Inter has no glyph for it). +pub(crate) fn drawn_as(c: char) -> char { + if c == '\u{ad}' { '-' } else { c } +} + +/// The line (1-based, lines split at `\n`) and character of the first one the bundled face can't +/// draw, or `None` when it draws all of `text`. +pub(crate) fn first_missing(text: &str) -> Option<(usize, char)> { + let face = face(); + let draws = |s: &str| !s.chars().any(char::is_control) && face.is_some_and(|f| pdfcraft_fonts::embedding::covers(f.bytes, s)); + let mut buf = [0u8; 4]; + text.split('\n').enumerate().find_map(|(i, line)| { + // A whole line at once; the character only for a line the face can't draw. + let drawn: String = line.chars().filter(|c| !layout(*c)).map(drawn_as).collect(); + if draws(&drawn) { + return None; + } + line.chars().find(|&c| !layout(c) && !draws(drawn_as(c).encode_utf8(&mut buf))).map(|c| (i + 1, c)) + }) +} + +/// The refusal for text with `c` on `line`, which neither Helvetica nor the bundled face can draw. +pub(crate) fn undrawable_message(line: usize, c: char) -> String { + format!( + "neither Helvetica nor the bundled Inter face can draw \"{}\" (U+{:04X}) in the text (line {line}); only Latin, Greek and Cyrillic letters can be written there for now", + c.escape_debug(), + u32::from(c) + ) +} + +/// The font a text document is set in. +pub(crate) struct TextFont { + font: ObjRef, + /// The bundled face's metrics; `None` for Helvetica. + inter: Option, +} + +impl TextFont { + /// Standard Helvetica in WinAnsiEncoding, added to `doc`. + pub(crate) fn helvetica(doc: &mut Document) -> Self { + let mut font = Dict::new(); + font.set(b"Type".to_vec(), Object::name("Font")); + font.set(b"Subtype".to_vec(), Object::name("Type1")); + font.set(b"BaseFont".to_vec(), Object::name("Helvetica")); + font.set(b"Encoding".to_vec(), Object::name("WinAnsiEncoding")); + Self { font: doc.add(font), inter: None } + } + + /// The bundled face, embedded in `doc` once for every page. + pub(crate) fn inter(doc: &mut Document) -> Result { + let face = face().ok_or_else(|| CreateError::Invalid("this build has no bundled Latin, Greek and Cyrillic face".into()))?; + let mut fonts = Dict::new(); + let name = pdfcraft_fonts::embedding::embed(doc, &mut fonts, face.bytes, face.family).map_err(CreateError::Invalid)?; + let font = fonts.reference(name.as_bytes()).ok_or_else(|| CreateError::Invalid("the bundled face could not be added".into()))?; + let dict = doc.dict(&Object::Ref(font)).ok_or_else(|| CreateError::Invalid("the bundled face could not be read".into()))?; + Ok(Self { font, inter: Some(Metrics::from_dict(doc, &dict)) }) + } + + /// The font object every page names. + pub(crate) fn font(&self) -> ObjRef { + self.font + } + + /// The advance of `s` at `size`; characters the face lacks count as nothing ([`Self::show`] + /// refuses them). + pub(crate) fn width(&self, s: &str, size: f64) -> f64 { + match &self.inter { + None => helvetica_width(s, size), + Some(m) => m.encode(s).map_or(0.0, |codes| m.codes(&codes).iter().map(|(code, _)| m.width(*code)).sum::() * size), + } + } + + /// The string operand that shows `line`. Helvetica writes what WinAnsiEncoding can't hold as + /// `?`, so its callers check first; the bundled face refuses a character it lacks. + pub(crate) fn show(&self, line: &str) -> Result, CreateError> { + let Some(m) = &self.inter else { return Ok(literal(&win_ansi(line))) }; + let codes = m.encode(line).ok_or_else(|| CreateError::Invalid(format!("the bundled Inter face can't draw \"{}\"", line.escape_debug())))?; + let mut out = Vec::with_capacity(codes.len().saturating_mul(2).saturating_add(2)); + out.push(b'<'); + for b in codes { + out.extend_from_slice(format!("{b:02X}").as_bytes()); + } + out.push(b'>'); + Ok(out) + } +} diff --git a/crates/engine/src/session/files.rs b/crates/engine/src/session/files.rs index b5db2312f8..40b4a4e050 100644 --- a/crates/engine/src/session/files.rs +++ b/crates/engine/src/session/files.rs @@ -25,15 +25,17 @@ impl Session { self.write_new(&pdfcraft_create::from_images_with_resolution(images, resolution)?) } - /// A new document from plain text (US Letter, 11 pt Helvetica). Text Helvetica can't draw is + /// A new document from plain text (US Letter, 11 pt Helvetica, or the bundled Inter face for + /// text with Latin, Greek or Cyrillic letters Helvetica can't draw). Text neither can draw is /// refused, naming the line and the character (never set as `?`). pub fn create_from_text(&self, title: &str, text: &str) -> Result>, EditError> { self.write_new(&pdfcraft_create::from_text(title, text, pdfcraft_create::LETTER, 11.0)?) } /// [`Self::create_from_text`] for a report PdfCraft writes from documents (comment summaries, - /// compare reports), which can quote any language: characters Helvetica can't draw are written - /// as `[U+XXXX]`, and the report's first line says so, rather than the report being refused. + /// compare reports), which can quote any language: when the bundled Inter face can't draw it + /// all, characters Helvetica can't draw are written as `[U+XXXX]`, and the report's first line + /// says so, rather than the report being refused. pub(crate) fn create_report(&self, title: &str, text: &str) -> Result>, EditError> { let (doc, _escaped) = pdfcraft_create::from_text_escaped(title, text, pdfcraft_create::LETTER, 11.0)?; self.write_new(&doc) diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index f41d033abd..28976be9fa 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -1420,6 +1420,35 @@ fn utf16_and_code_page_text_files_convert_to_their_text() { assert_eq!(page_texts(&s, id), ["Crème brûlée"]); } +/// Create ▸ PDF from a text file in Cyrillic was refused, and a comment summary quoting Cyrillic +/// wrote each letter as `[U+XXXX]`. Both are set in the bundled Inter face now and read back as +/// typed. +#[test] +fn cyrillic_text_files_and_comment_summaries_read_back_as_typed() { + let mut s = Session::new(); + let text = "Договор № 12\nЖълтата дюля беше щастлива, че пухът, който цъфна, замръзна като гьон."; + let (_, pdf) = s.convert_to_pdf("бележки.txt", &Arc::new(text.as_bytes().to_vec())).unwrap(); + let id = s.open_new("бележки.pdf", pdf).unwrap(); + assert_eq!(page_texts(&s, id), [text]); + // A sticky note in Russian, summarized. + let note = "Проверьте сумму в разделе 3"; + let hex: String = note.encode_utf16().map(|u| format!("{u:04X}")).collect(); + let pdf = format!( + "%PDF-1.7 +1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj +2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 >> endobj +3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 300 200] /Annots [4 0 R] >> endobj +4 0 obj << /Type /Annot /Subtype /Text /Rect [20 140 40 160] /T (Reviewer) /Contents >> endobj +trailer << /Root 1 0 R >> +%%EOF" + ); + let id = s.open("note.pdf", None, Arc::new(pdf.into_bytes()), None).expect("opens"); + let summary = s.summarize_comments(id, SummarySort::Page).unwrap(); + let id = s.open_new("summary.pdf", summary).unwrap(); + let shown = page_texts(&s, id).join("\n"); + assert!(shown.contains(note) && !shown.contains("[U+"), "{shown}"); +} + #[test] fn a_file_split_around_another_combines_in_order_with_one_bookmark() { let mut s = Session::new(); diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index d7a6a406eb..a6caffa329 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -7059,8 +7059,8 @@ milestone = "M10" status = "shipped" commands = ["create.file"] tools = ["doc_create"] -evidence = ["crates/create/src/tests.rs::text_is_wrapped_and_paginated", "crates/automation/tests/it/automation.rs::creating_and_reducing_through_tools"] -notes = "Helvetica 11 pt on US Letter; form feeds start pages" +evidence = ["crates/create/src/tests.rs::text_is_wrapped_and_paginated", "crates/automation/tests/it/automation.rs::creating_and_reducing_through_tools", "crates/create/src/tests.rs::text_with_cyrillic_is_set_in_the_bundled_face", "crates/engine/src/tests.rs::cyrillic_text_files_and_comment_summaries_read_back_as_typed"] +notes = "Helvetica 11 pt on US Letter; text with letters Helvetica can't draw (Cyrillic, Greek, Central European) is set whole in the bundled Inter face, embedded once; text Inter can't draw either (CJK, Arabic, Hebrew, Thai) is refused, naming the line and character; form feeds start pages" [[feature]] id = "create.from-markdown" From 476effc1c8deccb4d0c8f250bb7b42969fdb52b9 Mon Sep 17 00:00:00 2001 From: oracola91 <118899664+oracola91@users.noreply.github.com> Date: Mon, 9 Nov 2026 12:31:47 +0200 Subject: [PATCH 24/41] Text from a justified line keeps one space between words (#5496) --- crates/edit/src/tests.rs | 26 +++++++++++++++++++++++++- crates/edit/src/text/interp.rs | 23 ++++++++++++++++++++--- crates/edit/src/text/lines.rs | 9 ++++++++- 3 files changed, 53 insertions(+), 5 deletions(-) diff --git a/crates/edit/src/tests.rs b/crates/edit/src/tests.rs index eb80c02c39..ec235c9d7e 100644 --- a/crates/edit/src/tests.rs +++ b/crates/edit/src/tests.rs @@ -1903,6 +1903,30 @@ fn tracked_text_preserves_larger_word_gaps() { } } +/// LibreOffice stretches a justified line on both sides of each space glyph, so a gap that reads +/// as a space comes right before the space glyph it widens: one space, not two. A gap after the +/// space glyph, a space glyph with no gap before it, and two space glyphs after a gap read as +/// before. +#[test] +fn a_gap_before_a_space_glyph_reads_as_one_space() { + for show in ["[(Word) -250 ( next) -250 ( last)] TJ", "[(Word ) -250 (next ) -250 (last)] TJ", "[(Word) -250 (next) 0 ( last)] TJ"] { + let doc = text_page(&format!("BT /F1 10 Tf 72 700 Td {show} ET")); + assert_eq!(text::text_lines(&doc, 0).unwrap()[0].text, "Word next last", "{show}"); + } + // Only the first space glyph after the gap is the gap's: two real spaces stay two. + let doc = text_page("BT /F1 10 Tf 72 700 Td [(Word) -250 ( next)] TJ ET"); + assert_eq!(text::text_lines(&doc, 0).unwrap()[0].text, "Word next"); + // The same line split over operators: the gap ends one run, its space glyph starts the next. + let doc = text_page("BT /F1 10 Tf 72 700 Td [(Word) -250] TJ [( next) -250] TJ ( last) Tj ET"); + assert_eq!(text::text_lines(&doc, 0).unwrap()[0].text, "Word next last"); + // Space glyphs on both sides of a break between runs, with no gap, are two spaces. + let doc = text_page("BT /F1 10 Tf 72 700 Td (Word ) Tj ( next) Tj ET"); + assert_eq!(text::text_lines(&doc, 0).unwrap()[0].text, "Word next"); + // Down a vertical column the same. + let doc = vertical_page("BT /V1 20 Tf 500 720 Td [<0041> 300 <00200042> 300 <00200043>] TJ ET"); + assert_eq!(text::text_lines(&doc, 0).unwrap()[0].text, "A B C"); +} + #[test] fn tracked_text_positions_do_not_count_character_spacing_twice() { let expected = "TRACKED SAMPLE TEXT"; @@ -9742,7 +9766,7 @@ fn vertical_page(content: &str) -> Document { ]); let unicode = doc.add(Object::Stream(Stream::flate( Dict::new(), - b"1 begincodespacerange <0000> endcodespacerange 6 beginbfchar <0041> <0041> <0042> <0042> <0043> <0043> <0044> <0044> <0045> <0045> <0046> <0046> endbfchar", + b"1 begincodespacerange <0000> endcodespacerange 7 beginbfchar <0020> <0020> <0041> <0041> <0042> <0042> <0043> <0043> <0044> <0044> <0045> <0045> <0046> <0046> endbfchar", ))); font.set(b"ToUnicode".to_vec(), Object::Ref(unicode)); font.set( diff --git a/crates/edit/src/text/interp.rs b/crates/edit/src/text/interp.rs index 019edf982a..bc0bce0c7b 100644 --- a/crates/edit/src/text/interp.rs +++ b/crates/edit/src/text/interp.rs @@ -493,6 +493,9 @@ pub(super) struct Carry { pub(super) layers: Vec>>, /// Bytes of glyph text the rest of the page may still produce (see [`MAX_PAGE_TEXT`]). pub(super) text_left: usize, + /// Whether the last run ended in a space read from a gap, with the next operator a `Tj` or + /// `TJ` that may start with the space glyph that gap widened. + pub(super) gap_space: bool, } /// The most glyph text one page's extraction produces, in bytes. A ToUnicode destination is @@ -523,7 +526,7 @@ impl Carry { dash: (Vec::new(), 0.0), line_cap: 0, }; - Carry { ts, stack: Vec::new(), marked: Vec::new(), layers: Vec::new(), text_left: MAX_PAGE_TEXT } + Carry { ts, stack: Vec::new(), marked: Vec::new(), layers: Vec::new(), text_left: MAX_PAGE_TEXT, gap_space: false } } } @@ -661,6 +664,9 @@ fn show_text(op: &Op, i: usize, state: &mut TextRunState<'_>) -> Result<(), Edit // the next glyph continues the last word. let mut words: Vec<(f64, f64)> = Vec::new(); let mut in_word = false; + // Whether the text ends in a space read from a gap rather than from a glyph. The run just + // before may have ended in one: a justified line can be split over `TJ` and `Tj`. + let mut gap_space = std::mem::take(&mut carry.gap_space); let mut glyph_count = 0_usize; for p in &pieces { match p { @@ -669,13 +675,17 @@ fn show_text(op: &Op, i: usize, state: &mut TextRunState<'_>) -> Result<(), Edit glyph_count = glyph_count.saturating_add(1); let shown = m.text_of(code); let blank = m.is_space(code, len) || shown.as_deref().is_some_and(|t| !t.is_empty() && t.chars().all(char::is_whitespace)); + // A space glyph right after a gap that read as a space is that space: + // LibreOffice stretches a justified line on both sides of each space glyph. + let after_gap = std::mem::take(&mut gap_space); match shown { - Some(t) => { + Some(shown) => { + let t: &str = if after_gap { shown.strip_prefix(' ').unwrap_or(&shown) } else { &shown }; carry.text_left = carry .text_left .checked_sub(t.len()) .ok_or_else(|| EditError::Invalid("the page's text is too large to read".into()))?; - text.push_str(&t); + text.push_str(t); } None => decodable = false, } @@ -716,6 +726,7 @@ fn show_text(op: &Op, i: usize, state: &mut TextRunState<'_>) -> Result<(), Edit let dy = -n / 1000.0 * ts.size; if -dy > ts.size * 0.2 && !text.ends_with(' ') { text.push(' '); + gap_space = true; } if dy.abs() > ts.size.abs() * 0.2 { in_word = false; @@ -727,6 +738,7 @@ fn show_text(op: &Op, i: usize, state: &mut TextRunState<'_>) -> Result<(), Edit // A large gap inside TJ reads as a space. if dx - tracking > (ts.size * ts.scale).abs() * 0.2 && !text.ends_with(' ') { text.push(' '); + gap_space = true; } // Tracking and kerning keep a word together; a larger jump // ends it (whatever the sign of the size or `Tz`). @@ -821,6 +833,7 @@ fn show_text(op: &Op, i: usize, state: &mut TextRunState<'_>) -> Result<(), Edit soft_gap: state.soft_gap, edited: marked.edited.last().copied().flatten(), }); + carry.gap_space = gap_space; Ok(()) } @@ -854,6 +867,10 @@ pub(super) fn interpret( // as 1, the single space a rejoin always used before #2529. let mut soft_gap = 1usize; for (i, op) in ops.iter().enumerate() { + // A gap's space carries only into a `Tj` or `TJ` right after it. + if !matches!(op.op.as_slice(), b"Tj" | b"TJ") { + carry.gap_space = false; + } match op.op.as_slice() { // No depth cap: every `EMC` pops, so a capped push would pair it with the enclosing // sequence (#3573). Both stacks stay bounded by the number of operators. diff --git a/crates/edit/src/text/lines.rs b/crates/edit/src/text/lines.rs index 54c249dbf1..8d0ad80cba 100644 --- a/crates/edit/src/text/lines.rs +++ b/crates/edit/src/text/lines.rs @@ -705,7 +705,14 @@ pub(super) fn reading_lines(doc: &Document, page: usize) -> Result<(Vec Date: Mon, 9 Nov 2026 04:32:11 -0600 Subject: [PATCH 25/41] Show form tab-order numbers (#5509) --- ATTRIBUTION.toml | 1 - crates/ui-egui/src/canvas.rs | 3 + crates/ui-egui/src/canvas/page_input.rs | 3 +- crates/ui-egui/src/panels/tools.rs | 4 + crates/ui-egui/src/tools/forms_ui.rs | 93 +++++++++++++++--- crates/ui-egui/src/tools/prepare.rs | 93 +++++++++++++++++- crates/ui-egui/tests/it/control.rs | 16 +++ crates/ui-egui/tests/it/forms.rs | 125 ++++++++++++++++++++++++ crates/ui-egui/tests/it/prepare.rs | 104 ++++++++++++++++++++ parity/acrobat-features.toml | 6 +- 10 files changed, 426 insertions(+), 22 deletions(-) diff --git a/ATTRIBUTION.toml b/ATTRIBUTION.toml index 67b588f25e..be935422ce 100644 --- a/ATTRIBUTION.toml +++ b/ATTRIBUTION.toml @@ -3244,4 +3244,3 @@ licence_file = "assets/icons/LICENSE-lucide.txt" kind = "icon" usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs" sha256 = "864ab2f19254e6ac7240bc6a98323197332e8f714d4d3f94831f41309e5ef2f7" - diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 8cfd56bc05..5af43213de 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -1321,6 +1321,7 @@ struct PageEnv<'a> { precise_scroll: Vec2, prefs: &'a comments::CommentPrefs, preparing: bool, + prepare_tab_order: Option<&'a [(usize, usize)]>, rects: &'a Vec, redact_mark: ([f64; 3], f64), scale: f32, @@ -3210,6 +3211,7 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) { scroll = refine_goto_point(scroll, view, &ScrollGeometry { rects: &rects, y_shift, content_w, content_h, avail, middle_gesture, hand }); let selection_wheel = view.selection_drag && navigation_enabled && unobstructed && !single_paging && ui.rect_contains_pointer(avail); + let prepare_tab_order = (preparing && view.prepare.show_tab_numbers).then(|| crate::forms_ui::tab_order(&form, info)); let env = PageEnv { added: &added, allowed, @@ -3251,6 +3253,7 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) { precise_scroll, prefs, preparing, + prepare_tab_order: prepare_tab_order.as_deref(), rects: &rects, redact_mark, scale, diff --git a/crates/ui-egui/src/canvas/page_input.rs b/crates/ui-egui/src/canvas/page_input.rs index 793211fec7..f96ff4e91e 100644 --- a/crates/ui-egui/src/canvas/page_input.rs +++ b/crates/ui-egui/src/canvas/page_input.rs @@ -840,6 +840,7 @@ pub(super) fn page_overlays(ui: &mut egui::Ui, view: &mut DocView, frame: &mut P form, generation, info, + prepare_tab_order, prefs, preparing, redact_mark, @@ -892,7 +893,7 @@ pub(super) fn page_overlays(ui: &mut egui::Ui, view: &mut DocView, frame: &mut P crate::link_ui::paint(painter, &xf, i, info, doc_links, view, pointer); } if preparing { - crate::prepare::paint_page(tool_cx, form, view); + crate::prepare::paint_page(tool_cx, form, prepare_tab_order.unwrap_or_default(), view); if let Some(preview) = &table_preview { crate::field_table_ui::paint_preview(painter, &xf, i, preview); } diff --git a/crates/ui-egui/src/panels/tools.rs b/crates/ui-egui/src/panels/tools.rs index fce62fb2ab..98063e6ef4 100644 --- a/crates/ui-egui/src/panels/tools.rs +++ b/crates/ui-egui/src/panels/tools.rs @@ -229,6 +229,10 @@ fn tool_detail(app: &mut PdfCraftApp, ui: &mut egui::Ui, t: &Tokens, g: &'static } // Prepare a form: Preview fills the form as a reader would; Edit goes back. if g.id == "form" { + if let Some(view) = app.active.and_then(|i| app.views.get_mut(i)) { + ui.add(widgets::checkbox(&mut view.prepare.show_tab_numbers, tl!("Show tab numbers"))); + ui.add_space(4.0); + } ui.horizontal(|ui| { let label = if app.form_preview { "Edit fields" } else { "Preview" }; if widgets::pill_button(ui, tl!(label), app.form_preview).on_hover_text(tl!("Try the form as people filling it in will see it")).clicked() diff --git a/crates/ui-egui/src/tools/forms_ui.rs b/crates/ui-egui/src/tools/forms_ui.rs index cf774bd048..e365f3d191 100644 --- a/crates/ui-egui/src/tools/forms_ui.rs +++ b/crates/ui-egui/src/tools/forms_ui.rs @@ -95,6 +95,38 @@ fn fillable(f: &FormField) -> bool { !f.read_only() && !matches!(f.kind, FormFieldKind::PushButton | FormFieldKind::Signature) } +/// The fillable widgets in the order Tab and Shift+Tab visit them. +pub fn tab_order(form: &[FormField], info: &DocInfo) -> Vec<(usize, usize)> { + let mut order: Vec<(usize, usize, usize)> = form + .iter() + .enumerate() + .filter(|(_, field)| fillable(field)) + .flat_map(|(fi, field)| { + field + .widgets + .iter() + .enumerate() + .filter(|(_, widget)| { + let Some(page) = widget.page.and_then(|page| info.pages.get(page)) else { return false }; + let [x0, y0, x1, y1] = widget.rect; + let [cx0, cy0, cx1, cy1] = page.crop.map(f64::from); + !widget.hidden + && !widget.read_only + && [x0, y0, x1, y1].into_iter().all(f64::is_finite) + && x0 < x1 + && y0 < y1 + && x1 > cx0 + && x0 < cx1 + && y1 > cy0 + && y0 < cy1 + }) + .map(move |(wi, widget)| (widget.tab, fi, wi)) + }) + .collect(); + order.sort_by_key(|(tab, _, _)| *tab); + order.into_iter().map(|(_, fi, wi)| (fi, wi)).collect() +} + /// Whether a combo box takes typed text as well as a choice from its list: the Edit flag /// (ISO 32000-2 Table 233), which only means something with Combo set. fn editable_combo(f: &FormField) -> bool { @@ -724,27 +756,56 @@ fn settle_text_editor( /// Tab / Shift+Tab: commits the field and moves to the next fillable widget in the document's tab /// order, returning the edit to apply (the commit's, or `edit`). -fn tab_to_next_field(view: &mut DocView, form: &[FormField], f: &FormField, widget: usize, forward: bool, edit: Option) -> Option { +fn tab_to_next_field( + view: &mut DocView, + info: &DocInfo, + form: &[FormField], + f: &FormField, + widget: usize, + forward: bool, + edit: Option, +) -> Option { commit(view, form); let pending = view.pending_edit.take(); // The next field that can be filled in, check boxes and radio buttons included (#2223), in // the document's tab order (each page's /Tabs: rows, columns or structure), wrapping. - let mut order: Vec<(usize, &FormField, usize)> = form - .iter() - .filter(|x| fillable(x)) - .flat_map(|x| x.widgets.iter().enumerate().filter(|(_, w)| w.page.is_some() && !w.hidden).map(move |(wi, w)| (w.tab, x, wi))) - .collect(); - order.sort_by_key(|o| o.0); - if let Some(pos) = - order.iter().position(|(_, x, wi)| x.name == f.name && *wi == widget).or_else(|| order.iter().position(|(_, x, _)| x.name == f.name)) - { - let k = if forward { (pos + 1) % order.len() } else { (pos + order.len() - 1) % order.len() }; - let (_, target, wi) = order[k]; + let order = tab_order(form, info); + let exact = order.iter().position(|(fi, wi)| form.get(*fi).is_some_and(|x| x.name == f.name && *wi == widget)); + let same_field = order.iter().position(|(fi, _)| form.get(*fi).is_some_and(|x| x.name == f.name)); + let next_after_removed = if exact.is_none() && same_field.is_none() { + form.iter().position(|field| field.name == f.name).and_then(|fi| { + let tab = form.get(fi)?.widgets.get(widget)?.tab; + let key = (tab, fi, widget); + let tab_key = |(fi, wi): &(usize, usize)| form.get(*fi).and_then(|field| field.widgets.get(*wi)).map(|other| (other.tab, *fi, *wi)); + if forward { + order.iter().position(|entry| tab_key(entry).is_some_and(|other| other > key)).or_else(|| (!order.is_empty()).then_some(0)) + } else { + order.iter().rposition(|entry| tab_key(entry).is_some_and(|other| other < key)).or_else(|| order.len().checked_sub(1)) + } + }) + } else { + None + }; + if let Some(pos) = exact.or(same_field).or(next_after_removed) { + // A focused widget that left the order is replaced by the field's first remaining stop. + let k = if next_after_removed.is_some() || (forward && exact.is_none()) { + pos + } else if forward { + (pos + 1) % order.len() + } else { + (pos + order.len() - 1) % order.len() + }; + let Some((fi, wi)) = order.get(k).copied() else { return edit.or(pending) }; + let Some(target) = form.get(fi) else { return edit.or(pending) }; view.forms.focus = Some(Focus { select_all: true, ..open_focus(target, wi) }); - if let Some(p) = target.widgets[wi].page - && view.page_screen_rect(p).is_none() + if let Some(widget) = target.widgets.get(wi) + && let Some(page) = widget.page + && !field_screen_rect(view, info, target, wi).is_some_and(|rect| { + let viewport = view.viewport_rect(); + viewport.contains(rect.min) && viewport.contains(rect.max) + }) { - view.go_to_page(p); + view.reveal_rect(page, widget.rect.map(|coord| coord as f32), info); } } edit.or(pending) @@ -1011,7 +1072,7 @@ pub(crate) fn overlay( view.forms.focus = None; } if let Some(forward) = next { - edit = tab_to_next_field(view, form, f, focus.widget, forward, edit); + edit = tab_to_next_field(view, info, form, f, focus.widget, forward, edit); } edit.or_else(|| view.pending_edit.take()) } diff --git a/crates/ui-egui/src/tools/prepare.rs b/crates/ui-egui/src/tools/prepare.rs index 128e033dc2..1ab23a7fd6 100644 --- a/crates/ui-egui/src/tools/prepare.rs +++ b/crates/ui-egui/src/tools/prepare.rs @@ -148,6 +148,8 @@ enum Grab { #[derive(Clone, Debug, Default, PartialEq)] pub struct PrepareView { + /// Show each fillable widget's position in the same order used by Tab and Shift+Tab. + pub show_tab_numbers: bool, /// The selected field and which of its widgets (the anchor of Align and Match Size). pub selected: Option<(String, usize)>, /// More fields selected with Shift- or ⌘-click. @@ -279,6 +281,39 @@ fn dragged(r: Rect, grab: Grab, delta: egui::Vec2, shift: bool) -> Rect { } } +fn tab_badge_rect(widget: Rect, page: Rect, size: egui::Vec2) -> Option { + if !page.is_positive() || !size.is_finite() || size.x < 0.0 || size.y < 0.0 || size.x > page.width() || size.y > page.height() { + return None; + } + let max_x = page.right() - size.x; + let max_y = page.bottom() - size.y; + let min = egui::pos2((widget.left() + 3.0).clamp(page.left(), max_x), (widget.top() + 3.0).clamp(page.top(), max_y)); + Some(Rect::from_min_size(min, size)) +} + +fn place_tab_badge(widget: Rect, page: Rect, size: egui::Vec2, placed: &[Rect]) -> Option { + let preferred = tab_badge_rect(widget, page, size)?; + if placed.iter().all(|badge| !badge.intersects(preferred)) { + return Some(preferred); + } + + let gap = 2.0; + let step = size + vec2(gap, gap); + let columns = (((page.width() - size.x) / step.x).floor().max(0.0) as usize).saturating_add(1); + let rows = (((page.height() - size.y) / step.y).floor().max(0.0) as usize).saturating_add(1); + let attempts = columns.saturating_mul(rows).min(placed.len().saturating_add(1)); + for slot in 0..attempts { + let col = slot % columns; + let row = slot / columns; + let min = page.min + vec2(col as f32 * step.x, row as f32 * step.y); + let candidate = Rect::from_min_size(min, size); + if placed.iter().all(|badge| !badge.intersects(candidate)) { + return Some(candidate); + } + } + None +} + pub(crate) struct Outcome { /// The click or drag belonged to Prepare a form. pub consumed: bool, @@ -620,7 +655,7 @@ pub fn arrange(form: &[FormField], anchor: &(String, usize), others: &[(String, /// Fields with their light-blue fill and name tags, the selection with its handles, and the /// rectangle being drawn or dragged to the pages' `pointer`. -pub(crate) fn paint_page(cx: PageToolContext<'_>, form: &[FormField], view: &DocView) { +pub(crate) fn paint_page(cx: PageToolContext<'_>, form: &[FormField], tab_order: &[(usize, usize)], view: &DocView) { let PageToolContext { ui, xf, page, info, ptr, .. } = cx; let painter = ui.painter(); let pointer = ptr.pos; @@ -664,6 +699,29 @@ pub(crate) fn paint_page(cx: PageToolContext<'_>, form: &[FormField], view: &Doc } } } + if prep.show_tab_numbers { + let mut placed = Vec::new(); + for (number, (fi, wi)) in tab_order.iter().copied().enumerate() { + let Some(field) = form.get(fi) else { continue }; + let Some(widget) = field.widgets.get(wi) else { continue }; + if widget.page != Some(page) { + continue; + } + let mut r = screen_rect(xf, info, page, widget.rect); + let selected = prep.selected.as_ref().is_some_and(|(name, index)| name == &field.name && *index == wi); + if selected && let (Some((_, grab, start)), Some(pointer)) = (grab, pointer) { + r = dragged(r, grab, pointer - start, ui.input(|i| i.modifiers.shift)); + } + let label = number.saturating_add(1).to_string(); + let font = theme::semibold((10.0 * zoom).clamp(8.0, 14.0)); + let galley = painter.layout_no_wrap(label, font, Color32::WHITE); + let Some(badge) = place_tab_badge(r, xf.rect, galley.size() + vec2(8.0, 5.0), &placed) else { continue }; + placed.push(badge); + let clipped = painter.with_clip_rect(painter.clip_rect().intersect(xf.rect)); + clipped.rect_filled(badge, CornerRadius::same(3), SELECT_BLUE); + clipped.galley(badge.min + vec2(4.0, 2.0), galley, Color32::WHITE); + } + } if let (Some((_, Grab::Draw(from) | Grab::Select(from), _)), Some(p)) = (grab, pointer) { let r = Rect::from_two_pos(from, p).intersect(xf.rect); painter.rect_filled(r, CornerRadius::ZERO, FIELD_FILL); @@ -855,4 +913,37 @@ mod tests { assert_eq!(lock_axis(None, vec2(40.0, 42.0), true), Some(Axis::Vertical), "and pressing it again chooses afresh"); assert_eq!(lock_axis(None, vec2(f32::NAN, 0.0), true), None, "a non-finite delta chooses nothing"); } + + #[test] + fn tab_badge_rect_tracks_dragged_rect_and_clamps_to_page_edges() { + let page = Rect::from_min_size(pos2(10.0, 20.0), vec2(100.0, 80.0)); + let widget = Rect::from_min_size(pos2(30.0, 40.0), vec2(20.0, 15.0)); + let size = vec2(24.0, 14.0); + let original = tab_badge_rect(widget, page, size).unwrap(); + let moved = dragged(widget, Grab::Move, vec2(12.0, 8.0), false); + assert_eq!(tab_badge_rect(moved, page, size).unwrap().min, original.min + vec2(12.0, 8.0)); + + let near_edge = Rect::from_min_size(pos2(100.0, 95.0), vec2(8.0, 8.0)); + let badge = tab_badge_rect(near_edge, page, size).unwrap(); + assert!(page.contains_rect(badge)); + } + + #[test] + fn tab_badge_is_omitted_when_it_cannot_fit_on_the_page() { + let page = Rect::from_min_size(pos2(10.0, 20.0), vec2(20.0, 12.0)); + let widget = Rect::from_min_size(pos2(10.0, 20.0), vec2(4.0, 4.0)); + assert_eq!(tab_badge_rect(widget, page, vec2(24.0, 14.0)), None); + } + + #[test] + fn tab_badges_near_the_right_edge_do_not_overlap() { + let page = Rect::from_min_size(pos2(10.0, 20.0), vec2(100.0, 80.0)); + let size = vec2(24.0, 14.0); + let first_widget = Rect::from_min_size(pos2(104.0, 45.0), vec2(8.0, 8.0)); + let second_widget = Rect::from_min_size(pos2(106.0, 45.0), vec2(8.0, 8.0)); + let first = place_tab_badge(first_widget, page, size, &[]).unwrap(); + let second = place_tab_badge(second_widget, page, size, &[first]).unwrap(); + assert!(!first.intersects(second), "{first:?} overlaps {second:?}"); + assert!(page.contains_rect(first) && page.contains_rect(second)); + } } diff --git a/crates/ui-egui/tests/it/control.rs b/crates/ui-egui/tests/it/control.rs index 8e397c803b..dcb795f130 100644 --- a/crates/ui-egui/tests/it/control.rs +++ b/crates/ui-egui/tests/it/control.rs @@ -140,6 +140,22 @@ fn thumbnail_size_is_settable_and_inspectable_through_the_control_channel() { assert!(ok(&mut h, &c, "ui.state", json!({}))["active"]["thumbnail_zoom"].as_f64().unwrap() > 0.75); } +#[test] +fn form_tab_numbers_can_be_shown_and_captured_through_the_control_channel() { + let (mut h, c) = harness(); + h.state_mut().open_bytes("form.pdf", None, include_bytes!("../data/form.pdf").to_vec()).unwrap(); + h.run_steps(3); + ok(&mut h, &c, "ui.command", json!({"id": "form.prepare"})); + let control = ok(&mut h, &c, "ui.inspect", json!({"query": "Show tab numbers"})); + assert!(control["widgets"].as_array().unwrap().iter().any(|widget| widget["label"] == "Show tab numbers")); + ok(&mut h, &c, "ui.click", json!({"label": "Show tab numbers"})); + h.run_steps(2); + assert!(h.state().views[h.state().active.unwrap()].prepare.show_tab_numbers); + assert_eq!(ok(&mut h, &c, "ui.state", json!({}))["documents"][1]["dirty"], false); + let shot = ok(&mut h, &c, "ui.screenshot", json!({})); + assert!(shot["png_base64"].as_str().is_some_and(|png| !png.is_empty()), "the control channel returns a nonempty screenshot"); +} + #[test] fn page_shadows_are_an_observable_app_wide_preference() { let (mut h, c) = harness(); diff --git a/crates/ui-egui/tests/it/forms.rs b/crates/ui-egui/tests/it/forms.rs index 3baa14bc53..0c64b3001e 100644 --- a/crates/ui-egui/tests/it/forms.rs +++ b/crates/ui-egui/tests/it/forms.rs @@ -18,6 +18,18 @@ fn harness() -> Harness<'static, PdfCraftApp> { h } +fn harness_with_bytes(name: &'static str, bytes: Vec) -> Harness<'static, PdfCraftApp> { + let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).build_eframe(move |_cc| { + let mut app = PdfCraftApp::new(); + app.set_option("language", "en").unwrap(); + app.open_bytes(name, None, bytes).unwrap(); + app.set_option("zoom", "150").unwrap(); + app + }); + crate::support::settle(&mut h); + h +} + fn value(h: &Harness<'static, PdfCraftApp>, name: &str) -> Vec { let s = h.state(); s.session.get(s.views[0].id).unwrap().form.iter().find(|f| f.name == name).unwrap().value.clone() @@ -87,6 +99,41 @@ fn a_read_only_fields_tooltip_shows_on_hover() { assert!(h.query_all_by_label("Where you live").next().is_some()); } +#[test] +fn read_only_widgets_are_skipped_by_tab_navigation() { + let bytes = pdf_from_objects(&[ + "<< /Type /Catalog /Pages 2 0 R /AcroForm << /Fields [4 0 R 5 0 R 7 0 R 8 0 R 9 0 R] >> >>", + "<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 200 120] >>", + "<< /Type /Page /Parent 2 0 R /Annots [4 0 R 6 0 R 10 0 R 7 0 R 8 0 R 9 0 R] >>", + "<< /Type /Annot /Subtype /Widget /FT /Tx /T (first) /Rect [20 80 150 100] /P 3 0 R /F 4 >>", + "<< /FT /Tx /T (locked) /Kids [6 0 R 10 0 R] >>", + "<< /Type /Annot /Subtype /Widget /Parent 5 0 R /Ff 1 /Rect [20 50 150 70] /P 3 0 R /F 4 >>", + "<< /Type /Annot /Subtype /Widget /FT /Tx /T (empty) /Rect [20 50 20 50] /P 3 0 R /F 4 >>", + "<< /Type /Annot /Subtype /Widget /FT /Tx /T (off-page) /Rect [220 50 240 70] /P 3 0 R /F 4 >>", + "<< /Type /Annot /Subtype /Widget /FT /Tx /T (last) /Rect [20 20 150 40] /P 3 0 R /F 4 >>", + "<< /Type /Annot /Subtype /Widget /Parent 5 0 R /Rect [20 35 150 45] /P 3 0 R /F 4 >>", + ]); + let mut h = harness_with_bytes("readonly-widget.pdf", bytes); + { + let doc = h.state().session.get(h.state().views[0].id).unwrap(); + let locked = doc.form.iter().find(|field| field.name == "locked").unwrap(); + assert!(!locked.read_only(), "the field itself is editable"); + assert!(locked.widgets[0].read_only, "only its widget is read-only"); + let order = pdfcraft_ui_egui::forms_ui::tab_order(&doc.form, &doc.info) + .into_iter() + .filter_map(|(fi, wi)| doc.form.get(fi).map(|field| (field.name.as_str(), wi))) + .collect::>(); + assert_eq!(order, vec![("first", 0), ("locked", 1), ("last", 0)], "read-only, empty and off-page widgets are excluded"); + } + click_field(&mut h, "first", 0); + h.key_press(egui::Key::Tab); + h.run_steps(3); + assert_eq!(focused(&h), Some(("locked".into(), 1)), "the writable widget in the same field remains in tab order"); + h.key_press(egui::Key::Tab); + h.run_steps(3); + assert_eq!(focused(&h), Some(("last".into(), 0))); +} + /// A `/TU` of only spaces shows no tooltip (no empty box under the pointer). #[test] fn a_blank_tooltip_shows_nothing() { @@ -277,6 +324,84 @@ fn tab_and_space_fill_in_a_check_box_between_text_fields() { assert_eq!(focused(&h), Some(("agree".into(), 0)), "Shift+Tab comes back to it"); } +#[test] +fn tab_uses_the_first_remaining_widget_when_focus_leaves_the_order() { + let mut h = check_box_harness(); + click_field(&mut h, "pick", 0); + h.state_mut().apply_edit(pdfcraft_engine::Edit::SetFieldProps { + name: "pick".into(), + props: Box::new(pdfcraft_engine::FieldProps { rect: Some((0, [320.0, 230.0, 336.0, 246.0])), ..Default::default() }), + }); + h.run_steps(3); + + h.key_press(egui::Key::Tab); + h.run_steps(3); + assert_eq!(focused(&h), Some(("pick".into(), 1)), "Tab replaces the off-page focus with the group's remaining widget"); +} + +#[test] +fn shift_tab_uses_the_previous_remaining_widget_when_focus_leaves_the_order() { + let mut h = check_box_harness(); + click_field(&mut h, "last", 0); + h.state_mut().apply_edit(pdfcraft_engine::Edit::SetFieldProps { + name: "last".into(), + props: Box::new(pdfcraft_engine::FieldProps { rect: Some((0, [320.0, 270.0, 340.0, 292.0])), ..Default::default() }), + }); + h.run_steps(3); + + h.key_press_modifiers(egui::Modifiers::SHIFT, egui::Key::Tab); + h.run_steps(3); + assert_eq!(focused(&h), Some(("agree".into(), 0)), "Shift+Tab restores focus to the last stop before the removed widget"); +} + +#[test] +fn shift_tab_wraps_to_the_last_widget_when_focus_leaves_the_order() { + let mut h = check_box_harness(); + click_field(&mut h, "first", 0); + h.state_mut().apply_edit(pdfcraft_engine::Edit::SetFieldProps { + name: "first".into(), + props: Box::new(pdfcraft_engine::FieldProps { rect: Some((0, [320.0, 350.0, 340.0, 372.0])), ..Default::default() }), + }); + h.run_steps(3); + + h.key_press_modifiers(egui::Modifiers::SHIFT, egui::Key::Tab); + h.run_steps(3); + assert_eq!(focused(&h), Some(("pick".into(), 1)), "Shift+Tab wraps to the last remaining stop"); +} + +#[test] +fn tab_and_shift_tab_follow_widget_order_across_pages() { + let mut h = check_box_harness(); + h.state_mut().apply_edit(pdfcraft_engine::Edit::AddField { + page: 1, + rect: [20.0, 350.0, 180.0, 372.0], + kind: pdfcraft_engine::NewField::Text { multiline: false }, + name: Some("second_page".into()), + }); + h.run_steps(4); + let order = { + let s = h.state(); + let doc = s.session.get(s.views[0].id).unwrap(); + pdfcraft_ui_egui::forms_ui::tab_order(&doc.form, &doc.info) + .into_iter() + .filter_map(|(fi, wi)| doc.form.get(fi).map(|field| (field.name.clone(), wi))) + .collect::>() + }; + assert_eq!(order.first().map(|entry| entry.0.as_str()), Some("first")); + assert_eq!(order.last().map(|entry| entry.0.as_str()), Some("second_page")); + click_field(&mut h, "first", 0); + for expected in order.iter().skip(1) { + h.key_press(egui::Key::Tab); + h.run_steps(4); + assert_eq!(focused(&h), Some(expected.clone()), "forward traversal: {order:?}"); + } + for expected in order.iter().rev().skip(1) { + h.key_press_modifiers(egui::Modifiers::SHIFT, egui::Key::Tab); + h.run_steps(4); + assert_eq!(focused(&h), Some(expected.clone()), "reverse traversal: {order:?}"); + } +} + #[test] fn clicking_a_check_box_while_typing_keeps_the_text_and_focuses_the_box() { // Issue #2223: the click ticked the box but left no field focused, so Tab started over. diff --git a/crates/ui-egui/tests/it/prepare.rs b/crates/ui-egui/tests/it/prepare.rs index d6abe1f8b3..50845e064a 100644 --- a/crates/ui-egui/tests/it/prepare.rs +++ b/crates/ui-egui/tests/it/prepare.rs @@ -55,6 +55,17 @@ fn rect_of(h: &Harness<'static, PdfCraftApp>, name: &str) -> [f64; 4] { s.session.get(s.views[0].id).unwrap().form.iter().find(|f| f.name == name).unwrap().widgets[0].rect } +fn text_center_near(h: &Harness<'static, PdfCraftApp>, label: &str, expected: Pos2) -> Option { + h.output() + .shapes + .iter() + .filter_map(|clipped| match &clipped.shape { + egui::Shape::Text(text) if text.galley.text() == label => Some(text.visual_bounding_rect().center()), + _ => None, + }) + .min_by(|a, b| a.distance(expected).total_cmp(&b.distance(expected))) +} + #[test] fn shift_selected_small_widgets_have_blue_tags_and_visible_outlines() { use egui::{Color32, Event, Modifiers, PointerButton, Shape, StrokeKind}; @@ -648,6 +659,99 @@ fn the_fields_panel_orders_tabs_manually() { assert_eq!(h.state().session.get(h.state().views[0].id).unwrap().can_undo(), Some("Set tab order")); } +#[test] +fn tab_numbers_follow_fill_order_without_dirtying_the_document() { + let mut h = harness(); + h.state_mut().execute("form.prepare"); + h.run_steps(2); + h.get_by_label("Show tab numbers").click(); + h.run_steps(3); + + let (order, rects, undo) = { + let s = h.state(); + let doc = s.session.get(s.views[0].id).unwrap(); + let order = pdfcraft_ui_egui::forms_ui::tab_order(&doc.form, &doc.info); + let rects = order + .iter() + .map(|(fi, wi)| { + let field = doc.form.get(*fi).expect("the traversal field exists"); + pdfcraft_ui_egui::forms_ui::field_screen_rect(&s.views[0], &doc.info, field, *wi).unwrap() + }) + .collect::>(); + (order, rects, doc.can_undo()) + }; + assert!(h.state().views[0].prepare.show_tab_numbers); + assert_eq!(undo, None, "displaying the numbers does not edit the PDF"); + let size_widgets = order + .iter() + .filter(|(fi, _)| h.state().session.get(h.state().views[0].id).unwrap().form.get(*fi).is_some_and(|field| field.name == "size")) + .count(); + assert_eq!(size_widgets, 2, "each radio widget has its own tab stop"); + + let drawn: Vec<(String, egui::Pos2, egui::Rect, egui::Rect)> = h + .output() + .shapes + .iter() + .filter_map(|clipped| match &clipped.shape { + egui::Shape::Text(text) => { + Some((text.galley.text().to_owned(), text.visual_bounding_rect().center(), text.visual_bounding_rect(), clipped.clip_rect)) + } + _ => None, + }) + .collect(); + for (i, rect) in rects.iter().enumerate() { + let expected = i.saturating_add(1).to_string(); + let badge_center = rect.min + egui::vec2(3.0 + 4.0 + 3.0, 3.0 + 2.0 + 5.0); + let badge = drawn.iter().find(|(label, center, _, _)| label == &expected && center.distance(badge_center) < 8.0); + let Some((_, _, text_rect, clip_rect)) = badge else { + panic!("widget {:?} should show tab number {expected} near {:?}; drawn: {drawn:?}", order.get(i), rect.min); + }; + assert!(clip_rect.contains_rect(*text_rect), "the whole tab number {expected} should be visible"); + } +} + +#[test] +fn tab_badge_tracks_its_widget_during_a_drag() { + let mut h = harness(); + h.state_mut().execute("form.prepare"); + h.run_steps(2); + h.get_by_label("Show tab numbers").click(); + h.run_steps(3); + + let (name, widget, rect) = { + let s = h.state(); + let doc = s.session.get(s.views[0].id).unwrap(); + let order = pdfcraft_ui_egui::forms_ui::tab_order(&doc.form, &doc.info); + let (fi, wi) = *order.first().expect("the form has a fillable widget"); + let field = doc.form.get(fi).expect("the first fillable field exists"); + (field.name.clone(), wi, pdfcraft_ui_egui::forms_ui::field_screen_rect(&s.views[0], &doc.info, field, wi).unwrap()) + }; + let start = rect.center(); + let badge_before = text_center_near(&h, "1", rect.min + egui::vec2(10.0, 10.0)).expect("the first badge is painted"); + + h.hover_at(start); + h.run_steps(1); + h.drag_at(start); + h.run_steps(1); + h.drop_at(start); + h.run_steps(3); + assert_eq!(h.state().views[0].prepare.selected.as_ref().map(|selected| (selected.0.as_str(), selected.1)), Some((name.as_str(), widget))); + + let delta = egui::vec2(24.0, 16.0); + h.hover_at(start); + h.run_steps(1); + h.drag_at(start); + h.run_steps(1); + h.hover_at(start + delta); + h.run_steps(3); + + let badge_during_drag = text_center_near(&h, "1", badge_before + delta).expect("the badge stays painted during the drag"); + assert!(badge_during_drag.distance(badge_before + delta) < 8.0, "badge {badge_before:?} moved to {badge_during_drag:?}"); + assert!(badge_during_drag.distance(badge_before) > 12.0, "the badge follows the dragged widget"); + h.drop_at(start + delta); + h.run_steps(3); +} + #[test] fn duplicating_a_field_onto_every_page() { let mut h = harness(); diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index a6caffa329..a1520f1b88 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -5288,11 +5288,11 @@ title = "Show tab numbers" area = "F" tier = "P2" milestone = "M6" -status = "planned" +status = "partial" commands = [] tools = [] -evidence = [] -notes = "" +evidence = ["crates/ui-egui/tests/it/prepare.rs::tab_numbers_follow_fill_order_without_dirtying_the_document", "crates/ui-egui/tests/it/control.rs::form_tab_numbers_can_be_shown_and_captured_through_the_control_channel", "crates/ui-egui/tests/it/forms.rs::tab_and_shift_tab_follow_widget_order_across_pages"] +notes = "The GUI's Prepare a form ▸ Show tab numbers overlays each visible, fillable widget with its 1-based position in the order used by Tab and Shift+Tab; repeated widgets receive separate numbers. This is a view-only toggle and does not dirty the PDF. Missing: headless engine and automation access to the setting." [[feature]] id = "form.preview-form" From b8d9497b93b8a4a2f7decb8c44db9756db536b23 Mon Sep 17 00:00:00 2001 From: mtlcl410 <2368091+mtlcl410@users.noreply.github.com> Date: Mon, 9 Nov 2026 18:32:44 +0800 Subject: [PATCH 26/41] M5: cloudy squares, circles and text boxes are drawn (comment.cloud) (#4195) --- crates/annot/src/appearance.rs | 150 ++++++++++++-- crates/annot/src/create.rs | 21 +- crates/annot/src/modify.rs | 22 +- crates/annot/src/tests.rs | 367 ++++++++++++++++++++++++++++++++- crates/edit/src/tests.rs | 13 +- parity/acrobat-features.toml | 4 +- 6 files changed, 528 insertions(+), 49 deletions(-) diff --git a/crates/annot/src/appearance.rs b/crates/annot/src/appearance.rs index 011656c7e7..0083761288 100644 --- a/crates/annot/src/appearance.rs +++ b/crates/annot/src/appearance.rs @@ -3,8 +3,8 @@ //! [`build`] draws a normal appearance (`/AP /N`) from the annotation dictionary and the //! objects it points at, so the same code serves new comments and restyled ones. Drawings are in page space with //! `/BBox = /Rect` (identity matrix), except note icons, which are drawn in a 20 × 20 box. -//! It returns `None` for anything it cannot draw faithfully (cloudy borders, unknown line -//! endings), so callers never replace an appearance with a worse one. It resolves `/QuadPoints` +//! It returns `None` for anything it cannot draw faithfully (unknown line endings, a cloudy +//! callout), so callers never replace an appearance with a worse one. It resolves `/QuadPoints` //! itself; for the other values, callers that hold the document follow references first //! (`draw::with_direct_values`). //! @@ -55,7 +55,9 @@ pub fn arrow_size(w: f64) -> f64 { 6.0 + 3.0 * w.max(0.0) } -/// Nominal radius of a cloudy border's bumps for a line of width `w` (intensity 1). +/// Nominal radius of a cloudy border's bumps for a line of width `w` (intensity 1). ISO 32000-2 +/// Table 169 leaves the size open; this is the size PdfCraft's polygon clouds already had, so +/// squares, circles and text boxes match them. pub fn cloud_radius(w: f64) -> f64 { 6.0 + 1.5 * w.max(0.0) } @@ -180,6 +182,80 @@ fn smooth_curve(pts: &[(f64, f64)]) -> String { /// Bumps one cloudy outline draws at most (plus one per edge); past it the bumps grow. pub(crate) const MAX_CLOUD_BUMPS: usize = 20_000; +/// A cloudy border's intensity (ISO 32000-2 §12.5.4, Table 169: `/BE /I`, "in the range 0 to 2. +/// Default value: 0"), or `None` for no cloud: no `/BE`, a style other than `/C`, or intensity 0, +/// which draws the border plain. +pub(crate) fn cloud_intensity(d: &Dict) -> Option { + let be = d.get(b"BE").and_then(|b| b.as_dict())?; + if be.name(b"S") != Some(b"C") { + return None; + } + let i = be.get(b"I").and_then(|i| i.as_f64()).unwrap_or(0.0); + (i.is_finite() && i > 0.0).then(|| i.min(2.0)) +} + +/// The bump radius for a cloudy border of width `w` at `intensity` (0–2]. +pub(crate) fn cloud_bump(w: f64, intensity: f64) -> f64 { + cloud_radius(w) * intensity.clamp(0.5, 2.0) +} + +/// How far past its outline a cloud with bumps of radius `bump` around an outline `perimeter` +/// long can reach. `cloud_path` rounds each edge to whole bumps, so one may span up to three +/// radii and bulge 1.5 radii; it also grows the radius to keep within its bump budget. +pub(crate) fn cloud_reach(perimeter: f64, bump: f64) -> f64 { + 1.5 * bump.max(perimeter / (2.0 * MAX_CLOUD_BUMPS as f64)) +} + +/// `/RD` when it is four finite, non-negative numbers (Tables 170 and 180). +fn rd_of(d: &Dict) -> Option> { + nums(d, b"RD").filter(|r| r.len() == 4 && r.iter().all(|x| x.is_finite() && *x >= 0.0)) +} + +/// `rect` less `rd`, whose four numbers are the left, top, right and bottom differences. +fn less_rd(rect: [f64; 4], rd: &[f64]) -> [f64; 4] { + let at = |i: usize| rd.get(i).copied().unwrap_or(0.0); + [rect[0] + at(0), rect[1] + at(3), rect[2] - at(2), rect[3] - at(1)] +} + +/// The box a plain (not cloudy) square or circle is drawn in: `/Rect` less a usable `/RD` (Table +/// 180: the differences between `/Rect` and "the actual boundaries of the underlying square or +/// circle"), or `None` when there is no `/RD`, it isn't four finite non-negative numbers, or it +/// leaves less than a point either way, so the caller draws in `/Rect` as without one. +fn rect_less_rd(d: &Dict, rect: [f64; 4]) -> Option<[f64; 4]> { + let inner = less_rd(rect, &rd_of(d)?); + (inner[2] - inner[0] >= 1.0 && inner[3] - inner[1] >= 1.0).then_some(inner) +} + +/// The shape a cloudy square, circle or text box draws its bumps around: `/Rect` less `/RD` +/// (Table 180: the differences between "the left, top, right, and bottom coordinates of Rect and +/// those of the square or circle, respectively"), but at least the bumps' greatest reach and half +/// the line from each side, so they stay inside `/Rect`; without a usable `/RD`, that margin. +fn cloud_inner(d: &Dict, rect: [f64; 4], bump: f64, w: f64) -> Option<[f64; 4]> { + // The cloud's reach and half the line keep it inside `/Rect`; an `/RD` leaving more is + // followed, one leaving less is not. + let perimeter = 2.0 * ((rect[2] - rect[0]) + (rect[3] - rect[1])); + let m = cloud_reach(perimeter, bump) + w / 2.0; + let safe = [rect[0] + m, rect[1] + m, rect[2] - m, rect[3] - m]; + let inner = match rd_of(d) { + Some(rd) => { + let r = less_rd(rect, &rd); + [r[0].max(safe[0]), r[1].max(safe[1]), r[2].min(safe[2]), r[3].min(safe[3])] + } + None => safe, + }; + (inner[2] - inner[0] >= 1.0 && inner[3] - inner[1] >= 1.0).then_some(inner) +} + +/// Points around the ellipse inscribed in `r`, counter-clockwise, one per bump of radius `bump` +/// (each edge of the outline becomes one bump), for a cloudy circle. +fn ellipse_points(r: [f64; 4], bump: f64) -> Vec<(f64, f64)> { + let (cx, cy, rx, ry) = ((r[0] + r[2]) / 2.0, (r[1] + r[3]) / 2.0, (r[2] - r[0]) / 2.0, (r[3] - r[1]) / 2.0); + // Ramanujan's approximation of the perimeter; the count stays within the bump budget. + let perimeter = std::f64::consts::PI * (3.0 * (rx + ry) - ((3.0 * rx + ry) * (rx + 3.0 * ry)).max(0.0).sqrt()); + let count = (perimeter / (2.0 * bump.max(0.5))).round().clamp(8.0, MAX_CLOUD_BUMPS as f64) as u32; + (0..count).map(|i| f64::from(i) * std::f64::consts::TAU / f64::from(count)).map(|a| (cx + rx * a.cos(), cy + ry * a.sin())).collect() +} + /// A closed cloudy outline through `pts` (§12.5.4 `/BE /S /C`): each edge becomes a row of /// half-circle bumps of about `r`, bulging outwards. pub fn cloud_path(pts: &[(f64, f64)], r: f64) -> String { @@ -536,7 +612,8 @@ struct AppearanceContext<'a> { dict: &'a Dict, subtype: &'a [u8], rect: [f64; 4], - cloudy: bool, + /// A cloudy border's intensity ([`cloud_intensity`]), or `None` for a plain one. + cloud: Option, /// The line's opacity (`parts.line`). opacity: f64, parts: PartOpacity, @@ -599,11 +676,13 @@ fn appearance_context<'a>(doc: &'a Document, d: &'a Dict, page: Option<[f64; 4]> return None; } let subtype = d.name(b"Subtype")?; - let rect = normalised_rect(d)?; + // A number too long for a double reads as infinite, and two near its limit are an infinite + // width apart: nothing is drawn rather than "inf" written into the appearance (review on #4195). + let rect = normalised_rect(d).filter(|r| r.iter().all(|x| x.is_finite()) && (r[2] - r[0]).is_finite() && (r[3] - r[1]).is_finite())?; let rect = page.and_then(|page| point_reach(doc, d, rect, page)).unwrap_or(rect); - // Cloudy borders (§12.5.4) are drawn for polygons only. - let cloudy = d.get(b"BE").and_then(|b| b.as_dict()).is_some_and(|b| b.name(b"S") == Some(b"C")); - if cloudy && subtype != b"Polygon" { + // Cloudy borders (§12.5.4, Table 169) apply to squares, circles, polygons and text boxes. + let cloud = cloud_intensity(d); + if cloud.is_some() && !matches!(subtype, b"Square" | b"Circle" | b"Polygon" | b"FreeText") { return None; } Some(AppearanceContext { @@ -611,7 +690,7 @@ fn appearance_context<'a>(doc: &'a Document, d: &'a Dict, page: Option<[f64; 4]> dict: d, subtype, rect, - cloudy, + cloud, opacity: part_opacity(d).line, parts: part_opacity(d), stroke: color(d, b"C")?, @@ -698,14 +777,18 @@ fn drawn_points(doc: &Document, d: &Dict) -> Option<(Vec, f64)> { if count < 4 || count % 2 != 0 || count / 2 > MAX_REACH_POINTS { return None; } - let cloudy = d.get(b"BE").and_then(Object::as_dict).is_some_and(|b| b.name(b"S") == Some(b"C")); - // A cloud's bumps are drawn at up to twice their nominal radius (`draw_polygon`). - let reach = match subtype { - b"Polygon" if cloudy => 2.0 * cloud_radius(w), - b"PolyLine" if has_ending() => arrow_size(w), + let vertices = nums(d, b"Vertices")?; + // A cloud's bumps (`draw_polygon`) reach as far past the outline as `cloud_reach` says. + let reach = match (subtype, cloud_intensity(d)) { + (b"Polygon", Some(intensity)) => { + let pts: Vec<(f64, f64)> = vertices.as_chunks::<2>().0.iter().map(|p| (p[0], p[1])).collect(); + let perimeter: f64 = pts.iter().zip(pts.iter().cycle().skip(1)).map(|(a, b)| (b.0 - a.0).hypot(b.1 - a.1)).sum(); + cloud_reach(if perimeter.is_finite() { perimeter } else { 0.0 }, cloud_bump(w, intensity)) + } + (b"PolyLine", _) if has_ending() => arrow_size(w), _ => 0.0, }; - (nums(d, b"Vertices")?, w / 2.0 + reach) + (vertices, w / 2.0 + reach) } b"Line" => { let [x0, y0, x1, y1] = <[f64; 4]>::try_from(nums(d, b"L")?).ok()?; @@ -921,15 +1004,27 @@ fn draw_box(context: &AppearanceContext<'_>, drawing: &mut AppearanceDrawing) -> if stroke.is_none() && fill.is_none() { return Some(()); } + // A plain square or circle lies within /Rect less /RD (Table 180), its stroke centred + // half the width inside that box. + let shape = rect_less_rd(d, rect).unwrap_or(rect); let inset = w / 2.0; - let [x0, y0, x1, y1] = [rect[0] + inset, rect[1] + inset, rect[2] - inset, rect[3] - inset]; + let [x0, y0, x1, y1] = [shape[0] + inset, shape[1] + inset, shape[2] - inset, shape[3] - inset]; if let Some(f) = fill { c.push_str(&rg(f)); } if let Some(s) = stroke { c.push_str(&format!("{}{} w\n{}", rg_stroke(s), n(w), dash(d))); } - if subtype == b"Square" { + if let Some(intensity) = context.cloud { + let bump = cloud_bump(w, intensity); + let inner = cloud_inner(d, rect, bump, w)?; + let pts = if subtype == b"Square" { + vec![(inner[0], inner[1]), (inner[2], inner[1]), (inner[2], inner[3]), (inner[0], inner[3])] + } else { + ellipse_points(inner, bump) + }; + c.push_str(&format!("1 j\n{}", cloud_path(&pts, bump))); + } else if subtype == b"Square" { c.push_str(&format!("{} {} {} {} re\n", n(x0), n(y0), n(x1 - x0), n(y1 - y0))); } else { c.push_str(&ellipse(x0, y0, x1, y1)); @@ -990,7 +1085,6 @@ fn draw_polygon(context: &AppearanceContext<'_>, drawing: &mut AppearanceDrawing let subtype = context.subtype; let stroke = context.stroke; let w = context.width; - let cloudy = context.cloudy; let c = &mut drawing.content; let v = nums(d, b"Vertices").filter(|v| v.len() >= 4 && v.len() % 2 == 0 && v.iter().all(|x| x.is_finite()))?; let pts: Vec<(f64, f64)> = v.as_chunks::<2>().0.iter().map(|p| (p[0], p[1])).collect(); @@ -1018,9 +1112,8 @@ fn draw_polygon(context: &AppearanceContext<'_>, drawing: &mut AppearanceDrawing if let Some(s) = col { c.push_str(&format!("{}{} w 1 J 1 j\n{}", rg_stroke(s), n(w), dash(d))); } - if cloudy { - let intensity = d.get(b"BE").and_then(|b| b.as_dict()).and_then(|b| b.get(b"I")).and_then(|i| i.as_f64()).unwrap_or(1.0); - c.push_str(&cloud_path(&pts, cloud_radius(w) * intensity.clamp(0.5, 2.0))); + if let Some(intensity) = context.cloud { + c.push_str(&cloud_path(&pts, cloud_bump(w, intensity))); } else { for (i, p) in pts.iter().enumerate() { c.push_str(&format!("{} {} {}\n", n(p.0), n(p.1), if i == 0 { "m" } else { "l" })); @@ -1332,7 +1425,20 @@ fn draw_free_text(context: &AppearanceContext<'_>, mut drawing: AppearanceDrawin // `/RD` and keeps its place: background and border take the shape, the text is laid out // in the text box as in a rectangle. let shape = crate::BorderShape::of(d); - if shape != crate::BorderShape::Rectangle { + // A cloudy box (not a callout): its background and border follow the cloud around + // the text, which keeps to the cloud's inner rectangle. + if let Some(intensity) = context.cloud.filter(|_| !d.contains(b"CL")) { + let cw = if bw > 0.0 { bw } else { 1.0 }; + let bump = cloud_bump(cw, intensity); + let inner = cloud_inner(d, rect, bump, cw)?; + let pts = [(inner[0], inner[1]), (inner[2], inner[1]), (inner[2], inner[3]), (inner[0], inner[3])]; + c.push_str(&format!("{}{}{} w 1 j\n{}", rg_stroke(line_color), rg(bg.unwrap_or([1.0; 3])), n(cw), dash(d))); + c.push_str(&cloud_path(&pts, bump)); + c.push_str(if bg.is_some() { "B\n[] 0 d\n" } else { "S\n[] 0 d\n" }); + rect = inner; + } else if context.cloud.is_some() { + return None; + } else if shape != crate::BorderShape::Rectangle { rect = shaped_border(d, shape, full, turn, bw, bg, line_color, &mut c)?; } else { if let Some(bg) = bg { diff --git a/crates/annot/src/create.rs b/crates/annot/src/create.rs index 6dbb7842e6..1b55e48932 100644 --- a/crates/annot/src/create.rs +++ b/crates/annot/src/create.rs @@ -103,12 +103,8 @@ pub(crate) fn rect_for(shape: &Shape, style: &Style) -> Result<[f64; 4], AnnotEr } grow(ink_bounds(strokes).unwrap_or_default(), half + 1.0) } - Shape::Polygon { vertices, cloud } => { - let b = bounds(vertices.iter().copied()) - .filter(|b| vertices.len() >= 3 && vertices.iter().all(|p| finite(p)) && b[2] - b[0] >= 1.0 && b[3] - b[1] >= 1.0); - let b = b.ok_or_else(|| bad("polygon (it needs three points)"))?; - grow(b, half + 1.0 + if *cloud { 1.5 * appearance::cloud_radius(style.width) } else { 0.0 }) - } + // A new cloud has intensity 1 (`/BE /I 1`). + Shape::Polygon { vertices, cloud } => polygon_rect(vertices, style.width, cloud.then_some(1.0))?, Shape::PolyLine { vertices, start, end } => { let b = bounds(vertices.iter().copied()) .filter(|b| vertices.len() >= 2 && vertices.iter().all(|p| finite(p)) && (b[2] - b[0]).max(b[3] - b[1]) >= 1.0); @@ -135,6 +131,19 @@ pub(crate) fn rect_for(shape: &Shape, style: &Style) -> Result<[f64; 4], AnnotEr Ok(r) } +/// Validate a polygon's corners and compute its `/Rect`: room for half the line, and with a +/// cloud of `cloud` intensity, for the farthest its bumps reach (`appearance::cloud_reach`). +pub(crate) fn polygon_rect(vertices: &[[f64; 2]], width: f64, cloud: Option) -> Result<[f64; 4], AnnotError> { + let b = bounds(vertices.iter().copied()) + .filter(|b| vertices.len() >= 3 && vertices.iter().all(|p| finite(p)) && b[2] - b[0] >= 1.0 && b[3] - b[1] >= 1.0); + let b = b.ok_or_else(|| AnnotError::Invalid("invalid polygon (it needs three points)".into()))?; + let reach = cloud.map_or(0.0, |intensity| { + let perimeter: f64 = vertices.iter().zip(vertices.iter().cycle().skip(1)).map(|(a, b)| (b[0] - a[0]).hypot(b[1] - a[1])).sum(); + appearance::cloud_reach(perimeter, appearance::cloud_bump(width, intensity)) + }); + Ok(grow(b, width.max(0.0) / 2.0 + 1.0 + reach)) +} + pub(crate) fn base_dict(subtype: &str, rect: [f64; 4], page: ObjRef, contents: &str, author: &str, meta: &Meta) -> Dict { let mut d = Dict::new(); d.set(b"Type".to_vec(), Object::name("Annot")); diff --git a/crates/annot/src/modify.rs b/crates/annot/src/modify.rs index 2156ad045a..153be15346 100644 --- a/crates/annot/src/modify.rs +++ b/crates/annot/src/modify.rs @@ -1,5 +1,6 @@ use super::create::{ - add_annotation, base_dict, bounds, callout_attach, ending_draws, ending_pair, finite, grow, ink_bounds, normalize, num_array, rect_for, rgb, + add_annotation, base_dict, bounds, callout_attach, ending_draws, ending_pair, finite, grow, ink_bounds, normalize, num_array, polygon_rect, + rect_for, rgb, }; use super::draw::{draw_appearance, redraw_or_restore, set_appearance}; use super::info::{line_endings_of, text_value}; @@ -625,22 +626,21 @@ pub fn set_points(doc: &mut Document, page: usize, index: usize, points: &[[f64; if points.len() > MAX_POINTS { return Err(AnnotError::Invalid(format!("at most {MAX_POINTS} points"))); } - let style = Style { width: border_width_of(&d), ..Style::default() }; + // The width the appearance draws with (`/BS`, else `/Border`), so the room is the drawn stroke's. + let style = Style { width: appearance::border_width(&d), ..Style::default() }; let ends = line_endings_of(&d).unwrap_or_default(); let (start, end) = (ends.first().copied().unwrap_or(LineEnding::None), ends.get(1).copied().unwrap_or(LineEnding::None)); let subtype = String::from_utf8_lossy(d.name(b"Subtype").unwrap_or_default()).into_owned(); - let (shape, key): (Shape, &[u8]) = match (subtype.as_str(), points) { - ("Line", [from, to]) => (Shape::Line { from: *from, to: *to, start, end }, b"L"), + // Each checks the points too: finite, and far enough apart to draw. + let (rect, key): ([f64; 4], &[u8]) = match (subtype.as_str(), points) { + ("Line", [from, to]) => (rect_for(&Shape::Line { from: *from, to: *to, start, end }, &style)?, b"L"), ("Line", _) => return Err(AnnotError::Invalid("a line has two ends".into())), - ("PolyLine", _) => (Shape::PolyLine { vertices: points.to_vec(), start, end }, b"Vertices"), - ("Polygon", _) => { - let cloud = d.get(b"BE").and_then(|b| b.as_dict()).is_some_and(|b| b.name(b"S") == Some(b"C")); - (Shape::Polygon { vertices: points.to_vec(), cloud }, b"Vertices") - } + ("PolyLine", _) => (rect_for(&Shape::PolyLine { vertices: points.to_vec(), start, end }, &style)?, b"Vertices"), + // Room for the cloud as the appearance draws it: `/BE /S /C` at its own intensity + // (Table 169), which may reach further than a new cloud's intensity 1. + ("Polygon", _) => (polygon_rect(points, style.width, appearance::cloud_intensity(&d))?, b"Vertices"), (other, _) => return Err(AnnotError::Invalid(format!("{other} comments have no points to move"))), }; - // Checks the points too: finite, and far enough apart to draw. - let rect = rect_for(&shape, &style)?; let before = d; doc.update_dict(r, |d| { d.set(key.to_vec(), num_array(&points.concat())); diff --git a/crates/annot/src/tests.rs b/crates/annot/src/tests.rs index 9d9a71dd34..9651263171 100644 --- a/crates/annot/src/tests.rs +++ b/crates/annot/src/tests.rs @@ -3166,6 +3166,366 @@ fn degenerate_and_absurd_leader_and_caption_values_do_not_panic() { assert!(!long.contains(&"w".repeat(257))); } +/// A comment's built appearance for the dictionary `body` (no `/AP`), as text, or `None` when +/// PdfCraft can't draw it. +fn built(body: &str) -> Option { + let doc = document_of(&[ + b"<< /Type /Catalog /Pages 2 0 R >>", + b"<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 600 600] >>", + b"<< /Type /Page /Parent 2 0 R /Annots [4 0 R] >>", + body.as_bytes(), + ]); + let d = list(&doc, 0).into_iter().next().unwrap(); + crate::appearance::build(&doc, &d).map(|s| String::from_utf8_lossy(&s.decoded().unwrap()).into_owned()) +} + +/// The x and y range a drawing's path covers: its points, and each curve's middle (a curve's +/// control points lie outside it, so they aren't counted). +fn extent(ops: &str) -> [f64; 4] { + let mut b = [f64::MAX, f64::MAX, f64::MIN, f64::MIN]; + let mut at = (0.0, 0.0); + let add = |p: (f64, f64), b: &mut [f64; 4]| *b = [b[0].min(p.0), b[1].min(p.1), b[2].max(p.0), b[3].max(p.1)]; + for line in ops.lines() { + let t: Vec<&str> = line.split_whitespace().collect(); + let Some((op, nums)) = t.split_last() else { continue }; + let v: Vec = nums.iter().filter_map(|n| n.parse().ok()).collect(); + match (*op, v.as_slice()) { + ("m" | "l", [x, y]) => { + at = (*x, *y); + add(at, &mut b); + } + ("c", [x1, y1, x2, y2, x3, y3]) => { + let mid = ((at.0 + 3.0 * x1 + 3.0 * x2 + x3) / 8.0, (at.1 + 3.0 * y1 + 3.0 * y2 + y3) / 8.0); + add(mid, &mut b); + at = (*x3, *y3); + add(at, &mut b); + } + _ => {} + } + } + b +} + +/// ISO 32000-2 §12.5.4, Table 169: a cloudy border (`/BE /S /C`) applies to squares, circles, +/// polygons and (PDF 1.6) text boxes, honours the `/BS` width and dashes, and its intensity +/// `/I` defaults to 0. Squares, circles and text boxes without an appearance were not drawn at +/// all, so a reviewer's marks vanished; intensity 0 (or none) drew a cloud on polygons. +#[test] +fn cloudy_borders_are_drawn_on_squares_circles_polygons_and_text_boxes() { + let base = "/Type /Annot /Rect [100 100 300 250] /C [1 0 0] /BS << /W 2 >>"; + for kind in ["Square", "Circle"] { + let ops = built(&format!("<< {base} /Subtype /{kind} /BE << /S /C /I 1 >> >>")).unwrap_or_else(|| panic!("{kind} is drawn")); + assert!(ops.matches(" c\n").count() > 8 && !ops.contains(" re\n"), "{kind} as bumps: {ops}"); + let e = extent(&ops); + assert!(e[0] >= 100.0 && e[1] >= 100.0 && e[2] <= 300.0 && e[3] <= 250.0, "{kind} stays in /Rect: {e:?}"); + } + let dashed = built(&format!("<< {base} /Subtype /Square /BS << /W 2 /S /D /D [6 3] >> /BE << /S /C /I 1 >> >>")).unwrap(); + assert!(dashed.contains("[6 3] 0 d") && dashed.contains(" c\n"), "dashes honoured: {dashed}"); + let text = built(&format!("<< {base} /Subtype /FreeText /DA (0 0 1 rg /Helv 10 Tf) /Contents (Look here) /BE << /S /C /I 1 >> >>")).unwrap(); + assert!(text.contains(" c\n") && text.contains("(Look here) Tj"), "a cloud around the text: {text}"); + let dashed_text = built( + "<< /Type /Annot /Rect [100 100 300 250] /C [1 0 0] /BS << /W 2 /S /D /D [4 2] >> /Subtype /FreeText /DA (0 g /Helv 10 Tf) /Contents (x) /BE << /S /C /I 1 >> >>", + ) + .unwrap(); + assert!(dashed_text.contains("[4 2] 0 d"), "a text box's cloud is dashed too: {dashed_text}"); + // Intensity 0, or none: a plain border (the default is 0). + for be in ["<< /S /C /I 0 >>", "<< /S /C >>", "<< /S /S /I 2 >>"] { + let square = built(&format!("<< {base} /Subtype /Square /BE {be} >>")).unwrap(); + assert!(square.contains(" re\n") && !square.contains(" c\n"), "{be}: {square}"); + let polygon = + built(&format!("<< /Type /Annot /Subtype /Polygon /Rect [0 0 100 100] /C [1 0 0] /Vertices [10 10 90 10 90 90 10 90] /BE {be} >>")) + .unwrap(); + assert!(!polygon.contains(" c\n"), "{be}: polygon edges stay straight: {polygon}"); + } + let cloudy_polygon = + built("<< /Type /Annot /Subtype /Polygon /Rect [0 0 100 100] /C [1 0 0] /Vertices [10 10 90 10 90 90 10 90] /BE << /S /C /I 1 >> >>") + .unwrap(); + assert!(cloudy_polygon.contains(" c\n")); + // A cloudy callout is still left to its own appearance. + assert!( + built(&format!("<< {base} /Subtype /FreeText /DA (0 g /Helv 10 Tf) /CL [10 10 100 100] /RD [5 5 5 5] /BE << /S /C /I 1 >> >>")).is_none() + ); +} + +/// Table 180: `/RD` gives the differences between "the left, top, right, and bottom coordinates +/// of Rect and those of the square or circle, respectively"; a cloudy square's bumps sit on that +/// inner square. +#[test] +fn a_cloudy_square_follows_rd_left_top_right_bottom() { + let ops = built("<< /Type /Annot /Subtype /Square /Rect [100 100 300 300] /C [1 0 0] /BS << /W 1 >> /BE << /S /C /I 1 >> /RD [20 40 30 25] >>") + .unwrap(); + // The corners of the inner square are path points: left 120, bottom 125, right 270, top 260. + for corner in ["120 125", "270 125", "270 260", "120 260"] { + assert!(ops.contains(corner), "corner {corner} in {ops}"); + } +} + +/// Review on #4195: without `/RD`, a small cloudy square still keeps its bumps inside `/Rect`. +/// Its edges are short enough that each rounds to a single bump about three radii long, which +/// bulges further than one radius. An `/RD` leaving less than that margin is widened to it, so +/// the cloud stays inside `/Rect` too. +#[test] +fn a_small_cloudy_square_without_rd_stays_inside_its_rect() { + for side in [40.0, 48.8, 60.0, 82.0] { + let ops = built(&format!( + "<< /Type /Annot /Subtype /Square /Rect [100 100 {x} {x}] /C [1 0 0] /BS << /W 2 >> /BE << /S /C /I 1 >> >>", + x = 100.0 + side + )) + .unwrap_or_else(|| panic!("side {side} is drawn")); + let e = extent(&ops); + assert!(e[0] >= 99.0 && e[1] >= 99.0 && e[2] <= 101.0 + side && e[3] <= 101.0 + side, "side {side}: {e:?}"); + } + let small_rd = + built("<< /Type /Annot /Subtype /Square /Rect [100 100 200 200] /C [1 0 0] /BS << /W 2 >> /BE << /S /C /I 1 >> /RD [1 1 1 1] >>").unwrap(); + let e = extent(&small_rd); + assert!(e[0] >= 99.0 && e[1] >= 99.0 && e[2] <= 201.0 && e[3] <= 201.0, "/RD [1 1 1 1] keeps the cloud inside /Rect: {e:?}"); +} + +/// ISO 32000-2 Table 180: `/RD` gives the differences between `/Rect` and "the actual boundaries +/// of the underlying square or circle", so a plain square or circle (no cloudy border) is drawn +/// inside `/Rect` less `/RD` (left, top, right, bottom) too, its stroke centred half the width +/// inside that box. An `/RD` that isn't four finite non-negative numbers, or leaves no box, is +/// ignored and the shape fills `/Rect` as before. +#[test] +fn plain_squares_and_circles_are_drawn_inside_rect_less_rd() { + let square = |rd: &str| built(&format!("<< /Type /Annot /Subtype /Square /Rect [100 100 200 200] /C [1 0 0] /BS << /W 3 >> {rd} >>")).unwrap(); + // Rect + 15 + 1.5 on every side: a 67 × 67 square from 116.5. + assert!(square("/RD [15 15 15 15]").contains("116.5 116.5 67 67 re"), "{}", square("/RD [15 15 15 15]")); + // Left 10, top 40, right 20, bottom 30: the box is 110..180 × 130..160. + assert!(square("/RD [10 40 20 30]").contains("111.5 131.5 67 27 re"), "{}", square("/RD [10 40 20 30]")); + for ignored in ["", "/RD [-1 0 0 0]", "/RD [1 2 3]", "/RD [60 0 60 0]", "/RD [0 50 0 49.5]", "/RD (x)"] { + assert!(square(ignored).contains("101.5 101.5 97 97 re"), "{ignored}: {}", square(ignored)); + } + let circle = built("<< /Type /Annot /Subtype /Circle /Rect [100 100 200 200] /C [1 0 0] /BS << /W 3 >> /RD [15 15 15 15] >>").unwrap(); + // The ellipse starts at its rightmost point: 200 - 15 - 1.5, at mid-height. + assert!(circle.starts_with("1 0 0 RG\n3 w\n183.5 150 m"), "{circle}"); +} + +/// The circles and rectangles PdfCraft draws write no `/RD`, so their appearance is unchanged by +/// it: the stroke still sits half its width inside `/Rect`. +#[test] +fn pdfcraft_rectangles_and_ellipses_still_fill_their_rect() { + let mut doc = fixture(); + let rect = [10.0, 10.0, 110.0, 60.0]; + for shape in [Shape::Rectangle { rect }, Shape::Oval { rect }] { + let oval = matches!(shape, Shape::Oval { .. }); + let i = add_annotation(&mut doc, &new(0, shape), &meta("r")).unwrap(); + let d = list(&doc, 0).remove(i); + assert!(!d.contains(b"RD")); + let w = appearance::border_width(&d); + let ap = ap_content(&doc, &d); + let r: Vec = d.get(b"Rect").and_then(Object::as_array).unwrap().iter().filter_map(Object::as_f64).collect(); + let expected = if oval { + format!("{} {} m", n(r[2] - w / 2.0), n((r[1] + r[3]) / 2.0)) + } else { + format!("{} {} {} {} re", n(r[0] + w / 2.0), n(r[1] + w / 2.0), n(r[2] - r[0] - w), n(r[3] - r[1] - w)) + }; + assert!(ap.contains(&expected), "{expected} in {ap}"); + } +} + +/// Review on #4195: a polygon reshaped with `/BE /S /C` and no `/I` (or `/I 0`) is a plain one, +/// as its appearance is drawn (Table 169: intensity 0 is no cloud), so its `/Rect` gets no room +/// for bumps; with an intensity above 0 it does. +#[test] +fn a_reshaped_polygon_pads_its_rect_only_for_a_drawn_cloud() { + // `/BE << /S /C >>` with `/I` when given; no `/BE` at all for `None`. + let reshaped = |be: Option>| { + let mut doc = fixture(); + let shape = Shape::Polygon { vertices: vec![[100.0, 100.0], [200.0, 100.0], [150.0, 200.0]], cloud: false }; + let i = add_annotation(&mut doc, &new(0, shape), &meta("p")).unwrap(); + let (_, r) = annot_ref(&mut doc, 0, i).unwrap(); + if let Some(intensity) = be { + let mut b = pdfcraft_cos::Dict::new(); + b.set(b"S".to_vec(), Object::name("C")); + if let Some(i) = intensity { + b.set(b"I".to_vec(), Object::Int(i)); + } + doc.update_dict(r, |d| d.set(b"BE".to_vec(), Object::Dict(b))).unwrap(); + } + set_points(&mut doc, 0, i, &[[100.0, 100.0], [300.0, 100.0], [200.0, 250.0]], &meta("")).unwrap(); + rect(&list(&doc, 0)[i]) + }; + let plain = reshaped(None); + for be in [None, Some(0)] { + assert_eq!(reshaped(Some(be)), plain, "/I {be:?}: no cloud drawn, no room for one"); + } + let cloudy = reshaped(Some(Some(1))); + assert!(cloudy[0] < plain[0] && cloudy[2] > plain[2], "room for the bumps: {cloudy:?} around {plain:?}"); +} + +/// Review on #4195: a reshaped polygon whose cloud has intensity 2 gets room for its bumps at +/// that intensity. With room only for intensity 1, the bumps on a 200 pt edge reached past +/// `/Rect` and the appearance's `/BBox` clipped them. +#[test] +fn a_reshaped_intensity_two_cloud_stays_inside_its_rect() { + let mut doc = fixture(); + let shape = Shape::Polygon { vertices: vec![[100.0, 100.0], [200.0, 100.0], [150.0, 200.0]], cloud: true }; + let i = add_annotation(&mut doc, &new(0, shape), &meta("p")).unwrap(); + let (_, r) = annot_ref(&mut doc, 0, i).unwrap(); + let mut be = pdfcraft_cos::Dict::new(); + be.set(b"S".to_vec(), Object::name("C")); + be.set(b"I".to_vec(), Object::Int(2)); + doc.update_dict(r, |d| d.set(b"BE".to_vec(), Object::Dict(be))).unwrap(); + set_points(&mut doc, 0, i, &[[100.0, 100.0], [300.0, 100.0], [200.0, 250.0]], &meta("")).unwrap(); + let d = &list(&doc, 0)[i]; + let bbox = rect(d); + // Every point along the drawn bumps, with half the 2 pt line, is inside `/Rect`. + let content = ap_content(&doc, d); + let (mut at, mut lowest, mut leftmost) = ((0.0, 0.0), f64::MAX, f64::MAX); + for line in content.lines() { + let v: Vec = line.split_whitespace().filter_map(|t| t.parse().ok()).collect(); + if line.ends_with(" m") && v.len() == 2 { + at = (v[0], v[1]); + } else if line.ends_with(" c") && v.len() == 6 { + for step in 0..=20 { + let t = f64::from(step) / 20.0; + let u = 1.0 - t; + let (a, b, c, e) = (u * u * u, 3.0 * u * u * t, 3.0 * u * t * t, t * t * t); + lowest = lowest.min(a * at.1 + b * v[1] + c * v[3] + e * v[5]); + leftmost = leftmost.min(a * at.0 + b * v[0] + c * v[2] + e * v[4]); + } + at = (v[4], v[5]); + } + } + assert!(lowest < 100.0 - 8.0, "the bumps bulge below the bottom edge: {lowest}"); + assert!(lowest - 1.0 >= bbox[1], "the bottom bumps ({lowest}) and the line fit in /Rect {bbox:?}"); + assert!(leftmost - 1.0 >= bbox[0], "the left bumps ({leftmost}) and the line fit in /Rect {bbox:?}"); +} + +/// Review on #4195: a reshaped polygon gets room for the border it is drawn with. With no `/BS`, +/// a `/Border [0 0 5]` is drawn 5 pt wide (`appearance::border_width`), so the new `/Rect` is the +/// one `/BS /W 5` gets; it was sized for a 1-pt border, and the cloud's outer bumps were clipped. +#[test] +fn a_reshaped_polygon_has_room_for_the_border_it_is_drawn_with() { + let reshaped = |border: &[u8], width: Object| { + let mut doc = fixture(); + let shape = Shape::Polygon { vertices: vec![[100.0, 100.0], [200.0, 100.0], [150.0, 200.0]], cloud: true }; + let i = add_annotation(&mut doc, &new(0, shape), &meta("p")).unwrap(); + let (_, r) = annot_ref(&mut doc, 0, i).unwrap(); + doc.update_dict(r, |d| { + d.remove(b"BS"); + if border == b"BS" { + let mut bs = pdfcraft_cos::Dict::new(); + bs.set(b"W".to_vec(), width); + d.set(b"BS".to_vec(), Object::Dict(bs)); + } else { + d.set(b"Border".to_vec(), Object::Array(vec![Object::Int(0), Object::Int(0), width])); + } + }) + .unwrap(); + set_points(&mut doc, 0, i, &[[100.0, 100.0], [300.0, 100.0], [200.0, 250.0]], &meta("")).unwrap(); + rect(&list(&doc, 0)[i]) + }; + let bs = reshaped(b"BS", Object::Int(5)); + assert_eq!(reshaped(b"Border", Object::Int(5)), bs, "the same 5-pt border, the same room"); + assert_ne!(reshaped(b"BS", Object::Int(1)), bs, "a 1-pt border needs less"); +} + +/// Every number a drawing writes, which must all be finite. +fn drawn_numbers(ops: &str) -> Vec { + ops.split_whitespace().filter(|t| t.starts_with(|c: char| c.is_ascii_digit() || c == '-' || c == '.')).filter_map(|t| t.parse().ok()).collect() +} + +/// Review on #4195: a cloud around a zero-size, inverted, sliver or huge `/Rect`, at any border +/// width and any `/BE /I` (tiny, negative, far above Table 169's 2), is either drawn inside +/// `/Rect` with finite numbers and a bounded number of bumps, or not built at all, never a +/// panic, a division by zero or a NaN. +#[test] +fn hostile_cloud_sizes_widths_and_intensities_draw_inside_rect_or_not_at_all() { + let rects = [ + "0 0 0 0", + "100 100 100 100", + "100 100 100.5 300", + "100 100 101 101", + "300 250 100 100", + "100 100 300 250", + "100 100 110 110", + "-1000000000000000 -1000000000000000 1000000000000000 1000000000000000", + "0 0 99999999999999999999999 1", + &format!("0 0 {} 100", "9".repeat(400)), + &format!("-{n} -{n} {n} {n}", n = "9".repeat(308)), + ]; + let (mut drawn, mut refused) = (0, 0); + for rect in rects { + let r: Vec = rect.split_whitespace().filter_map(|t| t.parse().ok()).collect(); + let bounds = [r[0].min(r[2]), r[1].min(r[3]), r[0].max(r[2]), r[1].max(r[3])]; + for width in ["0", "0.001", "2", "1000000", "-3"] { + for intensity in ["0.0000001", "2", "1000000000000000000000000000000", "-1"] { + for kind in ["Square", "Circle", "FreeText"] { + let body = format!( + "<< /Type /Annot /Subtype /{kind} /Rect [{rect}] /C [1 0 0] /BS << /W {width} >> /BE << /S /C /I {intensity} >> /DA (0 g /Helv 10 Tf) /Contents (x) >>" + ); + let Some(ops) = built(&body) else { + refused += 1; + continue; + }; + drawn += 1; + assert!(!ops.contains("NaN") && !ops.contains("inf"), "{body}: {ops}"); + assert!(drawn_numbers(&ops).iter().all(|v| v.is_finite()), "{body}"); + assert!(ops.matches(" c\n").count() <= crate::appearance::MAX_CLOUD_BUMPS + 8, "{body}: bumps are capped"); + // A cloud (intensity above 0); a negative one is a plain border (Table 169). + if !intensity.starts_with('-') && ops.contains(" c\n") { + let e = extent(&ops); + let slack = 0.5 + 1e-9 * (bounds[2] - bounds[0]).abs().max((bounds[3] - bounds[1]).abs()); + assert!( + e[0] >= bounds[0] - slack && e[1] >= bounds[1] - slack && e[2] <= bounds[2] + slack && e[3] <= bounds[3] + slack, + "{body}: the cloud {e:?} stays in /Rect" + ); + } + } + } + } + } + assert!(drawn > 0 && refused > 0, "both outcomes are exercised: {drawn} drawn, {refused} refused"); +} + +/// Review on #4195: a cloud around a rotated rectangle (a polygon turned 45°, its corners in +/// either order) bulges outwards the same way and stays inside its `/Rect`, and `cloud_path` +/// itself takes a zero, negative, infinite or NaN bump size and repeated corners without +/// dividing by zero. +#[test] +fn a_cloud_around_a_rotated_rectangle_bulges_outwards_either_way_round() { + let ccw = "150 100 200 150 150 200 100 150"; + let cw = "150 100 100 150 150 200 200 150"; + let extents: Vec<[f64; 4]> = [ccw, cw] + .iter() + .map(|v| { + let ops = built(&format!("<< /Type /Annot /Subtype /Polygon /Rect [80 80 220 220] /C [1 0 0] /Vertices [{v}] /BE << /S /C /I 1 >> >>")) + .unwrap(); + assert!(ops.contains(" c\n"), "{ops}"); + let e = extent(&ops); + assert!(e[0] >= 80.0 && e[1] >= 80.0 && e[2] <= 220.0 && e[3] <= 220.0, "inside /Rect: {e:?}"); + // Each bump's middle lies outside the turned square |x − 150| + |y − 150| = 50. + let mut at = (0.0, 0.0); + for line in ops.lines() { + let v: Vec = line.split_whitespace().filter_map(|t| t.parse().ok()).collect(); + if line.ends_with(" m") && v.len() == 2 { + at = (v[0], v[1]); + } else if line.ends_with(" c") && v.len() == 6 { + let mid = ((at.0 + 3.0 * v[0] + 3.0 * v[2] + v[4]) / 8.0, (at.1 + 3.0 * v[1] + 3.0 * v[3] + v[5]) / 8.0); + assert!((mid.0 - 150.0).abs() + (mid.1 - 150.0).abs() > 50.0 + 1.0, "the bump at {mid:?} bulges outwards"); + at = (v[4], v[5]); + } + } + e + }) + .collect(); + for k in 0..4 { + assert!((extents[0][k] - extents[1][k]).abs() < 0.5, "the same cloud either way round: {extents:?}"); + } + let square = [(0.0, 0.0), (10.0, 0.0), (10.0, 10.0), (0.0, 10.0)]; + for r in [0.0, -1.0, f64::INFINITY, f64::NAN, 1e-300, 1e300] { + let path = crate::appearance::cloud_path(&square, r); + assert!(!path.contains("NaN") && !path.contains("inf"), "r {r}: {path}"); + assert!(path.ends_with("h\n")); + } + for pts in [vec![], vec![(5.0, 5.0)], vec![(5.0, 5.0); 4], vec![(0.0, 0.0), (10.0, 0.0), (10.0, 0.0), (0.0, 0.0)]] { + let path = crate::appearance::cloud_path(&pts, 2.0); + assert!(!path.contains("NaN") && !path.contains("inf"), "{pts:?}: {path}"); + } +} + #[test] fn freehand_highlight_is_compatible_ink_with_marker_blending() { let shape = Shape::FreehandHighlight { strokes: vec![vec![[20.0, 100.0], [120.0, 100.0]]] }; @@ -3657,15 +4017,16 @@ fn polyline_whose_vertices_reach_past_its_rect_is_drawn_through_them() { fn polygon_whose_vertices_reach_past_its_rect_is_drawn_through_them() { let (_, display) = displayed(&[ b"<< /Type /Annot /Subtype /Polygon /Rect [10 10 20 20] /BS << /W 1 >> /C [0 0 1] /IC [1 1 0] /Vertices [100 100 200 100 150 180] >>", - b"<< /Type /Annot /Subtype /Polygon /Rect [10 10 20 20] /BS << /W 2 >> /C [0 0 1] /BE << /S /C >> /Vertices [100 100 200 100 150 180] >>", + b"<< /Type /Annot /Subtype /Polygon /Rect [10 10 20 20] /BS << /W 2 >> /C [0 0 1] /BE << /S /C /I 1 >> /Vertices [100 100 200 100 150 180] >>", ]); let shown = list(&display, 0); let rect = |d: &Dict| nums_of(d, b"Rect"); assert_eq!(rect(&shown[0]), [10.0, 10.0, 200.5, 180.5], "the polygon's /Rect takes in its vertices"); assert_eq!(ap_bbox(&display, &shown[0]), rect(&shown[0])); assert!(ap_content(&display, &shown[0]).contains("100 100 m"), "the polygon is drawn"); - // Half the width (1) and twice the cloud radius (2 × (6 + 1.5 × 2)) past the vertices. - assert_eq!(rect(&shown[1]), [10.0, 10.0, 219.0, 199.0], "and its cloudy border"); + // Half the width (1) and the cloud's reach (`cloud_reach`: 1.5 bumps of 6 + 1.5 × 2 at + // intensity 1) past the vertices. + assert_eq!(rect(&shown[1]), [10.0, 10.0, 214.5, 194.5], "and its cloudy border"); } /// A line's `/L` (Table 178) outside its `/Rect` is drawn, with its line ending and its leader diff --git a/crates/edit/src/tests.rs b/crates/edit/src/tests.rs index ec235c9d7e..242659f531 100644 --- a/crates/edit/src/tests.rs +++ b/crates/edit/src/tests.rs @@ -549,7 +549,7 @@ fn flattening_keeps_a_no_rotate_widget_upright_on_a_rotated_page() { /// #1317: a cloudy square this builder cannot draw stays on the page. A solid square, a hidden /// cloudy square and a cloudy square that already has an appearance keep their previous results. #[test] -fn flattening_keeps_a_cloudy_square_it_cannot_draw() { +fn flattening_draws_cloudy_squares_and_keeps_one_it_cannot_draw() { let page = stream("", " "); let form = "<< /Type /XObject /Subtype /Form /BBox [100 600 200 650] /Length 28 >>\nstream\n1 0 0 RG 100 600 100 50 re S\nendstream"; let case = |extra: &str, drawn: usize, left: bool| { @@ -571,10 +571,13 @@ fn flattening_keeps_a_cloudy_square_it_cannot_draw() { assert_eq!(streams(&doc, 0), before, "nothing was drawn in place of the comment"); } }; - case("/BE << /S /C /I 2 >>", 0, true); + // A cloudy square is drawn (ISO 32000-2 Table 169); one whose /RD leaves no square inside + // its /Rect (Table 180: the sums "shall be less than" the width and height) cannot be, and stays. + case("/BE << /S /C /I 2 >>", 1, false); + case("/BE << /S /C /I 2 >> /RD [60 30 60 30]", 0, true); case("/BE << /S /S >>", 1, false); - case("/BE << /S /C /I 2 >> /F 2", 0, true); - case("/BE << /S /C /I 2 >> /AP << /N 6 0 R >>", 1, false); + case("/BE << /S /C /I 2 >> /RD [60 30 60 30] /F 2", 0, true); + case("/BE << /S /C /I 2 >> /RD [60 30 60 30] /AP << /N 6 0 R >>", 1, false); } /// #3213: flattening wraps transferable optional-content memberships (`/OC` indirect OCGs and @@ -815,7 +818,7 @@ fn flattening_takes_an_undrawable_reply_with_its_comment() { "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 600 800] /Resources << >> /Contents 4 0 R /Annots [5 0 R 6 0 R 7 0 R] >>", &page, "<< /Type /Annot /Subtype /Square /Rect [100 600 200 650] /C [1 0 0] /Contents (Synthetic comment) /AP << /N 8 0 R >> /Popup 7 0 R >>", - "<< /Type /Annot /Subtype /Square /Rect [100 500 200 550] /C [1 0 0] /Contents (Synthetic reply) /BE << /S /C /I 2 >> /IRT 5 0 R >>", + "<< /Type /Annot /Subtype /Square /Rect [100 500 200 550] /C [1 0 0] /Contents (Synthetic reply) /BE << /S /C /I 2 >> /RD [60 30 60 30] /IRT 5 0 R >>", "<< /Type /Annot /Subtype /Popup /Rect [300 600 400 700] /Parent 5 0 R >>", "<< /Type /XObject /Subtype /Form /BBox [100 600 200 650] /Length 28 >>\nstream\n1 0 0 RG 100 600 100 50 re S\nendstream", ]); diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index a1520f1b88..951ef61b38 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -3914,8 +3914,8 @@ milestone = "M5" status = "shipped" commands = ["comment.cloud"] tools = ["comment_add"] -evidence = ["crates/annot/src/tests.rs::polygons_clouds_connected_lines_callouts_and_carets_are_drawn", "crates/annot/src/tests.rs::cloud_paths_bulge_outwards_either_way_round", "crates/ui-egui/tests/it/comments.rs::polygons_clouds_connected_lines_callouts_and_inserted_text", "crates/automation/tests/it/automation.rs::drawing_comments_through_tools"] -notes = "polygon with a cloudy border (/BE /S /C, /IT /PolygonCloud)" +evidence = ["crates/annot/src/tests.rs::polygons_clouds_connected_lines_callouts_and_carets_are_drawn", "crates/annot/src/tests.rs::cloud_paths_bulge_outwards_either_way_round", "crates/ui-egui/tests/it/comments.rs::polygons_clouds_connected_lines_callouts_and_inserted_text", "crates/automation/tests/it/automation.rs::drawing_comments_through_tools", "crates/annot/src/tests.rs::cloudy_borders_are_drawn_on_squares_circles_polygons_and_text_boxes", "crates/annot/src/tests.rs::a_cloudy_square_follows_rd_left_top_right_bottom", "crates/annot/src/tests.rs::plain_squares_and_circles_are_drawn_inside_rect_less_rd"] +notes = "polygon with a cloudy border (/BE /S /C, /IT /PolygonCloud). Cloudy borders other PDFs carry are drawn too (ISO 32000-2 Table 169): on squares, circles and text boxes as well as polygons, with the /BS width and dashes, inside /Rect less /RD (left, top, right, bottom), where plain squares and circles without an appearance are drawn too (Table 180); /I defaults to 0, which draws the border plain. Not yet: a cloudy callout keeps its own appearance only, and the bump size is PdfCraft's own (the standard leaves it open)." [[feature]] id = "comment.area-highlight" From 8f5373a7d11f7269914526a8589ba0c1e2544851 Mon Sep 17 00:00:00 2001 From: pat26-dotcom <185797667+pat26-dotcom@users.noreply.github.com> Date: Mon, 9 Nov 2026 11:33:01 +0100 Subject: [PATCH 27/41] M12: turn a layout table into Div and P tags in one call (#4105) (#4250) --- crates/a11y/src/layout_table.rs | 359 +++++++++++++++++++++++ crates/a11y/src/lib.rs | 2 + crates/automation/src/a11y.rs | 14 + crates/automation/src/dispatch.rs | 1 + crates/automation/src/tools.rs | 5 + crates/automation/tests/it/automation.rs | 53 ++++ crates/engine/src/edit.rs | 5 + crates/engine/src/edit/labels.rs | 1 + crates/engine/src/edit/run.rs | 4 + parity/acrobat-features.toml | 6 +- 10 files changed, 447 insertions(+), 3 deletions(-) create mode 100644 crates/a11y/src/layout_table.rs diff --git a/crates/a11y/src/layout_table.rs b/crates/a11y/src/layout_table.rs new file mode 100644 index 0000000000..6b2bab2d09 --- /dev/null +++ b/crates/a11y/src/layout_table.rs @@ -0,0 +1,359 @@ +//! A layout table as plain blocks: a table used only to place content (label and value pairs, a +//! letterhead grid) is read as a data table, with headers and cell navigation it doesn't have. One +//! edit turns the table, its row groups and rows into Div elements and its cells into P elements +//! (Div when a cell holds tags of its own), keeping the content, its order and everything inside +//! the cells, and drops the Table attributes (summary, scope, spans) they no longer have (#4105). + +use std::collections::HashSet; + +use pdfcraft_cos::{CosError, Dict, Document, ObjRef, Object}; + +use crate::alt; + +/// The most structure elements one table may have: past it the edit is refused. +const MAX_ELEMENTS: usize = 100_000; +/// The deepest nesting of direct (inline) elements followed. +const MAX_DEPTH: usize = 256; + +#[derive(Debug, thiserror::Error, PartialEq)] +pub enum LayoutTableError { + #[error("object {0} is not a reachable indirect structure tag")] + NotATag(ObjRef), + #[error("the tag is not a table")] + NotATable, + #[error("the table has too many tags, or tags nested too deeply")] + TooLarge, + #[error(transparent)] + Cos(#[from] CosError), +} + +/// What a table part becomes; `None` for anything else (which keeps its type and contents). +fn block_type(ty: &[u8], has_tags: bool, root: bool) -> Option<&'static str> { + match ty { + b"Table" if root => Some("Div"), + b"THead" | b"TBody" | b"TFoot" | b"TR" => Some("Div"), + b"TH" | b"TD" | b"Caption" => Some(if has_tags { "Div" } else { "P" }), + _ => None, + } +} + +struct Retag<'a> { + doc: &'a Document, + role_map: Dict, + /// Indirect elements under the table still to look at. + queue: Vec, + /// How many elements changed. + changed: usize, +} + +impl Retag<'_> { + fn standard(&self, d: &Dict) -> Option> { + Some(alt::standard(self.doc, &self.role_map, d.name(b"S")?)) + } + + /// Whether children `k` include structure elements (indirect or direct, also in an indirect + /// array of children). + fn has_tags(&self, k: &Object, depth: usize) -> bool { + if depth > MAX_DEPTH { + return false; + } + match k { + Object::Array(items) => items.iter().any(|i| self.has_tags(i, depth + 1)), + Object::Ref(r) => match &*self.doc.get(*r) { + Object::Dict(d) => d.contains(b"S"), + array @ Object::Array(_) => self.has_tags(array, depth + 1), + _ => false, + }, + Object::Dict(d) => d.contains(b"S"), + _ => false, + } + } + + /// Element `d` as a block when it is a part of the table (`root`: the table itself), with its + /// direct children converted the same way and its indirect ones queued; `None` otherwise. + fn convert(&mut self, d: &Dict, root: bool, depth: usize) -> Result, LayoutTableError> { + if depth > MAX_DEPTH { + return Err(LayoutTableError::TooLarge); + } + let Some(ty) = self.standard(d) else { return Ok(None) }; + // As written: a single child is often a reference to it, which must stay one. + let kids = d.get(b"K").cloned(); + let has_tags = kids.as_ref().is_some_and(|k| self.has_tags(k, 0)); + let Some(block) = block_type(&ty, has_tags, root) else { return Ok(None) }; + let mut out = d.clone(); + out.set(b"S".to_vec(), Object::name(block)); + self.drop_table_attributes(&mut out); + if let Some(kids) = kids { + let kids = self.convert_kids(&kids, depth + 1)?; + out.set(b"K".to_vec(), kids); + } + self.changed = self.changed.saturating_add(1); + Ok(Some(out)) + } + + /// Children `k` with direct table parts converted and indirect elements queued (references + /// stay references; an indirect array of children is written out only when a direct table + /// part in it changed). + fn convert_kids(&mut self, k: &Object, depth: usize) -> Result { + if depth > MAX_DEPTH { + return Err(LayoutTableError::TooLarge); + } + Ok(match k { + Object::Array(items) => Object::Array(items.iter().map(|i| self.convert_kids(i, depth)).collect::>()?), + Object::Ref(r) => match &*self.doc.get(*r) { + Object::Dict(d) if d.contains(b"S") => { + self.queue.push(*r); + k.clone() + } + array @ Object::Array(_) => { + let converted = self.convert_kids(array, depth + 1)?; + if converted == *array { k.clone() } else { converted } + } + _ => k.clone(), + }, + Object::Dict(d) if d.contains(b"S") => match self.convert(d, false, depth)? { + Some(block) => Object::Dict(block), + None => k.clone(), + }, + _ => k.clone(), + }) + } + + /// Remove the Table attribute objects (`/O /Table`) from `d`'s `/A`, keeping the others and + /// their revision numbers; an `/A` left empty goes. + fn drop_table_attributes(&self, d: &mut Dict) { + let Some(a) = d.get(b"A").cloned() else { return }; + let is_table = |o: &Object| self.doc.resolve(o).as_dict().is_some_and(|x| x.name(b"O") == Some(b"Table")); + let resolved = self.doc.resolve(&a); + let Object::Array(items) = &*resolved else { + if is_table(&a) { + d.remove(b"A"); + } + return; + }; + let mut kept = Vec::with_capacity(items.len()); + let mut dropping = false; + for item in items { + match item { + // A revision number goes with the attribute object before it. + Object::Int(_) if dropping => {} + _ => { + dropping = is_table(item); + if !dropping { + kept.push(item.clone()); + } + } + } + } + if kept.is_empty() { + d.remove(b"A"); + } else if kept.len() != items.len() { + d.set(b"A".to_vec(), Object::Array(kept)); + } + } +} + +/// Turn layout table `table` (a reachable indirect tag whose type is Table after the role map) into +/// Div and P elements as the module documentation says; returns how many elements changed. Tables +/// nested in its cells stay tables. +pub fn layout_table_to_blocks(doc: &mut Document, table: ObjRef) -> Result { + let Some(element) = alt::elements(doc).into_iter().find(|e| e.object == table) else { + return Err(LayoutTableError::NotATag(table)); + }; + let role_map = alt::tree_root(doc).and_then(|r| r.get(b"RoleMap").map(|o| doc.resolve(o))).and_then(|o| o.as_dict().cloned()).unwrap_or_default(); + let mut retag = Retag { doc: &*doc, role_map, queue: Vec::new(), changed: 0 }; + if retag.standard(&element.dict).as_deref() != Some(b"Table".as_slice()) { + return Err(LayoutTableError::NotATable); + } + // Read everything first, then write: a refused table changes nothing. + let mut updates: Vec<(ObjRef, Dict)> = Vec::new(); + let mut seen = HashSet::new(); + if let Some(block) = retag.convert(&element.dict, true, 0)? { + seen.insert(table); + updates.push((table, block)); + } + while let Some(r) = retag.queue.pop() { + if !seen.insert(r) { + continue; + } + if seen.len() > MAX_ELEMENTS { + return Err(LayoutTableError::TooLarge); + } + let Some(d) = retag.doc.get(r).as_dict().cloned() else { continue }; + if let Some(block) = retag.convert(&d, false, 0)? { + updates.push((r, block)); + } + } + let changed = retag.changed; + for (r, block) in updates { + doc.update_dict(r, |d| *d = block)?; + } + Ok(changed) +} + +#[cfg(test)] +mod tests { + use std::sync::Arc; + + use pdfcraft_cos::{Document, ObjRef, Object}; + + use super::*; + + /// A tagged document: the catalog (1), the structure tree root (2, `root` its other entries) + /// over the Document element (3), then `objects` from 4 on. + fn doc(root: &str, objects: &[&str]) -> Document { + let mut bodies = vec!["<< /Type /Catalog /StructTreeRoot 2 0 R >>".to_string(), format!("<< /Type /StructTreeRoot /K 3 0 R {root} >>")]; + bodies.extend(objects.iter().map(|s| s.to_string())); + let mut out = b"%PDF-1.7\n".to_vec(); + let mut offsets = Vec::new(); + for (i, b) in bodies.iter().enumerate() { + offsets.push(out.len()); + out.extend_from_slice(format!("{} 0 obj\n{b}\nendobj\n", i + 1).as_bytes()); + } + let xref = out.len(); + out.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", bodies.len() + 1).as_bytes()); + for o in offsets { + out.extend_from_slice(format!("{o:010} 00000 n \n").as_bytes()); + } + out.extend_from_slice(format!("trailer << /Size {} /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n", bodies.len() + 1).as_bytes()); + Document::open(Arc::new(out)).unwrap() + } + + fn r(n: u32) -> ObjRef { + ObjRef::new(n, 0) + } + + fn dict(doc: &Document, n: u32) -> Dict { + doc.get(r(n)).as_dict().cloned().unwrap() + } + + fn ty(doc: &Document, n: u32) -> Vec { + dict(doc, n).name(b"S").unwrap().to_vec() + } + + #[test] + fn a_label_and_value_table_becomes_divs_and_paragraphs() { + let mut d = doc( + "", + &[ + "<< /S /Document /K [4 0 R] >>", + // 4: the table, with a layout attribute and a summary (revision 1). + "<< /S /Table /K [5 0 R 8 0 R] /A [ << /O /Layout /Placement /Block >> << /O /Table /Summary (s) >> 1 ] >>", + "<< /S /TR /K [6 0 R 7 0 R] >>", + "<< /S /TH /K 0 /A << /O /Table /Scope /Row >> >>", + "<< /S /TD /K 1 >>", + "<< /S /TR /K [9 0 R 10 0 R] >>", + "<< /S /TD /K 2 >>", + "<< /S /TD /K 3 /A [ << /O /Table /ColSpan 1 >> << /O /Layout /TextAlign /End >> ] >>", + ], + ); + let before: Vec = crate::tags(&d).iter().map(|t| t.object).collect(); + assert_eq!(layout_table_to_blocks(&mut d, r(4)), Ok(7)); + for (n, want) in [(4, "Div"), (5, "Div"), (6, "P"), (7, "P"), (8, "Div"), (9, "P"), (10, "P")] { + assert_eq!(ty(&d, n), want.as_bytes(), "object {n}"); + } + // The content and the order stay; Table attributes go, layout ones stay. + assert_eq!(crate::tags(&d).iter().map(|t| t.object).collect::>(), before); + assert_eq!(dict(&d, 6).get(b"K"), Some(&Object::Int(0))); + assert_eq!(dict(&d, 6).get(b"A"), None); + let Some(Object::Array(a)) = dict(&d, 4).get(b"A").cloned() else { panic!("{:?}", dict(&d, 4)) }; + assert_eq!(a.len(), 1, "the summary and its revision number go: {a:?}"); + assert_eq!(a[0].as_dict().and_then(|x| x.name(b"O")), Some(&b"Layout"[..])); + let Some(Object::Array(a)) = dict(&d, 10).get(b"A").cloned() else { panic!() }; + assert_eq!(a.len(), 1); + } + + #[test] + fn what_the_cells_hold_stays_and_a_table_in_a_cell_stays_a_table() { + let mut d = doc( + "", + &[ + "<< /S /Document /K [4 0 R] >>", + "<< /S /Table /K [5 0 R] >>", + "<< /S /TR /K [6 0 R 7 0 R] >>", + // 6: a cell holding a paragraph; 7: a cell holding a table of its own. + "<< /S /TD /K [8 0 R] >>", + "<< /S /TD /K [9 0 R] >>", + "<< /S /P /K 0 >>", + "<< /S /Table /K [10 0 R] >>", + "<< /S /TR /K [11 0 R] >>", + "<< /S /TD /K 1 >>", + ], + ); + layout_table_to_blocks(&mut d, r(4)).unwrap(); + for (n, want) in [(4, "Div"), (5, "Div"), (6, "Div"), (7, "Div"), (8, "P"), (9, "Table"), (10, "TR"), (11, "TD")] { + assert_eq!(ty(&d, n), want.as_bytes(), "object {n}"); + } + } + + #[test] + fn direct_rows_and_cells_are_converted_in_place() { + let mut d = doc( + "/RoleMap << /Grid /Table /Row /TR >>", + &[ + "<< /S /Document /K [4 0 R] >>", + "<< /S /Grid /K [ << /S /Row /K [ << /S /TD /K 0 >> << /S /TD /K [5 0 R] >> ] >> ] >>", + "<< /S /Span /K 1 >>", + ], + ); + assert_eq!(layout_table_to_blocks(&mut d, r(4)), Ok(4)); + let table = dict(&d, 4); + assert_eq!(table.name(b"S"), Some(&b"Div"[..]), "the role map makes /Grid a table"); + let Some(Object::Array(rows)) = table.get(b"K").cloned() else { panic!() }; + let row = rows[0].as_dict().unwrap(); + assert_eq!(row.name(b"S"), Some(&b"Div"[..])); + let Some(Object::Array(cells)) = row.get(b"K").cloned() else { panic!() }; + assert_eq!(cells[0].as_dict().and_then(|c| c.name(b"S")), Some(&b"P"[..])); + assert_eq!(cells[1].as_dict().and_then(|c| c.name(b"S")), Some(&b"Div"[..]), "it holds a Span tag"); + assert_eq!(ty(&d, 5), b"Span"); + } + + #[test] + fn only_a_reachable_table_is_changed() { + let mut d = doc("", &["<< /S /Document /K [4 0 R] >>", "<< /S /P /K 0 >>", "<< /S /Table >>"]); + assert_eq!(layout_table_to_blocks(&mut d, r(4)), Err(LayoutTableError::NotATable)); + assert_eq!(layout_table_to_blocks(&mut d, r(5)), Err(LayoutTableError::NotATag(r(5))), "not in the structure tree"); + assert_eq!(layout_table_to_blocks(&mut d, ObjRef::new(4, 1)), Err(LayoutTableError::NotATag(ObjRef::new(4, 1)))); + assert_eq!(ty(&d, 5), b"Table"); + } + + #[test] + fn a_single_child_written_as_a_reference_stays_its_own_object() { + // /K 5 0 R: the table's one row, the row's one cell; 8: a row whose cells are an indirect + // array (object 9) holding a direct cell and a reference. + let mut d = doc( + "", + &[ + "<< /S /Document /K [4 0 R 7 0 R] >>", + "<< /S /Table /K 5 0 R >>", + "<< /S /TR /K 6 0 R >>", + "<< /S /TD /K 0 >>", + "<< /S /Table /K 8 0 R >>", + "<< /S /TR /K 9 0 R >>", + "[ << /S /TD /K 1 >> 10 0 R ]", + "<< /S /TD /K 2 >>", + ], + ); + assert_eq!(layout_table_to_blocks(&mut d, r(4)), Ok(3)); + assert_eq!(dict(&d, 4).get(b"K"), Some(&Object::Ref(r(5))), "the row stays a reference"); + assert_eq!(dict(&d, 5).get(b"K"), Some(&Object::Ref(r(6)))); + for (n, want) in [(4, "Div"), (5, "Div"), (6, "P")] { + assert_eq!(ty(&d, n), want.as_bytes(), "object {n}"); + } + assert!(crate::tags(&d).iter().any(|t| t.object == r(6)), "the cell is still listed"); + assert_eq!(layout_table_to_blocks(&mut d, r(7)), Ok(4)); + let Some(Object::Array(cells)) = dict(&d, 8).get(b"K").cloned() else { panic!("{:?}", dict(&d, 8)) }; + assert_eq!(cells[0].as_dict().and_then(|c| c.name(b"S")), Some(&b"P"[..])); + assert_eq!(cells[1], Object::Ref(r(10))); + assert_eq!(ty(&d, 10), b"P"); + } + + #[test] + fn rows_that_point_at_each_other_end() { + // A row listed twice and a cell that lists its own row: each is converted once. + let mut d = + doc("", &["<< /S /Document /K [4 0 R] >>", "<< /S /Table /K [5 0 R 5 0 R] >>", "<< /S /TR /K [6 0 R] >>", "<< /S /TD /K [5 0 R] >>"]); + assert_eq!(layout_table_to_blocks(&mut d, r(4)), Ok(3)); + assert_eq!((ty(&d, 5), ty(&d, 6)), (b"Div".to_vec(), b"Div".to_vec())); + } +} diff --git a/crates/a11y/src/lib.rs b/crates/a11y/src/lib.rs index 280f4e845f..50e632dec8 100644 --- a/crates/a11y/src/lib.rs +++ b/crates/a11y/src/lib.rs @@ -17,6 +17,7 @@ mod autotag; mod autotag_fields; mod content; mod headings; +mod layout_table; mod report; mod structure; mod tags; @@ -41,6 +42,7 @@ pub use alt::{AltError, ArtifactError, Figure, figures, mark_decorative, mark_ta pub use autotag::{AutoTagError, AutoTagResult, autotag_text}; pub use autotag_fields::tag_form_fields; pub use headings::{Heading, headings}; +pub use layout_table::{LayoutTableError, layout_table_to_blocks}; pub use report::{REPORT_WORDS, report_html}; pub use tags::{TagContentItem, TagError, TagInfo, TagPlacement, TagProperty, move_tag, set_tag_property, tag_content, tags}; diff --git a/crates/automation/src/a11y.rs b/crates/automation/src/a11y.rs index f47e8a2527..c5692ff9a7 100644 --- a/crates/automation/src/a11y.rs +++ b/crates/automation/src/a11y.rs @@ -557,6 +557,20 @@ impl Automation { Ok(with_warnings(self.accessibility_tags(a)?, warnings)) } + pub(crate) fn accessibility_layout_table_to_blocks(&mut self, a: &Args) -> Result { + let object = u32::try_from(a.int("object")?).map_err(|_| ToolError::InvalidArgs("object must be a positive tag object number".into()))?; + if object == 0 { + return Err(ToolError::InvalidArgs("object must be a positive tag object number".into())); + } + let generation = u16::try_from(a.int("generation")?) + .map_err(|_| ToolError::InvalidArgs("generation must be a PDF object generation from accessibility_tags".into()))?; + let id = self.doc(a)?.id; + self.session + .apply(id, pdfcraft_engine::Edit::LayoutTableToBlocks { object: pdfcraft_cos::ObjRef::new(object, generation) }) + .map_err(failed)?; + self.accessibility_tags(a) + } + pub(crate) fn accessibility_move_tag(&mut self, a: &Args) -> Result { let reference = |number: &str, generation: &str| -> Result { let n = u32::try_from(a.int(number)?).map_err(|_| ToolError::InvalidArgs(format!("{number} must be a positive tag object number")))?; diff --git a/crates/automation/src/dispatch.rs b/crates/automation/src/dispatch.rs index 276642b930..43eacc7f95 100644 --- a/crates/automation/src/dispatch.rs +++ b/crates/automation/src/dispatch.rs @@ -835,6 +835,7 @@ impl Automation { "accessibility_tag_content" => self.accessibility_tag_content(&a)?, "accessibility_set_tag_property" => self.accessibility_set_tag_property(&a)?, "accessibility_mark_tag_artifact" => self.accessibility_mark_tag_artifact(&a)?, + "accessibility_layout_table_to_blocks" => self.accessibility_layout_table_to_blocks(&a)?, "accessibility_move_tag" => self.accessibility_move_tag(&a)?, "sign_id_create" => self.sign_id_create(&a)?, "sign_document" => self.sign_document(&a)?, diff --git a/crates/automation/src/tools.rs b/crates/automation/src/tools.rs index 81be15b3c9..d33313052e 100644 --- a/crates/automation/src/tools.rs +++ b/crates/automation/src/tools.rs @@ -1332,6 +1332,11 @@ pub fn tools() -> Vec { "doc": doc(), "object": { "type": "integer", "minimum": 1 }, "generation": { "type": "integer", "minimum": 0, "maximum": 65535 }, }), &["doc", "object", "generation"])), + t("accessibility_layout_table_to_blocks", "Change layout table to blocks", "Turn a table used only for layout (label and value pairs, a letterhead grid) into plain blocks in one step: the Table tag, its THead/TBody/TFoot and TR tags become Div, its TH, TD and Caption tags P (Div when they hold tags of their own). Content, reading order and the tags inside cells stay; a table nested in a cell stays a table; Table attributes (Summary, Scope, spans) are removed, other attributes kept. The tag's type must be Table after the role map; use object and generation from accessibility_tags. Undoable. Returns the tags as accessibility_tags does.") + .with(schema(json!({ + "doc": doc(), "object": { "type": "integer", "minimum": 1 }, + "generation": { "type": "integer", "minimum": 0, "maximum": 65535 }, + }), &["doc", "object", "generation"])), t("accessibility_move_tag", "Move structure tag", "Move a reachable indirect structure tag before or after a direct sibling in their parent's /K array, changing tagged reading order without changing page appearance. Use object and generation values from accessibility_tags. Rejects distinct parents, duplicate references and positions that are already current. Undoable.") .with(schema(json!({ "doc": doc(), "object": { "type": "integer", "minimum": 1 }, diff --git a/crates/automation/tests/it/automation.rs b/crates/automation/tests/it/automation.rs index de9e996fcd..ac6b2374e5 100644 --- a/crates/automation/tests/it/automation.rs +++ b/crates/automation/tests/it/automation.rs @@ -3653,6 +3653,59 @@ trailer << /Root 1 0 R >> assert_eq!(saved.iter().find(|t| t.kind == "TH").and_then(|t| t.scope.as_deref()), Some("Column")); } +/// A layout table becomes plain blocks through `accessibility_layout_table_to_blocks` (#4105): the +/// table findings go, the content keeps its order, and the change is undone and saved. +#[test] +fn a_layout_table_becomes_blocks_through_automation() { + let dir = workdir("a11y-layout-table"); + let content = "/TD << /MCID 0 >> BDC 10 150 40 20 re f EMC /TD << /MCID 1 >> BDC 60 150 40 20 re f EMC"; + let pdf = format!( + "%PDF-1.7 +1 0 obj << /Type /Catalog /Pages 2 0 R /MarkInfo << /Marked true >> /StructTreeRoot 5 0 R >> endobj +2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 >> endobj +3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 200 200] /Contents 4 0 R /StructParents 0 >> endobj +4 0 obj << /Length {} >> stream +{content} +endstream endobj +5 0 obj << /Type /StructTreeRoot /K 6 0 R /ParentTree 11 0 R >> endobj +6 0 obj << /S /Document /P 5 0 R /K [7 0 R] >> endobj +7 0 obj << /S /Table /P 6 0 R /K [8 0 R] >> endobj +8 0 obj << /S /TR /P 7 0 R /K [9 0 R 10 0 R] >> endobj +9 0 obj << /S /TD /P 8 0 R /Pg 3 0 R /K 0 >> endobj +10 0 obj << /S /TD /P 8 0 R /Pg 3 0 R /K 1 >> endobj +11 0 obj << /Nums [0 [9 0 R 10 0 R]] >> endobj +trailer << /Root 1 0 R >> +%%EOF", + content.len() + ); + std::fs::write(dir.join("layout.pdf"), pdf).unwrap(); + let mut a = auto(&dir); + let fd = ok(&mut a, "doc_open", json!({ "path": "layout.pdf" }))["doc"].as_u64().unwrap(); + let table_rules = |a: &mut Automation| -> Vec { + let r = ok(a, "accessibility_check", json!({ "doc": fd, "rules": ["table-summary", "table-headers"] })); + ["table-summary", "table-headers"] + .map(|id| r["results"].as_array().unwrap().iter().find(|x| x["rule"] == id).unwrap()["status"].as_str().unwrap().to_owned()) + .to_vec() + }; + assert_eq!(table_rules(&mut a), ["failed", "failed"]); + let types = |tags: &Value| tags["tags"].as_array().unwrap().iter().map(|t| t["type"].as_str().unwrap().to_owned()).collect::>(); + let tags = ok(&mut a, "accessibility_layout_table_to_blocks", json!({ "doc": fd, "object": 7, "generation": 0 })); + assert_eq!(types(&tags), ["Document", "Div", "Div", "P", "P"]); + assert_eq!(table_rules(&mut a), ["passed", "passed"]); + let content = |a: &mut Automation, object: u32| { + ok(a, "accessibility_tag_content", json!({ "doc": fd, "object": object, "generation": 0 }))["content"][0]["mcid"].clone() + }; + assert_eq!((content(&mut a, 9), content(&mut a, 10)), (json!(0), json!(1)), "each cell keeps its content"); + // Only a table: a cell is refused. + assert!(matches!(err(&mut a, "accessibility_layout_table_to_blocks", json!({ "doc": fd, "object": 9, "generation": 0 })), ToolError::Failed(_))); + assert_eq!(ok(&mut a, "edit_undo", json!({ "doc": fd }))["undone"], "Change tag properties"); + assert_eq!(types(&ok(&mut a, "accessibility_tags", json!({ "doc": fd }))), ["Document", "Table", "TR", "TD", "TD"]); + ok(&mut a, "edit_redo", json!({ "doc": fd })); + ok(&mut a, "doc_save", json!({ "doc": fd, "path": "blocks.pdf" })); + let saved = pdfcraft_cos::Document::open(std::sync::Arc::new(std::fs::read(dir.join("blocks.pdf")).unwrap())).unwrap(); + assert_eq!(pdfcraft_engine::a11y::tags(&saved).into_iter().map(|t| t.kind).collect::>(), ["Document", "Div", "Div", "P", "P"]); +} + #[test] fn marking_a_leaf_tag_as_an_artifact_through_automation_is_undoable_and_saved() { let dir = workdir("a11y-artifact"); diff --git a/crates/engine/src/edit.rs b/crates/engine/src/edit.rs index af34efff47..b4b8e56ffd 100644 --- a/crates/engine/src/edit.rs +++ b/crates/engine/src/edit.rs @@ -397,6 +397,11 @@ pub enum Edit { MarkTagArtifact { object: pdfcraft_cos::ObjRef, }, + /// A layout table as plain blocks: the table, its row groups and rows become Div tags and its + /// cells P tags (Div when they hold tags), without their Table attributes. + LayoutTableToBlocks { + object: pdfcraft_cos::ObjRef, + }, /// Document JavaScripts: add, replace (`script`) or remove (`None`) the document-level /// script `name`. SetDocumentScript { diff --git a/crates/engine/src/edit/labels.rs b/crates/engine/src/edit/labels.rs index 879601488a..1979c86348 100644 --- a/crates/engine/src/edit/labels.rs +++ b/crates/engine/src/edit/labels.rs @@ -260,6 +260,7 @@ impl Edit { Edit::MoveTag { .. } => Label::of("Move object"), Edit::MarkDecorative { .. } => Label::of("Mark figure as decorative"), Edit::MarkTagArtifact { .. } => Label::of("Mark content as artifact"), + Edit::LayoutTableToBlocks { .. } => Label::of("Change tag properties"), Edit::AddOcrText { .. } => Label::of("Recognize text"), Edit::EnhanceScan { .. } => Label::of("Enhance scanned file"), Edit::ApplyScriptChanges { .. } => Label::of("Run JavaScript"), diff --git a/crates/engine/src/edit/run.rs b/crates/engine/src/edit/run.rs index e2b8a43e14..e584ac2413 100644 --- a/crates/engine/src/edit/run.rs +++ b/crates/engine/src/edit/run.rs @@ -144,6 +144,7 @@ pub(crate) fn check_permission(edit: &Edit, p: &pdfcraft_cos::Permissions) -> Re | Edit::MoveTag { .. } | Edit::MarkDecorative { .. } | Edit::MarkTagArtifact { .. } + | Edit::LayoutTableToBlocks { .. } | Edit::AddOcrText { .. } | Edit::EnhanceScan { .. } | Edit::SetDocumentScript { .. } @@ -799,6 +800,9 @@ pub(crate) fn run_edit(doc: &mut pdfcraft_cos::Document, edit: &Edit, cx: &mut E Edit::MarkTagArtifact { object } => { a11y::mark_tag_artifact(doc, *object).map_err(|e| EditError::Accessibility(e.to_string()))?; } + Edit::LayoutTableToBlocks { object } => { + a11y::layout_table_to_blocks(doc, *object).map_err(|e| EditError::Accessibility(e.to_string()))?; + } Edit::AddHeaderFooter { pages, settings, replace } => { let date = cx.today; pdfcraft_edit::add_header_footer(doc, pages, settings, *replace, &pdfcraft_edit::Context { date })?; diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index 951ef61b38..0991c7cf1d 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -9248,9 +9248,9 @@ tier = "P2" milestone = "M12" status = "planned" commands = [] -tools = [] -evidence = [] -notes = "" +tools = ["accessibility_layout_table_to_blocks"] +evidence = ["crates/a11y/src/layout_table.rs::a_label_and_value_table_becomes_divs_and_paragraphs", "crates/a11y/src/layout_table.rs::what_the_cells_hold_stays_and_a_table_in_a_cell_stays_a_table", "crates/a11y/src/layout_table.rs::direct_rows_and_cells_are_converted_in_place", "crates/automation/tests/it/automation.rs::a_layout_table_becomes_blocks_through_automation"] +notes = "Headless only so far: accessibility_layout_table_to_blocks turns a layout table into Div and P tags in one undoable step (cells holding tags become Div, nested tables stay, Table attributes go). The editor itself (header cells, Scope, Headers/IDs, spans) and a Tags panel command remain planned." [[feature]] id = "a11y.alt-text-workflow" From 5588db4b629b31569349fb9553084e9e796feeb9 Mon Sep 17 00:00:00 2001 From: mtlcl410 <2368091+mtlcl410@users.noreply.github.com> Date: Mon, 9 Nov 2026 18:33:17 +0800 Subject: [PATCH 28/41] M10: (n) names the page at position n, whatever its label, in page ranges and the page box (print.page-range) (#4282) --- crates/automation/src/tools.rs | 2 +- crates/print/src/lib.rs | 2 +- crates/print/src/range.rs | 28 +++++++++++---- crates/print/src/tests.rs | 48 +++++++++++++++++++++++++ crates/ui-egui/src/canvas/navigation.rs | 5 +-- crates/ui-egui/tests/it/viewer.rs | 18 ++++++++++ parity/acrobat-features.toml | 8 ++--- 7 files changed, 97 insertions(+), 14 deletions(-) diff --git a/crates/automation/src/tools.rs b/crates/automation/src/tools.rs index d33313052e..c2eed3651c 100644 --- a/crates/automation/src/tools.rs +++ b/crates/automation/src/tools.rs @@ -719,7 +719,7 @@ pub fn tools() -> Vec { t( "doc_print", "Print", - "Print with Acrobat's Print dialog options, or save the print-ready PDF. pages: a range such as \"1-3, 6, 9-\" (page labels allowed; default all); subset odd/even (by page number, as Acrobat counts them: pages 2, 7-10 with even prints 2, 8 and 10, not every second page of the range); reverse. layout: fit (default), actual, shrink, custom (scale %), multiple (per_sheet 2/4/6/9/16, which turns the sheet to suit the grid, or a custom grid of cols across by rows down, each 1-99, on the sheet as oriented (auto prints portrait); order, border, auto_rotate; cut-stack order arranges single-sided sheets for cutting into piles and stacking left to right, top to bottom, keeping sheet order within each pile; duplex must be off), booklet (booklet_subset both/front/back, binding left/right; a booklet sets two-sided itself, as Acrobat does: both flips on the short edge, front or back prints one-sided), poster (scale %, overlap pt, cut_marks). orientation auto/portrait/landscape; comments_forms document / document-and-markups (default) / document-and-stamps / form-fields-only; paper Letter/Legal/Tabloid, ARCH A/B/C/D/E1/E, ANSI C/D/E, A0/A1/A2/A3/A4/A5/A6, or US #10 Envelope (short names or full preset labels); use layout actual for 100% in the print-ready PDF; printer drivers may still substitute paper or scale. Then path (save) or printer (a name or \"default\") with copies, collate, duplex off/long-edge/short-edge, grayscale, and options (driver options from printer_options, CUPS).", + "Print with Acrobat's Print dialog options, or save the print-ready PDF. pages: a range such as \"1-3, 6, 9-\" (page labels allowed, \"(n)\" for the nth page whatever its label, unless a page is labelled exactly \"(n)\", since labels are looked up first; default all); subset odd/even (by page number, as Acrobat counts them: pages 2, 7-10 with even prints 2, 8 and 10, not every second page of the range); reverse. layout: fit (default), actual, shrink, custom (scale %), multiple (per_sheet 2/4/6/9/16, which turns the sheet to suit the grid, or a custom grid of cols across by rows down, each 1-99, on the sheet as oriented (auto prints portrait); order, border, auto_rotate; cut-stack order arranges single-sided sheets for cutting into piles and stacking left to right, top to bottom, keeping sheet order within each pile; duplex must be off), booklet (booklet_subset both/front/back, binding left/right; a booklet sets two-sided itself, as Acrobat does: both flips on the short edge, front or back prints one-sided), poster (scale %, overlap pt, cut_marks). orientation auto/portrait/landscape; comments_forms document / document-and-markups (default) / document-and-stamps / form-fields-only; paper Letter/Legal/Tabloid, ARCH A/B/C/D/E1/E, ANSI C/D/E, A0/A1/A2/A3/A4/A5/A6, or US #10 Envelope (short names or full preset labels); use layout actual for 100% in the print-ready PDF; printer drivers may still substitute paper or scale. Then path (save) or printer (a name or \"default\") with copies, collate, duplex off/long-edge/short-edge, grayscale, and options (driver options from printer_options, CUPS).", ) .with(schema( json!({ diff --git a/crates/print/src/lib.rs b/crates/print/src/lib.rs index 8fffbdf649..3564b86687 100644 --- a/crates/print/src/lib.rs +++ b/crates/print/src/lib.rs @@ -25,7 +25,7 @@ pub mod spool; #[cfg(test)] mod tests; -pub use range::{Subset, select_listed, select_pages}; +pub use range::{Subset, position, select_listed, select_pages}; mod error; mod imposition; diff --git a/crates/print/src/range.rs b/crates/print/src/range.rs index 027158df01..e68d1d31d9 100644 --- a/crates/print/src/range.rs +++ b/crates/print/src/range.rs @@ -1,4 +1,5 @@ -//! Pages to Print: all, a range ("1-3, 6, 9-", page labels allowed), odd or even pages, reverse. +//! Pages to Print: all, a range ("1-3, 6, 9-", page labels allowed, "(n)" for the page at +//! position n), odd or even pages, reverse. use std::collections::HashMap; @@ -44,24 +45,39 @@ pub enum Subset { Even, } -/// Resolve one range token: a page label, or a number (1-based). +/// Resolve one range token: a page label, a number in parentheses for the page at that position, +/// or a number (1-based). fn page_of(tok: &str, count: usize, labels: &Labels) -> Result { let t = tok.trim(); // Page labels win over numbers (Acrobat's "use logical page numbers"). if let Some(i) = labels.get(t) { return Ok(i); } + let number = parenthesized(t).unwrap_or(t); // `usize::from_str` accepts a leading '+', which is not part of a page number. - if !t.bytes().all(|b| b.is_ascii_digit()) { + if !number.bytes().all(|b| b.is_ascii_digit()) { return Err(PrintError::NotAPage(t.to_string())); } - match t.parse::() { + match number.parse::() { Ok(n) if n >= 1 && n <= count => Ok(n - 1), Ok(n) => Err(PrintError::OutOfRange(n, count)), Err(_) => Err(PrintError::NotAPage(t.to_string())), } } +/// What a token holds between one pair of parentheses, trimmed: "( 3 )" holds "3". +fn parenthesized(t: &str) -> Option<&str> { + t.trim().strip_prefix('(')?.strip_suffix(')').map(str::trim) +} + +/// The page position (1-based) a token names in parentheses. A page's label and its position +/// need not coincide (ISO 32000-2 §12.4.2): "(n)" always names the nth page, so a page can be +/// named by position where a label such as "1" belongs to another page. `None` for anything +/// else. Callers look labels up first, so a label spelled "(1)" keeps naming its own page. +pub fn position(token: &str) -> Option { + parenthesized(token).filter(|n| n.bytes().all(|b| b.is_ascii_digit()))?.parse().ok() +} + /// Split a range whose endpoint is a logical page label containing a hyphen, such /// as "A-1-A-3" or "A-2-". The plain range parser must not mistake the first /// hyphen inside the label for the delimiter between endpoints. @@ -83,8 +99,8 @@ fn hyphenated_label_range(part: &str, count: usize, labels: &Labels) -> Option<( } /// The pages to print (0-based, in print order). `range` is `None` for all pages; it may list -/// numbers and labels with `-` ranges (open-ended allowed: `5-`, `-3`). The subset keeps the odd or -/// even pages of those chosen, by page number (see [`narrow`]). +/// numbers, labels and `(n)` positions with `-` ranges (open-ended allowed: `5-`, `-3`). The subset +/// keeps the odd or even pages of those chosen, by page number (see [`narrow`]). pub fn select_pages(count: usize, range: Option<&str>, labels: &[String], subset: Subset, reverse: bool) -> Result, PrintError> { let mut pages = Vec::new(); match range.map(str::trim).filter(|r| !r.is_empty()) { diff --git a/crates/print/src/tests.rs b/crates/print/src/tests.rs index 5b8954fee9..a1026308ea 100644 --- a/crates/print/src/tests.rs +++ b/crates/print/src/tests.rs @@ -126,6 +126,54 @@ fn page_selection_handles_ranges_between_hyphenated_labels() { assert_eq!(select_pages(3, Some("A-1-B-C"), &ambiguous, Subset::All, false).unwrap(), [1, 2]); } +/// A page's label and its position need not coincide (ISO 32000-2 §12.4.2). With front matter +/// labelled i–iii and the body 1–7, "1" is the page labelled 1 (the fourth), while "(1)" is the +/// first page, whatever it is labelled; positions combine with labels and open ends in ranges. +#[test] +fn a_number_in_parentheses_names_the_page_at_that_position() { + let labels: Vec = ["i", "ii", "iii", "1", "2", "3", "4", "5", "6", "7"].iter().map(|s| s.to_string()).collect(); + let pages = |range| select_pages(10, Some(range), &labels, Subset::All, false); + assert_eq!(pages("1").unwrap(), [3], "a bare number is the label"); + assert_eq!(pages("(1)").unwrap(), [0], "in parentheses it is the position"); + assert_eq!(pages("( 2 )").unwrap(), [1]); + assert_eq!(pages("(1)-(3)").unwrap(), [0, 1, 2]); + assert_eq!(pages("(9)-").unwrap(), [8, 9]); + assert_eq!(pages("-(2)").unwrap(), [0, 1]); + assert_eq!(pages("(2)-1").unwrap(), [1, 2, 3], "a position and a label"); + assert_eq!(pages("ii-(5)").unwrap(), [1, 2, 3, 4]); + assert_eq!(pages("(4)-(2), 7").unwrap(), [3, 2, 1, 9]); + assert_eq!(select_pages(10, Some("(1)-(4)"), &labels, Subset::Even, false).unwrap(), [1, 3], "odd and even stay page numbers"); + assert_eq!(pages("(0)"), Err(PrintError::OutOfRange(0, 10))); + assert_eq!(pages("(11)"), Err(PrintError::OutOfRange(11, 10))); + for bad in ["(x)", "()", "(1", "1)", "((1))", "(+3)"] { + assert_eq!(pages(bad), Err(PrintError::NotAPage(bad.into())), "{bad}"); + } + // The page box takes positions through `position`, with the same digits-only rule. + assert_eq!(crate::position("( 3 )"), Some(3)); + assert_eq!(crate::position("(+3)"), None, "a sign is not part of a page number"); + assert!(pages("(-1)").is_err(), "a dash inside parentheses is still a range, of nothing"); + // A label that is itself "(1)" still names its own page, as every label does. + let odd: Vec = ["a", "b", "(1)"].iter().map(|s| s.to_string()).collect(); + assert_eq!(select_pages(3, Some("(1)"), &odd, Subset::All, false).unwrap(), [2]); + // Without labels a position is the same page as its number. + assert_eq!(select_pages(5, Some("(2)-4"), &[], Subset::All, false).unwrap(), [1, 2, 3]); +} + +/// Odd and even count page positions, never the numbers inside labels: page indices are fixed +/// and consecutive, while labels can restart, repeat, use letters or carry no number (ISO 32000-2 +/// §12.4.2). With front matter labelled i–iii the page labelled 1 is the fourth, so it is even, and +/// a range typed in labels keeps the same pages as the same range typed as positions. +#[test] +fn odd_and_even_count_positions_on_a_labelled_document() { + let labels: Vec = ["i", "ii", "iii", "1", "2", "3", "4", "5", "6", "7"].iter().map(|s| s.to_string()).collect(); + let pick = |range: Option<&str>, subset| select_pages(10, range, &labels, subset, false).unwrap(); + assert_eq!(pick(Some("1-4"), Subset::Even), [3, 5], "labels 1 and 3, the fourth and sixth pages"); + assert_eq!(pick(Some("1-4"), Subset::Odd), [4, 6], "labels 2 and 4, the fifth and seventh pages"); + assert_eq!(pick(Some("1-4"), Subset::Even), pick(Some("(4)-(7)"), Subset::Even), "labels or positions, the same pages"); + assert_eq!(pick(None, Subset::Odd), [0, 2, 4, 6, 8], "i, iii, 2, 4, 6"); + assert_eq!(pick(None, Subset::Even), [1, 3, 5, 7, 9]); +} + #[test] fn size_modes() { let sizes = [(200.0, 300.0), (1000.0, 1500.0)]; diff --git a/crates/ui-egui/src/canvas/navigation.rs b/crates/ui-egui/src/canvas/navigation.rs index 49dc3336c6..4e491d514c 100644 --- a/crates/ui-egui/src/canvas/navigation.rs +++ b/crates/ui-egui/src/canvas/navigation.rs @@ -240,7 +240,8 @@ impl DocView { /// Retain the current page when `typed` exactly matches its displayed label, even if /// another page has that label. Otherwise resolve a trimmed label (case-insensitive), - /// then a physical page number. `false` when it names no page. + /// then a position in parentheses ("(3)", as print ranges take it), then a physical page + /// number. `false` when it names no page. pub fn go_to_typed(&mut self, typed: &str, labels: &[String]) -> bool { // Compare the displayed value before trimming: a label may itself contain spaces. // Repeated labels occur in restarted sections and prefix-only ranges. @@ -251,7 +252,7 @@ impl DocView { let page = labels .iter() .position(|l| !l.is_empty() && l.eq_ignore_ascii_case(t)) - .or_else(|| t.parse::().ok().filter(|n| *n >= 1).map(|n| n - 1)); + .or_else(|| pdfcraft_engine::print::position(t).or_else(|| t.parse::().ok()).filter(|n| *n >= 1).map(|n| n - 1)); match page { Some(p) if p < self.page_count => { self.go_to_page(p); diff --git a/crates/ui-egui/tests/it/viewer.rs b/crates/ui-egui/tests/it/viewer.rs index 464b63a17c..fb94cc2c5b 100644 --- a/crates/ui-egui/tests/it/viewer.rs +++ b/crates/ui-egui/tests/it/viewer.rs @@ -655,6 +655,24 @@ fn page_label_input_resolves_labels_numbers_and_restores_invalid_input() { assert_eq!(h.state().views[0].current, 0, "the current fallback number must not jump to another page labelled 1"); } +/// A page's label and its position need not coincide (ISO 32000-2 §12.4.2). Typed in the page +/// box, "(n)" goes to the nth page whatever its label, as print ranges take it, while a bare +/// number is still a label first. On these pages (i, ii, 1, 2, 3) "1" is the third page and +/// "(1)" the first; a position past the end, or anything else in parentheses, leaves the page. +#[test] +fn page_label_input_takes_a_position_in_parentheses() { + let mut h = harness(); + restart_page_labels(&mut h); + h.run_steps(3); + for (typed, page, displayed) in [("(1)", 0, "i"), ("1", 2, "1"), ("(4)", 3, "2"), ("( 2 )", 1, "ii"), ("(9)", 1, "ii"), ("(x)", 1, "ii")] { + type_page_input(&mut h, typed); + h.key_press(egui::Key::Enter); + h.run_steps(3); + assert_eq!(h.state().views[0].current, page, "{typed}"); + assert_eq!(h.state().views[0].page_input, displayed, "{typed}"); + } +} + #[test] fn page_label_input_preserves_literal_whitespace_before_trimmed_lookup() { use pdfcraft_render::{DocInfo, PageInfo}; diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index 0991c7cf1d..927e32df83 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -8225,8 +8225,8 @@ milestone = "M10" status = "shipped" commands = ["print.dialog"] tools = ["doc_print"] -evidence = ["crates/print/src/tests.rs::page_selection", "crates/automation/tests/it/automation.rs::printing_through_tools"] -notes = "all, current, ranges with labels and open ends" +evidence = ["crates/print/src/tests.rs::page_selection", "crates/print/src/tests.rs::a_number_in_parentheses_names_the_page_at_that_position", "crates/automation/tests/it/automation.rs::printing_through_tools"] +notes = "all, current, ranges with labels and open ends; (n) names the nth page whatever its label (ISO 32000-2 §12.4.2: labels and page indices need not coincide), alone or as a range end; labels are looked up first, so a page labelled exactly \"(n)\" keeps that name. The same parser serves Combine files ranges. Organize Pages range selection still takes positions only, without labels or (n)." [[feature]] id = "print.odd-even-reverse" @@ -8237,8 +8237,8 @@ milestone = "M10" status = "shipped" commands = ["print.dialog"] tools = ["doc_print"] -evidence = ["crates/print/src/tests.rs::page_selection", "crates/print/src/tests.rs::odd_and_even_pages_are_page_numbers_not_places_in_the_range", "crates/automation/tests/it/automation.rs::cut_stack_printing_through_tools"] -notes = "Odd and even are page numbers, as Acrobat counts them (Adobe Acrobat DC Help, 11/28/2019, Options in the Print dialog box, p. 515: a range of 2, 7–10 with Even Pages Only prints pages 2, 8 and 10), for a typed range, picked thumbnails and the current page; Reverse turns the chosen pages round (#3984). Not yet: with page labels, odd and even still count page numbers (a document whose front matter is numbered i, ii, iii… keeps label 2 among the odd pages when it is page 5); the Acrobat help consulted doesn't say how Acrobat counts there, and the other Print-dialog behaviours #3984 mentions are open." +evidence = ["crates/print/src/tests.rs::page_selection", "crates/print/src/tests.rs::odd_and_even_pages_are_page_numbers_not_places_in_the_range", "crates/print/src/tests.rs::odd_and_even_count_positions_on_a_labelled_document", "crates/automation/tests/it/automation.rs::cut_stack_printing_through_tools"] +notes = "Odd and even are page numbers, as Acrobat counts them (Adobe Acrobat DC Help, 11/28/2019, Options in the Print dialog box, p. 515: a range of 2, 7–10 with Even Pages Only prints pages 2, 8 and 10), for a typed range, picked thumbnails and the current page; Reverse turns the chosen pages round (#3984). With page labels, odd and even count page positions, not the numbers in the labels: page indices are fixed and consecutive while labels can restart, repeat, use letters or carry no number at all (ISO 32000-2 §12.4.2), so with front matter labelled i–iii the page labelled 1 (the fourth) is even. The other Print-dialog behaviours #3984 mentions are open." [[feature]] id = "print.scaling" From ba2846cba800926c63a64996f089c3fc4f5255ec Mon Sep 17 00:00:00 2001 From: Livio Oswald <125505497+Lcoding15@users.noreply.github.com> Date: Mon, 9 Nov 2026 11:33:33 +0100 Subject: [PATCH 29/41] M4: a recovered document's first Save asks where instead of writing the sidecar's path (#3923) (#4327) --- ROADMAP.md | 1 + crates/ui-egui/src/app.rs | 1 + crates/ui-egui/src/editing/save.rs | 13 ++- crates/ui-egui/src/lib.rs | 3 + crates/ui-egui/src/persist/recovery.rs | 153 +++++++++++++++++++++++-- crates/ui-egui/tests/it/recovery.rs | 2 +- parity/acrobat-features.toml | 4 +- 7 files changed, 161 insertions(+), 16 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 85f06fcd1e..1fb7459f49 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -313,6 +313,7 @@ Ranked; detail and the full milestone table in [docs/roadmap.md](docs/roadmap.md - **2026-10-23 (M11, JBIG2):** The PDF Optimizer can store black-and-white images as lossless JBIG2 (a generic region, written from the JBIG2 draft standard and checked against its arithmetic-coder test sequence), about a third smaller than Group 4 on 300 dpi text, and Export ▸ Images reads JBIG2 images. JBIG2 symbol coding, where its largest gains on text are, is next. - **2026-10-23 (M11, monochrome compression):** The PDF Optimizer and Reduce File Size store black-and-white images (scans, image masks) losslessly as CCITT Group 4 when that is smaller, with a Monochrome Images setting in the Optimizer and `doc_optimize` `mono`. JBIG2 and monochrome downsampling are next. - **2026-10-23 (M6, calculation checks):** The calculation order now flags a formula that reads a field the form doesn't have (a field deleted from under a sum, a typo in notation, a script's `getField`) and a script that builds the names it reads at run time. Prepare a form ▸ Calculation order marks both on the row and in a card that names the broken formulas, in place of its all-clear. `form_calculation_order` gives each field's formula, and `form_fields` shows a custom Calculate script. +- **2026-10-23 (M4, #3923):** A recovered document's original path is only a suggestion: the first Save after recovering opens Save As at that path instead of writing it, since anyone who can write the recovery folder (a shared portable `PdfCraftData`, for example) can name any file in a sidecar. The file chosen is the document's file from then on. Sidecars are read through a 64 KiB cap; a longer one is not read, and its snapshot recovers without metadata. Estimates unchanged. - **2026-10-23 (M7, #4280):** Edit text names each standard stand-in font after its face (`PCEdTimesBold`, ...) instead of one shared `PCEdHelv`. Rewriting a bold paragraph whose subset lacks an accent no longer repoints the paragraphs set earlier in a regular stand-in at Times-Bold, and the editor's frame stays on the paragraph being edited. Pages saved by older builds keep their shared name and are not repaired. Estimates unchanged. - **2026-10-23 (M8, #3205 follow-up):** Phone-pattern redaction matches complete labelled Italian geographic numbers such as `Tel. +39 0426/123456`, with synthetic matcher and mark/apply/save/reopen coverage; unlabelled international slash numbers remain unsupported. - **2026-10-23 (M7, #2636 partial):** A new `PasteContent` edit pastes an added text box or image copied in the same document or another, keeping its style, picture, turns, flips and crop, as one undo step; `content_copy` drives it headlessly. A paste lands beside earlier copies (18 pt steps) and stays on the page. A pasted image reuses its picture object in its own document and after the first paste into another; there a colour profile is dropped and CMYK colours stored losslessly are turned to RGB. The desktop keys and menus follow in separate PRs. Estimates unchanged. diff --git a/crates/ui-egui/src/app.rs b/crates/ui-egui/src/app.rs index 010b7f7bd7..fd6d2dcbc6 100644 --- a/crates/ui-egui/src/app.rs +++ b/crates/ui-egui/src/app.rs @@ -279,6 +279,7 @@ impl PdfCraftApp { recovery: None, recoverable: Vec::new(), recovery_keys: Default::default(), + recovered_paths: Default::default(), last_autosave: None, allow_quit: false, deferred_commands: Vec::new(), diff --git a/crates/ui-egui/src/editing/save.rs b/crates/ui-egui/src/editing/save.rs index ffb533b243..35867559f6 100644 --- a/crates/ui-egui/src/editing/save.rs +++ b/crates/ui-egui/src/editing/save.rs @@ -57,7 +57,9 @@ impl PdfCraftApp { (_, _, Some(p)) => p.clone(), // An email attachment's temporary copy is no place to keep work: ask, as for a new // document (`is_mail_temp_copy`). - (SaveTarget::InPlace, Some(p), _) if !is_mail_temp_copy(&p) => p, + // Neither is a path read back from a recovery sidecar until a save confirms it: + // anyone who can write the recovery folder can name any file there (#3923). + (SaveTarget::InPlace, Some(p), _) if !is_mail_temp_copy(&p) && !self.recovered_paths.contains(&id) => p, _ => { let name = if name.to_ascii_lowercase().ends_with(".pdf") { name } else { format!("{name}.pdf") }; let dialog = rfd::AsyncFileDialog::new().add_filter_any_case("PDF", &["pdf"]).set_file_name(name); @@ -164,11 +166,12 @@ impl PdfCraftApp { if !self.commit_typing_in(id) || !self.flatten_fill_sign_for_save(id) { return false; } + let recovered = self.recovered_paths.contains(&id); let Some((name, full)) = self.session.get(id).map(|d| { - // Save As to another file is a clean copy, without the earlier revisions that still - // hold deleted pages or replaced text (#2165). A signed document stays incremental so - // its signatures keep covering the file. - let full = target == SaveTarget::As + // Save As to another file and a recovered document are clean copies, without earlier + // revisions that may retain deleted pages or replaced text (#2165, #3923). A signed + // document stays incremental so its signatures keep covering the file. + let full = (target == SaveTarget::As || recovered) && !d.path.as_deref().is_some_and(|p| crate::same_file(p, dest)) && d.read_only_reason.is_none() && !d.is_signed(); diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 0d527d5ef5..0e47d4baff 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -680,6 +680,9 @@ pub struct PdfCraftApp { /// Entries left by a previous session, offered in the Recovery dialog. pub recoverable: Vec, recovery_keys: std::collections::HashMap, + /// Recovered documents whose path came from a recovery sidecar and no save has confirmed + /// yet: their first Save asks where, starting at that path, instead of writing it (#3923). + recovered_paths: std::collections::HashSet, last_autosave: Option, allow_quit: bool, /// Shortcuts pressed while a text field had the keyboard, run on the next frame (see diff --git a/crates/ui-egui/src/persist/recovery.rs b/crates/ui-egui/src/persist/recovery.rs index 34b6bdf76a..aa10cceb19 100644 --- a/crates/ui-egui/src/persist/recovery.rs +++ b/crates/ui-egui/src/persist/recovery.rs @@ -5,6 +5,9 @@ //! `.pdf` plus `.json` (name, original path, time). Saving, discarding or closing a //! document removes its entry. At startup the app offers snapshots even when their metadata is //! missing or damaged; those recover without an original path, so saving asks for a destination. +//! A recovered document's original path is only a suggestion: the folder is the user's, but +//! anyone who can write it can name any file in a sidecar, so the first Save after recovering +//! asks where, starting at that path, and later saves write where the user chose (#3923). //! //! Encrypted documents are stored encrypted (the working file is), so recovery never writes //! plaintext of a protected document to disk. The web build has no recovery store yet. @@ -32,7 +35,8 @@ pub const AUTOSAVE_SECS: f64 = 60.0; pub struct RecoveryMeta { pub key: String, pub name: String, - /// Where the document was saved before (Save writes back there after recovery). + /// Where the document was saved before. After recovery the first Save offers it in the + /// Save As dialog rather than writing it unasked (#3923). pub path: Option, /// Seconds since the Unix epoch when the snapshot was taken. pub saved_at: u64, @@ -230,8 +234,7 @@ impl RecoveryStore { continue; } let Some(key) = path.file_stem().and_then(std::ffi::OsStr::to_str) else { continue }; - let meta = std::fs::read(path.with_extension("json")) - .and_then(|b| serde_json::from_slice::(&b).map_err(std::io::Error::other)) + let meta = read_meta(&path.with_extension("json")) .and_then(|m| if m.key == key { Ok(m) } else { Err(std::io::Error::new(std::io::ErrorKind::InvalidData, "snapshot key mismatch")) }); out.push(meta.unwrap_or_else(|e| { log::warn!("recovering snapshot without metadata: {}: {e}", path.display()); @@ -261,7 +264,7 @@ impl RecoveryStore { /// recovered without metadata), so damaged metadata doesn't stay behind. The PDF is untouched. pub fn repair_meta(&self, meta: &RecoveryMeta) { let path = self.dir.join(format!("{}.json", meta.key)); - let readable = std::fs::read(&path).ok().and_then(|b| serde_json::from_slice::(&b).ok()).is_some_and(|m| m.key == meta.key); + let readable = read_meta(&path).is_ok_and(|m| m.key == meta.key); if readable { return; } @@ -273,8 +276,25 @@ impl RecoveryStore { } } + /// + /// Only a regular file is read, and no more than it held when it was opened: anyone who can + /// write the recovery folder can put a FIFO there, which would hold the read forever, or a + /// link to a device such as `/dev/zero`, which never ends (#3923). Snapshots have no size + /// limit of their own, as autosave writes whatever the document holds. pub fn read(&self, key: &str) -> std::io::Result> { - std::fs::read(self.dir.join(format!("{key}.pdf"))) + use std::io::Read; + let path = self.dir.join(format!("{key}.pdf")); + let meta = std::fs::metadata(&path)?; + if !meta.is_file() { + return Err(std::io::Error::new(std::io::ErrorKind::InvalidInput, "the recovery snapshot is not a regular file")); + } + let len = meta.len(); + let mut bytes = Vec::new(); + File::open(&path)?.take(len.saturating_add(1)).read_to_end(&mut bytes)?; + if bytes.len() as u64 > len { + return Err(std::io::Error::new(std::io::ErrorKind::InvalidData, "the recovery snapshot grew while it was read")); + } + Ok(bytes) } /// Remove every entry (clean quit, or "Discard all"), and metadata left without a snapshot. @@ -296,6 +316,22 @@ impl RecoveryStore { } } +/// The most a sidecar may hold. One PdfCraft writes is a name, a path and a few fields, well under +/// this; anything longer is not one of ours (#3923). +const MAX_META_BYTES: u64 = 64 * 1024; + +/// A sidecar's metadata, read through [`MAX_META_BYTES`] so a planted file can't make the start-up +/// listing read gigabytes. A longer file is refused, and its snapshot recovers without metadata. +fn read_meta(path: &Path) -> std::io::Result { + use std::io::Read; + let mut bytes = Vec::new(); + File::open(path)?.take(MAX_META_BYTES.saturating_add(1)).read_to_end(&mut bytes)?; + if bytes.len() as u64 > MAX_META_BYTES { + return Err(std::io::Error::new(std::io::ErrorKind::InvalidData, "recovery metadata is too large")); + } + serde_json::from_slice(&bytes).map_err(std::io::Error::other) +} + fn now_secs() -> u64 { std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map(|d| d.as_secs()).unwrap_or(0) } @@ -329,6 +365,9 @@ impl PdfCraftApp { /// Drop a document's recovery entry (it was saved, discarded or closed). pub(crate) fn forget_recovery(&mut self, doc: pdfcraft_engine::DocId) { + // Saved (to a destination the user chose), closed or reloaded from its file: the path is + // no longer only what the sidecar said. + self.recovered_paths.remove(&doc); if let (Some(store), Some(key)) = (&self.recovery, self.recovery_keys.remove(&doc)) { store.remove(&key); } @@ -422,6 +461,9 @@ impl PdfCraftApp { pub(crate) fn finish_recovery(&mut self, meta: &RecoveryMeta) { let Some(id) = self.views.last().map(|v| v.id) else { return }; self.session.mark_recovered(id, meta.path.clone()); + if meta.path.is_some() { + self.recovered_paths.insert(id); + } self.recovery_keys.insert(id, meta.key.clone()); // A snapshot recovered without readable metadata gets a fresh sidecar now, so the // damaged one doesn't linger until the next autosave (or forever, if none comes). @@ -462,13 +504,13 @@ mod idle_tests { use super::*; use std::time::Duration; - struct TestDir(PathBuf); + pub(super) struct TestDir(PathBuf); impl TestDir { /// A new folder of this test's own. The tests run on parallel threads of one process, and /// two can read the same clock time (it moves in microseconds on macOS), so a counter /// tells their folders apart; the time keeps runs of the process apart. - fn new() -> Self { + pub(super) fn new() -> Self { static NEXT: AtomicU64 = AtomicU64::new(0); let n = NEXT.fetch_add(1, Ordering::Relaxed); let nonce = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_nanos(); @@ -478,7 +520,7 @@ mod idle_tests { } /// The folder. - fn path(&self) -> &Path { + pub(super) fn path(&self) -> &Path { &self.0 } } @@ -519,6 +561,31 @@ mod idle_tests { out.viewport_output[&egui::ViewportId::ROOT].repaint_delay } + /// #3923 (review on #4327): a snapshot that is not a regular file is refused before it is + /// opened. A FIFO in the recovery folder would otherwise hold the read, and the recovery with + /// it, until something wrote to it. + #[cfg(unix)] + #[test] + fn a_snapshot_that_is_not_a_regular_file_is_refused_at_once() { + let dir = TestDir::new(); + let store = RecoveryStore::new(dir.path()); + let fifo = dir.path().join("planted.pdf"); + match std::process::Command::new("mkfifo").arg(&fifo).status() { + Ok(s) if s.success() => {} + _ => return, // No mkfifo here: nothing to plant. + } + let (tx, rx) = std::sync::mpsc::channel(); + let reader = store.clone(); + std::thread::spawn(move || { + let _ = tx.send(reader.read("planted").map(|b| b.len())); + }); + let read = rx.recv_timeout(Duration::from_secs(5)).expect("the read returned instead of waiting on the FIFO"); + assert_eq!(read.map_err(|e| e.kind()), Err(std::io::ErrorKind::InvalidInput)); + // A directory under the name is refused the same way. + std::fs::create_dir(dir.path().join("folder.pdf")).unwrap(); + assert_eq!(store.read("folder").map_err(|e| e.kind()), Err(std::io::ErrorKind::InvalidInput)); + } + /// Check that an idle dirty document schedules a bounded write and autosaves without input, then stops /// waking the window. #[test] @@ -624,3 +691,73 @@ mod idle_tests { } } } + +#[cfg(test)] +mod sidecar_tests { + use super::idle_tests::TestDir; + use super::*; + use crate::SaveTarget; + + /// #3923: a sidecar names a file the user can write (here, one that is not the document's). + /// After Recover, Save asks where instead of writing that file; the file the user picks is + /// the document's file from then on. + #[test] + fn the_first_save_after_recovering_asks_instead_of_writing_the_sidecars_path() { + let dir = TestDir::new(); + let store = RecoveryStore::new(dir.path().join("recovery")); + let named = dir.path().join("named.pdf"); + std::fs::write(&named, b"someone else's file").unwrap(); + { + let mut crashed = PdfCraftApp::new(); + crashed.enable_recovery(store.clone()); + let bytes = crashed.session.create_blank(200.0, 300.0, 1).unwrap(); + crashed.open_bytes("work.pdf", Some(named.to_string_lossy().into_owned()), bytes.to_vec()).unwrap(); + assert!(crashed.apply_edit(pdfcraft_engine::Edit::RotatePages { pages: vec![0], degrees: 90 })); + crashed.autosave_now(); + } + + let mut app = PdfCraftApp::new(); + app.set_option("language", "en").unwrap(); + app.enable_recovery(store.clone()); + let keys: Vec = app.recoverable.iter().map(|m| m.key.clone()).collect(); + assert_eq!(keys.len(), 1); + app.recover(&keys); + assert_eq!(app.views.len(), 1); + let chosen = dir.path().join("chosen.pdf"); + app.pick_override = Some(vec![chosen.to_string_lossy().into_owned()]); + + assert!(!app.save_active(SaveTarget::InPlace), "Save asks instead of writing the sidecar's path"); + app.process_picked(); + assert_eq!(std::fs::read(&named).unwrap(), b"someone else's file", "the named file is untouched"); + assert!(std::fs::read(&chosen).unwrap().starts_with(b"%PDF"), "the document went where the user chose"); + + // The chosen file is the document's file now: the next Save writes it without asking. + app.pick_override = None; + assert!(app.apply_edit(pdfcraft_engine::Edit::RotatePages { pages: vec![0], degrees: 90 })); + assert!(app.save_active(SaveTarget::InPlace)); + } + + /// #3923: a sidecar longer than any PdfCraft writes is not read whole; its snapshot is + /// offered without metadata, so it has no path to save to. + #[test] + fn an_oversized_sidecar_is_not_read() { + let dir = TestDir::new(); + let store = RecoveryStore::new(dir.path()); + let meta = RecoveryMeta { + key: "planted".into(), + // Over 64 KiB, and still valid metadata that names a file. + name: "x".repeat(70_000), + path: Some(dir.path().join("named.pdf").to_string_lossy().into_owned()), + saved_at: 1, + encrypted: false, + instance: None, + }; + store.write(&meta, b"%PDF-1.7\n").unwrap(); + let listed = store.list(); + assert_eq!(listed.len(), 1); + assert_eq!((listed[0].name.as_str(), listed[0].path.as_deref()), ("planted.pdf", None)); + // A sidecar of ordinary size is still read. + store.write(&RecoveryMeta { name: "work.pdf".into(), ..meta.clone() }, b"%PDF-1.7\n").unwrap(); + assert_eq!(store.list()[0].path, meta.path); + } +} diff --git a/crates/ui-egui/tests/it/recovery.rs b/crates/ui-egui/tests/it/recovery.rs index 93ea045b6e..57500b310c 100644 --- a/crates/ui-egui/tests/it/recovery.rs +++ b/crates/ui-egui/tests/it/recovery.rs @@ -90,7 +90,7 @@ fn a_crashed_session_is_offered_and_recovered_unsaved() { assert_eq!(app.views.len(), 1); let doc = app.session.get(app.views[0].id).unwrap(); assert!(doc.dirty, "recovered changes are unsaved"); - assert_eq!(doc.path.as_deref(), Some("/docs/work.pdf"), "Save goes back to the original file"); + assert_eq!(doc.path.as_deref(), Some("/docs/work.pdf"), "the original path is kept, as what Save offers (#3923)"); assert_eq!(doc.info.pages.len(), 2, "the edit survived the crash"); assert_eq!(s.list().len(), 1, "the entry stays until the document is saved or closed"); } diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index 927e32df83..3c17a3ed70 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -550,8 +550,8 @@ milestone = "M4" status = "shipped" commands = [] tools = [] -evidence = ["crates/ui-egui/tests/it/recovery.rs::a_crashed_session_is_offered_and_recovered_unsaved", "crates/ui-egui/tests/it/recovery.rs::discarding_removes_the_autosaves", "crates/ui-egui/tests/it/recovery.rs::a_running_instances_autosaves_are_not_offered_to_a_second_instance", "crates/engine/src/tests.rs::recovered_documents_reopen_unsaved_at_their_original_path"] -notes = "A running instance holds an exclusively locked instance file in the recovery folder and names it in its snapshots, so a second instance neither offers nor discards what the first is autosaving (#2166); the OS releases the lock when the process dies. Where the folder can't be written or the platform has no file locks, snapshots are offered as before." +evidence = ["crates/ui-egui/tests/it/recovery.rs::a_crashed_session_is_offered_and_recovered_unsaved", "crates/ui-egui/tests/it/recovery.rs::discarding_removes_the_autosaves", "crates/ui-egui/tests/it/recovery.rs::a_running_instances_autosaves_are_not_offered_to_a_second_instance", "crates/engine/src/tests.rs::recovered_documents_reopen_unsaved_at_their_original_path", "crates/ui-egui/src/persist/recovery.rs::the_first_save_after_recovering_asks_instead_of_writing_the_sidecars_path", "crates/ui-egui/src/persist/recovery.rs::an_oversized_sidecar_is_not_read", "crates/ui-egui/src/persist/recovery.rs::a_snapshot_that_is_not_a_regular_file_is_refused_at_once"] +notes = "A recovered document keeps its original path only as a suggestion: the first Save asks where, starting there, and the file chosen is its file from then on, since anyone who can write the recovery folder can name any file in a sidecar (#3923); sidecars longer than 64 KiB are not read, and their snapshot is offered without metadata. A running instance holds an exclusively locked instance file in the recovery folder and names it in its snapshots, so a second instance neither offers nor discards what the first is autosaving (#2166); the OS releases the lock when the process dies. Where the folder can't be written or the platform has no file locks, snapshots are offered as before." [[feature]] id = "core.encrypted-autosave" From e78102c90711d31b0e3e666697e67cbda0f5e18f Mon Sep 17 00:00:00 2001 From: ".CooCoo" Date: Mon, 9 Nov 2026 02:35:01 -0800 Subject: [PATCH 30/41] M11: PDF Optimizer can discard form submission, import and reset actions (optimize.discard-form-actions) (#4416) --- crates/automation/src/documents.rs | 3 + crates/automation/src/tools.rs | 3 +- crates/automation/tests/it/automation.rs | 26 ++ crates/optimize/README.md | 5 +- crates/optimize/src/form_actions.rs | 468 ++++++++++++++++++++ crates/optimize/src/lib.rs | 13 +- crates/optimize/tests/optimize.rs | 341 ++++++++++++++ crates/ui-egui/src/workflows/optimize_ui.rs | 1 + parity/acrobat-features.toml | 10 +- 9 files changed, 862 insertions(+), 8 deletions(-) create mode 100644 crates/optimize/src/form_actions.rs diff --git a/crates/automation/src/documents.rs b/crates/automation/src/documents.rs index 8741b2829f..823bc078c0 100644 --- a/crates/automation/src/documents.rs +++ b/crates/automation/src/documents.rs @@ -361,6 +361,7 @@ impl Automation { ("discard_alternate_images", &mut settings.discard_alternate_images), ("discard_tags", &mut settings.discard_tags), ("discard_print_settings", &mut settings.discard_print_settings), + ("discard_form_actions", &mut settings.discard_form_actions), ("flate_unencoded", &mut settings.flate_unencoded), ("object_streams", &mut settings.object_streams), ("lzw_to_flate", &mut settings.lzw_to_flate), @@ -471,6 +472,8 @@ impl Automation { "thumbnails": o.thumbnails, "alternate_images": o.alternate_images, "tags_removed": o.tags_removed, + "print_settings": o.print_settings, + "form_actions": o.form_actions, "streams_compressed": o.streams_compressed, "lzw_streams": o.lzw_streams, "invalid_links": o.invalid_links, diff --git a/crates/automation/src/tools.rs b/crates/automation/src/tools.rs index c2eed3651c..27ce2d8b7c 100644 --- a/crates/automation/src/tools.rs +++ b/crates/automation/src/tools.rs @@ -1678,7 +1678,7 @@ pub fn tools() -> Vec { .with(schema(json!({ "doc": doc() }), &["doc"])), t("doc_open_revision", "Open a revision", "Open saved revision `revision` (1 = the oldest) of a document as a new, unsaved document, to see the file as it was then.") .with(schema(json!({ "doc": doc(), "revision": { "type": "integer", "minimum": 1 } }), &["doc", "revision"])), - t("doc_optimize", "Optimize PDF", "Write an optimized copy to `path` (Acrobat's PDF Optimizer). preset: standard (default; Reduce File Size's choices) or mobile (images to 96 ppi above 144, JPEG high quality); the options below change it. compatibility: retain (default; keeps the document's version, or 1.5 when object streams are written), 1.4, 1.5, 1.6, 1.7 or 2.0. merge_fragments (default true) first puts a page picture drawn as many stacked image strips (as some PDF printers write it) back together into one image, so it can be resampled (merged_images, fragments_merged). color / gray: { downsample, ppi, above_ppi, compression: jpeg|zip|retain, quality 1–100 } (defaults: downsample to 150 ppi above 225, JPEG 60). mono: { downsample, ppi, above_ppi, compression: ccitt_g4|jbig2|retain } for black-and-white images and image masks (default: averaged down to 300 ppi above 450 ppi, counted in images_resampled; stored losslessly as CCITT Group 4 or JBIG2 (or 1-bit Flate, once resampled or packed from more bits) when that is smaller, up to 16 JBIG2 images sharing one symbol dictionary (/JBIG2Globals) where that is smaller still (image masks and DeviceGray images named directly, of at most 64 Mi pixels, which redaction can store again without it), Group 3 fax images, JBIG2 images, black-and-white RGB and indexed images using only palette entries 0 and 1 among them, counted in images_recompressed; retain resamples only, a CCITT image staying CCITT, a JBIG2 image JBIG2 and any other becoming Flate). Images past what one run may decode stay as they are, counted in images_refused; streams it only stores with Flate past that stay LZW, uncounted. Images are measured where pages draw them and replaced only when smaller. discard_*: thumbnails (default true), alternate_images (true), tags, print_settings; flate_unencoded (true); object_streams (default true; always off for 1.4); lzw_to_flate (true) decodes LZW streams and stores them with Flate instead, leaving any it cannot decode unchanged (lzw_streams counts the streams converted); remove_invalid_links and remove_unreferenced_dests (true); images and forms the pages list but never draw are always dropped (unused_xobjects). discard: Remove Hidden Information categories (metadata, attachments, comments, form-fields, hidden-text, hidden-layers, bookmarks, links-actions-scripts, private-data). Signed documents are refused. target_bytes (optional): if the copy is larger, retry with up to 5 progressively stronger image settings (downsample to at most 110, 96, 72, 54, 36 ppi, applied to colour and grey images; images set to JPEG also get JPEG quality at most 45, 35, 28, 20, 12, while zip and retain images keep their encoding and only get the lower ppi) and stop at the first copy that fits; reports target_reached, attempts and target_settings, and writes the smallest copy with target_reached false when none fits. Without it nothing changes. The open document is unchanged.") + t("doc_optimize", "Optimize PDF", "Write an optimized copy to `path` (Acrobat's PDF Optimizer). preset: standard (default; Reduce File Size's choices) or mobile (images to 96 ppi above 144, JPEG high quality); the options below change it. compatibility: retain (default; keeps the document's version, or 1.5 when object streams are written), 1.4, 1.5, 1.6, 1.7 or 2.0. merge_fragments (default true) first puts a page picture drawn as many stacked image strips (as some PDF printers write it) back together into one image, so it can be resampled (merged_images, fragments_merged). color / gray: { downsample, ppi, above_ppi, compression: jpeg|zip|retain, quality 1–100 } (defaults: downsample to 150 ppi above 225, JPEG 60). mono: { downsample, ppi, above_ppi, compression: ccitt_g4|jbig2|retain } for black-and-white images and image masks (default: averaged down to 300 ppi above 450 ppi, counted in images_resampled; stored losslessly as CCITT Group 4 or JBIG2 (or 1-bit Flate, once resampled or packed from more bits) when that is smaller, up to 16 JBIG2 images sharing one symbol dictionary (/JBIG2Globals) where that is smaller still (image masks and DeviceGray images named directly, of at most 64 Mi pixels, which redaction can store again without it), Group 3 fax images, JBIG2 images, black-and-white RGB and indexed images using only palette entries 0 and 1 among them, counted in images_recompressed; retain resamples only, a CCITT image staying CCITT, a JBIG2 image JBIG2 and any other becoming Flate). Images past what one run may decode stay as they are, counted in images_refused; streams it only stores with Flate past that stay LZW, uncounted. Images are measured where pages draw them and replaced only when smaller. discard_*: thumbnails (default true), alternate_images (true), tags, print_settings (entries removed: print_settings), form_actions (submit, import and reset actions removed: form_actions; the fields and links stay, and the actions chained after one still run); flate_unencoded (true); object_streams (default true; always off for 1.4); lzw_to_flate (true) decodes LZW streams and stores them with Flate instead, leaving any it cannot decode unchanged (lzw_streams counts the streams converted); remove_invalid_links and remove_unreferenced_dests (true); images and forms the pages list but never draw are always dropped (unused_xobjects). discard: Remove Hidden Information categories (metadata, attachments, comments, form-fields, hidden-text, hidden-layers, bookmarks, links-actions-scripts, private-data). Signed documents are refused. target_bytes (optional): if the copy is larger, retry with up to 5 progressively stronger image settings (downsample to at most 110, 96, 72, 54, 36 ppi, applied to colour and grey images; images set to JPEG also get JPEG quality at most 45, 35, 28, 20, 12, while zip and retain images keep their encoding and only get the lower ppi) and stop at the first copy that fits; reports target_reached, attempts and target_settings, and writes the smallest copy with target_reached false when none fits. Without it nothing changes. The open document is unchanged.") .cmd("optimize.advanced") .with(schema( json!({ @@ -1695,6 +1695,7 @@ pub fn tools() -> Vec { "discard_alternate_images": { "type": "boolean" }, "discard_tags": { "type": "boolean" }, "discard_print_settings": { "type": "boolean" }, + "discard_form_actions": { "type": "boolean" }, "flate_unencoded": { "type": "boolean" }, "object_streams": { "type": "boolean" }, "lzw_to_flate": { "type": "boolean" }, diff --git a/crates/automation/tests/it/automation.rs b/crates/automation/tests/it/automation.rs index ac6b2374e5..e209d3102a 100644 --- a/crates/automation/tests/it/automation.rs +++ b/crates/automation/tests/it/automation.rs @@ -7710,6 +7710,32 @@ fn optimizing_through_tools() { assert!(matches!(a.call("doc_optimize", &json!({ "doc": doc, "path": "x.pdf", "discard": ["everything"] })), Err(ToolError::InvalidArgs(_)))); } +#[test] +fn optimizing_discards_form_actions_through_tools() { + let dir = workdir("optimize-form-actions"); + let mut a = auto(&dir); + let doc = ok(&mut a, "doc_open", json!({ "path": "a.pdf" }))["doc"].as_u64().unwrap(); + ok(&mut a, "form_add_field", json!({ "doc": doc, "page": 1, "type": "button", "rect": [20, 20, 120, 42], "name": "Send" })); + ok( + &mut a, + "form_set_actions", + json!({ "doc": doc, "field": "Send", "actions": [ + { "trigger": "mouse_up", "submit": "https://example.com/submit" }, + { "trigger": "mouse_down", "reset": [] }, + { "trigger": "mouse_enter", "javascript": "app.alert(1);" } + ] }), + ); + let kept = ok(&mut a, "doc_optimize", json!({ "doc": doc, "path": "kept.pdf" })); + assert_eq!(kept["form_actions"], 0, "{kept}"); + let r = ok(&mut a, "doc_optimize", json!({ "doc": doc, "path": "small.pdf", "discard_form_actions": true })); + assert_eq!(r["form_actions"], 2, "{r}"); + let small = ok(&mut a, "doc_open", json!({ "path": "small.pdf" }))["doc"].as_u64().unwrap(); + let fields = ok(&mut a, "form_fields", json!({ "doc": small })); + assert!(fields.to_string().contains("Send"), "the button stays: {fields}"); + let actions = ok(&mut a, "form_actions", json!({ "doc": small, "field": "Send" })); + assert_eq!(actions["actions"], json!([{ "trigger": "mouse_enter", "javascript": "app.alert(1);" }]), "{actions}"); +} + /// #3227 review: retaining the version, a catalog `/Version` below the 1.5 an object stream /// needs is not what viewers read: the header is raised, and the later of the two is reported. #[test] diff --git a/crates/optimize/README.md b/crates/optimize/README.md index 4c0549b1c8..1a571452ec 100644 --- a/crates/optimize/README.md +++ b/crates/optimize/README.md @@ -127,7 +127,10 @@ let report = optimize_with_progress(&mut doc, &settings, &mut |stage| keep_going its priority. The document only changes in order on one thread, and what a picture costs is paid in that order, so the file written, and the pictures the run's decoding allowance refuses, are the same with any number. -- **Discard objects:** thumbnails, alternate images, document tags, print settings. +- **Discard objects:** thumbnails, alternate images, document tags, print settings, and form + submission, import and reset actions (`/SubmitForm`, `/ImportData`, `/ResetForm`, off by + default). The fields, links, bookmarks and pages that ran a removed action keep their other + actions, and the actions chained after it through `/Next` still run in order. - **Clean up:** Flate for streams with no filter; for streams with an outer ASCII85 or ASCIIHex layer, that layer is removed when the result is smaller (the inner filters keep their encoded bytes; streams with `/DecodeParms`, images and object streams keep their ASCII layer. LZW-compressed diff --git a/crates/optimize/src/form_actions.rs b/crates/optimize/src/form_actions.rs new file mode 100644 index 0000000000..fbcb24a2fe --- /dev/null +++ b/crates/optimize/src/form_actions.rs @@ -0,0 +1,468 @@ +//! Discard Objects: form submission, import and reset actions (`/SubmitForm`, `/ImportData`, +//! `/ResetForm`). The fields, buttons, links, bookmarks and pages that ran them stay; a removed +//! action's `/Next` actions take its place, so the rest of a chain still runs in order. + +use std::collections::{HashMap, HashSet}; + +use pdfcraft_cos::{Dict, Document, ObjRef, Object}; + +use crate::OptimizeError; + +const FORM_ACTIONS: [&[u8]; 3] = [b"SubmitForm", b"ImportData", b"ResetForm"]; + +/// Keys whose value is one action (or, for `/OpenAction`, an action or a destination). +const ACTION_KEYS: [&[u8]; 2] = [b"A", b"OpenAction"]; + +/// How deep direct objects and action chains are followed. +const MAX_DEPTH: usize = 64; + +/// How many action values one object may make the walk look at. +const MAX_STEPS: usize = 100_000; + +/// How many action values the walk may look at in the whole document. +const MAX_TOTAL_STEPS: usize = 1_000_000; + +/// Remove every form submission, import and reset action. Returns how many the document held in +/// action entries, each counted once: an indirect action however many entries refer to it, and a +/// direct one in the object that holds it. Fails with [`OptimizeError::ActionLimit`] when an +/// object holds a form action more than [`MAX_DEPTH`] direct objects deep, chains form actions +/// more than [`MAX_DEPTH`] deep, or makes the walk look at more than [`MAX_STEPS`] action values, +/// or when the walk has looked at more than [`MAX_TOTAL_STEPS`] action values in the document. +pub(crate) fn discard(doc: &mut Document) -> Result { + let numbers = doc.object_numbers(); + let mut count = Count::default(); + for &num in &numbers { + let r = ObjRef { num, generation: doc.generation(num) }; + if let o @ (Object::Dict(_) | Object::Array(_)) = &*doc.get(r) { + count.object(doc, o, 0); + } + } + let mut total = 0usize; + for num in numbers { + let r = ObjRef { num, generation: doc.generation(num) }; + let mut obj = match &*doc.get(r) { + o @ (Object::Dict(_) | Object::Array(_)) => o.clone(), + _ => continue, + }; + let limit = MAX_STEPS.min(MAX_TOTAL_STEPS - total); + let mut walk = + Walk { this: r, changed: false, steps: 0, limit, exhausted: false, chain: HashSet::new(), looped: false, writes: HashMap::new() }; + walk.clean(doc, &mut obj, 0); + total += walk.steps.min(limit); + if walk.exhausted { + return Err(OptimizeError::ActionLimit(r)); + } + if walk.changed { + doc.set(r, obj); + } + for (shared, cleaned) in walk.writes { + doc.set(shared, cleaned); + } + } + Ok(count.direct + count.indirect.len()) +} + +/// Whether `d` is a form action; its `/S` may be stored as an indirect name. +fn is_form_action(doc: &Document, d: &Dict) -> bool { + d.get(b"S").is_some_and(|s| doc.resolve(s).as_name().is_some_and(|s| FORM_ACTIONS.contains(&s))) +} + +/// The form actions in action entries, counted before they are removed. Each object's pass looks +/// only at its own direct objects, so a direct action is counted once, in the object that holds +/// it; references are counted by identity. +#[derive(Default)] +struct Count { + direct: usize, + indirect: HashSet, + /// Indirect `/AA` dictionaries and `/Next` arrays already counted. + shared: HashSet, +} + +impl Count { + fn object(&mut self, doc: &Document, o: &Object, depth: usize) { + if depth > MAX_DEPTH { + return; + } + match o { + Object::Dict(d) => { + for key in [b"A".as_slice(), b"OpenAction", b"Next"] { + if let Some(v) = d.get(key) { + self.entry(doc, v, depth); + } + } + match d.get(b"AA") { + Some(Object::Ref(r)) if self.shared.insert(*r) => { + if let Object::Dict(t) = &*doc.get(*r) { + for (_, v) in t.iter() { + self.entry(doc, v, depth); + } + } + } + Some(Object::Dict(t)) => { + for (_, v) in t.iter() { + self.entry(doc, v, depth); + } + } + _ => {} + } + for (_, v) in d.iter() { + self.object(doc, v, depth + 1); + } + } + Object::Array(items) => { + for item in items { + self.object(doc, item, depth + 1); + } + } + _ => {} + } + } + + /// One action entry's value: an action, or (in `/Next`) a list of them. + fn entry(&mut self, doc: &Document, v: &Object, depth: usize) { + if depth > MAX_DEPTH { + return; + } + match v { + Object::Dict(a) if is_form_action(doc, a) => self.direct += 1, + Object::Array(items) => { + for item in items { + self.entry(doc, item, depth + 1); + } + } + Object::Ref(r) => match &*doc.get(*r) { + Object::Dict(a) if is_form_action(doc, a) => { + self.indirect.insert(*r); + } + // An indirect `/Next` array: its direct actions are counted from the first entry + // that refers to it, and their own entries by the array's own pass. + Object::Array(items) if self.shared.insert(*r) => { + for item in items { + self.entry(doc, item, depth + 1); + } + } + _ => {} + }, + _ => {} + } + } +} + +struct Walk { + /// The object being walked. + this: ObjRef, + /// Whether the object changed. + changed: bool, + steps: usize, + /// How many action values this object's walk may look at: [`MAX_STEPS`], or what is left of + /// [`MAX_TOTAL_STEPS`] when that is less. + limit: usize, + /// Whether a limit stopped the walk before it looked at everything. + exhausted: bool, + /// Indirect form actions and indirect arrays on the path of the chain being followed. + chain: HashSet, + /// Whether the entry being cleaned holds an indirect array that lists itself. + looped: bool, + /// Cleaned replacements for indirect actions, `/Next` arrays and `/AA` dictionaries that + /// action entries of this object refer to, written over them after the walk. + writes: HashMap, +} + +impl Walk { + /// Take form actions out of the action entries of `o` and of the direct objects inside it. + fn clean(&mut self, doc: &Document, o: &mut Object, depth: usize) { + if depth > MAX_DEPTH { + if holds_form_action(doc, o) { + self.exhausted = true; + } + return; + } + match o { + Object::Dict(d) => self.clean_dict(doc, d, depth), + Object::Array(items) => { + for item in items { + self.clean(doc, item, depth + 1); + } + } + _ => {} + } + } + + fn clean_dict(&mut self, doc: &Document, d: &mut Dict, depth: usize) { + for key in ACTION_KEYS { + if let Some(v) = d.get(key) + && let shared = v.as_ref() + && let Some(rest) = self.take(doc, v) + { + let a = chain(doc, rest); + self.put(doc, d, key, shared, a, depth); + } + } + if let Some(v) = d.get(b"Next") + && let shared = v.as_ref() + && let Some(mut rest) = self.take(doc, v) + { + let next = match rest.len() { + 0 => None, + 1 => Some(rest.remove(0)), + // An action that other entries may run as their `/A` stays a dictionary. + _ if shared.is_some_and(|r| doc.get(r).as_dict().is_some()) => chain(doc, rest), + _ => Some(Object::Array(rest)), + }; + self.put(doc, d, b"Next", shared, next, depth); + } + // Additional actions: each trigger holds one action. + if let Some(aa) = d.get(b"AA") + && let shared = aa.as_ref() + && let Object::Dict(t) = &*doc.resolve(aa) + { + let mut triggers = t.clone(); + let mut changed = false; + let keys: Vec> = triggers.iter().map(|(k, _)| k.to_vec()).collect(); + for key in keys { + if let Some(v) = triggers.get(&key) + && let Some(rest) = self.take(doc, v) + { + changed = true; + match chain(doc, rest) { + Some(a) => triggers.set(key, a), + None => { + triggers.remove(&key); + } + } + } + } + if changed { + let aa = (!triggers.is_empty()).then_some(Object::Dict(triggers)); + self.put(doc, d, b"AA", shared, aa, depth); + } + } + let keys: Vec> = d.iter().map(|(k, _)| k.to_vec()).collect(); + for key in keys { + if let Some(v) = d.get_mut(&key) { + self.clean(doc, v, depth + 1); + } + } + } + + /// Set entry `key` of `d` to its cleaned value, or remove it when nothing is left. When the + /// entry referred to indirect object `shared` and the cleaned value is a direct object, the + /// value (with the form actions inside it taken out too) replaces `shared` and the entry keeps + /// its reference, so the objects that share it share one cleaned copy. An entry that refers to + /// an object already replaced, or being cleaned, keeps its reference. + fn put(&mut self, doc: &Document, d: &mut Dict, key: &[u8], shared: Option, value: Option, depth: usize) { + match (shared, value) { + (_, None) => { + d.remove(key); + } + (Some(r), Some(_)) if self.writes.contains_key(&r) => {} + (Some(r), Some(mut v)) if r != self.this && !matches!(v, Object::Ref(_)) => { + self.writes.insert(r, Object::Null); + self.clean(doc, &mut v, depth + 1); + self.writes.insert(r, v); + } + (_, Some(v)) => d.set(key.to_vec(), v), + } + } + + /// [`Walk::without`] for one action entry; marks the object changed when it changes. An entry + /// that changes while it holds an array that lists itself stops the walk when what is left + /// still reaches a form action through that array. + fn take(&mut self, doc: &Document, v: &Object) -> Option> { + self.chain.clear(); + self.looped = false; + let rest = self.without(doc, v, 0)?; + if self.looped && self.reaches_form_action(doc, &rest) { + self.exhausted = true; + return None; + } + self.changed = true; + Some(rest) + } + + /// The actions an action value stands for once the form actions at the head of its chain are + /// taken out: a form action gives way to the actions of its `/Next`. `None` when nothing + /// changes (the value is kept as it is, references included). + fn without(&mut self, doc: &Document, v: &Object, depth: usize) -> Option> { + self.steps += 1; + if depth > MAX_DEPTH || self.steps > self.limit { + self.exhausted = true; + return None; + } + let resolved = doc.resolve(v); + match &*resolved { + Object::Dict(a) if is_form_action(doc, a) => { + let r = v.as_ref(); + // A chain that comes back to an action it is still inside ends there. + if let Some(r) = r + && !self.chain.insert(r) + { + return Some(Vec::new()); + } + let rest = match a.get(b"Next") { + None => Vec::new(), + Some(next) => match self.without(doc, next, depth + 1) { + Some(rest) => rest, + None => match &*doc.resolve(next) { + Object::Array(items) => items.clone(), + _ => vec![next.clone()], + }, + }, + }; + if let Some(r) = r { + self.chain.remove(&r); + } + Some(rest) + } + Object::Array(items) => { + let r = v.as_ref(); + // An indirect array that lists itself, directly or through other arrays, is left + // as it is where it comes back. + if let Some(r) = r + && !self.chain.insert(r) + { + self.looped = true; + return None; + } + let mut out = Vec::new(); + let mut changed = false; + for item in items { + match self.without(doc, item, depth + 1) { + Some(rest) => { + changed = true; + out.extend(rest); + } + None => out.push(item.clone()), + } + } + if let Some(r) = r { + self.chain.remove(&r); + } + changed.then_some(out) + } + _ => None, + } + } + + /// Whether `items`, or an indirect array they list, lists a form action. Each value looked at + /// counts as a step. + fn reaches_form_action(&mut self, doc: &Document, items: &[Object]) -> bool { + let mut seen = HashSet::new(); + let mut pending = Vec::new(); + if self.lists_form_action(doc, items, &mut seen, &mut pending) { + return true; + } + while let Some(r) = pending.pop() { + if let Object::Array(items) = &*doc.get(r) + && self.lists_form_action(doc, items, &mut seen, &mut pending) + { + return true; + } + } + false + } + + /// Whether `items`, or a direct array inside them, lists a form action; indirect arrays not + /// yet seen go to `pending`. + fn lists_form_action(&mut self, doc: &Document, items: &[Object], seen: &mut HashSet, pending: &mut Vec) -> bool { + let mut stack: Vec<&Object> = items.iter().collect(); + while let Some(item) = stack.pop() { + self.steps += 1; + if self.steps > self.limit { + self.exhausted = true; + return false; + } + match item { + Object::Dict(a) if is_form_action(doc, a) => return true, + Object::Array(inner) => stack.extend(inner.iter()), + Object::Ref(r) => match &*doc.get(*r) { + Object::Dict(a) if is_form_action(doc, a) => return true, + Object::Array(_) if seen.insert(*r) => pending.push(*r), + _ => {} + }, + _ => {} + } + } + false + } +} + +/// Whether the walk would take a form action out of `o` or the direct objects inside it: a form +/// action, or an action entry (or a `/Next` array or `/AA` dictionary, direct or indirect) that +/// refers to one. Looks through `o` without recursion. +fn holds_form_action(doc: &Document, o: &Object) -> bool { + let mut stack = vec![o]; + while let Some(o) = stack.pop() { + match o { + Object::Dict(d) => { + if is_form_action(doc, d) { + return true; + } + for key in [b"A".as_slice(), b"OpenAction", b"Next"] { + let refers = match d.get(key) { + Some(Object::Array(items)) => items.iter().any(|item| refers_to_form_action(doc, item)), + Some(v) => refers_to_form_action(doc, v), + None => false, + }; + if refers { + return true; + } + } + if let Some(aa) = d.get(b"AA") + && let Object::Dict(t) = &*doc.resolve(aa) + && t.iter().any(|(_, v)| is_or_refers_to_form_action(doc, v)) + { + return true; + } + stack.extend(d.iter().map(|(_, v)| v)); + } + Object::Array(items) => stack.extend(items.iter()), + _ => {} + } + } + false +} + +/// Whether `v` is a direct form action or a reference to one. +fn is_or_refers_to_form_action(doc: &Document, v: &Object) -> bool { + match v { + Object::Dict(a) => is_form_action(doc, a), + Object::Ref(r) => matches!(&*doc.get(*r), Object::Dict(a) if is_form_action(doc, a)), + _ => false, + } +} + +/// Whether `v` refers to a form action, or to an indirect array that lists one. +fn refers_to_form_action(doc: &Document, v: &Object) -> bool { + let Object::Ref(r) = v else { return false }; + match &*doc.get(*r) { + Object::Dict(a) => is_form_action(doc, a), + Object::Array(items) => items.iter().any(|item| is_or_refers_to_form_action(doc, item)), + _ => false, + } +} + +/// One action value for a list of actions run in order: the first action, with the others +/// after its own `/Next` actions. `None` when no action remains. +fn chain(doc: &Document, actions: Vec) -> Option { + let mut actions = actions.into_iter(); + loop { + let first = actions.next()?; + let rest: Vec = actions.as_slice().to_vec(); + if rest.is_empty() { + return Some(first); + } + // A list needs a dictionary at its head to carry the rest; anything else is dropped. + let Some(mut head) = doc.resolve(&first).as_dict().cloned() else { continue }; + let mut next = match head.get(b"Next") { + None => Vec::new(), + Some(n) => match &*doc.resolve(n) { + Object::Array(items) => items.clone(), + _ => vec![n.clone()], + }, + }; + next.extend(rest); + head.set(b"Next".to_vec(), Object::Array(next)); + return Some(Object::Dict(head)); + } +} diff --git a/crates/optimize/src/lib.rs b/crates/optimize/src/lib.rs index 275c6f32a6..fc29a45d4e 100644 --- a/crates/optimize/src/lib.rs +++ b/crates/optimize/src/lib.rs @@ -17,7 +17,7 @@ //! are stored as CCITT Group 4 or lossless JBIG2, or 1-bit //! Flate once resampled or packed, when that is smaller. //! - **Discard objects:** page thumbnails, alternate images, document tags (structure tree), -//! print settings. +//! print settings, form submission, import and reset actions. //! - **Clean up:** drop images and forms that pages list but never draw (such as a deleted //! image); Flate-compress streams that have no filter; remove a redundant outer ASCII85 or //! ASCIIHex layer from streams that have one; store LZW-compressed streams with Flate instead. @@ -29,6 +29,7 @@ #![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] mod audit; +mod form_actions; pub mod fragments; mod images; mod links; @@ -49,6 +50,8 @@ pub use samples::{NotDecoded, Samples, decode_samples, encode_samples}; pub enum OptimizeError { #[error("the document has no page tree")] NoPages, + #[error("object {0} nests or chains its form actions too deeply, or the document holds too many actions, to discard them")] + ActionLimit(ObjRef), #[error("the document's encryption requires PDF {required} or later (chosen: PDF {chosen})")] IncompatibleEncryption { required: &'static str, chosen: &'static str }, #[error(transparent)] @@ -197,6 +200,8 @@ pub struct Settings { pub discard_alternate_images: bool, pub discard_tags: bool, pub discard_print_settings: bool, + /// Remove form submission, import and reset actions; the fields and links that ran them stay. + pub discard_form_actions: bool, /// Flate-compress streams stored without a filter, and strip a redundant outer ASCII85 or /// ASCIIHex layer from streams that have one. pub flate_unencoded: bool, @@ -233,6 +238,7 @@ impl Default for Settings { discard_alternate_images: true, discard_tags: false, discard_print_settings: false, + discard_form_actions: false, flate_unencoded: true, object_streams: true, lzw_to_flate: true, @@ -297,6 +303,8 @@ pub struct Report { pub alternate_images: usize, pub tags_removed: bool, pub print_settings: usize, + /// Form submission, import and reset actions removed. + pub form_actions: usize, pub streams_compressed: usize, /// Streams whose LZW compression was replaced with Flate (Clean Up). pub lzw_streams: usize, @@ -426,6 +434,9 @@ pub fn optimize_with_progress(doc: &mut Document, settings: &Settings, progress: _ => {} } } + if settings.discard_form_actions { + report.form_actions = form_actions::discard(doc)?; + } if settings.remove_invalid_links { let (l, b) = links::remove_invalid(doc, &pages)?; report.invalid_links = l; diff --git a/crates/optimize/tests/optimize.rs b/crates/optimize/tests/optimize.rs index b6fe624082..92203852c7 100644 --- a/crates/optimize/tests/optimize.rs +++ b/crates/optimize/tests/optimize.rs @@ -2450,6 +2450,347 @@ fn discards_and_clean_up() { assert!(vp.contains(b"HideToolbar") && !vp.contains(b"Duplex"), "other preferences stay"); } +fn action(kind: &str) -> Dict { + let mut a = Dict::new(); + a.set(b"S".to_vec(), Object::name(kind)); + a +} + +fn with(mut d: Dict, key: &str, value: impl Into) -> Dict { + d.set(key.as_bytes().to_vec(), value); + d +} + +/// The kinds of the actions `o` runs, in order: each action, then its `/Next` actions. +fn kinds(doc: &Document, o: Option<&Object>) -> Vec { + let mut out = Vec::new(); + let Some(o) = o else { return out }; + match &*doc.resolve(o) { + Object::Array(items) => { + for item in items { + out.extend(kinds(doc, Some(item))); + } + } + Object::Dict(a) => { + out.push(String::from_utf8_lossy(a.name(b"S").unwrap_or_default()).into_owned()); + out.extend(kinds(doc, a.get(b"Next"))); + } + _ => {} + } + out +} + +fn dict(doc: &Document, r: ObjRef) -> Dict { + doc.get(r).as_dict().cloned().unwrap() +} + +#[test] +fn form_submit_import_and_reset_actions_are_discarded_and_the_fields_stay() { + let mut doc = Document::new_empty(); + let p = page(&mut doc, &[], ""); + let root = doc.root().unwrap(); + let triggers = with(Dict::new(), "U", action("ResetForm")); + let triggers = with(triggers, "E", with(action("JavaScript"), "JS", Object::String(pdfcraft_cos::PdfString::literal("app.alert(1)")))); + let button = with(with(with(Dict::new(), "Subtype", Object::name("Widget")), "FT", Object::name("Btn")), "A", action("SubmitForm")); + let button = doc.add(Object::Dict(with(button, "AA", triggers))); + let import = doc.add(Object::Dict(action("ImportData"))); + let field = with(with(Dict::new(), "Subtype", Object::name("Widget")), "FT", Object::name("Tx")); + let field = doc.add(Object::Dict(with(field, "AA", with(Dict::new(), "K", Object::Ref(import))))); + let link = with(with(Dict::new(), "Subtype", Object::name("Link")), "A", with(action("GoTo"), "Next", action("SubmitForm"))); + let link = doc.add(Object::Dict(with(link, "D", Object::Array(vec![Object::Ref(p), Object::name("Fit")])))); + let reset = doc.add(Object::Dict(action("ResetForm"))); + doc.update_dict(p, |d| d.set(b"Annots".to_vec(), Object::Array(vec![Object::Ref(button), Object::Ref(field), Object::Ref(link)]))).unwrap(); + doc.update_dict(root, |c| c.set(b"OpenAction".to_vec(), Object::Ref(reset))).unwrap(); + + let mut kept = doc.clone(); + assert_eq!(optimize(&mut kept, &Settings::default()).unwrap().form_actions, 0, "the option is off by default"); + assert_eq!(kinds(&kept, dict(&kept, button).get(b"A")), ["SubmitForm"]); + + let r = optimize(&mut doc, &Settings { discard_form_actions: true, ..Settings::default() }).unwrap(); + assert_eq!(r.form_actions, 5, "{r:?}"); + let annots = dict(&doc, p).get(b"Annots").and_then(|a| a.as_array().cloned()).unwrap(); + assert_eq!(annots.len(), 3, "the fields and the link stay"); + let b = dict(&doc, button); + assert_eq!(b.name(b"FT"), Some(&b"Btn"[..])); + assert!(!b.contains(b"A"), "{b:?}"); + let aa = b.get(b"AA").and_then(|a| a.as_dict().cloned()).unwrap(); + assert_eq!((aa.contains(b"U"), kinds(&doc, aa.get(b"E"))), (false, vec!["JavaScript".to_string()]), "other triggers stay"); + assert!(!dict(&doc, field).contains(b"AA"), "an emptied /AA goes"); + assert_eq!(kinds(&doc, dict(&doc, link).get(b"A")), ["GoTo"]); + assert!(!dict(&doc, root).contains(b"OpenAction")); +} + +#[test] +fn a_discarded_form_action_hands_its_place_to_the_actions_after_it() { + let mut doc = Document::new_empty(); + let p = page(&mut doc, &[], ""); + let root = doc.root().unwrap(); + // A reset whose /S is an indirect name. + let name = doc.add(Object::name("ResetForm")); + let reset = doc.add(Object::Dict(with(with(Dict::new(), "S", Object::Ref(name)), "Next", action("URI")))); + let submit = with( + action("SubmitForm"), + "Next", + Object::Array(vec![Object::Dict(action("JavaScript")), Object::Ref(reset), Object::Dict(action("Named"))]), + ); + let link = doc.add(Object::Dict(with(with(Dict::new(), "Subtype", Object::name("Link")), "A", submit))); + // A reset whose chain comes back to itself. + let looped = doc.add(Object::Dict(action("ResetForm"))); + doc.update_dict(looped, |a| a.set(b"Next".to_vec(), Object::Ref(looped))).unwrap(); + doc.update_dict(p, |d| d.set(b"Annots".to_vec(), Object::Array(vec![Object::Ref(link)]))).unwrap(); + doc.update_dict(root, |c| c.set(b"OpenAction".to_vec(), Object::Ref(looped))).unwrap(); + + let r = optimize(&mut doc, &Settings { discard_form_actions: true, ..Settings::default() }).unwrap(); + assert_eq!(r.form_actions, 3, "{r:?}"); + assert_eq!(kinds(&doc, dict(&doc, link).get(b"A")), ["JavaScript", "URI", "Named"]); + assert!(!dict(&doc, root).contains(b"OpenAction")); +} + +#[test] +fn a_form_action_listed_twice_in_a_chain_gives_way_both_times() { + let mut doc = Document::new_empty(); + let p = page(&mut doc, &[], ""); + let reset = doc.add(Object::Dict(with(action("ResetForm"), "Next", action("JavaScript")))); + let submit = with(action("SubmitForm"), "Next", Object::Array(vec![Object::Ref(reset), Object::Ref(reset)])); + let link = doc.add(Object::Dict(with(with(Dict::new(), "Subtype", Object::name("Link")), "A", submit))); + doc.update_dict(p, |d| d.set(b"Annots".to_vec(), Object::Array(vec![Object::Ref(link)]))).unwrap(); + // An indirect submit that holds a direct reset in its /Next. + let root = doc.root().unwrap(); + let opening = doc.add(Object::Dict(with(action("SubmitForm"), "Next", action("ResetForm")))); + doc.update_dict(root, |c| c.set(b"OpenAction".to_vec(), Object::Ref(opening))).unwrap(); + + let r = optimize(&mut doc, &Settings { discard_form_actions: true, ..Settings::default() }).unwrap(); + assert_eq!(r.form_actions, 4, "each action counts once: {r:?}"); + assert_eq!(kinds(&doc, dict(&doc, link).get(b"A")), ["JavaScript", "JavaScript"]); + assert!(!dict(&doc, root).contains(b"OpenAction")); +} + +#[test] +fn form_actions_chained_past_the_limit_stop_the_optimization() { + let mut doc = Document::new_empty(); + page(&mut doc, &[], ""); + let root = doc.root().unwrap(); + let mut chain = action("ResetForm"); + for _ in 0..80 { + chain = with(action("SubmitForm"), "Next", chain); + } + doc.update_dict(root, |c| c.set(b"OpenAction".to_vec(), chain)).unwrap(); + let r = optimize(&mut doc, &Settings { discard_form_actions: true, ..Settings::default() }); + assert!(matches!(r, Err(pdfcraft_optimize::OptimizeError::ActionLimit(at)) if at == root), "{r:?}"); +} + +#[test] +fn a_next_array_that_lists_itself_is_left_as_it_is() { + let mut doc = Document::new_empty(); + let p = page(&mut doc, &[], ""); + let looped = doc.add(Object::Null); + doc.set(looped, Object::Array(vec![Object::Ref(looped)])); + let (first, second) = (doc.add(Object::Null), doc.add(Object::Null)); + doc.set(first, Object::Array(vec![Object::Ref(second)])); + doc.set(second, Object::Array(vec![Object::Ref(first)])); + let link = |doc: &mut Document, next: ObjRef| { + let a = with(action("GoTo"), "Next", Object::Ref(next)); + doc.add(Object::Dict(with(with(Dict::new(), "Subtype", Object::name("Link")), "A", a))) + }; + let (own, pair) = (link(&mut doc, looped), link(&mut doc, first)); + let submit = with(action("SubmitForm"), "Next", Object::Ref(looped)); + let button = doc.add(Object::Dict(with(with(Dict::new(), "Subtype", Object::name("Widget")), "A", submit))); + doc.update_dict(p, |d| d.set(b"Annots".to_vec(), Object::Array(vec![Object::Ref(own), Object::Ref(pair), Object::Ref(button)]))).unwrap(); + let settings = Settings { discard_form_actions: true, ..Settings::default() }; + + let mut cleaned = doc.clone(); + let r = optimize(&mut cleaned, &settings).unwrap(); + assert_eq!(r.form_actions, 1, "{r:?}"); + for (l, next) in [(own, looped), (pair, first)] { + let a = dict(&cleaned, l).get(b"A").and_then(|a| a.as_dict().cloned()).unwrap(); + assert_eq!(a.get(b"Next"), Some(&Object::Ref(next)), "{l:?}"); + } + assert_eq!(dict(&cleaned, button).get(b"A"), Some(&Object::Ref(looped)), "the submit's /Next takes its place"); + + // An array that lists itself and a submit, directly or in an array inside it: a cleaned copy + // would still reach the submit. + for submit in [Object::Dict(action("SubmitForm")), Object::Array(vec![Object::Dict(action("SubmitForm"))])] { + let mut mixed = doc.clone(); + mixed.set(first, Object::Array(vec![Object::Ref(second), submit])); + let r = optimize(&mut mixed, &settings); + assert!(matches!(r, Err(pdfcraft_optimize::OptimizeError::ActionLimit(_))), "{r:?}"); + } +} + +/// `referrers` actions whose `/Next` is one shared array of 2,000 actions, a submit among them. +fn shared_next_array(referrers: usize) -> Document { + let mut doc = Document::new_empty(); + page(&mut doc, &[], ""); + let script = doc.add(Object::Dict(action("JavaScript"))); + let mut items = vec![Object::Ref(script); 1_999]; + items.push(Object::Dict(action("SubmitForm"))); + let shared = doc.add(Object::Array(items)); + for _ in 0..referrers { + doc.add(Object::Dict(with(action("GoTo"), "Next", Object::Ref(shared)))); + } + doc +} + +#[test] +fn a_shared_next_array_cleaned_for_too_many_actions_stops_the_optimization() { + let settings = Settings { discard_form_actions: true, ..Settings::default() }; + let mut doc = shared_next_array(400); + assert_eq!(optimize(&mut doc, &settings).unwrap().form_actions, 1); + // Each referrer is under the limit for one object; together they pass the document's. + let mut doc = shared_next_array(600); + let r = optimize(&mut doc, &settings); + assert!(matches!(r, Err(pdfcraft_optimize::OptimizeError::ActionLimit(_))), "{r:?}"); +} + +/// 80 direct dictionaries nested in each other under `/Deep`, the innermost one being `inner`. +fn nested(inner: Dict) -> Object { + let mut o = Object::Dict(inner); + for _ in 0..80 { + o = Object::Dict(with(Dict::new(), "Deep", o)); + } + o +} + +#[test] +fn deep_nesting_stops_the_optimization_only_when_a_form_action_is_past_the_limit() { + let mut doc = Document::new_empty(); + let p = page(&mut doc, &[], ""); + let plain = doc.add(nested(with(action("GoTo"), "Next", action("URI")))); + let link = doc.add(Object::Dict(with(with(Dict::new(), "Subtype", Object::name("Link")), "A", action("SubmitForm")))); + doc.update_dict(p, |d| { + d.set(b"Annots".to_vec(), Object::Array(vec![Object::Ref(link)])); + d.set(b"PieceInfo".to_vec(), Object::Ref(plain)); + }) + .unwrap(); + let settings = Settings { discard_form_actions: true, ..Settings::default() }; + let mut ok = doc.clone(); + let r = optimize(&mut ok, &settings).unwrap(); + assert_eq!(r.form_actions, 1, "deep nesting with no form action in it is left as it is: {r:?}"); + + let reset = doc.add(Object::Dict(action("ResetForm"))); + for (n, inner) in [ + action("SubmitForm"), + with(Dict::new(), "A", Object::Ref(reset)), + with(Dict::new(), "Next", Object::Ref(doc.add(Object::Array(vec![Object::Ref(reset)])))), + with(Dict::new(), "AA", Object::Ref(doc.add(Object::Dict(with(Dict::new(), "U", action("ImportData")))))), + with(Dict::new(), "AA", with(Dict::new(), "U", Object::Ref(reset))), + with(Dict::new(), "Next", Object::Array(vec![Object::Ref(reset)])), + ] + .into_iter() + .enumerate() + { + let mut deep = doc.clone(); + let held = deep.add(nested(inner)); + deep.update_dict(p, |d| d.set(b"PieceInfo".to_vec(), Object::Ref(held))).unwrap(); + let r = optimize(&mut deep, &settings); + assert!(matches!(r, Err(pdfcraft_optimize::OptimizeError::ActionLimit(at)) if at == held), "case {n}: {r:?}"); + } +} + +#[test] +fn a_shared_additional_actions_dictionary_is_cleaned_for_each_field_that_uses_it() { + let mut doc = Document::new_empty(); + let p = page(&mut doc, &[], ""); + let triggers = with(with(Dict::new(), "U", action("SubmitForm")), "E", action("JavaScript")); + let shared = doc.add(Object::Dict(triggers)); + let widget = |doc: &mut Document| doc.add(Object::Dict(with(with(Dict::new(), "Subtype", Object::name("Widget")), "AA", Object::Ref(shared)))); + let (first, second) = (widget(&mut doc), widget(&mut doc)); + doc.update_dict(p, |d| d.set(b"Annots".to_vec(), Object::Array(vec![Object::Ref(first), Object::Ref(second)]))).unwrap(); + + let r = optimize(&mut doc, &Settings { discard_form_actions: true, ..Settings::default() }).unwrap(); + assert_eq!(r.form_actions, 1, "{r:?}"); + for w in [first, second] { + assert_eq!(dict(&doc, w).get(b"AA"), Some(&Object::Ref(shared)), "the widgets still share one dictionary: {w:?}"); + } + let aa = dict(&doc, shared); + assert_eq!((aa.contains(b"U"), kinds(&doc, aa.get(b"E"))), (false, vec!["JavaScript".to_string()])); +} + +#[test] +fn shared_indirect_actions_and_next_arrays_are_cleaned_once_and_stay_shared() { + let mut doc = Document::new_empty(); + let p = page(&mut doc, &[], ""); + // A shared submit whose /Next is a script that resets the form after it runs. + let script = with(action("JavaScript"), "Next", action("ResetForm")); + let submit = doc.add(Object::Dict(with(action("SubmitForm"), "Next", script))); + let next = doc.add(Object::Array(vec![Object::Dict(action("ImportData")), Object::Dict(action("URI")), Object::Dict(action("Named"))])); + let link = |doc: &mut Document| { + let a = with(action("GoTo"), "Next", Object::Ref(next)); + doc.add(Object::Dict(with(with(Dict::new(), "Subtype", Object::name("Link")), "A", a))) + }; + let widget = |doc: &mut Document| doc.add(Object::Dict(with(with(Dict::new(), "Subtype", Object::name("Widget")), "A", Object::Ref(submit)))); + let (w1, w2, l1, l2) = (widget(&mut doc), widget(&mut doc), link(&mut doc), link(&mut doc)); + doc.update_dict(p, |d| d.set(b"Annots".to_vec(), Object::Array([w1, w2, l1, l2].map(Object::Ref).to_vec()))).unwrap(); + + let r = optimize(&mut doc, &Settings { discard_form_actions: true, ..Settings::default() }).unwrap(); + assert_eq!(r.form_actions, 3, "{r:?}"); + for w in [w1, w2] { + assert_eq!(dict(&doc, w).get(b"A"), Some(&Object::Ref(submit)), "{w:?}"); + assert_eq!(kinds(&doc, dict(&doc, w).get(b"A")), ["JavaScript"], "{w:?}"); + } + for l in [l1, l2] { + let a = dict(&doc, l).get(b"A").and_then(|a| a.as_dict().cloned()).unwrap(); + assert_eq!(a.get(b"Next"), Some(&Object::Ref(next)), "{l:?}"); + assert_eq!(kinds(&doc, dict(&doc, l).get(b"A")), ["GoTo", "URI", "Named"], "{l:?}"); + } +} + +#[test] +fn a_form_action_in_an_array_inside_an_indirect_next_array_is_counted() { + let mut doc = Document::new_empty(); + let p = page(&mut doc, &[], ""); + let next = doc.add(Object::Array(vec![Object::Array(vec![Object::Dict(action("ResetForm"))]), Object::Dict(action("URI"))])); + let a = with(action("GoTo"), "Next", Object::Ref(next)); + let link = doc.add(Object::Dict(with(with(Dict::new(), "Subtype", Object::name("Link")), "A", a))); + doc.update_dict(p, |d| d.set(b"Annots".to_vec(), Object::Array(vec![Object::Ref(link)]))).unwrap(); + + let r = optimize(&mut doc, &Settings { discard_form_actions: true, ..Settings::default() }).unwrap(); + assert_eq!(r.form_actions, 1, "{r:?}"); + assert_eq!(kinds(&doc, dict(&doc, link).get(b"A")), ["GoTo", "URI"]); +} + +#[test] +fn a_shared_action_run_as_a_next_action_and_as_an_a_entry_stays_a_dictionary() { + let mut doc = Document::new_empty(); + let p = page(&mut doc, &[], ""); + // A reset before two actions, run by one link after a GoTo and by another as its /A. + let reset = doc.add(Object::Dict(with( + action("ResetForm"), + "Next", + Object::Array(vec![Object::Dict(action("JavaScript")), Object::Dict(action("URI"))]), + ))); + let link = |doc: &mut Document, a: Object| doc.add(Object::Dict(with(with(Dict::new(), "Subtype", Object::name("Link")), "A", a))); + let after = link(&mut doc, Object::Dict(with(action("GoTo"), "Next", Object::Ref(reset)))); + let runs = link(&mut doc, Object::Ref(reset)); + doc.update_dict(p, |d| d.set(b"Annots".to_vec(), Object::Array([after, runs].map(Object::Ref).to_vec()))).unwrap(); + + let r = optimize(&mut doc, &Settings { discard_form_actions: true, ..Settings::default() }).unwrap(); + assert_eq!(r.form_actions, 1, "{r:?}"); + assert_eq!(dict(&doc, reset).name(b"S"), Some(&b"JavaScript"[..]), "the reset's place is taken by a dictionary"); + assert_eq!(dict(&doc, runs).get(b"A"), Some(&Object::Ref(reset))); + assert_eq!(kinds(&doc, dict(&doc, runs).get(b"A")), ["JavaScript", "URI"]); + let a = dict(&doc, after).get(b"A").and_then(|a| a.as_dict().cloned()).unwrap(); + assert_eq!(a.get(b"Next"), Some(&Object::Ref(reset))); + assert_eq!(kinds(&doc, dict(&doc, after).get(b"A")), ["GoTo", "JavaScript", "URI"]); +} + +#[test] +fn a_shared_submit_whose_script_runs_it_again_gives_way_to_the_script() { + let mut doc = Document::new_empty(); + let p = page(&mut doc, &[], ""); + // The widget comes first, so the walk reaches the submit through it. + let widget = doc.add(Object::Null); + let submit = doc.add(Object::Null); + doc.set(widget, Object::Dict(with(with(Dict::new(), "Subtype", Object::name("Widget")), "A", Object::Ref(submit)))); + doc.set(submit, Object::Dict(with(action("SubmitForm"), "Next", with(action("JavaScript"), "Next", Object::Ref(submit))))); + doc.update_dict(p, |d| d.set(b"Annots".to_vec(), Object::Array(vec![Object::Ref(widget)]))).unwrap(); + let r = optimize(&mut doc, &Settings { discard_form_actions: true, ..Settings::default() }).unwrap(); + assert_eq!(r.form_actions, 1, "{r:?}"); + assert_eq!(dict(&doc, widget).get(b"A"), Some(&Object::Ref(submit))); + let script = dict(&doc, submit); + assert_eq!((script.name(b"S"), script.get(b"Next")), (Some(&b"JavaScript"[..]), Some(&Object::Ref(submit))), "the script runs itself again"); +} + #[test] fn space_audit_shares_the_file_out_by_kind() { let mut doc = Document::new_empty(); diff --git a/crates/ui-egui/src/workflows/optimize_ui.rs b/crates/ui-egui/src/workflows/optimize_ui.rs index f54fc177f0..6d46e5dfca 100644 --- a/crates/ui-egui/src/workflows/optimize_ui.rs +++ b/crates/ui-egui/src/workflows/optimize_ui.rs @@ -251,6 +251,7 @@ pub(crate) fn body(ui: &mut egui::Ui, d: &mut OptimizeDraft, t: &Tokens) -> (boo ui.add(widgets::checkbox(&mut s.discard_thumbnails, tl!("Discard embedded page thumbnails"))); ui.add(widgets::checkbox(&mut s.discard_tags, tl!("Discard document tags"))); ui.add(widgets::checkbox(&mut s.discard_print_settings, tl!("Discard embedded print settings"))); + ui.add(widgets::checkbox(&mut s.discard_form_actions, tl!("Discard all form submission, import and reset actions"))); discard_box(ui, &mut d.discard, Hidden::Bookmarks, "Discard bookmarks"); discard_box(ui, &mut d.discard, Hidden::FormFields, "Flatten form fields"); discard_box(ui, &mut d.discard, Hidden::HiddenLayers, "Discard hidden layer content"); diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index 3c17a3ed70..6557c2b1f8 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -7678,11 +7678,11 @@ title = "Discard form submit, import and reset actions" area = "K" tier = "P2" milestone = "M11" -status = "planned" -commands = [] -tools = [] -evidence = [] -notes = "" +status = "shipped" +commands = ["optimize.advanced"] +tools = ["doc_optimize"] +evidence = ["crates/optimize/tests/optimize.rs::form_submit_import_and_reset_actions_are_discarded_and_the_fields_stay", "crates/optimize/tests/optimize.rs::a_discarded_form_action_hands_its_place_to_the_actions_after_it", "crates/optimize/tests/optimize.rs::a_form_action_listed_twice_in_a_chain_gives_way_both_times", "crates/optimize/tests/optimize.rs::a_shared_additional_actions_dictionary_is_cleaned_for_each_field_that_uses_it", "crates/optimize/tests/optimize.rs::shared_indirect_actions_and_next_arrays_are_cleaned_once_and_stay_shared", "crates/optimize/tests/optimize.rs::deep_nesting_stops_the_optimization_only_when_a_form_action_is_past_the_limit", "crates/automation/tests/it/automation.rs::optimizing_discards_form_actions_through_tools"] +notes = "PDF Optimizer ▸ Discard Objects, off by default. Removes /SubmitForm, /ImportData and /ResetForm actions from link, widget and bookmark /A, catalog /OpenAction, every /AA trigger and /Next chains; the actions after a removed one still run in order. An object that holds a form action more than 64 direct objects deep, chains form actions more than 64 deep, or makes the walk look at more than 100,000 action values, or a document that makes it look at more than 1,000,000 in all, stops the optimization with an error. Not changed: JavaScript that calls submitForm or resetForm (Discard all links, actions and JavaScript removes the JavaScript in links, widgets, /AA triggers, /OpenAction and the document's scripts), and XFA form submissions." [[feature]] id = "optimize.flatten-form-fields" From 743a772ee946621af869b67cd331730701fa4a82 Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Mon, 9 Nov 2026 05:36:04 -0500 Subject: [PATCH 31/41] fix(content): an inline image only the renderer reads counts as ambiguous (#5874) --- crates/content/src/lib.rs | 16 ++++++++++------ crates/content/src/tests.rs | 10 ++++++++++ crates/redact/src/tests/inline_images.rs | 9 +++++++++ 3 files changed, 29 insertions(+), 6 deletions(-) diff --git a/crates/content/src/lib.rs b/crates/content/src/lib.rs index 4957f86861..fb17a903d1 100644 --- a/crates/content/src/lib.rs +++ b/crates/content/src/lib.rs @@ -67,8 +67,9 @@ pub struct Parsed { /// Bytes that could not be read as operands or operators. pub skipped: usize, /// Inline images the renderer's tokenizer reads no image for (it stops, or finds no `EI` it - /// accepts). Their end here is a guess, so the operators after them may not be the ones the - /// page draws. + /// accepts), so their end here is a guess and the operators after them may not be the ones + /// the page draws; and images it reads whose parameters this parser can't, which are missing + /// from `ops` although the page draws them. pub ambiguous_images: usize, /// For [`Pieces::parse`]: per piece, whether a comment runs into it from the piece before /// and whether one runs out of it into the next. Only boundaries are recorded, so the size @@ -139,8 +140,8 @@ impl<'a> Ops<'a> { self.skipped } - /// Inline images read so far whose end is this parser's guess (as in - /// [`Parsed::ambiguous_images`]). + /// Inline images read so far that are ambiguous (as in [`Parsed::ambiguous_images`]): either + /// their end is this parser's guess, or the renderer reads them and this parser cannot. pub fn ambiguous_images(&self) -> usize { self.ambiguous_images } @@ -291,7 +292,8 @@ struct Inline { image: Option<(Dict, Vec)>, /// The position after its `EI` (or the end of the stream). end: usize, - /// The renderer's tokenizer reads no image here, so `end` is this parser's guess. + /// The renderer's tokenizer reads no image here, so `end` is this parser's guess; or it reads + /// one whose parameters this parser can't, so `image` is `None` for an image the page draws. ambiguous: bool, } @@ -339,7 +341,9 @@ fn inline_image(data: &[u8], bi: usize, pos: usize) -> Inline { { samples = rest; } - return Inline { image: readable.then(|| (dict, samples.to_vec())), end, ambiguous: false }; + // The renderer draws an image whose parameters this parser can't read: it is not + // represented here, so a redaction can't inspect or remove it and must not trust the page. + return Inline { image: readable.then(|| (dict, samples.to_vec())), end, ambiguous: !readable }; } if !readable { // Without an `EI` after it, the image runs to the end of the stream. diff --git a/crates/content/src/tests.rs b/crates/content/src/tests.rs index 9ac9297cd2..9ca12c4536 100644 --- a/crates/content/src/tests.rs +++ b/crates/content/src/tests.rs @@ -209,6 +209,16 @@ fn filtered_unknown_and_damaged_inline_images_end_where_the_renderer_ends_them() } } +/// The renderer reads an image whose parameters this parser can't (a stray operand before `ID`): +/// the page draws an image `ops` doesn't hold, so it is counted as ambiguous rather than dropped +/// silently. +#[test] +fn an_inline_image_read_by_the_renderer_but_not_here_is_ambiguous() { + let parsed = parse(b"q BI /W 1 /H 1 /BPC 8 /CS /G 7 ID \x80 EI Q"); + assert_eq!(parsed.ops.iter().map(|op| op.op.as_slice()).collect::>(), [b"q".as_slice(), b"Q"]); + assert_eq!(parsed.ambiguous_images, 1); +} + /// A zlib stream (one stored block) for 64 DeviceGray samples whose bytes hold " EI\n" at offset 41, /// the way a stored deflate block copies incompressible samples verbatim (#3579). const FLATE_WITH_EI: &str = "789c014000bfffed746159d50788f7ea604984cd08e00b4c8983207f56fe82c77ef8dfb8b9a3a586382045490a3120\ diff --git a/crates/redact/src/tests/inline_images.rs b/crates/redact/src/tests/inline_images.rs index 77fd4dc2d7..f1c61b71a5 100644 --- a/crates/redact/src/tests/inline_images.rs +++ b/crates/redact/src/tests/inline_images.rs @@ -32,3 +32,12 @@ fn a_page_whose_inline_image_the_renderer_cannot_end_is_refused() { mark(&mut doc, 0, &[[5.0, 95.0, 45.0, 110.0]], ""); assert_eq!(apply(&mut doc, None), Err(RedactError::Unreadable(1))); } + +/// The renderer draws an image whose parameters the content parser can't read (a stray operand +/// before `ID`): it would stay under the mark unseen, so the page is refused. +#[test] +fn a_page_whose_inline_image_only_the_renderer_reads_is_refused() { + let mut doc = one_page(b"q 10 0 0 10 50 50 cm BI /W 1 /H 1 /CS /G /BPC 8 7 ID \x80 EI Q", "", vec![]); + mark(&mut doc, 0, &[[45.0, 45.0, 65.0, 65.0]], ""); + assert_eq!(apply(&mut doc, None), Err(RedactError::Unreadable(1))); +} From 44a26f4b930ba05fb64bc1835bedac19b83640eb Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Mon, 9 Nov 2026 05:36:19 -0500 Subject: [PATCH 32/41] fix(model,oracle): read a page box only when it has four entries; skip empty images (#5941) --- crates/model/src/lib.rs | 8 ++++++-- crates/oracle/src/pixels.rs | 12 ++++++++++++ 2 files changed, 18 insertions(+), 2 deletions(-) diff --git a/crates/model/src/lib.rs b/crates/model/src/lib.rs index 94da24ea1c..23eede9b45 100644 --- a/crates/model/src/lib.rs +++ b/crates/model/src/lib.rs @@ -24,8 +24,12 @@ const MIN_BOX_SIDE: f64 = 1.0 / 4096.0; fn rect(doc: &Document, o: Option<&Object>) -> Option<[f64; 4]> { let o = doc.resolve(o?); - let v: Vec = o.as_array()?.iter().filter_map(|x| doc.resolve(x).as_f64()).collect(); - (v.len() == 4 && v.iter().all(|x| x.is_finite())).then(|| [v[0].min(v[2]), v[1].min(v[3]), v[0].max(v[2]), v[1].max(v[3])]) + // Four entries, or not a rectangle: a longer array (one shared by many pages, say) is not + // resolved entry by entry only to be discarded. + let [a, b, c, d] = o.as_array()?.as_slice() else { return None }; + let v = [a, b, c, d].map(|x| doc.resolve(x).as_f64()); + let [Some(x0), Some(y0), Some(x1), Some(y1)] = v else { return None }; + [x0, y0, x1, y1].iter().all(|x| x.is_finite()).then(|| [x0.min(x1), y0.min(y1), x0.max(x1), y0.max(y1)]) } impl Page { diff --git a/crates/oracle/src/pixels.rs b/crates/oracle/src/pixels.rs index 4ff0ebaf13..6681aaa5df 100644 --- a/crates/oracle/src/pixels.rs +++ b/crates/oracle/src/pixels.rs @@ -105,6 +105,10 @@ pub fn compare(a: Gray<'_>, b: Gray<'_>, tolerance: u8) -> Result, b: Gray<'_>, tolerance: u8) -> usize { + // No pixels, nothing to match: a zero-width image of enormous height is not walked row by row. + if a.pixels.is_empty() { + return 0; + } let mut count = 0usize; for y in 0..a.height { for x in 0..a.width { @@ -210,6 +214,14 @@ fn find(haystack: &[u8], needle: &[u8]) -> Option { #[cfg(test)] mod tests { + /// A zero-width image of enormous height has no pixels to walk. + #[test] + fn an_image_without_pixels_compares_at_once() { + let a = Gray { width: 0, height: usize::MAX, pixels: &[] }; + let d = compare(a, a, 0).unwrap(); + assert_eq!((d.differing, d.total), (0, 0)); + } + use super::*; fn gray(w: usize, h: usize, fill: u8) -> Vec { From ca56433f5fb17332a744dc1511cb283cfa66331f Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Mon, 9 Nov 2026 05:36:34 -0500 Subject: [PATCH 33/41] fix(redact): read text-state operands from the end, as the renderer does (#5951) --- crates/redact/src/interp.rs | 27 +++++++++++++++++---------- crates/redact/src/tests/text_state.rs | 14 ++++++++++++++ 2 files changed, 31 insertions(+), 10 deletions(-) diff --git a/crates/redact/src/interp.rs b/crates/redact/src/interp.rs index 1ac7cb6714..326adff769 100644 --- a/crates/redact/src/interp.rs +++ b/crates/redact/src/interp.rs @@ -591,8 +591,10 @@ impl Walk { } fn select_font(&mut self, doc: &Document, scope: &mut Scope<'_>, op: &Op) { - self.gs.size = op.num(1).unwrap_or(self.gs.size); - let Some(name) = op.name(0) else { return }; + // `/F1 12 Tf` takes its last two operands, as the renderer does: a surplus before them + // is ignored, not read in their place. + self.gs.size = op.nums::<1>().map_or(self.gs.size, |[size]| size); + let Some(name) = op.operands.len().checked_sub(2).and_then(|i| op.name(i)) else { return }; let font_dict = self.fonts_res.get(name).and_then(|f| doc.resolve(f).as_dict().cloned()); self.gs.vertical = font_dict.as_ref().is_some_and(|d| is_vertical(doc, d)); self.gs.type3_inherits = font_dict.as_ref().is_some_and(|d| type3_without_resources(doc, d)); @@ -609,17 +611,20 @@ impl Walk { /// Text state and text-positioning operators. fn text_state(&mut self, op: &Op) { let gs = &mut self.gs; + // The operand immediately before the operator, as the renderer reads it: surplus operands + // before it must not move where glyphs are placed (`1 50 Ts` raises text by 50, not 1). + let last = op.nums::<1>().map(|[v]| v); match op.op.as_slice() { b"BT" => { self.tm = Matrix::IDENTITY; self.tlm = Matrix::IDENTITY; } - b"Tc" => gs.char_spacing = op.num(0).unwrap_or(0.0), - b"Tw" => gs.word_spacing = op.num(0).unwrap_or(0.0), - b"Tz" => gs.scale = op.num(0).unwrap_or(100.0) / 100.0, - b"TL" => gs.leading = op.num(0).unwrap_or(0.0), - b"Ts" => gs.rise = op.num(0).unwrap_or(0.0), - b"Tr" => gs.render_mode = op.num(0).unwrap_or(0.0) as i64, + b"Tc" => gs.char_spacing = last.unwrap_or(0.0), + b"Tw" => gs.word_spacing = last.unwrap_or(0.0), + b"Tz" => gs.scale = last.unwrap_or(100.0) / 100.0, + b"TL" => gs.leading = last.unwrap_or(0.0), + b"Ts" => gs.rise = last.unwrap_or(0.0), + b"Tr" => gs.render_mode = last.unwrap_or(0.0) as i64, b"Td" | b"TD" => { if let Some([x, y]) = op.nums::<2>() { if op.is("TD") { @@ -656,8 +661,10 @@ impl Walk { } let o = op.op.as_slice(); if o == b"\"" { - self.gs.word_spacing = op.num(0).unwrap_or(self.gs.word_spacing); - self.gs.char_spacing = op.num(1).unwrap_or(self.gs.char_spacing); + // `aw ac string "`: the two numbers before the string, whatever surplus precedes them. + let at = |back: usize| op.operands.len().checked_sub(back).and_then(|i| op.num(i)); + self.gs.word_spacing = at(3).unwrap_or(self.gs.word_spacing); + self.gs.char_spacing = at(2).unwrap_or(self.gs.char_spacing); } if matches!(o, b"'" | b"\"") { self.tlm = Matrix::translate(0.0, -self.gs.leading).then(&self.tlm); diff --git a/crates/redact/src/tests/text_state.rs b/crates/redact/src/tests/text_state.rs index 4e888f262c..2df6b53fad 100644 --- a/crates/redact/src/tests/text_state.rs +++ b/crates/redact/src/tests/text_state.rs @@ -25,3 +25,17 @@ fn a_form_inherits_the_text_state_of_the_page_that_draws_it() { let r = apply(&mut doc, None).unwrap(); assert_eq!((r.glyphs, r.forms_rewritten), (6, 1), "{r:?}"); } + +/// `1 50 Ts` raises text by 50, the operand before the operator, as the renderer reads it; the +/// surplus `1` before it is ignored. Reading the first operand put the glyphs 49 pt too low, so a +/// mark over the text the page shows missed it. +#[test] +fn surplus_text_state_operands_do_not_move_the_glyphs() { + let mut doc = one_page(b"BT /F1 10 Tf 1 50 Ts 10 100 Td (SECRET) Tj ET", "", vec![]); + assert_eq!(under(&mut doc, 0, &[SECRET_AT_10_100]), 0, "nothing is drawn at y 100"); + let raised = [5.0, 145.0, 45.0, 160.0]; + assert_eq!(under(&mut doc, 0, &[raised]), 6); + mark(&mut doc, 0, &[raised], ""); + assert_eq!(apply(&mut doc, None).unwrap().glyphs, 6); + assert!(!content(&doc, 0).contains("SECRET")); +} From 0d4074755d8382e758f6e9ffc9a4afe7b8e6bcc4 Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Mon, 9 Nov 2026 05:36:50 -0500 Subject: [PATCH 34/41] fix(automation): sign_trust deduplicates certificates with a set (#5965) --- crates/automation/src/signing.rs | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/crates/automation/src/signing.rs b/crates/automation/src/signing.rs index df0fd643fb..c78baf25e9 100644 --- a/crates/automation/src/signing.rs +++ b/crates/automation/src/signing.rs @@ -310,6 +310,9 @@ impl Automation { pub(crate) fn sign_trust(&mut self, a: &Args) -> Result { let mut certs: Vec = if a.opt_bool("clear")?.unwrap_or(false) { Vec::new() } else { self.session.trusted_certificates().to_vec() }; + // Which certificates are already in, by their bytes: a set, so a bundle of many distinct + // certificates isn't compared against every one before it. + let mut known: std::collections::HashSet> = certs.iter().map(|c| c.raw.clone()).collect(); let paths: Vec = match a.get("paths") { None => Vec::new(), Some(v) => v @@ -330,7 +333,7 @@ impl Automation { sign::x509::load_certificates(&bytes).map_err(failed)? }; for c in found { - if !certs.iter().any(|t| t.raw == c.raw) { + if known.insert(c.raw.clone()) { certs.push(c); } } From 3fe86a118e6c02d07030cf8759f9c567d16265d3 Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Mon, 9 Nov 2026 05:37:07 -0500 Subject: [PATCH 35/41] fix(print,a11y): read summary scans one operator at a time and borrow form resources (#5968) --- crates/a11y/src/content.rs | 3 ++- crates/print/src/sheets.rs | 12 ++++++++---- 2 files changed, 10 insertions(+), 5 deletions(-) diff --git a/crates/a11y/src/content.rs b/crates/a11y/src/content.rs index d548c57f9d..118b7af4e5 100644 --- a/crates/a11y/src/content.rs +++ b/crates/a11y/src/content.rs @@ -73,7 +73,8 @@ impl Walk<'_> { // Marked-content stack: whether each level is tagged or an artifact. let mut stack: Vec = Vec::new(); let mut untagged = 0; - for op in pdfcraft_content::parse(data).ops { + // One operator at a time: the walk keeps counts, never the stream's operators. + for op in pdfcraft_content::ops(data) { let inside = covered || stack.last().copied().unwrap_or(false); match op.op.as_slice() { b"BMC" => stack.push(inside || op.name(0) == Some(b"Artifact")), diff --git a/crates/print/src/sheets.rs b/crates/print/src/sheets.rs index fa34f5c6f8..b4ee125fd3 100644 --- a/crates/print/src/sheets.rs +++ b/crates/print/src/sheets.rs @@ -20,7 +20,8 @@ pub fn text_reads_up(doc: &Document, index: usize) -> bool { pub(crate) fn text_direction(doc: &Document, data: &[u8], res: &Dict, base: Matrix, depth: usize, score: &mut i64, budget: &mut DecodeBudget) { let (mut ctm, mut tm, mut stack) = (base, Matrix::IDENTITY, Vec::new()); - for op in pdfcraft_content::parse(data).ops { + // One operator at a time: only the running score is kept, never the stream's operators. + for op in pdfcraft_content::ops(data) { match op.op.as_slice() { // Bounded like the renderer's graphics-state stack: deeper saves are ignored. b"q" if stack.len() < 256 => stack.push(ctm), @@ -39,13 +40,16 @@ pub(crate) fn text_direction(doc: &Document, data: &[u8], res: &Dict, base: Matr if form.dict.get(b"Subtype").and_then(|s| doc.resolve(s).as_name().map(<[u8]>::to_vec)).as_deref() != Some(b"Form".as_slice()) { continue; } - let m = form.dict.get(b"Matrix").and_then(|m| doc.resolve(m).as_array().and_then(|a| Matrix::from_operands(a))).unwrap_or_default(); - let inner = form.dict.get(b"Resources").and_then(|r| doc.resolve(r).as_dict().cloned()).unwrap_or_else(|| res.clone()); + // The visit is paid for before anything else is read, and the form's resources + // (or the inherited ones) are borrowed, not copied for each invocation. if !budget.enter_form() { continue; } + let m = form.dict.get(b"Matrix").and_then(|m| doc.resolve(m).as_array().and_then(|a| Matrix::from_operands(a))).unwrap_or_default(); + let own = form.dict.get(b"Resources").map(|r| doc.resolve(r)); + let inner = own.as_deref().and_then(Object::as_dict).unwrap_or(res); if let Ok(body) = budget.decode(form) { - text_direction(doc, &body, &inner, m.then(&ctm), depth + 1, score, budget); + text_direction(doc, &body, inner, m.then(&ctm), depth + 1, score, budget); } } _ => {} From 8f29ed66f93280b14c7e373f5fd19239ff197ac5 Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Mon, 9 Nov 2026 05:37:30 -0500 Subject: [PATCH 36/41] fix(engine): open linked documents without blocking and check the handle is a regular file (#5982) --- crates/engine/src/links.rs | 20 +++++++++++++++++++- 1 file changed, 19 insertions(+), 1 deletion(-) diff --git a/crates/engine/src/links.rs b/crates/engine/src/links.rs index e1d54d56ce..da3f0fc904 100644 --- a/crates/engine/src/links.rs +++ b/crates/engine/src/links.rs @@ -227,7 +227,25 @@ pub fn read_remote_document(path: &Path) -> Result, RemoteDocumentPathEr if meta.len() > MAX_REMOTE_DOCUMENT_BYTES { return Err(RemoteDocumentPathError::TooLarge); } - let file = std::fs::File::open(path).map_err(|e| RemoteDocumentPathError::Read(e.to_string()))?; + // The path can change between that check and the open (a file replaced by a FIFO): opened + // without blocking, and checked again on the handle, which is what is read. + let mut options = std::fs::OpenOptions::new(); + options.read(true); + #[cfg(unix)] + { + use std::os::unix::fs::OpenOptionsExt as _; + // O_NONBLOCK: Linux 0o4000, macOS and the BSDs 0x4. + options.custom_flags(if cfg!(target_os = "linux") { 0o4000 } else { 0x4 }); + } + let file = options.open(path).map_err(|e| RemoteDocumentPathError::Read(e.to_string()))?; + let meta = file.metadata().map_err(|e| RemoteDocumentPathError::Read(e.to_string()))?; + if !meta.is_file() { + return Err(RemoteDocumentPathError::NotAFile); + } + // the opened handle's own length: a file replaced after the path check must not size the buffer + if meta.len() > MAX_REMOTE_DOCUMENT_BYTES { + return Err(RemoteDocumentPathError::TooLarge); + } let mut bytes = Vec::with_capacity(usize::try_from(meta.len()).unwrap_or(0)); file.take(MAX_REMOTE_DOCUMENT_BYTES.saturating_add(1)).read_to_end(&mut bytes).map_err(|e| RemoteDocumentPathError::Read(e.to_string()))?; if u64::try_from(bytes.len()).map_or(true, |n| n > MAX_REMOTE_DOCUMENT_BYTES) { From 4cdc3d15dde53f4cd423c54b23c840152a4824e6 Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Mon, 9 Nov 2026 05:37:46 -0500 Subject: [PATCH 37/41] fix(redact): read the page tree once per sanitize pass, not per page (#5983) --- crates/redact/src/sanitize.rs | 8 ++++++-- crates/redact/src/tests/limits.rs | 20 ++++++++++++++++++++ 2 files changed, 26 insertions(+), 2 deletions(-) diff --git a/crates/redact/src/sanitize.rs b/crates/redact/src/sanitize.rs index 35eac975f8..5a4ea3bfcd 100644 --- a/crates/redact/src/sanitize.rs +++ b/crates/redact/src/sanitize.rs @@ -587,9 +587,12 @@ fn content_pass(doc: &mut Document, text: bool, layers: bool, write: bool) -> Re // The original image and form XObjects the rewrite drops or replaces, on any page. let mut doomed = Vec::new(); let mut retired_properties = Vec::new(); - let n = pdfcraft_model::pages(doc).len(); + // The page tree is read once, and again only after a page's rewrite (pinning its inherited + // resources can change its ancestors), not once per page: a scan of many pages stays linear. + let mut pages = pdfcraft_model::pages(doc); + let n = pages.len(); for pi in 0..n { - let page = pdfcraft_model::pages(doc).swap_remove(pi); + let Some(page) = pages.get(pi).cloned() else { break }; let mut budget = pdfcraft_cos::DecodeBudget::page(); let Ok((list, data)) = page_streams_within(doc, &page.dict, pi, &mut budget) else { if write { @@ -672,6 +675,7 @@ fn content_pass(doc: &mut Document, text: bool, layers: bool, write: bool) -> Re pdfcraft_content::Pieces::join(&page_content).data(), ); crate::tags::clean(doc, page.obj, None, &changed_ids, &empty_ids)?; + pages = pdfcraft_model::pages(doc); } for (old, new, changed, empty) in out.tag_changes { crate::tags::clean(doc, page.obj, Some((old, new)), &changed, &empty)?; diff --git a/crates/redact/src/tests/limits.rs b/crates/redact/src/tests/limits.rs index 8519e08608..b241dc4cdc 100644 --- a/crates/redact/src/tests/limits.rs +++ b/crates/redact/src/tests/limits.rs @@ -53,3 +53,23 @@ fn a_change_inside_nested_marked_content_touches_every_enclosing_mcid() { assert_eq!(changed, HashSet::from([0, 1])); assert_eq!(empty, HashSet::from([1])); } + +/// The hidden-information scan read the whole page tree again for every page: 20 000 pages cost +/// 400 million page visits. It reads the tree once per pass now. +#[test] +fn a_scan_of_many_pages_reads_the_page_tree_once() { + use crate::sanitize::{Hidden, scan}; + const N: usize = 20_000; + let kids: String = (0..N).map(|i| format!("{} 0 R ", 4 + i)).collect(); + let mut objs = vec![ + b"<< /Type /Catalog /Pages 2 0 R >>".to_vec(), + format!("<< /Type /Pages /Kids [{kids}] /Count {N} /MediaBox [0 0 300 300] >>").into_bytes(), + stream("", b"BT 3 Tr (x) Tj ET"), + ]; + objs.extend((0..N).map(|_| b"<< /Type /Page /Parent 2 0 R /Contents 3 0 R >>".to_vec())); + let doc = pdf(objs); + let started = std::time::Instant::now(); + let counts: HashMap = scan(&doc).into_iter().collect(); + assert_eq!(counts[&Hidden::HiddenText], N, "one invisible glyph on every page"); + assert!(started.elapsed() < std::time::Duration::from_secs(60), "{:?}", started.elapsed()); +} From f800c12a7eb23bc613edd79a1489055fbbc99e6e Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Mon, 9 Nov 2026 05:38:01 -0500 Subject: [PATCH 38/41] fix(xtask): render-oracle writes page pixels into a private temporary folder (#5988) --- xtask/src/gates.rs | 54 +++++++++++++++++++++++++++++++++++++++++++--- 1 file changed, 51 insertions(+), 3 deletions(-) diff --git a/xtask/src/gates.rs b/xtask/src/gates.rs index 58efe66777..c6f1e06d83 100644 --- a/xtask/src/gates.rs +++ b/xtask/src/gates.rs @@ -323,6 +323,35 @@ pub fn text_oracle(args: &[String]) -> anyhow::Result<()> { Ok(()) } +/// A new folder in the temporary directory that only this user can open (0700 on Unix), removed +/// when dropped, on success and on error alike. +struct PrivateDir(PathBuf); + +impl PrivateDir { + fn new(prefix: &str) -> anyhow::Result { + let nanos = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map_or(0, |d| d.subsec_nanos()); + for attempt in 0..100u32 { + let path = std::env::temp_dir().join(format!("{prefix}-{}-{nanos}-{attempt}", std::process::id())); + let mut builder = std::fs::DirBuilder::new(); + #[cfg(unix)] + std::os::unix::fs::DirBuilderExt::mode(&mut builder, 0o700); + // `create`, not `create_all`: a name that is already there is never used. + match builder.create(&path) { + Ok(()) => return Ok(Self(path)), + Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => continue, + Err(e) => return Err(e).with_context(|| format!("create {}", path.display())), + } + } + bail!("no unused temporary folder name for {prefix}") + } +} + +impl Drop for PrivateDir { + fn drop(&mut self) { + let _ = std::fs::remove_dir_all(&self.0); + } +} + /// Compare our rendering against Poppler's, page 1 of every PDF in a directory. /// /// This is the measured half of the fidelity gap (`docs/gaps.md` gap 12): until it exists, every @@ -368,7 +397,10 @@ pub fn render_oracle(args: &[String]) -> anyhow::Result<()> { .collect(); files.sort(); - let out_pam = std::env::temp_dir().join(format!("pdfcraft-render-oracle-{}.pam", std::process::id())); + // The page's pixels go into a folder only this user can open, made for this run (never an + // existing one, which another user could have put a link in), and removed with it. + let private = PrivateDir::new("pdfcraft-render-oracle")?; + let out_pam = private.0.join("page.pam"); let (mut scores, mut sizes_differed, mut ours_failed, mut theirs_failed) = (Vec::new(), 0usize, 0usize, 0usize); let mut worst: Vec<(f64, String)> = Vec::new(); for file in files.iter().take(limit) { @@ -410,7 +442,7 @@ pub fn render_oracle(args: &[String]) -> anyhow::Result<()> { Err(_) => sizes_differed = sizes_differed.saturating_add(1), } } - let _ = std::fs::remove_file(&out_pam); + drop(private); if scores.is_empty() { bail!( @@ -551,7 +583,23 @@ fn f1(a: &BTreeMap, b: &BTreeMap) -> f64 { #[cfg(test)] mod tests { - use super::{f1, order_score, root, word_sequence, words}; + use super::{PrivateDir, f1, order_score, root, word_sequence, words}; + + /// Each run gets a new folder of its own, private to the user, gone when the run ends. + #[test] + fn the_oracle_folder_is_new_private_and_removed() { + let (a, b) = (PrivateDir::new("pdfcraft-private-test").unwrap(), PrivateDir::new("pdfcraft-private-test").unwrap()); + assert_ne!(a.0, b.0); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + assert_eq!(std::fs::metadata(&a.0).unwrap().permissions().mode() & 0o777, 0o700); + } + let path = a.0.clone(); + std::fs::write(path.join("page.pam"), b"x").unwrap(); + drop(a); + assert!(!path.exists()); + } /// The two scores answer different questions, and the point of adding the second one is the /// first row: F1 cannot tell a page from the same page read backwards. From ba47f7bbc5d8e6e8df77b41bbac9ff4ccb2d0ec5 Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Mon, 9 Nov 2026 05:38:17 -0500 Subject: [PATCH 39/41] fix(cli): render needs the copying permission, as text does (#5990) --- apps/pdfcraft-cli/src/commands.rs | 8 ++++++++ apps/pdfcraft-cli/tests/it/owner_password.rs | 6 ++++++ 2 files changed, 14 insertions(+) diff --git a/apps/pdfcraft-cli/src/commands.rs b/apps/pdfcraft-cli/src/commands.rs index 86b31bafd3..1df3cf8789 100644 --- a/apps/pdfcraft-cli/src/commands.rs +++ b/apps/pdfcraft-cli/src/commands.rs @@ -387,6 +387,14 @@ pub(crate) fn render(args: &[String]) -> Result<(), CliError> { // The renderer takes only the user password of R2–R4 files; `Session::open` and the display // copy take either. let render_password = pdfcraft_engine::render_password_for(bytes.clone(), password); + // A page image is an image taken out of the document, as a snapshot or Export is in the app: + // like `text`, it needs the document to allow copying (or the permissions password). + let mut session = pdfcraft_engine::Session::new(); + if let Some(doc) = session.open(path, None, bytes.clone(), password).ok().and_then(|id| session.get(id)) { + doc.check_copying().map_err( + |_| "render: the document's permissions don't allow copying its pages as images; pass the owner password with --password to render it", + )?; + } let display = if inspect(bytes.clone(), render_password.as_deref()).is_ok_and(|info| info.xfa == Some(pdfcraft_render::Xfa::Dynamic)) { let mut session = pdfcraft_engine::Session::new(); let id = session.open(path, None, bytes, password).map_err(|e| e.to_string())?; diff --git a/apps/pdfcraft-cli/tests/it/owner_password.rs b/apps/pdfcraft-cli/tests/it/owner_password.rs index 6796980982..5a3e061728 100644 --- a/apps/pdfcraft-cli/tests/it/owner_password.rs +++ b/apps/pdfcraft-cli/tests/it/owner_password.rs @@ -130,6 +130,12 @@ fn render_accepts_the_owner_password() { .output() .unwrap(); let stderr = String::from_utf8_lossy(&out.stderr); + if pw == "user" { + // The user password opens it, but a page image is a copy the permissions forbid. + assert!(!out.status.success() && stderr.contains("don't allow copying"), "{alg:?} with {pw:?}: {stderr}"); + assert!(!png.exists(), "{alg:?}: no image is written"); + continue; + } assert!(out.status.success(), "{alg:?} with {pw:?}: {stderr}"); assert_rendered_page(&std::fs::read(&png).unwrap(), DPI, &format!("{alg:?} with {pw:?}")); } From b4679deafd9105d050dd4b3bc0d83f320857c833 Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Mon, 9 Nov 2026 05:38:34 -0500 Subject: [PATCH 40/41] fix(cli): cap input reads, budget check's edit pass and escape its stderr records (#5997) --- apps/pdfcraft-cli/src/check.rs | 37 +++++++++++++++++++++------ apps/pdfcraft-cli/src/commands.rs | 13 +++++++++- apps/pdfcraft-cli/tests/it/check.rs | 15 +++++++++++ apps/pdfcraft-cli/tests/it/options.rs | 11 ++++++++ 4 files changed, 67 insertions(+), 9 deletions(-) diff --git a/apps/pdfcraft-cli/src/check.rs b/apps/pdfcraft-cli/src/check.rs index 62993ad6dc..4521181b6f 100644 --- a/apps/pdfcraft-cli/src/check.rs +++ b/apps/pdfcraft-cli/src/check.rs @@ -43,7 +43,18 @@ pub(crate) fn check_one(args: &[String]) -> Result<(), CliError> { let path = *positional(args, CHECK_ONE_OPTIONS).first().ok_or("check-one: missing file")?; let dpi = parse_dpi(flag(args, "--dpi").unwrap_or("36"))?; let start = Instant::now(); - let bytes = read(path)?; + // A file that can't be read (past the input cap, say) is this file's record, not a crash of the child. + let bytes = match read(path) { + Ok(bytes) => bytes, + Err(e) => { + let status = if e.contains("larger than") { "input-too-large" } else { "read-error" }; + let line = serde_json::json!({ "file": path, "status": status, "pages": 0, "failed_pages": 0, "warnings": 0, + "ms": start.elapsed().as_millis() as u64, "detail": e.chars().take(400).collect::() }); + stdout_line(format_args!("{line}"))?; + // (the record is the answer `check` reads; the error and exit status are for a direct run) + return Err(e.into()); + } + }; let (status, pages, failed, detail, warnings) = match inspect(bytes.clone(), None) { Err(e) => (format!("open-error:{}", short(&e.to_string())), 0, 0, e.to_string(), 0), Ok(info) => { @@ -65,7 +76,7 @@ pub(crate) fn check_one(args: &[String]) -> Result<(), CliError> { // `--edit` (fuzzing): also run our own object layer end to end, with no panic safety net, // so a panic in cos shows up as a crash of this process. if args.iter().any(|a| a == "--edit") { - edit_round_trip(&std::fs::read(path).map_err(|e| e.to_string())?); + edit_round_trip(&read(path)?); } let line = serde_json::json!({ "file": path, "status": status, "pages": pages, "failed_pages": failed, "warnings": warnings, "ms": start.elapsed().as_millis() as u64, "detail": detail.chars().take(400).collect::() }); @@ -76,13 +87,19 @@ pub(crate) fn check_one(args: &[String]) -> Result<(), CliError> { /// Open with cos, touch every object, edit the catalog, save incrementally and in full (both /// output styles), and reopen each result. Errors are fine; panics, hangs and aborts are bugs. fn edit_round_trip(bytes: &[u8]) { - use pdfcraft_cos::{Document, Object, SaveOptions, write_full, write_incremental}; + use pdfcraft_cos::{DecodeBudget, Document, Object, SaveOptions, write_full, write_incremental}; let Ok(mut doc) = Document::open(Arc::new(bytes.to_vec())) else { return }; + // One allowance for every stream, charged for what each decode did (failed ones too), so + // many small bombs can't each inflate to the per-stream limit. + let mut budget = DecodeBudget::page(); for num in doc.object_numbers().into_iter().take(20_000) { + if budget.exhausted() { + break; + } if let Ok(o) = doc.try_get(num) && let Object::Stream(s) = &*o { - let _ = s.decoded(); + let _ = budget.decode(s); } } if let Some(root) = doc.root() { @@ -113,7 +130,7 @@ fn collect(paths: &[&str]) -> Vec { let identity = match p.canonicalize() { Ok(identity) => identity, Err(error) => { - eprintln!("{}: could not resolve directory: {error}", p.display()); + eprintln!("{}: could not resolve directory: {error}", p.display().to_string().escape_debug()); continue; } }; @@ -158,12 +175,16 @@ pub(crate) fn check(args: &[String]) -> Result<(), CliError> { let status = r["status"].as_str().unwrap_or("?").to_string(); *counts.entry(status.split(':').next().unwrap_or("?").to_string()).or_default() += 1; if status != "ok" { + // One record per line: a file name or detail with a line break or a terminal control in + // it is written escaped, so it can't forge another record (the JSON report keeps both). + let detail: String = r["detail"].as_str().unwrap_or("").chars().take(160).collect(); eprintln!( - "[{}/{}] {status:<28} {} {}", + "[{}/{}] {:<28} {} {}", n + 1, files.len(), - f.display(), - r["detail"].as_str().unwrap_or("").chars().take(160).collect::() + status.escape_debug().to_string(), + f.display().to_string().escape_debug(), + detail.escape_debug() ); } results.push(r); diff --git a/apps/pdfcraft-cli/src/commands.rs b/apps/pdfcraft-cli/src/commands.rs index 1df3cf8789..5a74f19f56 100644 --- a/apps/pdfcraft-cli/src/commands.rs +++ b/apps/pdfcraft-cli/src/commands.rs @@ -4,8 +4,19 @@ use super::options::{ }; use super::{Arc, CliError, ImageFormat, PageRenderer, Path, PathBuf, RenderConfig, RenderRequest, RequestKind, Write, inspect, stdout_line}; +/// Largest input file read into memory (512 MiB, as automation's `doc_open` reads). +pub(crate) const MAX_INPUT: u64 = 512 * 1024 * 1024; + +/// The file at `path`, read no further than [`MAX_INPUT`]: a name that leads to something +/// endless (a device, a pipe nobody closes) fails at the cap instead of filling memory. pub(crate) fn read(path: &str) -> Result>, String> { - std::fs::read(path).map(Arc::new).map_err(|e| format!("{path}: {e}")) + use std::io::Read as _; + let mut bytes = Vec::new(); + std::fs::File::open(path).and_then(|f| f.take(MAX_INPUT + 1).read_to_end(&mut bytes)).map_err(|e| format!("{path}: {e}"))?; + if bytes.len() as u64 > MAX_INPUT { + return Err(format!("{path}: the file is larger than 512 MiB")); + } + Ok(Arc::new(bytes)) } /// `info FILE`: a JSON summary of the document as the app opens it (pages, fields, metadata, the diff --git a/apps/pdfcraft-cli/tests/it/check.rs b/apps/pdfcraft-cli/tests/it/check.rs index 7a6236419c..3f32459089 100644 --- a/apps/pdfcraft-cli/tests/it/check.rs +++ b/apps/pdfcraft-cli/tests/it/check.rs @@ -129,3 +129,18 @@ fn a_bad_timeout_or_dpi_is_refused_before_scanning() { ); } } + +/// A failing file's name goes to stderr escaped: a line break in it can't start a second, +/// forged record. The JSON report keeps the name as it is. +#[cfg(unix)] +#[test] +fn a_file_name_cannot_forge_a_stderr_record() { + let dir = Workdir::new("escaped-name"); + let name = "bad\n[9] ok forged.pdf"; + std::fs::write(dir.0.join("input").join(name), b"not a pdf").unwrap(); + let out = Command::new(BIN).arg("check").arg(dir.0.join("input")).args(["--dpi", "36", "--timeout", "5"]).output().unwrap(); + let stderr = String::from_utf8_lossy(&out.stderr); + let records: Vec<&str> = stderr.lines().filter(|l| l.starts_with('[')).collect(); + assert_eq!(records.len(), 1, "{stderr}"); + assert!(records[0].contains("bad\\n[9] ok forged.pdf"), "{stderr}"); +} diff --git a/apps/pdfcraft-cli/tests/it/options.rs b/apps/pdfcraft-cli/tests/it/options.rs index 7955157e28..d414922998 100644 --- a/apps/pdfcraft-cli/tests/it/options.rs +++ b/apps/pdfcraft-cli/tests/it/options.rs @@ -113,3 +113,14 @@ fn an_argument_that_is_not_utf8_is_an_error_not_a_panic() { assert!(!stderr.contains("panicked"), "{stderr}"); } } + +/// A file `check-one` cannot read is a record on stdout with its own status and the reason, which +/// `check` relays, not an empty stdout that reads as a crash. +#[test] +fn check_one_reports_an_unreadable_file_as_a_record() { + let out = Command::new(BIN).args(["check-one", "missing.pdf"]).output().expect("run cli"); + let stdout = String::from_utf8_lossy(&out.stdout); + let record: serde_json::Value = serde_json::from_str(stdout.trim()).unwrap_or_else(|e| panic!("{e}: {stdout:?}")); + assert_eq!(record["status"], "read-error"); + assert!(record["detail"].as_str().is_some_and(|d| d.contains("missing.pdf")), "{record}"); +} From dafb3be3de1373465018912c0157bc0df12d0b83 Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Mon, 9 Nov 2026 05:38:52 -0500 Subject: [PATCH 41/41] fix(xfa): one decoding allowance for all XFA packets (#6000) --- crates/xfa/src/packets.rs | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/crates/xfa/src/packets.rs b/crates/xfa/src/packets.rs index f79b071c0e..4202ffbb55 100644 --- a/crates/xfa/src/packets.rs +++ b/crates/xfa/src/packets.rs @@ -7,6 +7,8 @@ use crate::XfaError; /// Most decoded packet bytes read in total. const MAX_XDP: usize = 64 << 20; +/// Most bytes all filter stages of all packets may decode together. +const MAX_DECODE_WORK: usize = 4 * MAX_XDP; /// What was found in the XFA entry. #[derive(Clone, Debug, Default, PartialEq)] @@ -116,13 +118,17 @@ pub fn read_packets(doc: &Document) -> Result, XfaError> { // A stream listed again adds nothing but another decode, which may cost far more than the // bytes it yields (a Flate stream of hex whitespace). let mut seen = std::collections::HashSet::new(); + // One decoding allowance for every packet, charged for what each filter stage produced (a + // stage a later one shrinks included): a fresh MAX_XDP per stream let many packets of + // compressed bulk that decode to little cost far more than the packets kept. + let mut budget = pdfcraft_cos::DecodeBudget::new(MAX_DECODE_WORK, 0); let mut read = |o: &Object| -> Result, XfaError> { if o.as_ref().is_some_and(|r| !seen.insert(r)) { return Ok(None); } let s = doc.resolve(o); let Object::Stream(s) = &*s else { return Ok(None) }; - let bytes = s.decoded_within(MAX_XDP)?; + let bytes = budget.decode_within(s, MAX_XDP)?; total = total.saturating_add(bytes.len()); if total > MAX_XDP { return Err(XfaError::TooLarge("the XFA packets are larger than 64 MB".into()));