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/a11y/src/autotag.rs b/crates/a11y/src/autotag.rs index 5c0dc2bd19..1b898fdfda 100644 --- a/crates/a11y/src/autotag.rs +++ b/crates/a11y/src/autotag.rs @@ -9,6 +9,10 @@ use pdfcraft_cos::{DecodeBudget, Dict, Document, ObjRef, Object, Stream}; const MAX_PAGES: usize = 1_000; const MAX_PAGE_BYTES: usize = 16 * 1024 * 1024; const MAX_OPERATORS: usize = 100_000; +/// What all pages together may decode and keep: pages can share one stream, so the per-page limits +/// alone let a small file retain a plan for every page. Real documents stay far under these. +const MAX_TOTAL_BYTES: usize = 128 * 1024 * 1024; +const MAX_TOTAL_OPERATORS: usize = 2_000_000; const MAX_TEXT_OBJECTS: usize = 4_096; #[derive(Clone, Copy, Debug, PartialEq, Eq)] @@ -186,6 +190,7 @@ fn plan(doc: &Document) -> Result, AutoTagError> { } let mut out = Vec::with_capacity(pages.len()); let mut total = 0usize; + let (mut total_bytes, mut total_ops) = (0usize, 0usize); for (index, page) in pages.iter().enumerate() { if page.dict.contains(b"StructParents") { return Err(unsupported(index, "page already has a structure parent key")); @@ -197,10 +202,15 @@ fn plan(doc: &Document) -> Result, AutoTagError> { return Err(unsupported(index, "annotations need separate structure tags")); } let bytes = content_bytes(doc, page, index)?; + total_bytes = total_bytes.saturating_add(bytes.len()); let parsed = parse(&bytes); if parsed.skipped > 0 || parsed.ops.len() > MAX_OPERATORS { return Err(unsupported(index, "content has malformed or too many operators")); } + total_ops = total_ops.saturating_add(parsed.ops.len()); + if total_bytes > MAX_TOTAL_BYTES || total_ops > MAX_TOTAL_OPERATORS { + return Err(unsupported(index, "the document has too much content to auto-tag in one edit")); + } let spans = text_spans(&parsed.ops, index)?; total = total.saturating_add(spans.len()); out.push(Plan { page: page.obj, ops: parsed.ops, spans }); diff --git a/crates/a11y/src/tests.rs b/crates/a11y/src/tests.rs index 66ae06fce4..e5bc3e425a 100644 --- a/crates/a11y/src/tests.rs +++ b/crates/a11y/src/tests.rs @@ -1763,3 +1763,20 @@ fn marking_a_tag_artifact_refuses_content_past_the_decode_budget() { assert_eq!(mark_tag_artifact(&mut doc, ObjRef::new(7, 0)), Err(ArtifactError::TooLarge)); assert_eq!(doc.get(ObjRef::new(3, 0)).as_dict().unwrap().get(b"Contents"), Some(&contents), "the page keeps its content"); } + +#[test] +fn autotag_text_refuses_pages_that_share_content_past_the_total_operator_allowance() { + // Each page is just under the per-page limit; sharing one stream makes the total 21 times it. + let content = format!("BT (a) Tj ET {}", "q Q ".repeat(49_990)); + let pages = 21; + let kids: Vec = (0..pages).map(|i| format!("{} 0 R", 4 + i)).collect(); + let mut bodies = vec![ + "<< /Type /Catalog /Pages 2 0 R >>".to_string(), + format!("<< /Type /Pages /Kids [{}] /Count {pages} /MediaBox [0 0 200 200] >>", kids.join(" ")), + stream(&content), + ]; + bodies.extend((0..pages).map(|_| "<< /Type /Page /Parent 2 0 R /Contents 3 0 R >>".to_string())); + let mut doc = pdf(&bodies, ""); + let Err(AutoTagError::Unsupported { reason, .. }) = autotag_text(&mut doc) else { panic!("expected a refusal") }; + assert!(reason.contains("too much content"), "{reason}"); +} 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/crates/export/src/zip.rs b/crates/export/src/zip.rs index a89d08cb7b..e15966c119 100644 --- a/crates/export/src/zip.rs +++ b/crates/export/src/zip.rs @@ -1,4 +1,6 @@ -//! A minimal zip writer (deflate or store, no zip64) for Office files. +//! A minimal zip writer (deflate or store) for Office files. Archives that outgrow the classic +//! end-of-central-directory record's 16-bit entry count or 32-bit sizes are written with the +//! ZIP64 records (APPNOTE 4.4), which every reader understands; small ones are unchanged. use std::io::Write; @@ -22,12 +24,102 @@ fn crc32(data: &[u8]) -> u32 { c ^ 0xFFFF_FFFF } -/// Builds a zip archive in memory. +/// The largest entry count the classic end-of-central-directory record holds (APPNOTE 4.3.12). +const MAX_CLASSIC_COUNT: u64 = u16::MAX as u64; +/// The largest size or offset the classic records hold: past it the field is a sentinel and the +/// ZIP64 record carries the real value (APPNOTE 4.4.1). +const MAX_CLASSIC_U32: u64 = u32::MAX as u64; + +/// The ZIP64 extended-information extra field (APPNOTE 4.5.3) holding `fields` — the values that +/// no longer fit their classic fields — in specification order. Empty when everything fits. +fn zip64_extra(fields: &[u64]) -> Vec { + let mut e = Vec::with_capacity(4 + 8 * fields.len()); + if !fields.is_empty() { + e.extend(0x0001u16.to_le_bytes()); + e.extend((8 * fields.len() as u16).to_le_bytes()); + for &f in fields { + e.extend(f.to_le_bytes()); + } + } + e +} + +/// The classic 32-bit field value for `value`: the value itself, or the 0xFFFFFFFF sentinel when it +/// no longer fits — including when it equals the sentinel, which a reader cannot tell from a real +/// value — in which case the ZIP64 extra field carries the real value (APPNOTE 4.4.3). +fn classic_u32(value: u64) -> u32 { + if value >= MAX_CLASSIC_U32 { u32::MAX } else { value as u32 } +} + +/// Builds one member's local file header and central directory entry. The sizes and `offset` are +/// parameters, not read from the body, so tests can inject values past the classic fields without +/// allocating the data. +fn entry_headers(name: &str, crc: u32, method: u16, compressed: u64, uncompressed: u64, offset: u64) -> (Vec, Vec) { + // A value at or above the sentinel cannot be held by a classic field, so it is offloaded. + let size_is_zip64 = uncompressed >= MAX_CLASSIC_U32 || compressed >= MAX_CLASSIC_U32; + // A local header that carries ZIP64 sizes carries both, original then compressed, even when only + // one overflowed (APPNOTE 4.5.3). A central entry carries one value for each field it set to its + // sentinel, and only those, so a reader walking the fields by their classic ones stays in step. + let local_extra = if size_is_zip64 { zip64_extra(&[uncompressed, compressed]) } else { Vec::new() }; + let mut central_fields = Vec::new(); + if uncompressed >= MAX_CLASSIC_U32 { + central_fields.push(uncompressed); + } + if compressed >= MAX_CLASSIC_U32 { + central_fields.push(compressed); + } + if offset >= MAX_CLASSIC_U32 { + central_fields.push(offset); + } + let central_extra = zip64_extra(¢ral_fields); + // An entry that carries ZIP64 fields needs version 4.5 to extract (APPNOTE 4.4.3); the version + // in the local header and the central entry agree. + let version_needed = if size_is_zip64 || offset >= MAX_CLASSIC_U32 { 45u16 } else { 20u16 }; + let header = |sig: u32, central: bool| { + let mut h = Vec::new(); + h.extend(sig.to_le_bytes()); + if central { + h.extend(20u16.to_le_bytes()); // made by + } + h.extend(version_needed.to_le_bytes()); // version needed + h.extend(0x0800u16.to_le_bytes()); // UTF-8 names + h.extend(method.to_le_bytes()); + h.extend([0, 0, 0x21, 0]); // 00:00, 1980-01-01 (a zero date is not a valid DOS date) + h.extend(crc.to_le_bytes()); + // A local ZIP64 extra carries both sizes, so both corresponding classic fields are + // sentinels even when only one size needs offloading (APPNOTE 4.5.3). Central entries + // remain selective and use a sentinel only for fields represented in their extra. + let (compressed_field, uncompressed_field) = + if !central && size_is_zip64 { (u32::MAX, u32::MAX) } else { (classic_u32(compressed), classic_u32(uncompressed)) }; + h.extend(compressed_field.to_le_bytes()); + h.extend(uncompressed_field.to_le_bytes()); + h.extend((name.len() as u16).to_le_bytes()); + let extra = if central { ¢ral_extra } else { &local_extra }; + h.extend((extra.len() as u16).to_le_bytes()); // extra + if central { + h.extend(0u16.to_le_bytes()); // comment + h.extend(0u16.to_le_bytes()); // disk + h.extend(0u16.to_le_bytes()); // internal attributes + h.extend(0u32.to_le_bytes()); // external attributes + h.extend(classic_u32(offset).to_le_bytes()); + } + h.extend(name.as_bytes()); + h.extend(extra); + h + }; + (header(0x0403_4b50, false), header(0x0201_4b50, true)) +} + +/// Builds a zip archive in memory. An archive past the classic record's 16-bit entry count or +/// 32-bit sizes and offsets is written with a ZIP64 end-of-central-directory record: a +/// PowerPoint export writes two entries per page plus ten fixed parts, so a document with +/// 32 763 pages already passes 65 535 entries, and without the ZIP64 record the count wraps +/// and readers see a central directory that does not describe the archive. #[derive(Default)] pub struct Zip { out: Vec, central: Vec, - count: u16, + count: usize, } impl Zip { @@ -41,49 +133,44 @@ impl Zip { } else { (0u16, data.to_vec()) }; - let offset = self.out.len() as u32; - let header = |sig: u32, central: bool| { - let mut h = Vec::new(); - h.extend(sig.to_le_bytes()); - if central { - h.extend(20u16.to_le_bytes()); // made by - } - h.extend(20u16.to_le_bytes()); // version needed - h.extend(0x0800u16.to_le_bytes()); // UTF-8 names - h.extend(method.to_le_bytes()); - h.extend([0, 0, 0x21, 0]); // 00:00, 1980-01-01 (a zero date is not a valid DOS date) - h.extend(crc.to_le_bytes()); - h.extend((body.len() as u32).to_le_bytes()); - h.extend((data.len() as u32).to_le_bytes()); - h.extend((name.len() as u16).to_le_bytes()); - h.extend(0u16.to_le_bytes()); // extra - if central { - h.extend(0u16.to_le_bytes()); // comment - h.extend(0u16.to_le_bytes()); // disk - h.extend(0u16.to_le_bytes()); // internal attributes - h.extend(0u32.to_le_bytes()); // external attributes - h.extend(offset.to_le_bytes()); - } - h.extend(name.as_bytes()); - h - }; - self.out.extend(header(0x0403_4b50, false)); + let offset = self.out.len() as u64; + let (local, central) = entry_headers(name, crc, method, body.len() as u64, data.len() as u64, offset); + self.out.extend(local); self.out.extend(&body); - self.central.extend(header(0x0201_4b50, true)); + self.central.extend(central); self.count += 1; } pub fn finish(mut self) -> Vec { - let start = self.out.len() as u32; - let size = self.central.len() as u32; + let start = self.out.len() as u64; + let size = self.central.len() as u64; + let count = self.count as u64; self.out.append(&mut self.central); + let classic_count = if count > MAX_CLASSIC_COUNT { u16::MAX } else { count as u16 }; + if count > MAX_CLASSIC_COUNT || size >= MAX_CLASSIC_U32 || start >= MAX_CLASSIC_U32 { + let eocd64_at = self.out.len() as u64; + self.out.extend(0x0606_4b50u32.to_le_bytes()); // ZIP64 end of central directory record + self.out.extend(44u64.to_le_bytes()); // size of the rest of this record + self.out.extend(45u16.to_le_bytes()); // version made by (4.5, DOS) + self.out.extend(45u16.to_le_bytes()); // version needed to extract + self.out.extend(0u32.to_le_bytes()); // number of this disk + self.out.extend(0u32.to_le_bytes()); // disk with the start of the central directory + self.out.extend(count.to_le_bytes()); // entries on this disk + self.out.extend(count.to_le_bytes()); // entries in total + self.out.extend(size.to_le_bytes()); // size of the central directory + self.out.extend(start.to_le_bytes()); // offset of the central directory + self.out.extend(0x0706_4b50u32.to_le_bytes()); // ZIP64 end of central directory locator + self.out.extend(0u32.to_le_bytes()); // disk with the ZIP64 record + self.out.extend(eocd64_at.to_le_bytes()); // offset of the ZIP64 record + self.out.extend(1u32.to_le_bytes()); // total number of disks + } self.out.extend(0x0605_4b50u32.to_le_bytes()); - self.out.extend([0u8; 4]); - self.out.extend(self.count.to_le_bytes()); - self.out.extend(self.count.to_le_bytes()); - self.out.extend(size.to_le_bytes()); - self.out.extend(start.to_le_bytes()); - self.out.extend(0u16.to_le_bytes()); + self.out.extend([0u8; 4]); // number of this disk, disk with the central directory + self.out.extend(classic_count.to_le_bytes()); // entries on this disk + self.out.extend(classic_count.to_le_bytes()); // entries in total + self.out.extend(classic_u32(size).to_le_bytes()); + self.out.extend(classic_u32(start).to_le_bytes()); + self.out.extend(0u16.to_le_bytes()); // comment length self.out } } @@ -107,4 +194,155 @@ mod tests { assert_eq!(b.windows(4).filter(|w| *w == b"PK\x01\x02").count(), 2); assert_eq!(&b[b.len() - 22..b.len() - 18], b"PK\x05\x06"); } + + #[test] + fn more_entries_than_the_classic_record_holds_writes_zip64() { + // A PowerPoint export writes two entries per page plus ten fixed parts, so a + // document with 32 763 pages already passes the classic record's 16-bit entry + // count. The count must not wrap: it overflows in a debug build, and in a release + // one it silently writes a central directory readers cannot match to the archive. + let mut z = Zip::default(); + for i in 0..u16::MAX as usize + 1 { + z.add(&format!("ppt/slides/slide{i}.xml"), b"x", true); + z.add(&format!("ppt/slides/_rels/slide{i}.xml.rels"), b"y", true); + } + let b = z.finish(); + let entries = 2 * (u16::MAX as usize + 1); + // The classic end-of-central-directory record holds sentinels… + let eocd = &b[b.len() - 22..]; + assert_eq!(&eocd[..4], &0x0605_4b50u32.to_le_bytes()); + assert_eq!(&eocd[8..10], &u16::MAX.to_le_bytes(), "entries on this disk"); + assert_eq!(&eocd[10..12], &u16::MAX.to_le_bytes(), "entries in total"); + // …and the ZIP64 locator and record carry the truth. + let locator = &b[b.len() - 42..b.len() - 22]; + assert_eq!(&locator[..4], &0x0706_4b50u32.to_le_bytes()); + let at = u64::from_le_bytes(locator[8..16].try_into().unwrap_or([0; 8])) as usize; + assert_eq!(&b[at..at + 4], &0x0606_4b50u32.to_le_bytes()); + assert_eq!(u64::from_le_bytes(b[at + 24..at + 32].try_into().unwrap_or([0; 8])), entries as u64, "entries on this disk"); + assert_eq!(u64::from_le_bytes(b[at + 32..at + 40].try_into().unwrap_or([0; 8])), entries as u64, "entries in total"); + } + + /// The extra field of a local file header (APPNOTE 4.3.7), which follows the file name. + fn local_extra(h: &[u8]) -> &[u8] { + let le = |i: usize, n: usize| h[i..i + n].iter().rev().fold(0usize, |v, b| v << 8 | *b as usize); + let (name_len, extra_len) = (le(26, 2), le(28, 2)); + &h[30 + name_len..30 + name_len + extra_len] + } + + /// The extra field of a central directory entry (APPNOTE 4.3.12), which follows the file name. + fn central_extra(h: &[u8]) -> &[u8] { + let le = |i: usize, n: usize| h[i..i + n].iter().rev().fold(0usize, |v, b| v << 8 | *b as usize); + let (name_len, extra_len) = (le(28, 2), le(30, 2)); + &h[46 + name_len..46 + name_len + extra_len] + } + + /// The classic compressed and original size fields of a local file header (`at` 18) or a + /// central directory entry (`at` 20). + fn classic_sizes(h: &[u8], at: usize) -> (u32, u32) { + let field = |i: usize| u32::from_le_bytes(h[i..i + 4].try_into().unwrap_or([0; 4])); + (field(at), field(at + 4)) + } + + /// Asserts the local header carries both sizes whenever either needs ZIP64, with both classic + /// size fields set to the sentinel (APPNOTE 4.5.3: a ZIP64 value appears only for a field set to + /// its sentinel, and the local entry must carry both sizes). The central entry stays selective: + /// only the fields that themselves overflow get a value, in specification order. + fn assert_local_pair_and_central_selectivity(compressed: u64, uncompressed: u64) { + let at_limit = |v: u64| v >= u32::MAX as u64; + let (local, central) = entry_headers("a", 0, 0, compressed, uncompressed, 0); + assert_eq!(classic_sizes(&local, 18), (u32::MAX, u32::MAX), "both local size fields are sentinels for ({compressed}, {uncompressed})"); + let extra = local_extra(&local); + assert_eq!( + u16::from_le_bytes(extra[2..4].try_into().unwrap_or([0; 2])), + 16, + "both local sizes are present for ({compressed}, {uncompressed})" + ); + assert_eq!(u64::from_le_bytes(extra[4..12].try_into().unwrap_or([0; 8])), uncompressed, "original size first"); + assert_eq!(u64::from_le_bytes(extra[12..20].try_into().unwrap_or([0; 8])), compressed, "compressed size second"); + let expected = + (if at_limit(compressed) { u32::MAX } else { compressed as u32 }, if at_limit(uncompressed) { u32::MAX } else { uncompressed as u32 }); + assert_eq!( + classic_sizes(¢ral, 20), + expected, + "the central entry offloads only the fields that overflow for ({compressed}, {uncompressed})" + ); + let fields = usize::from(at_limit(uncompressed)) + usize::from(at_limit(compressed)); + let extra = central_extra(¢ral); + assert_eq!( + u16::from_le_bytes(extra[2..4].try_into().unwrap_or([0; 2])), + 8 * fields as u16, + "one central value per sentinel field for ({compressed}, {uncompressed})" + ); + if at_limit(uncompressed) { + assert_eq!(u64::from_le_bytes(extra[4..12].try_into().unwrap_or([0; 8])), uncompressed, "original size first"); + } + if at_limit(compressed) { + let at = if at_limit(uncompressed) { 12 } else { 4 }; + assert_eq!(u64::from_le_bytes(extra[at..at + 8].try_into().unwrap_or([0; 8])), compressed, "compressed size in specification order"); + } + } + + #[test] + fn both_local_sizes_are_sentinels_when_only_one_needs_zip64() { + // A local ZIP64 extra must carry both sizes, and its values correspond to classic fields set + // to their sentinel — so a small classic size next to an overflowing one is wrong even + // though the extra holds the truth. Both orders of which size overflows. + assert_local_pair_and_central_selectivity(100, u32::MAX as u64 + 1); + assert_local_pair_and_central_selectivity(u32::MAX as u64 + 1, 100); + } + + #[test] + fn both_local_sizes_are_sentinels_when_one_equals_the_sentinel() { + // A size of exactly 0xFFFFFFFF is indistinguishable from the sentinel, so it needs ZIP64 + // just like a larger one; the other local size must then become a sentinel too. Both orders. + assert_local_pair_and_central_selectivity(100, u32::MAX as u64); + assert_local_pair_and_central_selectivity(u32::MAX as u64, 100); + } + + #[test] + fn local_zip64_extra_carries_both_sizes() { + // When only one size overflows the u32 limit, the local header still carries both sizes — + // original then compressed — because a local ZIP64 extra that carries either must carry + // both (APPNOTE 4.5.3). Either size may be the one past the limit. + for (compressed, uncompressed) in [(100u64, u32::MAX as u64 + 1), (u32::MAX as u64 + 1, 100)] { + let (local, _) = entry_headers("a", 0, 0, compressed, uncompressed, 0); + let extra = local_extra(&local); + assert_eq!(u16::from_le_bytes(extra[..2].try_into().unwrap_or([0; 2])), 0x0001, "ZIP64 extra id"); + assert_eq!(u16::from_le_bytes(extra[2..4].try_into().unwrap_or([0; 2])), 16, "both sizes are present"); + assert_eq!(u64::from_le_bytes(extra[4..12].try_into().unwrap_or([0; 8])), uncompressed, "original size first"); + assert_eq!(u64::from_le_bytes(extra[12..20].try_into().unwrap_or([0; 8])), compressed, "compressed size second"); + } + } + + #[test] + fn zip64_entries_need_version_4_5() { + // An entry that carries ZIP64 fields must advertise version 4.5 to extract, in both the + // local file header and the central directory entry (APPNOTE 4.4.3). An entry that fits + // every classic field stays at 2.0, so small archives are unchanged. + let (local, central) = entry_headers("a", 0, 0, u32::MAX as u64 + 1, u32::MAX as u64 + 1, 0); + assert_eq!(u16::from_le_bytes(local[4..6].try_into().unwrap_or([0; 2])), 45, "local version needed"); + assert_eq!(u16::from_le_bytes(central[6..8].try_into().unwrap_or([0; 2])), 45, "central version needed"); + let (local, central) = entry_headers("a", 0, 0, 4, 8, 0); + assert_eq!(u16::from_le_bytes(local[4..6].try_into().unwrap_or([0; 2])), 20, "local version needed"); + assert_eq!(u16::from_le_bytes(central[6..8].try_into().unwrap_or([0; 2])), 20, "central version needed"); + } + + #[test] + fn size_equal_to_the_sentinel_is_offloaded() { + // A real size of exactly u32::MAX is indistinguishable from the 0xFFFFFFFF sentinel, so it + // too must be offloaded: every classic field set to the sentinel needs its 8-byte value in + // the central extra, or a reader such as Python's zipfile reports "Compress size not + // found". Both orders of which size is the MAX+1 one must work. + for (compressed, uncompressed) in [(u32::MAX as u64, u32::MAX as u64 + 1), (u32::MAX as u64 + 1, u32::MAX as u64)] { + let (local, central) = entry_headers("a", 0, 0, compressed, uncompressed, 0); + assert_eq!(u32::from_le_bytes(local[18..22].try_into().unwrap_or([0; 4])), u32::MAX, "local compressed sentinel"); + assert_eq!(u32::from_le_bytes(local[22..26].try_into().unwrap_or([0; 4])), u32::MAX, "local original sentinel"); + assert_eq!(u32::from_le_bytes(central[20..24].try_into().unwrap_or([0; 4])), u32::MAX, "central compressed sentinel"); + assert_eq!(u32::from_le_bytes(central[24..28].try_into().unwrap_or([0; 4])), u32::MAX, "central original sentinel"); + let extra = central_extra(¢ral); + assert_eq!(u16::from_le_bytes(extra[2..4].try_into().unwrap_or([0; 2])), 16, "a value for each sentinel field"); + assert_eq!(u64::from_le_bytes(extra[4..12].try_into().unwrap_or([0; 8])), uncompressed, "original size first"); + assert_eq!(u64::from_le_bytes(extra[12..20].try_into().unwrap_or([0; 8])), compressed, "compressed size second"); + } + } } diff --git a/crates/organize/src/import/combine.rs b/crates/organize/src/import/combine.rs index 7782e5de18..c1a1fed520 100644 --- a/crates/organize/src/import/combine.rs +++ b/crates/organize/src/import/combine.rs @@ -7,9 +7,13 @@ use super::{Dict, Document, FieldConflicts, HashMap, HashSet, ObjRef, Object, Or /// go to the copied pages. pub fn extract_pages(src: &Document, pages: &[usize]) -> Result { let mut out = Document::new_empty(); + let mut labels = Vec::new(); + let mut has_labels = false; + crate::labels::collect_page_labels(src, pages, 0, &mut labels, &mut has_labels)?; // One source: nothing to deduplicate. Its fields keep their names (a source with two // same-named field roots stays one field, as it was), so Extract and Split never rename. let imported = import_pages_mapped(&mut out, src, pages, 0, FieldConflicts::Merge)?; + crate::labels::finish_page_labels(&mut out, &labels, has_labels)?; // The bookmarks of these pages come along, mapped to the copies (#4020). let outline = kept_bookmarks(source_outline(src, imported.page_map)); if !outline.is_empty() { @@ -119,6 +123,8 @@ pub struct Combiner { out: Document, marks: Vec, attachments: Vec<(Vec, Object)>, + labels: Vec, + has_labels: bool, /// The first source's print standard, and what its pages brought along. standard: Option, /// Whether every source so far declares the first source's print standard. @@ -156,6 +162,8 @@ impl Combiner { out: Document::new_empty(), marks: Vec::new(), attachments: Vec::new(), + labels: Vec::new(), + has_labels: false, standard: None, agreed: true, field_conflicts, @@ -182,7 +190,12 @@ impl Combiner { None => (0..n).collect(), }; let at = crate::page_count(&self.out)?; + let mut labels = self.labels.clone(); + let mut has_labels = self.has_labels; + crate::labels::collect_page_labels(src, &pages, at, &mut labels, &mut has_labels)?; let imported = import_pages_mapped(&mut self.out, src, &pages, at, self.field_conflicts)?; + self.labels = labels; + self.has_labels = has_labels; self.renamed_fields.extend(imported.renamed_fields); if let Some(first) = imported.pages.first() { let outline = source_outline(src, imported.page_map); @@ -212,13 +225,14 @@ impl Combiner { /// [`Combiner::finish`] reporting what the combine renamed. pub fn finish_with_renames(self) -> Result { - let Combiner { mut out, marks, attachments, standard, agreed, renamed_fields, .. } = self; + let Combiner { mut out, marks, attachments, labels, has_labels, standard, agreed, renamed_fields, .. } = self; if let Some(first) = standard.filter(|_| agreed) { crate::pdfx::carry(&mut out, first.source.as_ref(), &first.declared, first.objects)?; } // Sources often share fonts, images and profiles (or are the same file): store them once. let all: Vec = out.object_numbers().into_iter().map(|n| ObjRef::new(n, out.generation(n))).collect(); crate::dedupe::dedupe_resources(&mut out, &all, false); + crate::labels::finish_page_labels(&mut out, &labels, has_labels)?; add_outline(&mut out, &marks)?; set_attachments(&mut out, attachments)?; Ok(CombineOutcome { doc: out, renamed_fields }) @@ -299,9 +313,18 @@ pub fn combine_grouped_with(runs: &[Run<'_>], conflicts: FieldConflicts) -> Resu // The pages in the runs' order. A file's first run comes before its others, and its pages // were copied in run order, so its bookmark (its first copied page) is its first page shown. let mut order: Vec<(ObjRef, Dict)> = Vec::new(); + let mut labels = Vec::new(); + let mut has_labels = false; for (file, start, len) in slices { - let run = copies.get(file).and_then(|c| c.get(start..start.saturating_add(len))).unwrap_or_default(); + let end = start.saturating_add(len); + let at = order.len(); + let run = copies.get(file).and_then(|c| c.get(start..end)).unwrap_or_default(); order.extend(run.iter().map(|r| (*r, Dict::new()))); + if let Some((_, f)) = files.get(file) + && let Some(pages) = f.pages.get(start..end) + { + crate::labels::collect_page_labels(f.src, pages, at, &mut labels, &mut has_labels)?; + } } rebuild(&mut out, &order)?; if let Some((declared, src, objects)) = standard.filter(|_| agreed) { @@ -309,6 +332,7 @@ pub fn combine_grouped_with(runs: &[Run<'_>], conflicts: FieldConflicts) -> Resu } let all: Vec = out.object_numbers().into_iter().map(|n| ObjRef::new(n, out.generation(n))).collect(); crate::dedupe::dedupe_resources(&mut out, &all, false); + crate::labels::finish_page_labels(&mut out, &labels, has_labels)?; add_outline(&mut out, &marks)?; set_attachments(&mut out, attachments)?; Ok(CombineOutcome { doc: out, renamed_fields }) diff --git a/crates/organize/src/labels.rs b/crates/organize/src/labels.rs index 8ee04bed53..e296148ebd 100644 --- a/crates/organize/src/labels.rs +++ b/crates/organize/src/labels.rs @@ -360,11 +360,13 @@ pub fn set_page_label_ranges(doc: &mut Document, ranges: &[LabelRange]) -> Resul return Err(invalid_label("the number tree has too many entries")); } // Validate every range and all effective labels before changing any document object. + let mut prefix_left = MAX_PREFIX_TOTAL_BYTES; for r in ranges { if r.first == 0 || i64::try_from(r.start).is_err() { return Err(invalid_label("range starts and first numbers must be valid positive PDF integers")); } r.label(r.start)?; + prefix_left = prefix_left.checked_sub(r.prefix.len()).ok_or_else(|| invalid_label("decoded prefixes exceed 4 MiB in total"))?; } let mut sorted = ranges.to_vec(); sorted.sort_by_key(|r| r.start); @@ -450,15 +452,9 @@ pub fn number_pages(doc: &mut Document, from: usize, to: usize, style: LabelStyl } // Drop ranges that only continue the previous one. let mut merged: Vec = Vec::new(); + let mut prefix_left = MAX_PREFIX_TOTAL_BYTES; for r in ranges { - let continues = merged.last().is_some_and(|p| { - p.style == r.style - && p.prefix == r.prefix - && r.start.checked_sub(p.start).and_then(|n| u32::try_from(n).ok()).and_then(|n| p.first.checked_add(n)) == Some(r.first) - }); - if !continues { - merged.push(r); - } + push_merged_range(&mut merged, &mut prefix_left, r)?; } // Plain 1, 2, 3 everywhere is what a document without labels shows: remove them. if merged.len() == 1 && merged[0] == default_range() { @@ -467,6 +463,117 @@ pub fn number_pages(doc: &mut Document, from: usize, to: usize, style: LabelStyl set_page_label_ranges(doc, &merged) } +fn push_merged_range(merged: &mut Vec, prefix_left: &mut usize, r: LabelRange) -> Result<(), OrganizeError> { + let continues = merged.last().is_some_and(|p| { + p.style == r.style + && p.prefix == r.prefix + && match r.style { + LabelStyle::None => p.first == r.first, + _ => r.start.checked_sub(p.start).and_then(|n| u32::try_from(n).ok()).and_then(|n| p.first.checked_add(n)) == Some(r.first), + } + }); + if !continues { + if merged.len() + 1 >= MAX_LABEL_TREE_WORK { + return Err(invalid_label("the number tree has too many entries")); + } + *prefix_left = prefix_left.checked_sub(r.prefix.len()).ok_or_else(|| invalid_label("decoded prefixes exceed 4 MiB in total"))?; + merged.push(r); + } + Ok(()) +} + +/// Decimal numbering that shows the source page's physical number (`src_page` is 0-based). +fn source_physical_range(dst_start: usize, src_page: usize) -> LabelRange { + LabelRange { start: dst_start, style: LabelStyle::Decimal, prefix: String::new(), first: u32::try_from(src_page.saturating_add(1)).unwrap_or(1) } +} + +/// Whether `set_page_label_ranges` can store `r` while it covers `r.start..end` (`end` exclusive). +/// Entry-count and aggregate-prefix limits stay hard errors in [`push_merged_range`]. +fn range_writable(r: &LabelRange, end: usize) -> bool { + r.label(r.start).is_ok() && check_spans(std::slice::from_ref(r), end).is_ok() +} + +/// Append the label ranges that `pages` (0-based indices in `src`) carry when placed at +/// `dst_start` in an output document. Sets `has_labels` when `src` has `/PageLabels`. +pub(crate) fn collect_page_labels( + src: &Document, + pages: &[usize], + dst_start: usize, + out: &mut Vec, + has_labels: &mut bool, +) -> Result<(), OrganizeError> { + if pages.is_empty() { + return Ok(()); + } + let mut src_ranges = checked_ranges(src).unwrap_or_default(); + if !src_ranges.is_empty() { + *has_labels = true; + } + for r in &mut src_ranges { + // An empty prefix-only range shows each page's physical number in the source. + if r.style == LabelStyle::None && r.prefix.is_empty() { + r.style = LabelStyle::Decimal; + r.first = u32::try_from(r.start.saturating_add(1)).unwrap_or(1); + } + } + if src_ranges.first().is_none_or(|r| r.start > 0) { + src_ranges.insert(0, default_range()); + } + let mut prefix_left = MAX_PREFIX_TOTAL_BYTES.saturating_sub(out.iter().map(|r| r.prefix.len()).sum::()); + let mut i = 0; + while i < pages.len() { + let src_start = pages[i]; + let mut len = 1; + while i + len < pages.len() && src_start.checked_add(len) == Some(pages[i + len]) { + len += 1; + } + let run_dst = dst_start + i; + let src_end = src_start.saturating_add(len); + let run_end = run_dst.saturating_add(len); + let idx = src_ranges.partition_point(|r| r.start <= src_start).saturating_sub(1); + let span_end = |following: Option| { + following + .filter(|start| *start > src_start && *start < src_end) + .and_then(|start| start.checked_sub(src_start).and_then(|delta| run_dst.checked_add(delta))) + .unwrap_or(run_end) + }; + if let Some(first_range) = src_ranges.get(idx) { + let mapped = match first_range.number(src_start) { + Ok(first) => LabelRange { start: run_dst, style: first_range.style, prefix: first_range.prefix.clone(), first }, + Err(_) => source_physical_range(run_dst, src_start), + }; + let mapped = if range_writable(&mapped, span_end(src_ranges.get(idx + 1).map(|r| r.start))) { + mapped + } else { + source_physical_range(run_dst, src_start) + }; + push_merged_range(out, &mut prefix_left, mapped)?; + } + let tail = src_ranges.get(idx + 1..).unwrap_or_default(); + for (n, r) in tail.iter().enumerate() { + if r.start >= src_end { + break; + } + let Some(delta) = r.start.checked_sub(src_start) else { continue }; + let Some(start) = run_dst.checked_add(delta) else { continue }; + let inner = LabelRange { start, style: r.style, prefix: r.prefix.clone(), first: r.first }; + let following = tail.get(n + 1).map(|next| next.start); + let inner = if range_writable(&inner, span_end(following)) { inner } else { source_physical_range(start, r.start) }; + push_merged_range(out, &mut prefix_left, inner)?; + } + i += len; + } + Ok(()) +} + +/// Write collected page label ranges to `dst` when at least one source carried `/PageLabels`. +pub(crate) fn finish_page_labels(dst: &mut Document, ranges: &[LabelRange], has_labels: bool) -> Result<(), OrganizeError> { + if has_labels && !ranges.is_empty() { + set_page_label_ranges(dst, ranges)?; + } + Ok(()) +} + #[cfg(test)] mod tests { use super::*; diff --git a/crates/organize/src/tests.rs b/crates/organize/src/tests.rs index c79c3d8667..0aa6d149d9 100644 --- a/crates/organize/src/tests.rs +++ b/crates/organize/src/tests.rs @@ -2223,6 +2223,176 @@ fn deleting_pages_keeps_the_label_ranges_on_their_pages() { } } +#[test] +fn extracting_splitting_and_combining_preserve_page_labels() { + use crate::{ + LabelRange, + LabelStyle::{Decimal, LowerRoman, UpperAlpha}, + SplitBy, + }; + let ten = || { + let mut d = doc_a(); + for at in 3..10 { + crate::insert_blank_page(&mut d, at, 300.0, 400.0).unwrap(); + } + let ranges = [ + LabelRange { start: 0, style: LowerRoman, prefix: String::new(), first: 1 }, + LabelRange { start: 3, style: Decimal, prefix: String::new(), first: 1 }, + LabelRange { start: 7, style: Decimal, prefix: "A-".into(), first: 1 }, + ]; + crate::set_page_label_ranges(&mut d, &ranges).unwrap(); + d + }; + let d = ten(); + + // Extracting non-contiguous pages (e.g. first front-matter page and first appendix page) + // keeps their labels and writes /PageLabels (#2670). + let pair = full_roundtrip(&extract_pages(&d, &[0, 7]).unwrap()); + assert_eq!(crate::page_labels(&pair).unwrap(), ["i", "A-1"]); + assert_eq!( + crate::page_label_ranges(&pair), + [ + LabelRange { start: 0, style: LowerRoman, prefix: String::new(), first: 1 }, + LabelRange { start: 1, style: Decimal, prefix: "A-".into(), first: 1 }, + ] + ); + + // Contiguous runs and out-of-order picks keep each page's label and merge continuations. + let picked = full_roundtrip(&extract_pages(&d, &[1, 2, 8, 9, 0]).unwrap()); + assert_eq!(crate::page_labels(&picked).unwrap(), ["ii", "iii", "A-2", "A-3", "i"]); + assert_eq!( + crate::page_label_ranges(&picked), + [ + LabelRange { start: 0, style: LowerRoman, prefix: String::new(), first: 2 }, + LabelRange { start: 2, style: Decimal, prefix: "A-".into(), first: 2 }, + LabelRange { start: 4, style: LowerRoman, prefix: String::new(), first: 1 }, + ] + ); + + // An unlabelled source stays unlabelled after extraction. + let unlabelled = full_roundtrip(&extract_pages(&doc_a(), &[2, 0]).unwrap()); + assert!(crate::page_label_ranges(&unlabelled).is_empty()); + + // Splitting preserves each part's labels, including mid-range starting numbers. + let parts = crate::split(&d, &SplitBy::PageCount(4)).unwrap(); + let part_labels: Vec> = parts.iter().map(|p| crate::page_labels(&full_roundtrip(p)).unwrap()).collect(); + assert_eq!(part_labels, vec![vec!["i", "ii", "iii", "1"], vec!["2", "3", "4", "A-1"], vec!["A-2", "A-3"],]); + + // Combining whole files, selected pages, and grouped runs preserves labels from each source. + let mut b = doc_b(); + crate::set_page_label_ranges(&mut b, &[LabelRange { start: 0, style: UpperAlpha, prefix: "B-".into(), first: 1 }]).unwrap(); + let combined = full_roundtrip(&combine_selected(&[("D", &d, Some(&[0, 1, 7])), ("Plain", &doc_a(), Some(&[0, 1])), ("B", &b, None)]).unwrap()); + assert_eq!(crate::page_labels(&combined).unwrap(), ["i", "ii", "A-1", "1", "2", "B-A", "B-B"]); + + let grouped = + full_roundtrip(&crate::combine_grouped(&[(0, "D", &d, Some(&[0, 1])), (1, "B", &b, Some(&[1])), (0, "D", &d, Some(&[2, 7]))]).unwrap()); + assert_eq!(crate::page_labels(&grouped).unwrap(), ["i", "ii", "B-B", "iii", "A-1"]); + + // Ranges starting after page 0 and prefix-only ranges (LabelStyle::None) merge and carry over. + let mut late = doc_a(); + crate::set_page_label_ranges( + &mut late, + &[ + LabelRange { start: 1, style: crate::LabelStyle::None, prefix: "Cover".into(), first: 1 }, + LabelRange { start: 2, style: LowerRoman, prefix: String::new(), first: 4 }, + ], + ) + .unwrap(); + let from_late = full_roundtrip(&extract_pages(&late, &[0, 1, 1, 2]).unwrap()); + assert_eq!(crate::page_labels(&from_late).unwrap(), ["1", "Cover", "Cover", "iv"]); + assert_eq!( + crate::page_label_ranges(&from_late), + [ + LabelRange { start: 0, style: Decimal, prefix: String::new(), first: 1 }, + LabelRange { start: 1, style: crate::LabelStyle::None, prefix: "Cover".into(), first: 1 }, + LabelRange { start: 3, style: LowerRoman, prefix: String::new(), first: 4 }, + ] + ); + + // An empty selection from a labelled source in combine_selected does not add /PageLabels. + let empty_pick = full_roundtrip(&combine_selected(&[("D", &d, Some(&[])), ("Plain", &doc_a(), Some(&[0, 1]))]).unwrap()); + assert!(crate::page_label_ranges(&empty_pick).is_empty()); + + // A damaged (cyclic) /PageLabels tree does not prevent extracting pages. + let mut cyclic = doc_a(); + let tree = cyclic.add(Object::Null); + let mut dict = Dict::new(); + dict.set(b"Kids".to_vec(), Object::Array(vec![Object::Ref(tree)])); + cyclic.set(tree, Object::Dict(dict)); + cyclic.update_dict(cyclic.root().unwrap(), |c| c.set(b"PageLabels".to_vec(), Object::Ref(tree))).unwrap(); + let extracted_cyclic = full_roundtrip(&extract_pages(&cyclic, &[0, 2]).unwrap()); + assert_eq!(crate::page_labels(&extracted_cyclic).unwrap(), ["1", "2"]); + + // An empty prefix-only range falls back to physical page numbers in the source; extracting a + // later page covered by it preserves that physical page number across save/reopen. + let mut empty_prefix = doc_a(); + crate::set_page_label_ranges( + &mut empty_prefix, + &[ + LabelRange { start: 0, style: crate::LabelStyle::None, prefix: String::new(), first: 1 }, + LabelRange { start: 2, style: LowerRoman, prefix: String::new(), first: 1 }, + ], + ) + .unwrap(); + assert_eq!(crate::PageLabels::new(&empty_prefix).unwrap().label(1).unwrap(), "2"); + let extracted_empty_prefix = full_roundtrip(&extract_pages(&empty_prefix, &[1]).unwrap()); + assert_eq!(crate::PageLabels::new(&extracted_empty_prefix).unwrap().label(0).unwrap(), "2"); + + // Repeated non-continuing ranges that exceed the 4 MiB total-prefix limit or the range-count + // limit are refused rather than writing an unreadable tree or silently dropping labels. + let mut one = flat_doc(1); + crate::number_pages(&mut one, 0, 0, Decimal, &"x".repeat(1_000), 1).unwrap(); + let err = extract_pages(&one, &vec![0; 4_195]).unwrap_err().to_string(); + assert!(err.contains("decoded prefixes exceed 4 MiB in total"), "{err}"); + + crate::number_pages(&mut one, 0, 0, LowerRoman, "", 2).unwrap(); + let err = extract_pages(&one, &vec![0; pdfcraft_cos::page_labels::MAX_LABEL_TREE_WORK]).unwrap_err().to_string(); + assert!(err.contains("the number tree has too many entries"), "{err}"); +} + +/// A range the reader accepts but the writer cannot store (a Roman numeral past 1,024 bytes, or a +/// decimal span whose labels grow past that) degrades to the source physical numbers. Extract +/// still succeeds, and a neighbouring range that does fit is kept. +#[test] +fn extracting_unrepresentable_labels_uses_physical_numbers() { + let mut roman = doc_a(); + let mut spec = Dict::new(); + spec.set(b"S".to_vec(), Object::name("R")); + spec.set(b"St".to_vec(), Object::Int(4_000_000)); + let mut tree = Dict::new(); + tree.set(b"Nums".to_vec(), Object::Array(vec![Object::Int(0), Object::Dict(spec)])); + let tree = roman.add(Object::Dict(tree)); + roman.update_dict(roman.root().unwrap(), |c| c.set(b"PageLabels".to_vec(), Object::Ref(tree))).unwrap(); + let extracted = full_roundtrip(&extract_pages(&roman, &[0, 2]).unwrap()); + assert_eq!(crate::page_labels(&extracted).unwrap(), ["1", "3"]); + + let mut wide = flat_doc(5); + let mut spec = Dict::new(); + spec.set(b"P".to_vec(), Object::String(PdfString::text(&"p".repeat(1_020)))); + spec.set(b"S".to_vec(), Object::name("D")); + spec.set(b"St".to_vec(), Object::Int(9_996)); + let mut tree = Dict::new(); + tree.set(b"Nums".to_vec(), Object::Array(vec![Object::Int(0), Object::Dict(spec)])); + let tree = wide.add(Object::Dict(tree)); + wide.update_dict(wide.root().unwrap(), |c| c.set(b"PageLabels".to_vec(), Object::Ref(tree))).unwrap(); + let extracted = full_roundtrip(&extract_pages(&wide, &[0, 1, 2, 3, 4]).unwrap()); + assert_eq!(crate::page_labels(&extracted).unwrap(), ["1", "2", "3", "4", "5"]); + + let mut mixed = doc_a(); + let mut ok = Dict::new(); + ok.set(b"P".to_vec(), Object::String(PdfString::text("ok-"))); + ok.set(b"S".to_vec(), Object::name("D")); + let mut huge = Dict::new(); + huge.set(b"S".to_vec(), Object::name("R")); + huge.set(b"St".to_vec(), Object::Int(4_000_000)); + let mut tree = Dict::new(); + tree.set(b"Nums".to_vec(), Object::Array(vec![Object::Int(0), Object::Dict(ok), Object::Int(1), Object::Dict(huge)])); + let tree = mixed.add(Object::Dict(tree)); + mixed.update_dict(mixed.root().unwrap(), |c| c.set(b"PageLabels".to_vec(), Object::Ref(tree))).unwrap(); + let extracted = full_roundtrip(&extract_pages(&mixed, &[0, 1]).unwrap()); + assert_eq!(crate::page_labels(&extracted).unwrap(), ["ok-1", "2"]); +} + #[test] fn page_label_limits_reject_unsupported_edits_before_mutating() { use crate::LabelStyle::*; diff --git a/crates/print/src/imposition.rs b/crates/print/src/imposition.rs index 348ae151ba..19a75af32c 100644 --- a/crates/print/src/imposition.rs +++ b/crates/print/src/imposition.rs @@ -219,14 +219,22 @@ pub(crate) fn page_form( body.extend(format!("q {} {} {} {} {} {} cm /{name} Do Q\n", n(a0), n(b0), n(c0), n(d0), n(e0), n(f0)).bytes()); xobjects.set(name.into_bytes(), Object::Ref(ap)); } - if !xobjects.is_empty() { - res.set(b"XObject".to_vec(), Object::Dict(xobjects)); - } + // With no appearance added the form keeps the page's own Resources, shared by reference, + // rather than a deep copy per printed page. + let resources = match page.dict.get(b"Resources") { + Some(r) if k == 0 && doc.resolve(r).as_dict().is_some() => r.clone(), + _ => { + if !xobjects.is_empty() { + res.set(b"XObject".to_vec(), Object::Dict(xobjects)); + } + Object::Dict(res) + } + }; let mut d = Dict::new(); d.set(b"Type".to_vec(), Object::name("XObject")); d.set(b"Subtype".to_vec(), Object::name("Form")); d.set(b"BBox".to_vec(), Object::Array(crop.iter().map(|v| Object::Real(*v)).collect())); - d.set(b"Resources".to_vec(), Object::Dict(res)); + d.set(b"Resources".to_vec(), resources); if let Some(g) = page.dict.get(b"Group") { d.set(b"Group".to_vec(), g.clone()); } diff --git a/crates/print/src/tests.rs b/crates/print/src/tests.rs index 63d8a351e7..5b8954fee9 100644 --- a/crates/print/src/tests.rs +++ b/crates/print/src/tests.rs @@ -1285,3 +1285,15 @@ fn contents_pieces_join_with_a_space_across_a_trailing_comment() { let printed_ctrl = impose(&with_newline, &settings(vec![0], Layout::Size(SizeMode::Actual))).unwrap(); assert_eq!(form_strings(printed_ctrl), [b"HIDDEN".to_vec(), b"SHOWN".to_vec()]); } + +#[test] +fn a_page_form_without_annotations_shares_the_pages_indirect_resources() { + let mut doc = fixture(2); + let pages = pdfcraft_model::pages(&doc); + let res = doc.add(Object::Dict([(b"Font".to_vec(), Object::Dict(Dict::new()))].into_iter().collect())); + doc.update_dict(pages[1].obj, |d| d.set(b"Resources".to_vec(), Object::Ref(res))).unwrap(); + let pages = pdfcraft_model::pages(&doc); + let form = imposition::page_form(&mut doc, &pages, 1, Content::default(), &mut Default::default()).unwrap(); + let Object::Stream(s) = &*doc.get(form) else { panic!("form stream") }; + assert_eq!(s.dict.get(b"Resources"), Some(&Object::Ref(res))); +} 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/src/inspect/core.rs b/crates/render/src/inspect/core.rs index c5df792f23..16de483e04 100644 --- a/crates/render/src/inspect/core.rs +++ b/crates/render/src/inspect/core.rs @@ -169,7 +169,8 @@ impl<'a> Inspector<'a> { if let Some(tree) = doc.catalog().ok().and_then(|c| c.get(b"Names").ok()).and_then(|o| me.dict(o)).and_then(|n| n.get(b"Dests").ok()).and_then(|o| me.dict(o)) { - me.name_tree_raw(tree, &mut HashSet::new(), 0, &mut named); + let mut key_budget = MAX_LINK_ATTACHMENT_TEXT_BYTES; + me.name_tree_raw(tree, &mut HashSet::new(), 0, &mut named, &mut key_budget); } me.named = named; me diff --git a/crates/render/src/inspect/navigation.rs b/crates/render/src/inspect/navigation.rs index fb19526875..4c6e88400e 100644 --- a/crates/render/src/inspect/navigation.rs +++ b/crates/render/src/inspect/navigation.rs @@ -79,6 +79,7 @@ impl<'a> Inspector<'a> { seen: &mut HashSet<*const Dictionary>, depth: u32, out: &mut HashMap, &'a Object>, + key_budget: &mut usize, ) { if depth > 32 || !seen.insert(node as *const _) { return; @@ -87,18 +88,25 @@ impl<'a> Inspector<'a> { for pair in pairs.chunks(2) { if let [k, v] = pair { let key = match self.resolve(k) { - Object::String(s, _) => s.clone(), - Object::Name(n) => n.clone(), + Object::String(s, _) => s, + Object::Name(n) => n, _ => continue, }; - out.entry(key).or_insert(v); + // Every key is hashed, so its bytes are spent from one allowance for the whole + // tree (a key repeated by reference is charged each time); one past it is + // not indexed. Only a first occurrence is copied. + let Some(left) = key_budget.checked_sub(key.len()) else { continue }; + *key_budget = left; + if !out.contains_key(key.as_slice()) { + out.insert(key.clone(), v); + } } } } if let Ok(Object::Array(kids)) = node.get(b"Kids").map(|o| self.resolve(o)) { for k in kids { if let Some(d) = self.dict(k) { - self.name_tree_raw(d, seen, depth + 1, out); + self.name_tree_raw(d, seen, depth + 1, out, key_budget); } } } @@ -559,4 +567,17 @@ mod open_dest_tests { let shallow = ["<< /Kids [7 0 R] >>".to_string(), "<< /Kids [8 0 R] >>".into(), "<< /Names [(x) [4 0 R /Fit]] >>".into()]; assert_eq!(open_dest("/OpenAction (x) /Names << /Dests 6 0 R >>", &shallow), Some((1, DestView::Fit))); } + + #[test] + fn a_key_repeated_by_reference_is_charged_each_time_against_the_name_tree_allowance() { + let big = format!("<{}>", "41".repeat(1 << 20)); + let tree = |repeats: usize| { + let names = format!("{}(x) [4 0 R /Fit]", "7 0 R [4 0 R /Fit] ".repeat(repeats)); + [format!("<< /Names [{names}] >>"), big.clone()] + }; + let catalog = "/OpenAction (x) /Names << /Dests 6 0 R >>"; + assert_eq!(open_dest(catalog, &tree(2)), Some((1, DestView::Fit))); + // 17 MiB of repeats of one reference spend the 16 MiB allowance before the real key. + assert_eq!(open_dest(catalog, &tree(17)), None); + } } 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"); + } +} diff --git a/crates/sign/src/x509.rs b/crates/sign/src/x509.rs index 5dd03d086d..1cf586b3e8 100644 --- a/crates/sign/src/x509.rs +++ b/crates/sign/src/x509.rs @@ -415,10 +415,19 @@ impl Certificate { /// Whether `key` verifies this certificate's signature. pub fn signed_by(&self, key: &PublicKey) -> bool { + self.signed_by_cached(key, &mut None) + } + + /// [`Self::signed_by`] keeping the digest of the signed part in `digest`, so checking one + /// certificate against many candidate keys hashes it once. + fn signed_by_cached(&self, key: &PublicKey, digest: &mut Option>) -> bool { let Ok(alg) = Tlv::parse_all(&self.sig_alg) else { return false }; match keys::signature_algorithm(&alg) { Ok((scheme @ keys::Scheme::Ed25519, _)) => key.verify_message(scheme, &self.tbs, &self.signature).unwrap_or(false), - Ok((scheme, Some(digest))) => key.verify(scheme, digest, &digest.digest(&[&self.tbs]), &self.signature).unwrap_or(false), + Ok((scheme, Some(d))) => { + let hash = digest.get_or_insert_with(|| d.digest(&[&self.tbs])); + key.verify(scheme, d, hash, &self.signature).unwrap_or(false) + } _ => false, } } @@ -533,7 +542,8 @@ pub fn build_chain_noted<'a>(leaf: &'a Certificate, pool: &'a [Certificate], at: // CA certificates between `last` and the leaf: what a candidate's pathLenConstraint limits. let below = chain.len() - 1; let mut found = None; - for c in pool.iter().filter(|c| c.subject.raw == last.issuer.raw && !chain.contains(c) && last.signed_by(&c.public_key)) { + let mut digest = None; + for c in pool.iter().filter(|c| c.subject.raw == last.issuer.raw && !chain.contains(c) && last.signed_by_cached(&c.public_key, &mut digest)) { // Each reason is a whole sentence, not a phrase stitched into one: a language puts the // two names where it needs them. let why = if !c.may_issue() { 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(); diff --git a/crates/xfa/src/pdf/strip.rs b/crates/xfa/src/pdf/strip.rs index 1d62f28b18..93f1a65307 100644 --- a/crates/xfa/src/pdf/strip.rs +++ b/crates/xfa/src/pdf/strip.rs @@ -151,6 +151,7 @@ pub fn strip(doc: &mut Document) -> Result { let fields = acro.get(b"Fields").map(|f| doc.resolve(f)).and_then(|f| f.as_array().cloned()).unwrap_or_default(); let mut seen = std::collections::HashSet::new(); let mut todo: Vec = Vec::new(); + let mut kids_seen = std::collections::HashSet::new(); for r in fields.iter().filter_map(Object::as_ref) { if seen.insert(r) && seen.len() <= MAX_STRIP_FIELDS { todo.push(r); @@ -177,7 +178,9 @@ pub fn strip(doc: &mut Document) -> Result { } })?; } - if let Some(kids) = d.get(b"Kids").map(|k| doc.resolve(k)).and_then(|k| k.as_array().cloned()) { + // An indirect /Kids array several fields share is read once: its entries are already queued. + let shared_seen = d.get(b"Kids").and_then(Object::as_ref).is_some_and(|k| !kids_seen.insert(k)); + if !shared_seen && let Some(kids) = d.get(b"Kids").map(|k| doc.resolve(k)).and_then(|k| k.as_array().cloned()) { for k in kids.iter().filter_map(Object::as_ref) { if seen.insert(k) && seen.len() <= MAX_STRIP_FIELDS { todo.push(k); @@ -283,3 +286,34 @@ pub fn existing_layout(doc: &Document) -> Option { warnings, }) } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn fields_sharing_a_kids_array_still_lose_their_private_keys_once() { + let mut doc = Document::new_empty(); + let mut kid = Dict::new(); + kid.set(CLICK_KEY.to_vec(), Object::Int(1)); + let kid = doc.add(Object::Dict(kid)); + let kids = doc.add(Object::Array(vec![Object::Ref(kid)])); + let parents: Vec = (0..3) + .map(|_| { + let mut p = Dict::new(); + p.set(b"Kids".to_vec(), Object::Ref(kids)); + Object::Ref(doc.add(Object::Dict(p))) + }) + .collect(); + let mut acro = Dict::new(); + acro.set(b"XFA".to_vec(), Object::Array(Vec::new())); + acro.set(b"Fields".to_vec(), Object::Array(parents)); + let root = doc.root().unwrap(); + let mut catalog = doc.get(root).as_dict().cloned().unwrap(); + catalog.set(b"AcroForm".to_vec(), Object::Dict(acro)); + doc.set(root, Object::Dict(catalog)); + let out = strip(&mut doc).unwrap(); + assert_eq!(out.script_buttons, 1); + assert!(!doc.get(kid).as_dict().unwrap().contains(CLICK_KEY)); + } +} diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index a9b7a646f4..90ab7bcd25 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"