From ce12a1278876f14792d0fea578773353af6c1c5f Mon Sep 17 00:00:00 2001 From: oracola91 <118899664+oracola91@users.noreply.github.com> Date: Fri, 30 Oct 2026 00:36:37 +0200 Subject: [PATCH 01/92] Exports keep a wrapped paragraph as one paragraph Reading splits a line wherever its style changes, and export joined those blocks along one line only (#4158). A sentence with a bold name in one line and an italic name in the next came out of Word, HTML, RTF and PowerPoint as one paragraph per line. A paragraph whose last line reaches the right edge of the page's text now runs on into the line just below it, when that line starts at the same left edge in the same size. Headings, list items, a line that ends a sentence, an indented line and a line after paragraph space keep their break. A slanted run whose box leans into the next word by up to 0.3 of its size still runs on into it. The new export and automation tests fail without the change. --- crates/automation/tests/it/automation.rs | 71 ++++++++++++ crates/export/src/flow.rs | 135 +++++++++++++++++++++++ crates/export/src/layout.rs | 28 ++++- crates/export/src/lib.rs | 11 +- crates/export/src/pptx.rs | 3 +- crates/export/src/tests.rs | 135 +++++++++++++++++++++++ parity/acrobat-features.toml | 15 +-- 7 files changed, 381 insertions(+), 17 deletions(-) create mode 100644 crates/export/src/flow.rs diff --git a/crates/automation/tests/it/automation.rs b/crates/automation/tests/it/automation.rs index 6a5765b911..4d3aa44f01 100644 --- a/crates/automation/tests/it/automation.rs +++ b/crates/automation/tests/it/automation.rs @@ -7614,6 +7614,77 @@ BT /F1 12 Tf 72 640 Td (A paragraph further down.) Tj ET"; assert_eq!(paragraphs.len(), 2, "{xml}"); } +/// A one-page PDF (612 x 792 pt) drawing `content`, with the standard fonts `fonts` as /F1, /F2 +/// and so on, each in its built-in encoding. +fn standard_fonts_pdf(content: &str, fonts: &[&str]) -> Vec { + let names: String = (1..=fonts.len()).map(|i| format!("/F{i} {} 0 R ", i + 4)).collect(); + let mut objs = vec![ + "<< /Type /Catalog /Pages 2 0 R >>".to_string(), + "<< /Type /Pages /Kids [3 0 R] /Count 1 >>".into(), + format!("<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] /Contents 4 0 R /Resources << /Font << {names}>> >> >>"), + format!("<< /Length {} >>\nstream\n{content}\nendstream", content.len()), + ]; + objs.extend(fonts.iter().map(|f| format!("<< /Type /Font /Subtype /Type1 /BaseFont /{f} >>"))); + let mut pdf = b"%PDF-1.7\n".to_vec(); + let mut offsets = Vec::new(); + for (i, o) in objs.iter().enumerate() { + offsets.push(pdf.len()); + pdf.extend_from_slice(format!("{} 0 obj\n{o}\nendobj\n", i + 1).as_bytes()); + } + let xref = pdf.len(); + pdf.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objs.len() + 1).as_bytes()); + for o in offsets { + pdf.extend_from_slice(format!("{o:010} 00000 n \n").as_bytes()); + } + pdf.extend_from_slice(format!("trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n", objs.len() + 1).as_bytes()); + pdf +} + +/// The text of each paragraph that holds any in `word/document.xml` of the .docx at `path`. +fn word_paragraph_texts(path: &Path) -> Vec { + let zip = std::fs::read(path).unwrap(); + let mut at = 0; + let mut xml = String::new(); + while zip[at..].starts_with(b"PK\x03\x04") { + let size = u32::from_le_bytes(zip[at + 18..at + 22].try_into().unwrap()) as usize; + let len = u16::from_le_bytes(zip[at + 26..at + 28].try_into().unwrap()) as usize; + let data = at + 30 + len; + if &zip[at + 30..data] == b"word/document.xml" { + std::io::Read::read_to_string(&mut flate2::read::DeflateDecoder::new(&zip[data..data + size]), &mut xml).unwrap(); + break; + } + at = data + size; + } + xml.split("") + .skip(1) + .map(|p| { + let p = p.split_once("").map_or(p, |(inside, _)| inside); + p.split("").skip(1).filter_map(|t| t.split_once("").map(|(t, _)| t)).collect::() + }) + .filter(|text| !text.is_empty()) + .collect() +} + +/// #4158: a sentence that wraps onto the next lines after a bold name and a slanted one reaches +/// Word as one paragraph, though reading splits each of its lines where the style changes. +#[test] +fn exporting_to_word_keeps_a_wrapped_sentence_in_one_paragraph() { + let dir = workdir("wrapped-sentence-word"); + let content = "BT /F1 10 Tf 72 700 Td (The parties to this lease are the owner, ) Tj /F2 10 Tf (Harbour Lane Properties) Tj \ +/F1 10 Tf (, of 4 Quay Street, and the) Tj ET \ +BT /F1 10 Tf 72 686 Td (tenant named in the schedule, ) Tj /F3 10 Tf (Blue Finch Studio) Tj \ +/F1 10 Tf (, who agree as follows to rent the upper) Tj ET \ +BT /F1 10 Tf 72 672 Td (rooms for the whole of the term.) Tj ET \ +BT /F1 10 Tf 72 640 Td (Rent is paid monthly in advance.) Tj ET"; + std::fs::write(dir.join("lease.pdf"), standard_fonts_pdf(content, &["Helvetica", "Helvetica-Bold", "Helvetica-Oblique"])).unwrap(); + let mut a = auto(&dir); + let doc = ok(&mut a, "doc_open", json!({ "path": "lease.pdf" }))["doc"].as_u64().unwrap(); + ok(&mut a, "doc_export_office", json!({ "doc": doc, "path": "lease.docx" })); + let sentence = "The parties to this lease are the owner, Harbour Lane Properties, of 4 Quay Street, and the tenant named in the \ +schedule, Blue Finch Studio, who agree as follows to rent the upper rooms for the whole of the term."; + assert_eq!(word_paragraph_texts(&dir.join("lease.docx")), [sentence, "Rent is paid monthly in advance."]); +} + /// Hundreds of calculations run on opening and again after a change through the tools. The /// time limits are raised so that a busy machine does not stop them (the engine's tests check /// that the limits stop them). diff --git a/crates/export/src/flow.rs b/crates/export/src/flow.rs new file mode 100644 index 0000000000..e064772864 --- /dev/null +++ b/crates/export/src/flow.rs @@ -0,0 +1,135 @@ +//! Text that runs on from one line to the next. +//! +//! The reading blocks keep style boundaries (#4158), so a sentence with a bold name or an italic +//! word in it reaches the exporter as several blocks, and `layout::paragraphs` joins only the ones +//! that run on along one line. A sentence that wraps onto the next line after +//! such a run came out as one paragraph per line. [`join_wrapped`] joins a paragraph whose last +//! line reaches the right edge of the page's text with the one that starts just below it at the +//! same left edge, in the same size: the text wrapped there. Headings, list items, a line that +//! ends a sentence and a line with paragraph space after it stay apart. + +use super::layout::Run; + +/// Bullets that mark a list item wherever they stand, including the Symbol and Wingdings bullets +/// Word writes into its PDFs (U+F0B7, U+F0A7). +const BULLETS: [char; 11] = ['•', '◦', '▪', '‣', '⁃', '●', '○', '■', '□', '\u{F0B7}', '\u{F0A7}']; + +/// Dashes and a middle dot: list markers at the start of a line, punctuation inside one. +const DASHES: [char; 4] = ['-', '\u{2013}', '\u{2014}', '·']; + +/// A run taller than this many times its font size is more than one line: a block of several +/// rows ends where its widest row does, not where its last one does. +const ONE_LINE: f64 = 1.6; + +/// How far (as a multiple of the font size) the space between two lines may be from none, and +/// they still follow each other within a paragraph: more is paragraph spacing. +const LINE_GAP: f64 = 0.5; + +/// How close to the right edge of the page's text a full line ends: within this many times the +/// font size, or this fraction of the text's width, whichever is more (a ragged line ends short +/// of the edge by up to a long word). +const FULL_LINE: (f64, f64) = (3.0, 0.12); + +/// How far (points) the first line of a paragraph may start from the left edge of the line above +/// it and still be at the same left edge. +const SAME_LEFT: f64 = 2.0; + +/// Whether two boxes ([x0, y0, x1, y1]) share a line: they overlap vertically by more than half +/// the shorter one, as `layout::continued_from` has it. +fn on_one_line(a: [f64; 4], b: [f64; 4]) -> bool { + a[3].min(b[3]) - a[1].max(b[1]) > 0.5 * (a[3] - a[1]).min(b[3] - b[1]) +} + +/// Whether `text` opens like a list item: a bullet, a dash or `*`; one to three digits followed +/// by `.` or `)`; or one letter followed by `)` ("a)", "а)"); then a space or nothing. "1.5 kg", +/// "i.e." and "2026. Year" are text. +pub(crate) fn list_item(text: &str) -> bool { + let t = text.trim_start(); + let Some(first) = t.chars().next() else { return false }; + if BULLETS.contains(&first) || DASHES.contains(&first) || first == '*' { + return true; + } + let digits = t.chars().take_while(char::is_ascii_digit).count(); + // ASCII digits are one byte each and `first` is one whole character, so both cuts fall on + // character boundaries. + let (rest, marks): (&str, &[char]) = if (1..=3).contains(&digits) { + (t.get(digits..).unwrap_or_default(), &['.', ')']) + } else if digits == 0 && first.is_alphabetic() { + (t.get(first.len_utf8()..).unwrap_or_default(), &[')']) + } else { + return false; + }; + let mut after = rest.chars(); + after.next().is_some_and(|c| marks.contains(&c)) && after.next().is_none_or(char::is_whitespace) +} + +/// Whether a line's text ends a sentence (closing quotes and brackets aside). Text set without +/// paragraph spacing gives no other sign that a paragraph ends there, so such a line is left +/// apart from the next rather than joined to it wrongly. +fn ends_sentence(text: &str) -> bool { + text.trim_end().trim_end_matches(['"', '\'', '”', '’', '“', '»', ')', ']']).ends_with(['.', '!', '?', ':', ';', '…']) +} + +/// `paras` (a page's paragraphs, in any order) top to bottom, with each paragraph that wraps onto +/// the next line joined to it. `level` is a paragraph's heading level (0 for body text). +pub(crate) fn join_wrapped<'a>(mut paras: Vec>>, level: impl Fn(&[Run<'a>]) -> u8) -> Vec>> { + // The left and right edges of the page's text: where a full line starts and ends. + let (left, right) = paras + .iter() + .flatten() + .map(|r| r.block.rect) + .filter(|r| r[0].is_finite() && r[2].is_finite()) + .fold((f64::INFINITY, f64::NEG_INFINITY), |(l, r), x| (l.min(x[0]), r.max(x[2]))); + if right <= left { + return paras; + } + // Top to bottom, then left to right, by each paragraph's first run. + paras.sort_by(|a, b| { + let top_left = |p: &[Run]| p.first().map_or([0.0; 2], |r| [r.block.rect[3], r.block.rect[0]]); + let (a, b) = (top_left(a), top_left(b)); + b[0].total_cmp(&a[0]).then(a[1].total_cmp(&b[1])) + }); + // Each paragraph with its heading level. A joined paragraph is body text: only body text joins. + let mut out: Vec<(Vec>, u8)> = Vec::with_capacity(paras.len()); + for para in paras { + let lvl = level(¶); + match out.last_mut() { + Some((prev, 0)) if lvl == 0 && wraps_into(prev, ¶, [left, right]) => { + // The line break was a word space, unless the line ends in a hyphen; the hyphen + // stays, as it may belong to the word ("well-known"). + if let Some(end) = prev.last_mut() + && !end.text.ends_with(char::is_whitespace) + && !end.text.ends_with('-') + { + end.text.push(' '); + } + prev.extend(para); + } + _ => out.push((para, lvl)), + } + } + out.into_iter().map(|(para, _)| para).collect() +} + +/// Whether the body text paragraph `prev` ends in a full line whose text runs on in `next`, the +/// body text paragraph just below it. `[left, right]` are the edges of the page's text. +fn wraps_into(prev: &[Run], next: &[Run], [left, right]: [f64; 2]) -> bool { + let (Some(last), Some(first)) = (prev.last(), next.first()) else { return false }; + let (a, b) = (last.block, first.block); + let size = a.size; + let alike = size.is_finite() && size > 0.0 && (a.size - b.size).abs() <= 0.5 && a.rotation.abs() <= 0.1 && b.rotation.abs() <= 0.1; + if !alike || a.rect[3] - a.rect[1] > ONE_LINE * size || list_item(&first.text) || ends_sentence(&last.text) { + return false; + } + // The last line: the runs at the end of `prev` that share the last run's line. + let (line_left, line_right) = prev + .iter() + .rev() + .take_while(|r| on_one_line(r.block.rect, a.rect)) + .fold((f64::INFINITY, f64::NEG_INFINITY), |(l, r), x| (l.min(x.block.rect[0]), r.max(x.block.rect[2]))); + let full = line_right >= right - (FULL_LINE.0 * size).max(FULL_LINE.1 * (right - left)); + let same_left = (b.rect[0] - line_left).abs() <= SAME_LEFT; + // Just below: no paragraph space between the two lines. + let gap = a.rect[1] - b.rect[3]; + full && same_left && (-LINE_GAP * size..=LINE_GAP * size).contains(&gap) +} diff --git a/crates/export/src/layout.rs b/crates/export/src/layout.rs index d1d074ac61..84c59f82bb 100644 --- a/crates/export/src/layout.rs +++ b/crates/export/src/layout.rs @@ -138,9 +138,20 @@ const INLINE_GAP: f64 = 0.5; /// page laid out so that many end at the same place stays quick. const CONTINUATION_PROBE: usize = 64; +/// How far (as a fraction of the font size) the block before may end past where a block starts +/// and still be the one it continues: a slanted italic's box leans into the next word by about a +/// quarter of its size. +const OVERHANG: f64 = 0.3; + +/// [`OVERHANG`] in points for a block of `size`: at least a point (rounding), at most six. +fn overhang(size: f64) -> f64 { + if size.is_finite() { (OVERHANG * size).clamp(1.0, 6.0) } else { 1.0 } +} + /// Per block, whether it continues another on the same line: it starts where that one ends (up to -/// [`INLINE_GAP`] later, or a point earlier for rounding), so it is the same line of text running -/// on (a link inside a sentence, a superscript), not a cell of its own. +/// [`INLINE_GAP`] later, or up to [`overhang`] earlier, as a slanted italic leans into the word +/// after it), so it is the same line of text running on (a link inside a sentence, a +/// superscript), not a cell of its own. fn continuations(blocks: &[Block]) -> Vec { continued_from(blocks).into_iter().map(|from| from.is_some()).collect() } @@ -159,7 +170,7 @@ fn continued_from(blocks: &[Block]) -> Vec> { .get(k..) .unwrap_or_default() .iter() - .take_while(|(end, _)| *end <= b.rect[0] + 1.0) + .take_while(|(end, _)| *end <= b.rect[0] + overhang(b.size)) .take(CONTINUATION_PROBE) .filter_map(|(_, ai)| blocks.get(*ai).filter(|_| *ai != bi).map(|a| (*ai, a))) .filter(|(_, a)| a.rect[3].min(b.rect[3]) - a.rect[1].max(b.rect[1]) > 0.5 * (a.rect[3] - a.rect[1]).min(b.rect[3] - b.rect[1])) @@ -234,6 +245,12 @@ pub(crate) fn paragraphs<'a>(blocks: &'a [Block], skip: &[bool]) -> Vec(blocks: &'a [Block], skip: &[bool], body: f64) -> Vec>> { + super::flow::join_wrapped(paragraphs(blocks, skip), |runs| para_level(runs, body)) +} + /// A paragraph's heading level: the lowest of its runs', so body text that runs on after a bold /// lead-in on the same line keeps the paragraph body text. A marker set smaller than the first run /// (a footnote number, "st" in "1st") does not count, so a heading keeps its footnote marker. @@ -450,8 +467,9 @@ pub(crate) fn items(pages: &[Page]) -> Vec> { } let (tables, consumed) = tables(&p.blocks); // Blocks and images by their top edge, top to bottom. - // A paragraph sits where its first run does: its other runs follow along the same line. - let mut parts: Vec<(f64, f64, Item)> = paragraphs(&p.blocks, &consumed) + // A paragraph sits where its first run does: its other runs follow along its line and the + // lines it wraps onto. + let mut parts: Vec<(f64, f64, Item)> = flowing_paragraphs(&p.blocks, &consumed, body) .into_iter() .filter_map(|runs| { let first = runs.first()?.block; diff --git a/crates/export/src/lib.rs b/crates/export/src/lib.rs index db53ee59c8..d1c4753aa8 100644 --- a/crates/export/src/lib.rs +++ b/crates/export/src/lib.rs @@ -2,9 +2,9 @@ //! //! The engine reduces each page to [`Page`]: paragraphs (text, box, size, bold/italic) and //! images (encoded bytes and box), in reading order. The writers turn that into a flowing -//! document: headings are the paragraphs set larger than the body text, and a block that -//! continues another on its line (an italic word, a link) is a run of that block's paragraph. -//! Word keeps pages with +//! document: headings are the paragraphs set larger than the body text; a block that +//! continues another on its line (an italic word, a link) is a run of that block's paragraph, and +//! a paragraph that wraps onto the next line runs on there. Word keeps pages with //! rotated text, or text overlapped by artwork or watermark-sized text, as positioned, editable //! shapes to avoid reflowing overlays; other pages flow with images inline. @@ -20,6 +20,7 @@ mod as_docx; mod as_html; mod as_markdown; mod as_rtf; +mod flow; mod layout; pub use as_docx::docx; @@ -34,10 +35,12 @@ pub use as_markdown::markdown; #[cfg(test)] use as_markdown::{emphasis, paragraph}; pub use as_rtf::rtf; +#[cfg(test)] +use flow::list_item; pub use layout::{Block, Cell, Image, Page, Table}; #[cfg(test)] use layout::{LOOKUP_STEPS, MAX_COLS, body_size}; -pub(crate) use layout::{Run, paragraphs, tables}; +pub(crate) use layout::{Run, flowing_paragraphs, tables}; #[cfg(test)] mod tests; diff --git a/crates/export/src/pptx.rs b/crates/export/src/pptx.rs index 49765000d3..ff1d1bad7d 100644 --- a/crates/export/src/pptx.rs +++ b/crates/export/src/pptx.rs @@ -125,6 +125,7 @@ pub fn pptx(pages: &[Page], title: &str) -> Vec { let mut global_media: Vec<(String, &Image)> = Vec::new(); let mut slides: Vec<(String, String)> = Vec::new(); // (slide_xml, slide_rels) + let body = crate::layout::body_size(pages); for p in pages { let (tables, consumed) = crate::tables(&p.blocks); @@ -139,7 +140,7 @@ pub fn pptx(pages: &[Page], title: &str) -> Vec { let mut items: Vec<(f64, f64, SlideBodyItem)> = Vec::new(); // The title is a shape of its own, so it joins no paragraph. let skip: Vec = (0..p.blocks.len()).map(|bi| Some(bi) == title_idx || consumed.get(bi).copied().unwrap_or(false)).collect(); - for runs in crate::paragraphs(&p.blocks, &skip) { + for runs in crate::flowing_paragraphs(&p.blocks, &skip, body) { let Some(first) = runs.first().map(|r| r.block) else { continue }; items.push((first.rect[3], first.rect[0], SlideBodyItem::Para(runs))); } diff --git a/crates/export/src/tests.rs b/crates/export/src/tests.rs index 27cd8264e0..11ad393206 100644 --- a/crates/export/src/tests.rs +++ b/crates/export/src/tests.rs @@ -841,3 +841,138 @@ fn markdown_escapes_markup_and_keeps_emphasis_and_tables() { assert!(rows.contains(&"| A1 | kWh | 37414.00 |"), "{md}"); assert!(rows.iter().all(|r| r.matches(" | ").count() == 2), "every row has the header's three cells: {md}"); } + +/// A line of 10 pt text from `x0` to `x1` whose box starts at `y0`: 12 pt tall, so lines 14 pt +/// apart leave 2 pt between their boxes. +fn line(t: &str, x0: f64, x1: f64, y0: f64) -> Block { + Block { size: 10.0, ..block(t, x0, y0, x1, y0 + 12.0) } +} + +/// The text of each flowing paragraph Word gets for a page of `blocks`. +fn word_texts(blocks: Vec) -> Vec { + let p = Page { width: 612.0, height: 792.0, blocks, ..Page::default() }; + let xml = part(&docx(&[p], "Lines"), "word/document.xml"); + word_paragraphs(&xml).into_iter().map(|runs| runs.into_iter().map(|(t, _)| t).collect()).collect() +} + +/// A sentence over three lines, as the reading blocks give it: split at each style change, a +/// bold name in the first line and a slanted name in the second whose box leans 2.5 pt into the +/// comma after it (#4158). The next paragraph follows after paragraph space. +fn wrapped_sentence() -> Vec { + vec![ + line("The parties to this lease are the owner,", 72.0, 262.0, 700.0), + Block { bold: true, ..line("Harbour Lane Properties", 265.0, 400.0, 700.0) }, + line(", of 4 Quay Street, and the", 400.5, 540.0, 700.0), + line("tenant named in the schedule,", 72.0, 215.0, 686.0), + Block { italic: true, ..line("Blue Finch Studio", 218.0, 312.0, 686.0) }, + line(", who agree as follows to rent the upper", 309.5, 538.0, 686.0), + line("rooms for the whole of the term.", 72.0, 230.0, 672.0), + line("Rent is paid monthly in advance.", 72.0, 230.0, 640.0), + ] +} + +/// #4158: a sentence that wraps onto the next line after a change of style is one paragraph in +/// every format, its lines joined by a word space. +#[test] +fn a_sentence_that_wraps_after_a_style_change_is_one_paragraph() { + let sentence = "The parties to this lease are the owner, Harbour Lane Properties, of 4 Quay Street, and the tenant named in the \ +schedule, Blue Finch Studio, who agree as follows to rent the upper rooms for the whole of the term."; + assert_eq!(word_texts(wrapped_sentence()), [sentence, "Rent is paid monthly in advance."]); + let p = Page { width: 612.0, height: 792.0, blocks: wrapped_sentence(), ..Page::default() }; + let h = html(std::slice::from_ref(&p), "Lease"); + assert_eq!(h.matches("

").count(), 2, "{h}"); + assert!( + h.contains( + "

The parties to this lease are the owner, Harbour Lane Properties, of 4 Quay Street, and the tenant \ +named in the schedule, Blue Finch Studio, who agree as follows to rent the upper rooms for the whole of the term.

" + ), + "{h}" + ); + let r = rtf(std::slice::from_ref(&p)); + assert_eq!(r.matches("{\\pard").count(), 2, "{r}"); + // On a slide the last of the paragraphs set largest is the title; the sentence is one paragraph. + let s = part(&pptx(std::slice::from_ref(&p), "Lease"), "ppt/slides/slide1.xml"); + let slide: Vec = + s.split("").skip(1).map(|p| p.split("").skip(1).filter_map(|t| t.split_once("").map(|(t, _)| t)).collect()).collect(); + assert!(slide.iter().any(|p| p == sentence), "{s}"); +} + +/// A slanted word's box leans into the word after it by about a quarter of its size, so that +/// word still runs on from it; text that starts further back is drawn over the text before it, +/// not after it. +#[test] +fn a_slanted_word_that_leans_into_the_next_stays_in_its_line() { + let leaning = vec![ + line("named in the schedule,", 72.0, 180.0, 700.0), + Block { italic: true, ..line("Blue Finch Studio", 183.0, 276.0, 700.0) }, + line(", who agree", 273.5, 330.0, 700.0), + ]; + assert_eq!(word_texts(leaning), ["named in the schedule, Blue Finch Studio, who agree"]); + let over = vec![line("A label", 72.0, 120.0, 700.0), line("over another", 115.0, 180.0, 700.0)]; + assert_eq!(word_texts(over), ["A label", "over another"]); +} + +/// A line runs on into the next only when it reaches the right edge of the text and the next +/// starts just below at the same left edge, in the same size. A line that ends a sentence or ends +/// short, and a next line that is a heading, a list item, indented or after paragraph space, keep +/// their paragraph break. Each case is a page of its own. +#[test] +fn a_paragraph_breaks_where_its_line_does_not_run_on() { + let full = "The tenant keeps the rooms, the stairs and the shared yard clean and in good repair"; + let next = "throughout the term of the lease"; + let first = |t: &str| line(t, 72.0, 540.0, 700.0); + let second = |t: &str| line(t, 72.0, 240.0, 686.0); + assert_eq!(word_texts(vec![first(full), second(next)]), [format!("{full} {next}")]); + assert_eq!(word_texts(vec![first("Изпълнителят поддържа сървърите и мрежата на"), second("възложителя в работно състояние")]).len(), 1); + let heading = |b: Block| Block { bold: true, ..b }; + for (why, blocks) in [ + ("a sentence ends", vec![first(&format!("{full}.")), second(next)]), + ("a sentence ends in quotes", vec![first("Страните се договориха „за срок от една година.“"), second(next)]), + ("the line ends short", vec![line(next, 72.0, 240.0, 700.0), line(full, 72.0, 540.0, 686.0)]), + ("paragraph space", vec![first(full), line(next, 72.0, 240.0, 676.0)]), + ("an indent", vec![first(full), line(next, 90.0, 258.0, 686.0)]), + ("a larger size", vec![first(full), Block { size: 12.0, ..second(next) }]), + ("a numbered item", vec![first(full), second("2. The tenant pays the rent")]), + ("a lettered item", vec![first(full), second("а) поддръжка на сървърите")]), + ("a bulleted item", vec![first(full), second("• the stairs")]), + ("a heading below", vec![first(full), heading(second("Rent and deposit"))]), + ("a heading above", vec![heading(first("Obligations of the tenant")), second(next)]), + ] { + assert_eq!(word_texts(blocks).len(), 2, "{why}"); + } +} + +#[test] +fn list_items_are_told_from_text() { + for item in ["• item", "\u{F0B7} item", "- item", "\u{2013} item", "* item", "1. Term", "12) Rent", "3.", "a) rooms", "а) стаи"] { + assert!(list_item(item), "{item}"); + } + for text in ["1.5 kg of", "2026. Year", "i.e. this", "т.е. така", "38 rooms", "Rent 12)", "1234) many", ""] { + assert!(!list_item(text), "{text}"); + } +} + +/// Wrapped lines join on hostile boxes and sizes without a panic, and never lose a line's text. +#[test] +fn joining_lines_survives_hostile_boxes() { + let mut blocks = Vec::new(); + for (i, v) in [f64::NAN, f64::INFINITY, f64::NEG_INFINITY, -1.0, 0.0, 1e300].into_iter().enumerate() { + let y = 700.0 - 14.0 * i as f64; + blocks.push(Block { size: v, ..line(&format!("size {i}"), 72.0, 540.0, y) }); + blocks.push(Block { rect: [v, y, 540.0, y + 12.0], ..line(&format!("left {i}"), 72.0, 540.0, y) }); + blocks.push(Block { rect: [72.0, v, v, y + 12.0], ..line(&format!("box {i}"), 72.0, 540.0, y) }); + blocks.push(Block { rotation: v, ..line(&format!("turn {i}"), 72.0, 540.0, y) }); + } + let p = Page { width: 612.0, height: 792.0, blocks, ..Page::default() }; + let outputs = [ + html(std::slice::from_ref(&p), "Hostile"), + rtf(std::slice::from_ref(&p)), + part(&pptx(std::slice::from_ref(&p), "Hostile"), "ppt/slides/slide1.xml"), + word_texts(p.blocks.clone()).concat(), + ]; + for output in outputs { + for b in &p.blocks { + assert!(output.contains(&b.text), "{} was lost", b.text); + } + } +} diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index 1404247cd7..cffbff4176 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -7076,8 +7076,8 @@ milestone = "M10" status = "partial" commands = ["export.docx"] tools = ["doc_export_office"] -evidence = ["crates/export/src/tests.rs::word_table_cell_widths_follow_the_columns_they_span", "crates/export/src/tests.rs::table_spans_do_not_consume_text_they_do_not_emit", "crates/edit/src/tests.rs::reading_keeps_dotted_leader_entries_whole_and_in_order", "crates/engine/src/tests.rs::exporting_office_keeps_dotted_leader_entries_as_paragraphs", "crates/export/src/tests.rs::word_does_not_invent_borders_for_aligned_invoice_fields", "crates/export/src/tests.rs::word_overlap_does_not_consume_invoice_body_space", "crates/export/src/tests.rs::word_keeps_a_page_with_a_clear_image_and_big_title_in_flow", "crates/export/src/tests.rs::word_rotated_pages_keep_separate_page_sizes_and_unique_shapes", "crates/engine/src/tests.rs::office_export_positions_follow_the_displayed_crop_rotation_and_units", "crates/automation/tests/it/automation.rs::exporting_an_invoice_to_word_keeps_the_watermark_out_of_flow", "crates/export/src/tests.rs::docx_is_a_word_package", "crates/export/src/tests.rs::text_colour_survives_every_format", "crates/engine/src/tests.rs::exporting_office_files_keeps_text_colour", "crates/export/src/tests.rs::grid_text_becomes_a_real_table_in_every_format", "crates/export/src/tests.rs::a_link_inside_a_sentence_is_not_a_table_column", "crates/export/src/tests.rs::sentences_that_run_on_into_links_are_not_a_table", "crates/export/src/tests.rs::a_superscript_beside_a_number_keeps_its_table", "crates/export/src/tests.rs::word_opens_text_with_control_characters_and_long_pages", "crates/engine/src/tests.rs::exporting_office_files_keeps_images", "crates/automation/tests/it/automation.rs::exporting_to_word_html_and_rtf", "crates/ui-egui/tests/it/compare.rs::export_to_word_html_and_rtf", "crates/export/src/tests.rs::docx_keeps_a_shared_page_background", "crates/edit/src/tests.rs::a_page_painted_first_with_a_full_page_fill_has_that_background", "crates/engine/src/tests.rs::exporting_to_word_keeps_the_page_background", "crates/export/src/tests.rs::an_italic_word_stays_in_its_sentence", "crates/export/src/tests.rs::a_sentence_keeps_its_reading_order", "crates/export/src/tests.rs::runs_keep_the_spaces_the_source_had_between_them", "crates/export/src/tests.rs::a_paragraph_is_a_heading_only_when_its_text_is", "crates/automation/tests/it/automation.rs::exporting_to_word_keeps_an_italic_word_inside_its_sentence"] -notes = "Text in slots covered by a preceding table-cell span remains in paragraph flow instead of being discarded. Flowing Word document: paragraphs, Heading 1/2 from larger text, bold/italic, text colour (fill as sRGB; gray/CMYK converted naively, colour spaces via cs/sc read as black), borderless detected tables with spans/empty cells (cell widths sum the grid columns they occupy, including cells after spans, verified in the XML; a row whose blocks run on into each other, such as a link or superscript inside a sentence, or overlap, as a paragraph's lines do, is text and not a table row, #2806), per-page sections/sizes, the page colour (a full-page opaque fill painted first, such as Edit a PDF ▸ Background) as the document background when every page shares one non-white colour (Word has no per-page background, so differing colours are dropped; #2992; checked in the XML only, not yet opened in Word), and positioned editable text/artwork only for pages with rotated text or text overlapped by an image or watermark-sized text, other pages flowing with inline images (#773); synthetic invoice regression; dotted-leader contents entries keep the title, dots and page number together in separate paragraphs (#1757); the emitted package validates with Open XML SDK 2.20 (Office 2010 schema). Word/LibreOffice rendering of the positioned output remains unverified. Pictures on positioned pages are DrawingML anchors behind the text (a VML rectangle with an image showed no picture, #1936; not yet opened in Word); a Word file embeds at most 1000 pictures and 512 MiB of them. The earlier flowing output was checked with macOS textutil and opened in Microsoft Word (Windows). Characters XML forbids are dropped; the page size stays within Word's 22 in. A block that continues another on its line (an italic case name, a link, a superscript) is a run in its own size, bold/italic and colour inside that paragraph, keeping the source's word space between them, rather than a paragraph of its own; a lead-in in bold no longer makes its whole line a heading (#4158). Not yet: a style change that wraps onto the next line still splits its paragraph there (the reading blocks keep style boundaries, #4158), exact font matching, vector rules, arbitrary paint-order/transparency, general exact layout fidelity." +evidence = ["crates/export/src/tests.rs::word_table_cell_widths_follow_the_columns_they_span", "crates/export/src/tests.rs::table_spans_do_not_consume_text_they_do_not_emit", "crates/edit/src/tests.rs::reading_keeps_dotted_leader_entries_whole_and_in_order", "crates/engine/src/tests.rs::exporting_office_keeps_dotted_leader_entries_as_paragraphs", "crates/export/src/tests.rs::word_does_not_invent_borders_for_aligned_invoice_fields", "crates/export/src/tests.rs::word_overlap_does_not_consume_invoice_body_space", "crates/export/src/tests.rs::word_keeps_a_page_with_a_clear_image_and_big_title_in_flow", "crates/export/src/tests.rs::word_rotated_pages_keep_separate_page_sizes_and_unique_shapes", "crates/engine/src/tests.rs::office_export_positions_follow_the_displayed_crop_rotation_and_units", "crates/automation/tests/it/automation.rs::exporting_an_invoice_to_word_keeps_the_watermark_out_of_flow", "crates/export/src/tests.rs::docx_is_a_word_package", "crates/export/src/tests.rs::text_colour_survives_every_format", "crates/engine/src/tests.rs::exporting_office_files_keeps_text_colour", "crates/export/src/tests.rs::grid_text_becomes_a_real_table_in_every_format", "crates/export/src/tests.rs::a_link_inside_a_sentence_is_not_a_table_column", "crates/export/src/tests.rs::sentences_that_run_on_into_links_are_not_a_table", "crates/export/src/tests.rs::a_superscript_beside_a_number_keeps_its_table", "crates/export/src/tests.rs::word_opens_text_with_control_characters_and_long_pages", "crates/engine/src/tests.rs::exporting_office_files_keeps_images", "crates/automation/tests/it/automation.rs::exporting_to_word_html_and_rtf", "crates/ui-egui/tests/it/compare.rs::export_to_word_html_and_rtf", "crates/export/src/tests.rs::docx_keeps_a_shared_page_background", "crates/edit/src/tests.rs::a_page_painted_first_with_a_full_page_fill_has_that_background", "crates/engine/src/tests.rs::exporting_to_word_keeps_the_page_background", "crates/export/src/tests.rs::an_italic_word_stays_in_its_sentence", "crates/export/src/tests.rs::a_sentence_keeps_its_reading_order", "crates/export/src/tests.rs::runs_keep_the_spaces_the_source_had_between_them", "crates/export/src/tests.rs::a_paragraph_is_a_heading_only_when_its_text_is", "crates/automation/tests/it/automation.rs::exporting_to_word_keeps_an_italic_word_inside_its_sentence", "crates/export/src/tests.rs::a_sentence_that_wraps_after_a_style_change_is_one_paragraph", "crates/export/src/tests.rs::a_slanted_word_that_leans_into_the_next_stays_in_its_line", "crates/export/src/tests.rs::a_paragraph_breaks_where_its_line_does_not_run_on", "crates/automation/tests/it/automation.rs::exporting_to_word_keeps_a_wrapped_sentence_in_one_paragraph"] +notes = "Text in slots covered by a preceding table-cell span remains in paragraph flow instead of being discarded. Flowing Word document: paragraphs, Heading 1/2 from larger text, bold/italic, text colour (fill as sRGB; gray/CMYK converted naively, colour spaces via cs/sc read as black), borderless detected tables with spans/empty cells (cell widths sum the grid columns they occupy, including cells after spans, verified in the XML; a row whose blocks run on into each other, such as a link or superscript inside a sentence, or overlap, as a paragraph's lines do, is text and not a table row, #2806), per-page sections/sizes, the page colour (a full-page opaque fill painted first, such as Edit a PDF ▸ Background) as the document background when every page shares one non-white colour (Word has no per-page background, so differing colours are dropped; #2992; checked in the XML only, not yet opened in Word), and positioned editable text/artwork only for pages with rotated text or text overlapped by an image or watermark-sized text, other pages flowing with inline images (#773); synthetic invoice regression; dotted-leader contents entries keep the title, dots and page number together in separate paragraphs (#1757); the emitted package validates with Open XML SDK 2.20 (Office 2010 schema). Word/LibreOffice rendering of the positioned output remains unverified. Pictures on positioned pages are DrawingML anchors behind the text (a VML rectangle with an image showed no picture, #1936; not yet opened in Word); a Word file embeds at most 1000 pictures and 512 MiB of them. The earlier flowing output was checked with macOS textutil and opened in Microsoft Word (Windows). Characters XML forbids are dropped; the page size stays within Word's 22 in. A block that continues another on its line (an italic case name, a link, a superscript) is a run in its own size, bold/italic and colour inside that paragraph, keeping the source's word space between them, rather than a paragraph of its own; a lead-in in bold no longer makes its whole line a heading (#4158); a slanted run whose box leans into the next word by up to 0.3 of its size still runs on into it. A paragraph whose last line reaches the right edge of the page's text runs on into the line just below it that starts at the same left edge in the same size, so a sentence the reading blocks split at a style change stays one paragraph across its lines; headings, list items, a line that ends a sentence, an indented line and one after paragraph space keep their break (#4158). Not yet: a paragraph whose rows the reading blocks already grouped past a style change (the exporter does not see where such a block's last row ends), a wrapped line that ends in a full stop or colon, a first-line indent, lines with more than half their font size of space between them (as 1.5 or double line spacing leaves), text in columns, exact font matching, vector rules, arbitrary paint-order/transparency, general exact layout fidelity." [[feature]] id = "create.export-rtf" @@ -7088,8 +7088,8 @@ milestone = "M10" status = "shipped" commands = ["export.rtf"] tools = ["doc_export_office"] -evidence = ["crates/export/src/tests.rs::table_spans_do_not_consume_text_they_do_not_emit", "crates/export/src/tests.rs::rtf_escapes_and_sizes", "crates/export/src/tests.rs::text_colour_survives_every_format", "crates/export/src/tests.rs::word_opens_text_with_control_characters_and_long_pages", "crates/automation/tests/it/automation.rs::exporting_to_word_html_and_rtf", "crates/ui-egui/tests/it/compare.rs::export_to_word_html_and_rtf"] -notes = "Text in slots covered by a preceding table-cell span remains in paragraph flow instead of being discarded. Paragraphs with sizes, bold, italic, text colour (colortbl), Unicode; a block that continues another on its line is a formatted group inside that paragraph (#4158), though a style change that wraps onto the next line still splits it; page breaks. Images are left out." +evidence = ["crates/export/src/tests.rs::table_spans_do_not_consume_text_they_do_not_emit", "crates/export/src/tests.rs::rtf_escapes_and_sizes", "crates/export/src/tests.rs::text_colour_survives_every_format", "crates/export/src/tests.rs::word_opens_text_with_control_characters_and_long_pages", "crates/automation/tests/it/automation.rs::exporting_to_word_html_and_rtf", "crates/ui-egui/tests/it/compare.rs::export_to_word_html_and_rtf", "crates/export/src/tests.rs::a_sentence_that_wraps_after_a_style_change_is_one_paragraph"] +notes = "Text in slots covered by a preceding table-cell span remains in paragraph flow instead of being discarded. Paragraphs with sizes, bold, italic, text colour (colortbl), Unicode; a block that continues another on its line is a formatted group inside that paragraph (#4158), and a paragraph that wraps onto the next line after a style change runs on there, with the same rules and limits as Word export; page breaks. Images are left out." [[feature]] @@ -7140,8 +7140,9 @@ evidence = ["crates/export/src/tests.rs::table_spans_do_not_consume_text_they_do "crates/engine/src/tests.rs::exporting_office_files_keeps_text_colour", "crates/automation/tests/it/automation.rs::exporting_to_word_html_and_rtf", "crates/ui-egui/tests/it/compare.rs::export_to_word_html_and_rtf", + "crates/export/src/tests.rs::a_sentence_that_wraps_after_a_style_change_is_one_paragraph", ] -notes = "Text in slots covered by a preceding table-cell span remains in paragraph flow instead of being discarded. PowerPoint presentation: each page becomes a slide, with the largest non-table text as slide title, remaining paragraphs as body text, images and detected tables in reading order; DrawingML formatting (font size, bold, italic, text fill colour, cell borders); wired into engine command export.pptx, automation tool doc_export_office, and Export dialog. Missing: content past the slide bottom is not paginated or shrunk across multiple slides (no normAutofit), all slides use the first page's dimensions, title is determined by largest font size rather than semantic structure, and there is no layout-preserving mode (Acrobat keeps positions). Not yet verified in PowerPoint or Keynote: pptx_opens_in_libreoffice only converts the file when LibreOffice is installed and passes silently otherwise (CI has none), so the package structure is checked by pptx_package_parts_are_complete_and_consistent instead." +notes = "Text in slots covered by a preceding table-cell span remains in paragraph flow instead of being discarded. PowerPoint presentation: each page becomes a slide, with the largest non-table text as slide title, remaining paragraphs as body text (a paragraph that wraps onto the next line after a style change stays one paragraph, with the same rules and limits as Word export, #4158), images and detected tables in reading order; DrawingML formatting (font size, bold, italic, text fill colour, cell borders); wired into engine command export.pptx, automation tool doc_export_office, and Export dialog. Missing: content past the slide bottom is not paginated or shrunk across multiple slides (no normAutofit), all slides use the first page's dimensions, title is determined by largest font size rather than semantic structure, and there is no layout-preserving mode (Acrobat keeps positions). Not yet verified in PowerPoint or Keynote: pptx_opens_in_libreoffice only converts the file when LibreOffice is installed and passes silently otherwise (CI has none), so the package structure is checked by pptx_package_parts_are_complete_and_consistent instead." [[feature]] id = "create.export-png" @@ -7200,8 +7201,8 @@ milestone = "M10" status = "partial" commands = ["export.html"] tools = ["doc_export_office"] -evidence = ["crates/export/src/tests.rs::table_spans_do_not_consume_text_they_do_not_emit", "crates/export/src/tests.rs::html_has_headings_paragraphs_and_images_in_order", "crates/export/src/tests.rs::text_colour_survives_every_format", "crates/export/src/tests.rs::grid_text_becomes_a_real_table_in_every_format", "crates/automation/tests/it/automation.rs::exporting_to_word_html_and_rtf", "crates/ui-egui/tests/it/compare.rs::export_to_word_html_and_rtf", "crates/automation/tests/it/automation.rs::exporting_office_files_says_which_pages_left_form_text_out", "crates/edit/src/tests.rs::reading_follows_forms_as_far_as_the_renderer_and_says_where_it_stops"] -notes = "Text in slots covered by a preceding table-cell span remains in paragraph flow instead of being discarded. Single HTML file with headings, paragraphs (with text colour), inline images, and detected tables with spans/empty cells. Text and images in form XObjects are read 50 levels deep, as the renderer does, and up to 50 000 forms a page; the tool lists pages past that in truncated_pages, with a warning (#3120). Not yet: multi-page folder output, CSS from fonts." +evidence = ["crates/export/src/tests.rs::table_spans_do_not_consume_text_they_do_not_emit", "crates/export/src/tests.rs::html_has_headings_paragraphs_and_images_in_order", "crates/export/src/tests.rs::text_colour_survives_every_format", "crates/export/src/tests.rs::grid_text_becomes_a_real_table_in_every_format", "crates/automation/tests/it/automation.rs::exporting_to_word_html_and_rtf", "crates/ui-egui/tests/it/compare.rs::export_to_word_html_and_rtf", "crates/automation/tests/it/automation.rs::exporting_office_files_says_which_pages_left_form_text_out", "crates/edit/src/tests.rs::reading_follows_forms_as_far_as_the_renderer_and_says_where_it_stops", "crates/export/src/tests.rs::a_sentence_that_wraps_after_a_style_change_is_one_paragraph"] +notes = "Text in slots covered by a preceding table-cell span remains in paragraph flow instead of being discarded. Single HTML file with headings, paragraphs (with text colour; a paragraph that wraps onto the next line after a style change stays one paragraph, with the same rules and limits as Word export, #4158), inline images, and detected tables with spans/empty cells. Text and images in form XObjects are read 50 levels deep, as the renderer does, and up to 50 000 forms a page; the tool lists pages past that in truncated_pages, with a warning (#3120). Not yet: multi-page folder output, CSS from fonts." [[feature]] id = "create.export-text" From b98b20633cfab25a675c17fd1414d74aaf7e5248 Mon Sep 17 00:00:00 2001 From: oracola91 <118899664+oracola91@users.noreply.github.com> Date: Fri, 30 Oct 2026 00:49:19 +0200 Subject: [PATCH 02/92] Exports keep a bulleted list as paragraphs, not a table Word and LibreOffice often draw a list's bullet in a symbol font, a tab stop before the item's text, so each bullet is a block of its own. Table detection read the bullets and the items as two columns, and every export wrote the list as a borderless two-column table. Each bullet is now paired with the text that starts within three times its size after it on its line. The text runs on from its bullet, and bullets stay out of table detection, so Word gets one paragraph per item with the bullet first. The Symbol and Wingdings bullets Word writes (U+F0B7, U+F0A7) count. A dash or a middle dot counts only when nothing starts left of it on its line, so a dash that stands for "none" in a table row stays a cell. The new export and automation tests fail without the change. --- crates/automation/tests/it/automation.rs | 50 +++++++++++-- crates/export/src/flow.rs | 75 +++++++++++++++++++- crates/export/src/layout.rs | 25 +++++-- crates/export/src/tests.rs | 89 ++++++++++++++++++++++++ parity/acrobat-features.toml | 15 ++-- 5 files changed, 237 insertions(+), 17 deletions(-) diff --git a/crates/automation/tests/it/automation.rs b/crates/automation/tests/it/automation.rs index 4d3aa44f01..674d8e7666 100644 --- a/crates/automation/tests/it/automation.rs +++ b/crates/automation/tests/it/automation.rs @@ -7615,8 +7615,9 @@ BT /F1 12 Tf 72 640 Td (A paragraph further down.) Tj ET"; } /// A one-page PDF (612 x 792 pt) drawing `content`, with the standard fonts `fonts` as /F1, /F2 -/// and so on, each in its built-in encoding. -fn standard_fonts_pdf(content: &str, fonts: &[&str]) -> Vec { +/// and so on, each in its built-in encoding. A font given `bfchar` pairs (" ") reads +/// its codes through that ToUnicode map, as a symbol font Word embeds does. +fn standard_fonts_pdf(content: &str, fonts: &[(&str, Option<&str>)]) -> Vec { let names: String = (1..=fonts.len()).map(|i| format!("/F{i} {} 0 R ", i + 4)).collect(); let mut objs = vec![ "<< /Type /Catalog /Pages 2 0 R >>".to_string(), @@ -7624,7 +7625,19 @@ fn standard_fonts_pdf(content: &str, fonts: &[&str]) -> Vec { format!("<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] /Contents 4 0 R /Resources << /Font << {names}>> >> >>"), format!("<< /Length {} >>\nstream\n{content}\nendstream", content.len()), ]; - objs.extend(fonts.iter().map(|f| format!("<< /Type /Font /Subtype /Type1 /BaseFont /{f} >>"))); + let mut maps = Vec::new(); + for (font, map) in fonts { + let to_unicode = map.map_or(String::new(), |map| { + let cmap = format!( + "/CIDInit /ProcSet findresource begin 12 dict begin begincmap /CMapType 2 def 1 begincodespacerange <00> endcodespacerange \ +1 beginbfchar {map} endbfchar endcmap CMapName currentdict /CMap defineresource pop end end" + ); + maps.push(format!("<< /Length {} >>\nstream\n{cmap}\nendstream", cmap.len())); + format!(" /ToUnicode {} 0 R", fonts.len() + 4 + maps.len()) + }); + objs.push(format!("<< /Type /Font /Subtype /Type1 /BaseFont /{font}{to_unicode} >>")); + } + objs.extend(maps); let mut pdf = b"%PDF-1.7\n".to_vec(); let mut offsets = Vec::new(); for (i, o) in objs.iter().enumerate() { @@ -7676,7 +7689,8 @@ BT /F1 10 Tf 72 686 Td (tenant named in the schedule, ) Tj /F3 10 Tf (Blue Finch /F1 10 Tf (, who agree as follows to rent the upper) Tj ET \ BT /F1 10 Tf 72 672 Td (rooms for the whole of the term.) Tj ET \ BT /F1 10 Tf 72 640 Td (Rent is paid monthly in advance.) Tj ET"; - std::fs::write(dir.join("lease.pdf"), standard_fonts_pdf(content, &["Helvetica", "Helvetica-Bold", "Helvetica-Oblique"])).unwrap(); + let fonts = [("Helvetica", None), ("Helvetica-Bold", None), ("Helvetica-Oblique", None)]; + std::fs::write(dir.join("lease.pdf"), standard_fonts_pdf(content, &fonts)).unwrap(); let mut a = auto(&dir); let doc = ok(&mut a, "doc_open", json!({ "path": "lease.pdf" }))["doc"].as_u64().unwrap(); ok(&mut a, "doc_export_office", json!({ "doc": doc, "path": "lease.docx" })); @@ -7685,6 +7699,34 @@ schedule, Blue Finch Studio, who agree as follows to rent the upper rooms for th assert_eq!(word_paragraph_texts(&dir.join("lease.docx")), [sentence, "Rent is paid monthly in advance."]); } +/// A bulleted list drawn as Word draws one, each bullet in the Symbol font (read as U+F0B7, as +/// Word maps it) a tab stop before its item's text, reaches Word as one paragraph per item and +/// not as a two-column table. +#[test] +fn exporting_to_word_keeps_a_bulleted_list_as_paragraphs() { + let dir = workdir("bulleted-list-word"); + let mut content = String::from("BT /F1 10 Tf 72 700 Td (The tenant looks after:) Tj ET\n"); + for (n, item) in ["the stairs and the landing;", "the shared yard and its gate;", "the bins, every week."].iter().enumerate() { + let y = 686 - 14 * n; + content.push_str(&format!("BT /F2 10 Tf 90 {y} Td (\\267) Tj ET BT /F1 10 Tf 108 {y} Td ({item}) Tj ET\n")); + } + content.push_str("BT /F1 10 Tf 72 630 Td (Rent is paid monthly in advance.) Tj ET"); + std::fs::write(dir.join("list.pdf"), standard_fonts_pdf(&content, &[("Helvetica", None), ("Symbol", Some(" "))])).unwrap(); + let mut a = auto(&dir); + let doc = ok(&mut a, "doc_open", json!({ "path": "list.pdf" }))["doc"].as_u64().unwrap(); + ok(&mut a, "doc_export_office", json!({ "doc": doc, "path": "list.docx" })); + assert_eq!( + word_paragraph_texts(&dir.join("list.docx")), + [ + "The tenant looks after:", + "\u{F0B7} the stairs and the landing;", + "\u{F0B7} the shared yard and its gate;", + "\u{F0B7} the bins, every week.", + "Rent is paid monthly in advance." + ] + ); +} + /// Hundreds of calculations run on opening and again after a change through the tools. The /// time limits are raised so that a busy machine does not stop them (the engine's tests check /// that the limits stop them). diff --git a/crates/export/src/flow.rs b/crates/export/src/flow.rs index e064772864..49ef73e38d 100644 --- a/crates/export/src/flow.rs +++ b/crates/export/src/flow.rs @@ -7,8 +7,13 @@ //! line reaches the right edge of the page's text with the one that starts just below it at the //! same left edge, in the same size: the text wrapped there. Headings, list items, a line that //! ends a sentence and a line with paragraph space after it stay apart. +//! +//! A list item's bullet is often a block of its own: drawn in a symbol font, a tab stop before +//! the item's text. Table detection read such a list as a two-column table, bullets in one +//! column and the items in the other. [`bullet_items`] pairs each bullet with its text, so the +//! text runs on from its bullet and the bullets stay out of table detection. -use super::layout::Run; +use super::layout::{Block, Run}; /// Bullets that mark a list item wherever they stand, including the Symbol and Wingdings bullets /// Word writes into its PDFs (U+F0B7, U+F0A7). @@ -34,12 +39,80 @@ const FULL_LINE: (f64, f64) = (3.0, 0.12); /// it and still be at the same left edge. const SAME_LEFT: f64 = 2.0; +/// How far after its bullet (as a multiple of the bullet's size) a list item's text may start: a +/// tab stop away. +const BULLET_GAP: f64 = 3.0; + +/// How many blocks [`bullet_items`] may look at on one page, so that a page of many bullets stays +/// quick; past it, the bullets left are not taken for bullets. +const BULLET_BUDGET: usize = 1_000_000; + +/// Takes a step from `budget`: whether there was one left. +fn step(budget: &mut usize) -> bool { + budget.checked_sub(1).map(|left| *budget = left).is_some() +} + /// Whether two boxes ([x0, y0, x1, y1]) share a line: they overlap vertically by more than half /// the shorter one, as `layout::continued_from` has it. fn on_one_line(a: [f64; 4], b: [f64; 4]) -> bool { a[3].min(b[3]) - a[1].max(b[1]) > 0.5 * (a[3] - a[1]).min(b[3] - b[1]) } +/// Whether a block is a bullet drawn on its own: one bullet, dash or middle dot, no wider than one +/// and a half times its size. +fn is_bullet(b: &Block) -> bool { + let mut chars = b.text.trim().chars(); + let (Some(c), None) = (chars.next(), chars.next()) else { return false }; + (BULLETS.contains(&c) || DASHES.contains(&c)) && b.rect[2] - b.rect[0] <= 1.5 * b.size +} + +/// Whether no other block on `blocks[i]`'s line starts left of it. Each block looked at takes a +/// step from `budget`; when the budget runs out the answer is no. +fn opens_line(i: usize, blocks: &[Block], budget: &mut usize) -> bool { + let Some(a) = blocks.get(i) else { return false }; + for (j, b) in blocks.iter().enumerate() { + if !step(budget) { + return false; + } + if j != i && b.rect[0] < a.rect[0] && on_one_line(a.rect, b.rect) { + return false; + } + } + true +} + +/// The list items of a page whose bullet is a block of its own, as (bullet, text): the text is the +/// block that starts nearest after the bullet on its line, within [`BULLET_GAP`] times its size. +/// A dash or a middle dot is a bullet only when it opens its line; inside a line it is +/// punctuation, or a table cell's "none". The items' texts of a list all start at one tab stop, +/// so the search looks through every block that starts there, within [`BULLET_BUDGET`]. +pub(crate) fn bullet_items(blocks: &[Block]) -> Vec<(usize, usize)> { + let mut by_start: Vec<(f64, usize)> = blocks.iter().enumerate().map(|(i, b)| (b.rect[0], i)).collect(); + by_start.sort_by(|a, b| a.0.total_cmp(&b.0)); + let mut budget = BULLET_BUDGET; + let mut out = Vec::new(); + for (ai, a) in blocks.iter().enumerate().filter(|(_, b)| is_bullet(b)) { + // The text may start a point inside the bullet's box, as a run that continues another can. + let k = by_start.partition_point(|(x, _)| *x < a.rect[2] - 1.0); + let text = by_start + .get(k..) + .unwrap_or_default() + .iter() + .take_while(|(x, _)| *x <= a.rect[2] + BULLET_GAP * a.size) + .take_while(|_| step(&mut budget)) + .filter(|(_, bi)| *bi != ai) + .filter_map(|(_, bi)| blocks.get(*bi).map(|b| (*bi, b))) + .find(|(_, b)| on_one_line(a.rect, b.rect) && (a.rotation - b.rotation).abs() <= 0.1 && !is_bullet(b)) + .map(|(bi, _)| bi); + let Some(text) = text else { continue }; + let dash = a.text.trim().chars().next().is_some_and(|c| DASHES.contains(&c)); + if !dash || opens_line(ai, blocks, &mut budget) { + out.push((ai, text)); + } + } + out +} + /// Whether `text` opens like a list item: a bullet, a dash or `*`; one to three digits followed /// by `.` or `)`; or one letter followed by `)` ("a)", "а)"); then a space or nothing. "1.5 kg", /// "i.e." and "2026. Year" are text. diff --git a/crates/export/src/layout.rs b/crates/export/src/layout.rs index 84c59f82bb..133862b1d9 100644 --- a/crates/export/src/layout.rs +++ b/crates/export/src/layout.rs @@ -157,11 +157,12 @@ fn continuations(blocks: &[Block]) -> Vec { } /// Per block, the block it continues on the same line (see [`continuations`]): of those it could -/// continue, the one that ends nearest to where it starts. +/// continue, the one that ends nearest to where it starts. A list item's text continues its +/// bullet, a tab stop before it (`flow::bullet_items`). fn continued_from(blocks: &[Block]) -> Vec> { let mut by_end: Vec<(f64, usize)> = blocks.iter().enumerate().map(|(i, b)| (b.rect[2], i)).collect(); by_end.sort_by(|a, b| a.0.total_cmp(&b.0)); - blocks + let mut from: Vec> = blocks .iter() .enumerate() .map(|(bi, b)| { @@ -177,7 +178,13 @@ fn continued_from(blocks: &[Block]) -> Vec> { .min_by(|(_, a), (_, c)| (a.rect[2] - b.rect[0]).abs().total_cmp(&(c.rect[2] - b.rect[0]).abs())) .map(|(ai, _)| ai) }) - .collect() + .collect(); + for (bullet, text) in super::flow::bullet_items(blocks) { + if let Some(slot @ None) = from.get_mut(text) { + *slot = Some(bullet); + } + } + from } /// A gap between two runs wider than this (as a fraction of the font size) is a word space the @@ -270,8 +277,16 @@ fn is_cell_like(b: &Block) -> bool { /// per block, whether a table consumed it (so `items` can drop it from the paragraph flow). pub(crate) fn tables(blocks: &[Block]) -> (Vec, Vec) { let mut consumed = vec![false; blocks.len()]; + // A list's bullets are not cells: beside their items' text they read as a two-column table. + let mut bullet = vec![false; blocks.len()]; + for (i, _) in super::flow::bullet_items(blocks) { + if let Some(b) = bullet.get_mut(i) { + *b = true; + } + } + let cell_like = |i: usize, b: &Block| is_cell_like(b) && !bullet.get(i).copied().unwrap_or(false); // Candidate columns: cluster the left edges of cell-like blocks. - let mut xs: Vec<(f64, usize)> = blocks.iter().enumerate().filter(|(_, b)| is_cell_like(b)).map(|(i, b)| (b.rect[0], i)).collect(); + let mut xs: Vec<(f64, usize)> = blocks.iter().enumerate().filter(|(i, b)| cell_like(*i, b)).map(|(i, b)| (b.rect[0], i)).collect(); xs.sort_by(|a, b| a.0.total_cmp(&b.0)); let mut cols: Vec<(f64, Vec)> = Vec::new(); // (left edge, member block indices) for (x, i) in xs { @@ -307,7 +322,7 @@ pub(crate) fn tables(blocks: &[Block]) -> (Vec
, Vec) { order.sort_by(|a, b| blocks[*b].rect[3].total_cmp(&blocks[*a].rect[3]).then(blocks[*a].rect[0].total_cmp(&blocks[*b].rect[0]))); for &bi in &order { let b = &blocks[bi]; - if !is_cell_like(b) { + if !cell_like(bi, b) { continue; } let Some(ci) = column_at(b.rect[0]) else { continue }; diff --git a/crates/export/src/tests.rs b/crates/export/src/tests.rs index 11ad393206..dc99894d1b 100644 --- a/crates/export/src/tests.rs +++ b/crates/export/src/tests.rs @@ -976,3 +976,92 @@ fn joining_lines_survives_hostile_boxes() { } } } + +/// A bulleted list as Word and LibreOffice draw one: each bullet a block of its own in a symbol +/// font, a tab stop before its item's text, between a lead-in line and the next paragraph. +fn bulleted_list(bullet: &str) -> Vec { + let mut blocks = vec![line("The tenant looks after:", 72.0, 180.0, 700.0)]; + for (n, (text, end)) in + [("the stairs and the landing;", 230.0), ("the shared yard and its gate;", 240.0), ("the bins, every week.", 200.0)].into_iter().enumerate() + { + let y = 686.0 - 14.0 * n as f64; + blocks.push(line(bullet, 90.0, 94.6, y)); + blocks.push(line(text, 108.0, end, y)); + } + blocks.push(line("Rent is paid monthly in advance.", 72.0, 230.0, 630.0)); + blocks +} + +/// A bulleted list whose bullets are blocks of their own is one paragraph per item, each item's +/// text after its bullet, in every format: not a two-column table of bullets and text. The +/// Symbol and Wingdings bullets Word writes count, and so does a dash that opens its line. +#[test] +fn a_bulleted_list_is_paragraphs_not_a_table() { + for bullet in ["•", "\u{F0B7}", "\u{F0A7}", "\u{2013}"] { + let p = Page { width: 612.0, height: 792.0, blocks: bulleted_list(bullet), ..Page::default() }; + let (found, _) = tables(&p.blocks); + assert!(found.is_empty(), "{bullet}: {found:?}"); + let items = ["the stairs and the landing;", "the shared yard and its gate;", "the bins, every week."].map(|t| format!("{bullet} {t}")); + let mut expected = vec!["The tenant looks after:".to_string()]; + expected.extend(items.iter().cloned()); + expected.push("Rent is paid monthly in advance.".into()); + assert_eq!(word_texts(p.blocks.clone()), expected, "{bullet}"); + let h = html(std::slice::from_ref(&p), "List"); + assert!(!h.contains("
") && items.iter().all(|i| h.contains(&format!("

{i}

"))), "{bullet}: {h}"); + assert!(!rtf(std::slice::from_ref(&p)).contains("\\trowd"), "{bullet}"); + let s = part(&pptx(std::slice::from_ref(&p), "List"), "ppt/slides/slide1.xml"); + assert!(!s.contains(""), "{bullet}: {s}"); + } +} + +/// A dash in a table row stands for "none": it is a cell like the others, not a bullet, so the +/// table keeps all its rows. +#[test] +fn a_dash_in_a_table_row_stays_a_cell() { + let mut blocks = Vec::new(); + for (n, (name, first, second)) in [("Apples", "\u{2013}", "5"), ("Pears", "3", "4"), ("Plums", "2", "6")].into_iter().enumerate() { + let y = 700.0 - 14.0 * n as f64; + blocks.push(line(name, 72.0, 110.0, y)); + blocks.push(line(first, 200.0, 205.0, y)); + blocks.push(line(second, 225.0, 230.0, y)); + } + let (found, consumed) = tables(&blocks); + assert_eq!(found.len(), 1, "{found:?}"); + assert_eq!(found[0].rows.len(), 3, "{found:?}"); + assert!(consumed.iter().all(|used| *used), "{consumed:?}"); +} + +/// A bullet is never a table cell, even when its items carry a second column such as an amount: +/// every item keeps its bullet in front of its text. +#[test] +fn a_bullet_is_never_a_table_cell() { + let mut blocks = Vec::new(); + for (n, (item, amount)) in [("apples", "5 kg"), ("pears", "4 kg"), ("plums", "2 kg")].into_iter().enumerate() { + let y = 700.0 - 14.0 * n as f64; + blocks.push(line("•", 90.0, 94.6, y)); + blocks.push(line(item, 108.0, 140.0, y)); + blocks.push(line(amount, 300.0, 325.0, y)); + } + let (found, _) = tables(&blocks); + assert!(found.iter().flat_map(|t| &t.rows).flatten().all(|cell| cell.text != "•"), "{found:?}"); + let paragraphs = word_texts(blocks); + for item in ["• apples", "• pears", "• plums"] { + assert!(paragraphs.iter().any(|p| p.starts_with(item)), "{item}: {paragraphs:?}"); + } +} + +/// Every item of a long list keeps its bullet, though all the items' texts start at one tab stop. +#[test] +fn a_long_bulleted_list_keeps_every_bullet_with_its_item() { + let mut blocks = Vec::new(); + for n in 0..150 { + let y = 2200.0 - 14.0 * f64::from(n); + blocks.push(line("\u{F0B7}", 90.0, 94.6, y)); + blocks.push(line(&format!("item {n};"), 108.0, 160.0, y)); + } + let (found, _) = tables(&blocks); + assert!(found.is_empty(), "{found:?}"); + let paragraphs = word_texts(blocks); + assert_eq!(paragraphs.len(), 150, "{paragraphs:?}"); + assert!(paragraphs.iter().enumerate().all(|(n, p)| *p == format!("\u{F0B7} item {n};")), "{paragraphs:?}"); +} diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index cffbff4176..a349c91fd8 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -7076,8 +7076,8 @@ milestone = "M10" status = "partial" commands = ["export.docx"] tools = ["doc_export_office"] -evidence = ["crates/export/src/tests.rs::word_table_cell_widths_follow_the_columns_they_span", "crates/export/src/tests.rs::table_spans_do_not_consume_text_they_do_not_emit", "crates/edit/src/tests.rs::reading_keeps_dotted_leader_entries_whole_and_in_order", "crates/engine/src/tests.rs::exporting_office_keeps_dotted_leader_entries_as_paragraphs", "crates/export/src/tests.rs::word_does_not_invent_borders_for_aligned_invoice_fields", "crates/export/src/tests.rs::word_overlap_does_not_consume_invoice_body_space", "crates/export/src/tests.rs::word_keeps_a_page_with_a_clear_image_and_big_title_in_flow", "crates/export/src/tests.rs::word_rotated_pages_keep_separate_page_sizes_and_unique_shapes", "crates/engine/src/tests.rs::office_export_positions_follow_the_displayed_crop_rotation_and_units", "crates/automation/tests/it/automation.rs::exporting_an_invoice_to_word_keeps_the_watermark_out_of_flow", "crates/export/src/tests.rs::docx_is_a_word_package", "crates/export/src/tests.rs::text_colour_survives_every_format", "crates/engine/src/tests.rs::exporting_office_files_keeps_text_colour", "crates/export/src/tests.rs::grid_text_becomes_a_real_table_in_every_format", "crates/export/src/tests.rs::a_link_inside_a_sentence_is_not_a_table_column", "crates/export/src/tests.rs::sentences_that_run_on_into_links_are_not_a_table", "crates/export/src/tests.rs::a_superscript_beside_a_number_keeps_its_table", "crates/export/src/tests.rs::word_opens_text_with_control_characters_and_long_pages", "crates/engine/src/tests.rs::exporting_office_files_keeps_images", "crates/automation/tests/it/automation.rs::exporting_to_word_html_and_rtf", "crates/ui-egui/tests/it/compare.rs::export_to_word_html_and_rtf", "crates/export/src/tests.rs::docx_keeps_a_shared_page_background", "crates/edit/src/tests.rs::a_page_painted_first_with_a_full_page_fill_has_that_background", "crates/engine/src/tests.rs::exporting_to_word_keeps_the_page_background", "crates/export/src/tests.rs::an_italic_word_stays_in_its_sentence", "crates/export/src/tests.rs::a_sentence_keeps_its_reading_order", "crates/export/src/tests.rs::runs_keep_the_spaces_the_source_had_between_them", "crates/export/src/tests.rs::a_paragraph_is_a_heading_only_when_its_text_is", "crates/automation/tests/it/automation.rs::exporting_to_word_keeps_an_italic_word_inside_its_sentence", "crates/export/src/tests.rs::a_sentence_that_wraps_after_a_style_change_is_one_paragraph", "crates/export/src/tests.rs::a_slanted_word_that_leans_into_the_next_stays_in_its_line", "crates/export/src/tests.rs::a_paragraph_breaks_where_its_line_does_not_run_on", "crates/automation/tests/it/automation.rs::exporting_to_word_keeps_a_wrapped_sentence_in_one_paragraph"] -notes = "Text in slots covered by a preceding table-cell span remains in paragraph flow instead of being discarded. Flowing Word document: paragraphs, Heading 1/2 from larger text, bold/italic, text colour (fill as sRGB; gray/CMYK converted naively, colour spaces via cs/sc read as black), borderless detected tables with spans/empty cells (cell widths sum the grid columns they occupy, including cells after spans, verified in the XML; a row whose blocks run on into each other, such as a link or superscript inside a sentence, or overlap, as a paragraph's lines do, is text and not a table row, #2806), per-page sections/sizes, the page colour (a full-page opaque fill painted first, such as Edit a PDF ▸ Background) as the document background when every page shares one non-white colour (Word has no per-page background, so differing colours are dropped; #2992; checked in the XML only, not yet opened in Word), and positioned editable text/artwork only for pages with rotated text or text overlapped by an image or watermark-sized text, other pages flowing with inline images (#773); synthetic invoice regression; dotted-leader contents entries keep the title, dots and page number together in separate paragraphs (#1757); the emitted package validates with Open XML SDK 2.20 (Office 2010 schema). Word/LibreOffice rendering of the positioned output remains unverified. Pictures on positioned pages are DrawingML anchors behind the text (a VML rectangle with an image showed no picture, #1936; not yet opened in Word); a Word file embeds at most 1000 pictures and 512 MiB of them. The earlier flowing output was checked with macOS textutil and opened in Microsoft Word (Windows). Characters XML forbids are dropped; the page size stays within Word's 22 in. A block that continues another on its line (an italic case name, a link, a superscript) is a run in its own size, bold/italic and colour inside that paragraph, keeping the source's word space between them, rather than a paragraph of its own; a lead-in in bold no longer makes its whole line a heading (#4158); a slanted run whose box leans into the next word by up to 0.3 of its size still runs on into it. A paragraph whose last line reaches the right edge of the page's text runs on into the line just below it that starts at the same left edge in the same size, so a sentence the reading blocks split at a style change stays one paragraph across its lines; headings, list items, a line that ends a sentence, an indented line and one after paragraph space keep their break (#4158). Not yet: a paragraph whose rows the reading blocks already grouped past a style change (the exporter does not see where such a block's last row ends), a wrapped line that ends in a full stop or colon, a first-line indent, lines with more than half their font size of space between them (as 1.5 or double line spacing leaves), text in columns, exact font matching, vector rules, arbitrary paint-order/transparency, general exact layout fidelity." +evidence = ["crates/export/src/tests.rs::word_table_cell_widths_follow_the_columns_they_span", "crates/export/src/tests.rs::table_spans_do_not_consume_text_they_do_not_emit", "crates/edit/src/tests.rs::reading_keeps_dotted_leader_entries_whole_and_in_order", "crates/engine/src/tests.rs::exporting_office_keeps_dotted_leader_entries_as_paragraphs", "crates/export/src/tests.rs::word_does_not_invent_borders_for_aligned_invoice_fields", "crates/export/src/tests.rs::word_overlap_does_not_consume_invoice_body_space", "crates/export/src/tests.rs::word_keeps_a_page_with_a_clear_image_and_big_title_in_flow", "crates/export/src/tests.rs::word_rotated_pages_keep_separate_page_sizes_and_unique_shapes", "crates/engine/src/tests.rs::office_export_positions_follow_the_displayed_crop_rotation_and_units", "crates/automation/tests/it/automation.rs::exporting_an_invoice_to_word_keeps_the_watermark_out_of_flow", "crates/export/src/tests.rs::docx_is_a_word_package", "crates/export/src/tests.rs::text_colour_survives_every_format", "crates/engine/src/tests.rs::exporting_office_files_keeps_text_colour", "crates/export/src/tests.rs::grid_text_becomes_a_real_table_in_every_format", "crates/export/src/tests.rs::a_link_inside_a_sentence_is_not_a_table_column", "crates/export/src/tests.rs::sentences_that_run_on_into_links_are_not_a_table", "crates/export/src/tests.rs::a_superscript_beside_a_number_keeps_its_table", "crates/export/src/tests.rs::word_opens_text_with_control_characters_and_long_pages", "crates/engine/src/tests.rs::exporting_office_files_keeps_images", "crates/automation/tests/it/automation.rs::exporting_to_word_html_and_rtf", "crates/ui-egui/tests/it/compare.rs::export_to_word_html_and_rtf", "crates/export/src/tests.rs::docx_keeps_a_shared_page_background", "crates/edit/src/tests.rs::a_page_painted_first_with_a_full_page_fill_has_that_background", "crates/engine/src/tests.rs::exporting_to_word_keeps_the_page_background", "crates/export/src/tests.rs::an_italic_word_stays_in_its_sentence", "crates/export/src/tests.rs::a_sentence_keeps_its_reading_order", "crates/export/src/tests.rs::runs_keep_the_spaces_the_source_had_between_them", "crates/export/src/tests.rs::a_paragraph_is_a_heading_only_when_its_text_is", "crates/automation/tests/it/automation.rs::exporting_to_word_keeps_an_italic_word_inside_its_sentence", "crates/export/src/tests.rs::a_sentence_that_wraps_after_a_style_change_is_one_paragraph", "crates/export/src/tests.rs::a_slanted_word_that_leans_into_the_next_stays_in_its_line", "crates/export/src/tests.rs::a_paragraph_breaks_where_its_line_does_not_run_on", "crates/automation/tests/it/automation.rs::exporting_to_word_keeps_a_wrapped_sentence_in_one_paragraph", "crates/export/src/tests.rs::a_bulleted_list_is_paragraphs_not_a_table", "crates/export/src/tests.rs::a_bullet_is_never_a_table_cell", "crates/export/src/tests.rs::a_long_bulleted_list_keeps_every_bullet_with_its_item", "crates/export/src/tests.rs::a_dash_in_a_table_row_stays_a_cell", "crates/automation/tests/it/automation.rs::exporting_to_word_keeps_a_bulleted_list_as_paragraphs"] +notes = "Text in slots covered by a preceding table-cell span remains in paragraph flow instead of being discarded. Flowing Word document: paragraphs, Heading 1/2 from larger text, bold/italic, text colour (fill as sRGB; gray/CMYK converted naively, colour spaces via cs/sc read as black), borderless detected tables with spans/empty cells (cell widths sum the grid columns they occupy, including cells after spans, verified in the XML; a row whose blocks run on into each other, such as a link or superscript inside a sentence, or overlap, as a paragraph's lines do, is text and not a table row, #2806), per-page sections/sizes, the page colour (a full-page opaque fill painted first, such as Edit a PDF ▸ Background) as the document background when every page shares one non-white colour (Word has no per-page background, so differing colours are dropped; #2992; checked in the XML only, not yet opened in Word), and positioned editable text/artwork only for pages with rotated text or text overlapped by an image or watermark-sized text, other pages flowing with inline images (#773); synthetic invoice regression; dotted-leader contents entries keep the title, dots and page number together in separate paragraphs (#1757); the emitted package validates with Open XML SDK 2.20 (Office 2010 schema). Word/LibreOffice rendering of the positioned output remains unverified. Pictures on positioned pages are DrawingML anchors behind the text (a VML rectangle with an image showed no picture, #1936; not yet opened in Word); a Word file embeds at most 1000 pictures and 512 MiB of them. The earlier flowing output was checked with macOS textutil and opened in Microsoft Word (Windows). Characters XML forbids are dropped; the page size stays within Word's 22 in. A block that continues another on its line (an italic case name, a link, a superscript) is a run in its own size, bold/italic and colour inside that paragraph, keeping the source's word space between them, rather than a paragraph of its own; a lead-in in bold no longer makes its whole line a heading (#4158); a slanted run whose box leans into the next word by up to 0.3 of its size still runs on into it. A paragraph whose last line reaches the right edge of the page's text runs on into the line just below it that starts at the same left edge in the same size, so a sentence the reading blocks split at a style change stays one paragraph across its lines; headings, list items, a line that ends a sentence, an indented line and one after paragraph space keep their break (#4158). A list whose bullets are blocks of their own (a bullet in a symbol font a tab stop before the item's text, as Word writes it, U+F0B7 and U+F0A7 included) is one paragraph per item, the bullet first, and not a borderless two-column table: a bullet is never a table cell, and a dash or middle dot counts as a bullet only when nothing starts left of it on its line. Not yet: Word list numbering (an item is a plain paragraph that starts with its bullet), a bullet drawn in the standard Symbol font without a ToUnicode map (it never reaches export, though text extraction reads it), a paragraph whose rows the reading blocks already grouped past a style change (the exporter does not see where such a block's last row ends), a wrapped line that ends in a full stop or colon, a first-line indent, lines with more than half their font size of space between them (as 1.5 or double line spacing leaves), text in columns, exact font matching, vector rules, arbitrary paint-order/transparency, general exact layout fidelity." [[feature]] id = "create.export-rtf" @@ -7088,8 +7088,8 @@ milestone = "M10" status = "shipped" commands = ["export.rtf"] tools = ["doc_export_office"] -evidence = ["crates/export/src/tests.rs::table_spans_do_not_consume_text_they_do_not_emit", "crates/export/src/tests.rs::rtf_escapes_and_sizes", "crates/export/src/tests.rs::text_colour_survives_every_format", "crates/export/src/tests.rs::word_opens_text_with_control_characters_and_long_pages", "crates/automation/tests/it/automation.rs::exporting_to_word_html_and_rtf", "crates/ui-egui/tests/it/compare.rs::export_to_word_html_and_rtf", "crates/export/src/tests.rs::a_sentence_that_wraps_after_a_style_change_is_one_paragraph"] -notes = "Text in slots covered by a preceding table-cell span remains in paragraph flow instead of being discarded. Paragraphs with sizes, bold, italic, text colour (colortbl), Unicode; a block that continues another on its line is a formatted group inside that paragraph (#4158), and a paragraph that wraps onto the next line after a style change runs on there, with the same rules and limits as Word export; page breaks. Images are left out." +evidence = ["crates/export/src/tests.rs::table_spans_do_not_consume_text_they_do_not_emit", "crates/export/src/tests.rs::rtf_escapes_and_sizes", "crates/export/src/tests.rs::text_colour_survives_every_format", "crates/export/src/tests.rs::word_opens_text_with_control_characters_and_long_pages", "crates/automation/tests/it/automation.rs::exporting_to_word_html_and_rtf", "crates/ui-egui/tests/it/compare.rs::export_to_word_html_and_rtf", "crates/export/src/tests.rs::a_sentence_that_wraps_after_a_style_change_is_one_paragraph", "crates/export/src/tests.rs::a_bulleted_list_is_paragraphs_not_a_table"] +notes = "Text in slots covered by a preceding table-cell span remains in paragraph flow instead of being discarded. Paragraphs with sizes, bold, italic, text colour (colortbl), Unicode; a block that continues another on its line is a formatted group inside that paragraph (#4158), and a paragraph that wraps onto the next line after a style change runs on there, with the same rules and limits as Word export; a list whose bullets are blocks of their own is a paragraph per item, not a table, as in Word export; page breaks. Images are left out." [[feature]] @@ -7141,8 +7141,9 @@ evidence = ["crates/export/src/tests.rs::table_spans_do_not_consume_text_they_do "crates/automation/tests/it/automation.rs::exporting_to_word_html_and_rtf", "crates/ui-egui/tests/it/compare.rs::export_to_word_html_and_rtf", "crates/export/src/tests.rs::a_sentence_that_wraps_after_a_style_change_is_one_paragraph", + "crates/export/src/tests.rs::a_bulleted_list_is_paragraphs_not_a_table", ] -notes = "Text in slots covered by a preceding table-cell span remains in paragraph flow instead of being discarded. PowerPoint presentation: each page becomes a slide, with the largest non-table text as slide title, remaining paragraphs as body text (a paragraph that wraps onto the next line after a style change stays one paragraph, with the same rules and limits as Word export, #4158), images and detected tables in reading order; DrawingML formatting (font size, bold, italic, text fill colour, cell borders); wired into engine command export.pptx, automation tool doc_export_office, and Export dialog. Missing: content past the slide bottom is not paginated or shrunk across multiple slides (no normAutofit), all slides use the first page's dimensions, title is determined by largest font size rather than semantic structure, and there is no layout-preserving mode (Acrobat keeps positions). Not yet verified in PowerPoint or Keynote: pptx_opens_in_libreoffice only converts the file when LibreOffice is installed and passes silently otherwise (CI has none), so the package structure is checked by pptx_package_parts_are_complete_and_consistent instead." +notes = "Text in slots covered by a preceding table-cell span remains in paragraph flow instead of being discarded. PowerPoint presentation: each page becomes a slide, with the largest non-table text as slide title, remaining paragraphs as body text (a paragraph that wraps onto the next line after a style change stays one paragraph, with the same rules and limits as Word export, #4158; a list whose bullets are blocks of their own is a paragraph per item, not a table), images and detected tables in reading order; DrawingML formatting (font size, bold, italic, text fill colour, cell borders); wired into engine command export.pptx, automation tool doc_export_office, and Export dialog. Missing: content past the slide bottom is not paginated or shrunk across multiple slides (no normAutofit), all slides use the first page's dimensions, title is determined by largest font size rather than semantic structure, and there is no layout-preserving mode (Acrobat keeps positions). Not yet verified in PowerPoint or Keynote: pptx_opens_in_libreoffice only converts the file when LibreOffice is installed and passes silently otherwise (CI has none), so the package structure is checked by pptx_package_parts_are_complete_and_consistent instead." [[feature]] id = "create.export-png" @@ -7201,8 +7202,8 @@ milestone = "M10" status = "partial" commands = ["export.html"] tools = ["doc_export_office"] -evidence = ["crates/export/src/tests.rs::table_spans_do_not_consume_text_they_do_not_emit", "crates/export/src/tests.rs::html_has_headings_paragraphs_and_images_in_order", "crates/export/src/tests.rs::text_colour_survives_every_format", "crates/export/src/tests.rs::grid_text_becomes_a_real_table_in_every_format", "crates/automation/tests/it/automation.rs::exporting_to_word_html_and_rtf", "crates/ui-egui/tests/it/compare.rs::export_to_word_html_and_rtf", "crates/automation/tests/it/automation.rs::exporting_office_files_says_which_pages_left_form_text_out", "crates/edit/src/tests.rs::reading_follows_forms_as_far_as_the_renderer_and_says_where_it_stops", "crates/export/src/tests.rs::a_sentence_that_wraps_after_a_style_change_is_one_paragraph"] -notes = "Text in slots covered by a preceding table-cell span remains in paragraph flow instead of being discarded. Single HTML file with headings, paragraphs (with text colour; a paragraph that wraps onto the next line after a style change stays one paragraph, with the same rules and limits as Word export, #4158), inline images, and detected tables with spans/empty cells. Text and images in form XObjects are read 50 levels deep, as the renderer does, and up to 50 000 forms a page; the tool lists pages past that in truncated_pages, with a warning (#3120). Not yet: multi-page folder output, CSS from fonts." +evidence = ["crates/export/src/tests.rs::table_spans_do_not_consume_text_they_do_not_emit", "crates/export/src/tests.rs::html_has_headings_paragraphs_and_images_in_order", "crates/export/src/tests.rs::text_colour_survives_every_format", "crates/export/src/tests.rs::grid_text_becomes_a_real_table_in_every_format", "crates/automation/tests/it/automation.rs::exporting_to_word_html_and_rtf", "crates/ui-egui/tests/it/compare.rs::export_to_word_html_and_rtf", "crates/automation/tests/it/automation.rs::exporting_office_files_says_which_pages_left_form_text_out", "crates/edit/src/tests.rs::reading_follows_forms_as_far_as_the_renderer_and_says_where_it_stops", "crates/export/src/tests.rs::a_sentence_that_wraps_after_a_style_change_is_one_paragraph", "crates/export/src/tests.rs::a_bulleted_list_is_paragraphs_not_a_table"] +notes = "Text in slots covered by a preceding table-cell span remains in paragraph flow instead of being discarded. Single HTML file with headings, paragraphs (with text colour; a paragraph that wraps onto the next line after a style change stays one paragraph, with the same rules and limits as Word export, #4158; a list whose bullets are blocks of their own is a paragraph per item, not a table, though not yet a
    list), inline images, and detected tables with spans/empty cells. Text and images in form XObjects are read 50 levels deep, as the renderer does, and up to 50 000 forms a page; the tool lists pages past that in truncated_pages, with a warning (#3120). Not yet: multi-page folder output, CSS from fonts." [[feature]] id = "create.export-text" From 78b8dfbaefd0c45765df33eea80d0a22e7c65717 Mon Sep 17 00:00:00 2001 From: oracola91 <118899664+oracola91@users.noreply.github.com> Date: Sat, 31 Oct 2026 00:51:26 +0200 Subject: [PATCH 03/92] Exports join wrapped Chinese and Japanese lines without a space Chinese and Japanese text wraps between characters, so the word space put at each joined line break showed up inside the text. No space is added now when both sides of the break are Chinese or Japanese characters, their punctuation or fullwidth forms; Korean, which spaces its words, still gets one. A line ending in a full-width full stop, exclamation or question mark (also inside closing corner brackets) now ends its paragraph like one ending in a full stop. The new test lines fail without the change. The wrapped sentence test now checks the Markdown export too, which goes through the same joining since it was added. --- crates/export/src/flow.rs | 27 +++++++++++++++++++++++++-- crates/export/src/tests.rs | 14 ++++++++++++++ 2 files changed, 39 insertions(+), 2 deletions(-) diff --git a/crates/export/src/flow.rs b/crates/export/src/flow.rs index 49ef73e38d..4349dd558e 100644 --- a/crates/export/src/flow.rs +++ b/crates/export/src/flow.rs @@ -140,7 +140,25 @@ pub(crate) fn list_item(text: &str) -> bool { /// paragraph spacing gives no other sign that a paragraph ends there, so such a line is left /// apart from the next rather than joined to it wrongly. fn ends_sentence(text: &str) -> bool { - text.trim_end().trim_end_matches(['"', '\'', '”', '’', '“', '»', ')', ']']).ends_with(['.', '!', '?', ':', ';', '…']) + text.trim_end() + .trim_end_matches(['"', '\'', '”', '’', '“', '»', ')', ']', '」', '』']) + .ends_with(['.', '!', '?', ':', ';', '…', '。', '!', '?']) +} + +/// Whether `c` is of the writing that puts no space between words: Chinese and Japanese +/// characters, their punctuation and fullwidth forms. A line that wraps between two of them +/// joins without a word space. +fn unspaced(c: char) -> bool { + matches!( + c, + '\u{3000}'..='\u{30FF}' // CJK punctuation, Hiragana, Katakana + | '\u{31F0}'..='\u{31FF}' // Katakana extension + | '\u{3400}'..='\u{4DBF}' // CJK extension A + | '\u{4E00}'..='\u{9FFF}' // CJK unified ideographs + | '\u{F900}'..='\u{FAFF}' // CJK compatibility ideographs + | '\u{FF01}'..='\u{FF9F}' // fullwidth forms, halfwidth Katakana + | '\u{20000}'..='\u{3FFFF}' // CJK extension B onwards + ) } /// `paras` (a page's paragraphs, in any order) top to bottom, with each paragraph that wraps onto @@ -169,10 +187,15 @@ pub(crate) fn join_wrapped<'a>(mut paras: Vec>>, level: impl Fn(&[Ru match out.last_mut() { Some((prev, 0)) if lvl == 0 && wraps_into(prev, ¶, [left, right]) => { // The line break was a word space, unless the line ends in a hyphen; the hyphen - // stays, as it may belong to the word ("well-known"). + // stays, as it may belong to the word ("well-known"). Chinese and Japanese text + // wraps between characters, with no space. + let unspaced_break = |end: &str| { + end.chars().next_back().is_some_and(unspaced) && para.first().and_then(|r| r.text.chars().next()).is_some_and(unspaced) + }; if let Some(end) = prev.last_mut() && !end.text.ends_with(char::is_whitespace) && !end.text.ends_with('-') + && !unspaced_break(&end.text) { end.text.push(' '); } diff --git a/crates/export/src/tests.rs b/crates/export/src/tests.rs index dc99894d1b..5af44d3c65 100644 --- a/crates/export/src/tests.rs +++ b/crates/export/src/tests.rs @@ -890,6 +890,9 @@ named in the schedule, Blue Finch Studio, who agree as follows to rent ); let r = rtf(std::slice::from_ref(&p)); assert_eq!(r.matches("{\\pard").count(), 2, "{r}"); + let md = markdown(std::slice::from_ref(&p)); + assert_eq!(md.trim().split("\n\n").count(), 2, "{md}"); + assert!(md.contains("of 4 Quay Street, and the tenant named in the schedule, "), "{md}"); // On a slide the last of the paragraphs set largest is the title; the sentence is one paragraph. let s = part(&pptx(std::slice::from_ref(&p), "Lease"), "ppt/slides/slide1.xml"); let slide: Vec = @@ -924,10 +927,21 @@ fn a_paragraph_breaks_where_its_line_does_not_run_on() { let second = |t: &str| line(t, 72.0, 240.0, 686.0); assert_eq!(word_texts(vec![first(full), second(next)]), [format!("{full} {next}")]); assert_eq!(word_texts(vec![first("Изпълнителят поддържа сървърите и мрежата на"), second("възложителя в работно състояние")]).len(), 1); + // Chinese and Japanese wrap between characters: their lines join without a space. Korean + // spaces its words, so its lines join with one. + let zh = ("本合同自双方签字之日起生效,有效期", "为一年,期满前一个月双方协商续签事宜"); + assert_eq!(word_texts(vec![first(zh.0), second(zh.1)]), [format!("{}{}", zh.0, zh.1)]); + assert_eq!( + word_texts(vec![first("この契約は双方が署名した日から"), second("一年間有効とする")]), + ["この契約は双方が署名した日から一年間有効とする"] + ); + assert_eq!(word_texts(vec![first("임차인은 방과 계단을 깨끗하게"), second("유지합니다")]), ["임차인은 방과 계단을 깨끗하게 유지합니다"]); let heading = |b: Block| Block { bold: true, ..b }; for (why, blocks) in [ ("a sentence ends", vec![first(&format!("{full}.")), second(next)]), ("a sentence ends in quotes", vec![first("Страните се договориха „за срок от една година.“"), second(next)]), + ("a Chinese sentence ends", vec![first("本合同自双方签字之日起生效。"), second(zh.1)]), + ("a Japanese sentence ends in quotes", vec![first("「この契約は一年間有効ですか?」"), second("双方が署名した日から")]), ("the line ends short", vec![line(next, 72.0, 240.0, 700.0), line(full, 72.0, 540.0, 686.0)]), ("paragraph space", vec![first(full), line(next, 72.0, 240.0, 676.0)]), ("an indent", vec![first(full), line(next, 90.0, 258.0, 686.0)]), From d2723b64fa46668ef40ef99d2eae4675d21dc1cc Mon Sep 17 00:00:00 2001 From: Dew Date: Mon, 19 Oct 2026 22:29:52 +0700 Subject: [PATCH 04/92] M7: break Thai rows between dictionary words MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Thai has no spaces between words, so rewrapping treated a whole Thai run as one word and Add text broke rows at any shaped cluster, splitting words such as ประปา or สะอาด across rows. Find the words with ICU4X's dictionary line segmenter (icu_segmenter 2.3, compiled dictionary data, no LSTM model; Unicode-3.0). linebreak::breaks gives every place a row may end inside a run without spaces: the existing Chinese and Japanese rule plus the dictionary's breaks next to Thai. A row never starts with a following vowel, a tone or other mark, Mai Yamok or Paiyannoi, nor ends with a leading vowel, whatever the dictionary says, and the CJK pair rule now honours the same Thai marks. - text.rs wrap: unchanged; linebreak::pieces now cuts Thai too. - text_regions.rs: breaks are worked out once per run of non-space characters instead of pair by pair, since Thai needs the whole run. - added.rs thai_lines: clusters are grouped into words and whole words wrap; only a word wider than the row breaks between clusters, as before. The spaces where a row ends are dropped, as in the Latin wrapper. Rewriting a paragraph into Thai still needs a source font with the Thai glyphs; there is no Thai fallback face for it yet. The compiled dictionaries (Thai, Lao, Khmer, Burmese) add about 1.8 MB to a stripped release binary. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_018cECVMvRnc9mHNFfVp7nXd --- Cargo.lock | 23 ++++ ROADMAP.md | 1 + crates/edit/Cargo.toml | 3 + crates/edit/src/added/layout.rs | 77 ++++++++++-- crates/edit/src/linebreak.rs | 156 ++++++++++++++++++++++--- crates/edit/src/tests.rs | 29 +++++ crates/edit/src/text.rs | 12 ++ crates/edit/src/text_regions/styled.rs | 34 ++++-- parity/acrobat-features.toml | 4 +- 9 files changed, 308 insertions(+), 31 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index c46eac4790..a7bddbe283 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3104,6 +3104,28 @@ dependencies = [ "zerovec", ] +[[package]] +name = "icu_segmenter" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82d07aafccd67af15d02512a6adf5896fbc5ed00f2e99b471d2efa14016db3db" +dependencies = [ + "icu_collections", + "icu_locale_fallback", + "icu_provider", + "icu_segmenter_data", + "potential_utf", + "smallvec", + "utf8_iter", + "zerovec", +] + +[[package]] +name = "icu_segmenter_data" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae293c039020f9ec10710af98d29ce6aa2051486638b49c9a6409f3b4a9e98ad" + [[package]] name = "idna" version = "1.1.0" @@ -4367,6 +4389,7 @@ name = "pdfcraft-edit" version = "0.18.0" dependencies = [ "hayro-syntax", + "icu_segmenter", "kurbo 0.13.1", "pdfcraft-annot", "pdfcraft-content", diff --git a/ROADMAP.md b/ROADMAP.md index 6da118da92..8ac5e2edba 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -219,6 +219,7 @@ Ranked; detail and the full milestone table in [docs/roadmap.md](docs/roadmap.md ## Progress log +- **2026-11-03 (community, M7, Thai word breaking):** Thai rows end between words, found with ICU4X's dictionary line segmenter (`icu_segmenter` 2.3, compiled dictionary data, no LSTM model; Unicode-3.0), instead of only at spaces when rewrapping and at any cluster in Add text. A row never starts with a following vowel, tone or other mark, Mai Yamok or Paiyannoi, nor ends with a leading vowel; a word wider than the row still breaks between clusters, and the spaces where an Add text row ends are dropped as in Latin text. Rewrapping existing text works out the breaks once per run of non-space characters. Rewriting a paragraph into Thai still needs a source font with the Thai glyphs (there is no Thai fallback face for it yet). The compiled dictionaries (Thai, Lao, Khmer, Burmese) add about 1.8 MB to a stripped release binary. Estimates unchanged. - **2026-11-03 (M7, #5835):** In image crop mode, a right-click on the image, its crop box or its handles keeps the crop and opens no menu, so the menu no longer switches as the pointer moves. A right-click elsewhere clears the selection and opens the page menu. UI tests cover all four page rotations, overlapping images, a 45° image, and a check that the Done/Cancel/Remove Crop pills at a page edge still keep the crop. - **2026-11-02 (M10, #5743):** macOS print jobs name their title and owner (`com.apple.print.JobInfo.PMJobName`, `PMJobOwner`), as the system print dialog does, because a Kyocera filter crashes on a job without them. Checked by a test and through `doc_print` against a stand-in `lp`; not yet on a physical printer. diff --git a/crates/edit/Cargo.toml b/crates/edit/Cargo.toml index 2cd33a275d..c5eae81e40 100644 --- a/crates/edit/Cargo.toml +++ b/crates/edit/Cargo.toml @@ -21,6 +21,9 @@ kurbo = { workspace = true } # Display order of right-to-left lines in added text, and reading and writing order in Edit text; # already in the tree via the interface. unicode-bidi = "0.3" +# Thai word boundaries for rewrapping and Add text (dictionary data compiled in, no LSTM model); +# the ICU4X 2.3 crates it builds on are already in the tree. +icu_segmenter = { version = "2.3", default-features = false, features = ["compiled_data"] } [dev-dependencies] hayro-syntax = { workspace = true } diff --git a/crates/edit/src/added/layout.rs b/crates/edit/src/added/layout.rs index 31a4dc409f..0520ada0ec 100644 --- a/crates/edit/src/added/layout.rs +++ b/crates/edit/src/added/layout.rs @@ -73,8 +73,11 @@ pub(super) fn wrapped(t: &AddedText) -> Vec<(String, bool, usize)> { out } -/// Thai wrapping at shaped cluster boundaries never detaches a tone/vowel mark from its base. -/// This is glyph-safe wrapping, not dictionary-based Thai word segmentation. +/// Thai wrapping: a row ends before a word that would overflow it, the words found by the +/// dictionary ([`crate::linebreak`]) inside runs without spaces and at the spaces between them. The +/// spaces where a row ends are dropped, as in the Latin wrapper. Only a word wider than the row is +/// broken inside, at shaped cluster boundaries, which never detach a tone or vowel mark from its +/// base. fn thai_lines(t: &AddedText, para: &str, width: f64) -> Result, EditError> { let (pieces, _) = shaped_layout(t, para, false)?; let mut atoms = Vec::new(); @@ -88,20 +91,78 @@ fn thai_lines(t: &AddedText, para: &str, width: f64) -> Result, Edit })), } } - let mut lines = Vec::new(); + let words = thai_words(atoms); + let blank = |s: &str| s.chars().all(char::is_whitespace); + let mut lines: Vec = Vec::new(); let (mut line, mut used) = (String::new(), 0.0); - for (text, advance) in atoms { - if !line.is_empty() && used + advance > width { - lines.push(std::mem::take(&mut line)); + let end_row = |lines: &mut Vec, line: &mut String| { + let done = std::mem::take(line); + lines.push(done.trim_end().to_string()); + }; + for word in words { + // The word's own width: the spaces after it may hang past the row's end. + let ink: f64 = word.iter().filter(|(s, _)| !blank(s)).map(|(_, w)| w).sum(); + if !line.is_empty() && used + ink > width { + end_row(&mut lines, &mut line); used = 0.0; } - line.push_str(&text); - used += advance; + let too_wide = line.is_empty() && ink > width; + for (text, advance) in word { + if too_wide && !line.is_empty() && !blank(&text) && used + advance > width { + end_row(&mut lines, &mut line); + used = 0.0; + } + // A row after a wrap does not begin with the spaces where the last one ended. + if line.is_empty() && !lines.is_empty() && blank(&text) { + continue; + } + line.push_str(&text); + used += advance; + } } lines.push(line); Ok(lines) } +/// The shaped clusters `atoms` (text and advance) grouped into words: a word starts after spaces, +/// or where [`crate::linebreak::breaks`] lets a row end inside a run without spaces, and keeps the +/// spaces that follow it. A break that falls inside a cluster is not one. +fn thai_words(atoms: Vec<(String, f64)>) -> Vec> { + let text: String = atoms.iter().map(|(s, _)| s.as_str()).collect(); + let mut cuts = Vec::new(); + let mut run_start: Option = None; + for (at, c) in text.char_indices().chain(std::iter::once((text.len(), ' '))) { + match (c.is_whitespace(), run_start) { + (false, None) => { + cuts.push(at); + run_start = Some(at); + } + (true, Some(start)) => { + let run = text.get(start..at).unwrap_or_default(); + cuts.extend(crate::linebreak::breaks(run).into_iter().map(|b| start.saturating_add(b))); + run_start = None; + } + _ => {} + } + } + // `cuts` is in order: each run's start, then the breaks inside it, then the next run's start. + let mut words: Vec> = Vec::new(); + let mut cuts = cuts.into_iter().peekable(); + let mut offset = 0usize; + for atom in atoms { + while cuts.peek().is_some_and(|&c| c < offset) { + cuts.next(); + } + let starts_word = cuts.peek() == Some(&offset); + offset = offset.saturating_add(atom.0.len()); + match words.last_mut() { + Some(word) if !starts_word => word.push(atom), + _ => words.push(vec![atom]), + } + } + words +} + pub(super) fn is_thai(c: char) -> bool { matches!(u32::from(c), 0x0E00..=0x0E7F) } diff --git a/crates/edit/src/linebreak.rs b/crates/edit/src/linebreak.rs index a4682679f8..cf044d9e5c 100644 --- a/crates/edit/src/linebreak.rs +++ b/crates/edit/src/linebreak.rs @@ -6,8 +6,56 @@ //! and quotes, currency signs before an amount): the line-start and line-end prohibitions of //! JIS X 4051 ("kinsoku"), which UAX #14 gives as classes CL, CP, EX, IS, NS and OP, PR. //! -//! Korean is written with spaces between words and keeps breaking only there; so does Thai, whose -//! words need a dictionary to find. +//! Thai is also written without spaces between words, but a row may not end between any two of its +//! characters: its words need a dictionary to find. They come from ICU4X's dictionary line +//! segmenter (UAX #14 with dictionary breaking for class SA), kept only where a Thai character is on +//! either side. A row never begins with a following vowel, a tone or other mark, Mai Yamok (ๆ) or +//! Paiyannoi (ฯ), nor ends with a leading vowel (เ แ โ ใ ไ), whatever the dictionary says. +//! +//! Korean is written with spaces between words and keeps breaking only there. + +use std::sync::LazyLock; + +use icu_segmenter::options::LineBreakOptions; +use icu_segmenter::{LineSegmenter, LineSegmenterBorrowed}; + +/// The dictionary segmenter, built once: its data is compiled in, so building it cannot fail. +static SEGMENTER: LazyLock> = LazyLock::new(|| LineSegmenter::new_dictionary(LineBreakOptions::default())); + +/// Letters, vowels, marks, digits and signs of the Thai block. +fn thai(c: char) -> bool { + matches!(c, '\u{0e01}'..='\u{0e5b}') +} + +/// Thai that belongs to the character before it: Paiyannoi, the following vowels, Lakkhangyao, +/// Mai Yamok and the tone and other marks. +fn thai_no_start(c: char) -> bool { + matches!(c, '\u{0e2f}' | '\u{0e30}'..='\u{0e3a}' | '\u{0e45}'..='\u{0e4e}') +} + +/// The leading vowels, written before the consonant they follow in speech. +fn thai_no_end(c: char) -> bool { + matches!(c, '\u{0e40}'..='\u{0e44}') +} + +/// Byte offsets where the dictionary lets a row of Thai end inside `word`. +fn thai_breaks(word: &str) -> Vec { + if !word.chars().any(thai) { + return Vec::new(); + } + SEGMENTER + .segment_str(word) + .filter(|&at| { + let before = word.get(..at).and_then(|s| s.chars().next_back()); + let after = word.get(at..).and_then(|s| s.chars().next()); + match (before, after) { + (Some(b), Some(a)) => (thai(b) || thai(a)) && !thai_no_start(a) && !thai_no_end(b) && !no_start(a) && !no_end(b), + // The start and end of the word are not breaks inside it. + _ => false, + } + }) + .collect() +} /// Writing with no spaces between words, where a row may end between two characters (UAX #14 ID). fn ideographic(c: char) -> bool { @@ -77,29 +125,49 @@ fn no_end(c: char) -> bool { ) } -/// Whether a row may end between `before` and `after`, neither of them a space. -pub(crate) fn between(before: char, after: char) -> bool { - (ideographic(before) || ideographic(after)) && !no_start(after) && !no_end(before) +/// Whether a row may end between `before` and `after` in Chinese or Japanese, neither of them a +/// space. Thai needs the whole word ([`breaks`]). +fn between(before: char, after: char) -> bool { + (ideographic(before) || ideographic(after)) && !no_start(after) && !no_end(before) && !thai_no_start(after) && !thai_no_end(before) +} + +/// Byte offsets inside `word` (text without spaces) where a row may end, in order: never 0 or the +/// word's length, always on a character boundary. +pub(crate) fn breaks(word: &str) -> Vec { + let mut out: Vec = Vec::new(); + let mut prev: Option = None; + for (at, c) in word.char_indices() { + if let Some(before) = prev + && between(before, c) + { + out.push(at); + } + prev = Some(c); + } + out.extend(thai_breaks(word)); + out.sort_unstable(); + out.dedup(); + out } /// `word` (text without spaces) cut where a row may end inside it; the pieces join with nothing. pub(crate) fn pieces(word: &str) -> Vec<&str> { let mut out = Vec::new(); let mut start = 0; - let mut prev: Option = None; - for (at, c) in word.char_indices() { - if let Some(before) = prev - && between(before, c) - { - out.extend(word.get(start..at)); - start = at; - } - prev = Some(c); + for at in breaks(word) { + out.extend(word.get(start..at)); + start = at; } out.extend(word.get(start..).filter(|rest| !rest.is_empty())); out } +/// For each character of `word` (text without spaces), whether a row may end just before it. +pub(crate) fn breaks_before_chars(word: &str) -> Vec { + let cuts = breaks(word); + word.char_indices().map(|(at, _)| cuts.binary_search(&at).is_ok()).collect() +} + #[cfg(test)] mod tests { use super::*; @@ -121,4 +189,64 @@ mod tests { assert_eq!(pieces(""), Vec::<&str>::new()); assert!(!between('a', 'b') && between('字', 'a') && !between('字', '。') && !between('「', '字')); } + + #[test] + fn thai_breaks_between_dictionary_words() { + assert_eq!(pieces("ภาษาไทยไม่มีช่องว่างระหว่างคำ"), ["ภาษา", "ไทย", "ไม่มี", "ช่อง", "ว่าง", "ระหว่าง", "คำ"]); + assert_eq!(pieces("น้ำประปาสะอาด"), ["น้ำ", "ประปา", "สะอาด"]); + assert_eq!(pieces("การประปาส่วนภูมิภาค"), ["การ", "ประปา", "ส่วน", "ภูมิภาค"]); + } + + #[test] + fn thai_vowels_marks_and_repetition_stay_with_the_word_before_them() { + // The dictionary alone would start a row with Mai Yamok, with the Sara Aa of a decomposed + // Sara Am, or with a closing bracket. + assert_eq!(pieces("เด็กๆชอบกินขนม"), ["เด็กๆ", "ชอบ", "กิน", "ขนม"]); + assert_eq!(pieces("ท\u{0e4d}\u{0e32}งาน"), ["ท\u{0e4d}\u{0e32}", "งาน"]); + assert_eq!(pieces("กรุงเทพฯเป็นเมืองหลวง"), ["กรุงเทพฯ", "เป็น", "เมือง", "หลวง"]); + assert_eq!(pieces("สวัสดีครับ(ทดสอบ)"), ["สวัสดี", "ครับ", "(ทดสอบ)"]); + } + + #[test] + fn thai_keeps_numbers_and_latin_whole() { + assert_eq!(pieces("ราคา100บาทต่อเดือน"), ["ราคา", "100บาท", "ต่อ", "เดือน"]); + assert_eq!(pieces("พ.ศ.2569"), ["พ.ศ.2569"]); + assert_eq!(pieces("PDF"), ["PDF"]); + assert_eq!(breaks("ไทย"), Vec::::new()); + } + + #[test] + fn breaks_before_chars_marks_the_first_character_of_each_later_piece() { + let flags = breaks_before_chars("น้ำประปา"); + let starts: Vec = flags.iter().enumerate().filter_map(|(i, &b)| b.then_some(i)).collect(); + // น ้ ำ | ป ร ะ ป า + assert_eq!(starts, [3]); + assert_eq!(breaks_before_chars(""), Vec::::new()); + } + + #[test] + fn pieces_of_arbitrary_thai_rejoin_to_the_input_and_never_start_with_a_mark() { + // A deterministic generator over Thai, Latin, digits and CJK, including mark-only runs and + // unusual orders that no dictionary word has. + let alphabet: Vec = ('\u{0e01}'..='\u{0e5b}').chain(['a', '1', '(', ')', '字', 'の', '.']).collect(); + let mut state: u64 = 0x2545_f491_4f6c_dd1d; + for _ in 0..2000 { + let mut word = String::new(); + state = state.wrapping_mul(6_364_136_223_846_793_005).wrapping_add(1); + let len = (state >> 59) as usize + 1; + for _ in 0..len { + state = state.wrapping_mul(6_364_136_223_846_793_005).wrapping_add(1); + let pick = (state >> 33) as usize % alphabet.len(); + word.extend(alphabet.get(pick)); + } + let parts = pieces(&word); + assert_eq!(parts.concat(), word); + assert!(parts.iter().all(|p| !p.is_empty()), "{word:?} gave {parts:?}"); + for part in parts.iter().skip(1) { + let first = part.chars().next().unwrap_or(' '); + assert!(!thai_no_start(first) && !no_start(first), "{word:?} gave {parts:?}"); + } + assert_eq!(breaks_before_chars(&word).len(), word.chars().count()); + } + } } diff --git a/crates/edit/src/tests.rs b/crates/edit/src/tests.rs index e78da842b8..ff91fef668 100644 --- a/crates/edit/src/tests.rs +++ b/crates/edit/src/tests.rs @@ -5790,6 +5790,35 @@ fn added_thai_text_wraps_clusters_and_survives_reopen() { assert!(arabic_font_names(&doc, 0).is_empty()); } +#[test] +fn added_thai_text_wraps_between_dictionary_words() { + if pdfcraft_fonts::document_thai_font().is_none() { + eprintln!("skipping the Thai word wrapping checks: built without craft-fonts (set CRAFT_FONTS_DIR)"); + return; + } + let text = "การประปาส่วนภูมิภาคให้บริการน้ำประปาที่สะอาดแก่ประชาชนทั่วประเทศ"; + let t = AddedText { rect: [0.0, 0.0, 90.0, 400.0], text: text.into(), size: 16.0, ..AddedText::default() }; + let rows = super::added::lines(&t); + assert!(rows.len() > 2, "{rows:?}"); + assert_eq!(rows.concat(), text, "no space is added or lost between the rows"); + // Every row ends where the dictionary lets one end, never inside a word such as ประปา. + let cuts = crate::linebreak::breaks(text); + let mut at = 0; + for row in rows.iter().take(rows.len() - 1) { + at += row.len(); + assert!(cuts.contains(&at), "row {row:?} ends inside a word: {rows:?}"); + } + // Spaces where a row ends are dropped, as for Latin text, and a word wider than the row is + // still broken between clusters. + let spaced = + AddedText { text: "น้ำประปา สะอาด ปลอดภัย".into(), rect: [0.0, 0.0, 60.0, 400.0], ..t.clone() }; + let rows = super::added::lines(&spaced); + assert!(rows.iter().all(|r| !r.starts_with(' ') && !r.ends_with(' ')), "{rows:?}"); + assert_eq!(rows.concat(), "น้ำประปาสะอาดปลอดภัย"); + let narrow = AddedText { rect: [0.0, 0.0, 16.0, 200.0], text: "กิ่กิ่กิ่".into(), ..t }; + assert_eq!(super::added::lines(&narrow), ["กิ่", "กิ่", "กิ่"]); +} + #[test] fn group_moves_preserve_glyph_bytes_and_unselected_text_state() { for content in [ diff --git a/crates/edit/src/text.rs b/crates/edit/src/text.rs index 0fb00324b6..d2e421f161 100644 --- a/crates/edit/src/text.rs +++ b/crates/edit/src/text.rs @@ -272,6 +272,18 @@ mod tests { assert_eq!(wrapped("一行目 二行目", 4.0), ["一行目 ", "二行目"]); } + #[test] + fn thai_text_wraps_between_dictionary_words_and_keeps_marks_on_their_word() { + // Without word breaks the whole run is one word and overflows a single row. + assert_eq!(wrapped("ภาษาไทยไม่มีช่องว่าง", 8.0), ["ภาษาไทย", "ไม่มี", "ช่องว่าง"]); + // Mai Yamok stays with the word it repeats; pieces join without a space, words keep theirs. + assert_eq!(wrapped("เด็กๆชอบกิน ขนม", 6.0), ["เด็กๆ", "ชอบกิน", "ขนม"]); + assert_eq!(wrapped("ไฟล์ PDF ประปาส่วนภูมิภาค", 9.0), ["ไฟล์ PDF", "ประปาส่วน", "ภูมิภาค"]); + // A wrap between Thai words consumed no separator, so a rejoin puts back no space. + let rows = wrap("ภาษาไทยไม่มีช่องว่าง", 8.0, |s| s.chars().count() as f64); + assert_eq!(rows.iter().map(|(_, soft, gap)| (*soft, *gap)).collect::>(), [(false, 0), (true, 0), (true, 0)]); + } + #[test] fn fallback_face_moves_on_when_the_best_face_lacks_a_character() { static JPAN: &[&str] = &["Jpan"]; diff --git a/crates/edit/src/text_regions/styled.rs b/crates/edit/src/text_regions/styled.rs index 885a0a7158..485eda5630 100644 --- a/crates/edit/src/text_regions/styled.rs +++ b/crates/edit/src/text_regions/styled.rs @@ -403,18 +403,41 @@ fn paragraph_geometry(doc: &Document, page: &pdfcraft_model::Page, block: &TextB type WrappedStyledRows = (StyledRows, RowRanges, Vec, Vec, Vec); +/// For each of `chars`, whether a row may end just before it inside a run of non-space characters +/// (Chinese, Japanese and Thai), worked out once per run: Thai words need the whole run, not just +/// two neighbours. +fn styled_break_before(chars: &[StyledChar]) -> Vec { + let mut break_before = vec![false; chars.len()]; + let mut run_start = 0; + while run_start < chars.len() { + let mut run_end = run_start; + while chars.get(run_end).is_some_and(|c| !c.0.is_whitespace()) { + run_end += 1; + } + let run: String = chars.get(run_start..run_end).unwrap_or_default().iter().map(|c| c.0).collect(); + for (i, cut) in crate::linebreak::breaks_before_chars(&run).into_iter().enumerate() { + if let Some(slot) = break_before.get_mut(run_start.saturating_add(i)) { + *slot = cut; + } + } + run_start = run_end.max(run_start.saturating_add(1)); + } + break_before +} + fn wrap_styled_rows( chars: &[StyledChar], width: f64, measure: impl Fn(&[StyledChar]) -> Result, ) -> Result { + let break_before = styled_break_before(chars); let mut rows: Vec> = Vec::new(); let mut row_ranges = Vec::new(); // True when this row starts because the previous word did not fit, not because of a newline. let mut row_soft = Vec::new(); let mut next_soft = false; // How many separator characters a wrap trimmed off the row that just ended, for the row - // about to start (0 for a Chinese/Japanese word split, which trims none; meaningless unless + // about to start (0 for a Chinese, Japanese or Thai word split, which trims none; meaningless unless // the row it belongs to is soft). Mirrors `next_soft`. let mut row_gap = Vec::new(); let mut next_gap = 0usize; @@ -447,11 +470,8 @@ fn wrap_styled_rows( } let mut end = at + 1; if !item.0.is_whitespace() { - // A word ends at a space, or where Chinese or Japanese text may break between characters. - while let (Some(before), Some(current)) = (chars.get(end - 1), chars.get(end)) - && !current.0.is_whitespace() - && !crate::linebreak::between(before.0, current.0) - { + // A word ends at a space, or where Chinese, Japanese or Thai text may break inside it. + while chars.get(end).is_some_and(|c| !c.0.is_whitespace()) && !break_before.get(end).copied().unwrap_or(false) { end += 1; } } @@ -779,7 +799,7 @@ fn push_whole_paragraph_marker(block_ops: &mut Vec, marked: bool, op: &str) /// An empty marker between two rows of one rewrite. `/PCHard` is a newline that was already in /// the text. `/PCSoft` is a wrap and rejoins when the box widens, with `/N` the exact number of -/// separator characters it consumed (0 for a Chinese/Japanese word split), so a rejoin restores +/// separator characters it consumed (0 for a Chinese, Japanese or Thai word split), so a rejoin restores /// what was actually there instead of guessing a single space (#2529). Neither is a paragraph /// region, so the lines stay in one scope. fn push_row_break_marker(block_ops: &mut Vec, row_soft: &[bool], row_gap: &[usize], row_index: usize) { diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index ef4007bc80..25858adb3c 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -2047,8 +2047,8 @@ milestone = "M7" status = "shipped" commands = ["edit.text"] tools = ["page_add_text", "content_update"] -evidence = ["crates/ui-egui/tests/it/content.rs::typing_moving_styling_and_deleting_added_content", "crates/ui-egui/tests/it/content.rs::clicking_elsewhere_keeps_the_text_and_starts_a_new_box", "crates/automation/tests/it/automation.rs::adding_content_through_tools", "crates/edit/src/tests.rs::added_text_and_images_are_page_content_that_stays_editable", "crates/engine/src/tests.rs::added_text_and_images_render_and_stay_editable", "crates/edit/src/tests.rs::added_arabic_text_is_shaped_in_display_order_and_stays_searchable", "crates/automation/tests/it/automation.rs::thai_text_through_tools_saves_reopens_extracts_and_renders", "crates/ui-egui/tests/it/content.rs::typing_thai_text_keeps_marks_and_commits_to_page", "crates/automation/tests/it/automation.rs::added_arabic_text_keeps_copy_search_and_editor_order_after_retyping_and_reopen", "crates/edit/src/tests.rs::added_hebrew_text_is_drawn_right_to_left_and_stays_searchable", "crates/automation/tests/it/automation.rs::hebrew_text_through_tools_saves_reopens_extracts_and_renders"] -notes = "page content, editable later (retype, move, restyle); standard fonts, Windows-1252 text. Arabic text is shaped with the craft-fonts Arab face (Type 3 outlines with ToUnicode) and laid out in bidirectional display order; per-line ActualText keeps copy, search and editor text in typing order through retyping and save/reopen; it is drawn regular whatever the bold/italic setting, and typing in the box is still left to right (misc.rtl-ui). Hebrew (#128) takes the same path with the craft-fonts Hebr face (Noto Sans Hebrew): right-to-left display order, points and cantillation kept on their letter as one glyph, ToUnicode and per-line ActualText; regular style only, and right-to-left scripts other than Arabic and Hebrew are not covered. Thai Add Text uses the craft-fonts Thai face, with shaped clusters and ToUnicode; Thai is regular style, wrapping preserves clusters but is not dictionary-based, and forms/Fill and Sign are not covered by this change. Each click on the page keeps the box being typed and starts a new one; Done and Discard beside the box" +evidence = ["crates/ui-egui/tests/it/content.rs::typing_moving_styling_and_deleting_added_content", "crates/ui-egui/tests/it/content.rs::clicking_elsewhere_keeps_the_text_and_starts_a_new_box", "crates/automation/tests/it/automation.rs::adding_content_through_tools", "crates/edit/src/tests.rs::added_text_and_images_are_page_content_that_stays_editable", "crates/engine/src/tests.rs::added_text_and_images_render_and_stay_editable", "crates/edit/src/tests.rs::added_arabic_text_is_shaped_in_display_order_and_stays_searchable", "crates/automation/tests/it/automation.rs::thai_text_through_tools_saves_reopens_extracts_and_renders", "crates/ui-egui/tests/it/content.rs::typing_thai_text_keeps_marks_and_commits_to_page", "crates/edit/src/tests.rs::added_thai_text_wraps_between_dictionary_words", "crates/automation/tests/it/automation.rs::added_arabic_text_keeps_copy_search_and_editor_order_after_retyping_and_reopen", "crates/edit/src/tests.rs::added_hebrew_text_is_drawn_right_to_left_and_stays_searchable", "crates/automation/tests/it/automation.rs::hebrew_text_through_tools_saves_reopens_extracts_and_renders"] +notes = "page content, editable later (retype, move, restyle); standard fonts, Windows-1252 text. Arabic text is shaped with the craft-fonts Arab face (Type 3 outlines with ToUnicode) and laid out in bidirectional display order; per-line ActualText keeps copy, search and editor text in typing order through retyping and save/reopen; it is drawn regular whatever the bold/italic setting, and typing in the box is still left to right (misc.rtl-ui). Hebrew (#128) takes the same path with the craft-fonts Hebr face (Noto Sans Hebrew): right-to-left display order, points and cantillation kept on their letter as one glyph, ToUnicode and per-line ActualText; regular style only, and right-to-left scripts other than Arabic and Hebrew are not covered. Thai Add Text uses the craft-fonts Thai face, with shaped clusters and ToUnicode; Thai is regular style; rows end between dictionary words (ICU4X), and a word wider than the row breaks between clusters; forms/Fill and Sign are not covered. Each click on the page keeps the box being typed and starts a new one; Done and Discard beside the box" [[feature]] id = "edit.find-replace" From fb6830c4dd4e37d26d43593ae8c768576c94f311 Mon Sep 17 00:00:00 2001 From: Dew Date: Mon, 19 Oct 2026 23:23:25 +0700 Subject: [PATCH 05/92] M7: review follow-up, opening spaces are not a Thai row of their own MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A Thai Add text paragraph that opens with spaces held a row of only those spaces. When the first word did not fit after them, that row was ended (trimmed to an empty row) and the opening spaces were lost. A row of only spaces now counts as empty when deciding to end it, so the first word stays on the first row after the spaces, or breaks between clusters there. Reported by the CodeRabbit review on #3492; the new case fails without the fix (at 16 pt the rows were "", "ป", "ระ", ...). Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_018cECVMvRnc9mHNFfVp7nXd --- crates/edit/src/added/layout.rs | 7 ++++--- crates/edit/src/tests.rs | 11 ++++++++++- 2 files changed, 14 insertions(+), 4 deletions(-) diff --git a/crates/edit/src/added/layout.rs b/crates/edit/src/added/layout.rs index 0520ada0ec..62f24c5c4c 100644 --- a/crates/edit/src/added/layout.rs +++ b/crates/edit/src/added/layout.rs @@ -102,13 +102,14 @@ fn thai_lines(t: &AddedText, para: &str, width: f64) -> Result, Edit for word in words { // The word's own width: the spaces after it may hang past the row's end. let ink: f64 = word.iter().filter(|(s, _)| !blank(s)).map(|(_, w)| w).sum(); - if !line.is_empty() && used + ink > width { + // A row holding only the spaces that open the paragraph has nothing to end yet. + if !blank(&line) && used + ink > width { end_row(&mut lines, &mut line); used = 0.0; } - let too_wide = line.is_empty() && ink > width; + let too_wide = blank(&line) && ink > width; for (text, advance) in word { - if too_wide && !line.is_empty() && !blank(&text) && used + advance > width { + if too_wide && !blank(&line) && !blank(&text) && used + advance > width { end_row(&mut lines, &mut line); used = 0.0; } diff --git a/crates/edit/src/tests.rs b/crates/edit/src/tests.rs index ff91fef668..d8ba0a8aeb 100644 --- a/crates/edit/src/tests.rs +++ b/crates/edit/src/tests.rs @@ -5815,8 +5815,17 @@ fn added_thai_text_wraps_between_dictionary_words() { let rows = super::added::lines(&spaced); assert!(rows.iter().all(|r| !r.starts_with(' ') && !r.ends_with(' ')), "{rows:?}"); assert_eq!(rows.concat(), "น้ำประปาสะอาดปลอดภัย"); - let narrow = AddedText { rect: [0.0, 0.0, 16.0, 200.0], text: "กิ่กิ่กิ่".into(), ..t }; + let narrow = AddedText { rect: [0.0, 0.0, 16.0, 200.0], text: "กิ่กิ่กิ่".into(), ..t.clone() }; assert_eq!(super::added::lines(&narrow), ["กิ่", "กิ่", "กิ่"]); + // Spaces that open a paragraph are kept, but they are not a row of their own: a first word + // too wide to fit after them stays on the first row (or breaks between clusters there). + for width in [16.0, 40.0, 60.0, 90.0] { + let indented = AddedText { rect: [0.0, 0.0, width, 400.0], text: " ประปาสะอาด".into(), ..t.clone() }; + let rows = super::added::lines(&indented); + assert!(rows.iter().all(|r| !r.trim().is_empty()), "an empty row at width {width}: {rows:?}"); + assert!(rows[0].starts_with(" "), "the opening spaces are kept at width {width}: {rows:?}"); + assert_eq!(rows.concat().trim_start(), "ประปาสะอาด"); + } } #[test] From ff556b537e2bee94aa042fa0a1326ff6263d973a Mon Sep 17 00:00:00 2001 From: Dew Date: Mon, 19 Oct 2026 23:38:29 +0700 Subject: [PATCH 06/92] M7: review follow-up, a Thai first word counts the opening spaces MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A first word that fit a row on its own but not after the spaces opening its paragraph was placed whole and ran past the row's end. Whether the word is too wide now counts the width those spaces already use, so it breaks between clusters on the first row instead. Reported by the CodeRabbit review on #3492. The new case fails without the fix: " ประปา" stayed one row, wider than its 52 pt box. shaped_width is pub(crate) so the test can measure rows. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_018cECVMvRnc9mHNFfVp7nXd --- crates/edit/src/added.rs | 2 ++ crates/edit/src/added/layout.rs | 5 +++-- crates/edit/src/tests.rs | 13 +++++++++++++ 3 files changed, 18 insertions(+), 2 deletions(-) diff --git a/crates/edit/src/added.rs b/crates/edit/src/added.rs index a603a37060..250f71a533 100644 --- a/crates/edit/src/added.rs +++ b/crates/edit/src/added.rs @@ -29,6 +29,8 @@ use draw::{GLYPHS_PER_FONT, LaidLine, draw_laid_shaped}; use layout::Piece; #[cfg(test)] pub(crate) use layout::WRAP_CALLS; +#[cfg(test)] +pub(crate) use layout::shaped_width; pub use layout::{PASTE_STEP, first_undrawable, lines, paste_place, paste_rect, text_rect, text_width}; pub use store::{add_content, delete_content, isolate_content, list_added, scan_markers, update_content}; pub use types::{Added, AddedImage, AddedText, Align, CHAR_SPACING_LIMIT, Family, MarkerScan}; diff --git a/crates/edit/src/added/layout.rs b/crates/edit/src/added/layout.rs index 62f24c5c4c..c76db6463a 100644 --- a/crates/edit/src/added/layout.rs +++ b/crates/edit/src/added/layout.rs @@ -107,7 +107,8 @@ fn thai_lines(t: &AddedText, para: &str, width: f64) -> Result, Edit end_row(&mut lines, &mut line); used = 0.0; } - let too_wide = blank(&line) && ink > width; + // Too wide for what is left of the row, counting the spaces that open the paragraph. + let too_wide = blank(&line) && used + ink > width; for (text, advance) in word { if too_wide && !blank(&line) && !blank(&text) && used + advance > width { end_row(&mut lines, &mut line); @@ -319,7 +320,7 @@ pub(super) fn shaped_layout(t: &AddedText, line: &str, rtl: bool) -> Result<(Vec /// The width of `s` in a shaped-text item; the item's font's estimate if it can't be shaped (drawing /// then reports why). -fn shaped_width(t: &AddedText, s: &str, rtl: bool) -> f64 { +pub(crate) fn shaped_width(t: &AddedText, s: &str, rtl: bool) -> f64 { shaped_layout(t, s, rtl).map(|(_, w)| w).unwrap_or_else(|_| latin_width(t, s)) } diff --git a/crates/edit/src/tests.rs b/crates/edit/src/tests.rs index d8ba0a8aeb..02378057b7 100644 --- a/crates/edit/src/tests.rs +++ b/crates/edit/src/tests.rs @@ -5826,6 +5826,19 @@ fn added_thai_text_wraps_between_dictionary_words() { assert!(rows[0].starts_with(" "), "the opening spaces are kept at width {width}: {rows:?}"); assert_eq!(rows.concat().trim_start(), "ประปาสะอาด"); } + // A first word that fits a row alone but not after the opening spaces breaks between clusters + // on the first row rather than running past its end. + let word = "ประปา"; + let word_w = super::added::shaped_width(&t, word, false); + let indent_w = super::added::shaped_width(&t, " ", false); + let width = word_w + indent_w / 2.0; + let indented = AddedText { rect: [0.0, 0.0, width, 400.0], text: format!(" {word}"), ..t.clone() }; + let rows = super::added::lines(&indented); + for row in &rows { + assert!(super::added::shaped_width(&t, row, false) <= width + 0.5, "{row:?} overflows {width}: {rows:?}"); + } + assert!(rows.len() > 1 && rows[0].starts_with(" "), "{rows:?}"); + assert_eq!(rows.concat().trim_start(), word); } #[test] From f8c29bf262e8710dd3d33204622b2f473adfd8e4 Mon Sep 17 00:00:00 2001 From: Dew Date: Thu, 22 Oct 2026 13:26:11 +0700 Subject: [PATCH 07/92] M7: review follow-up, attribute the ICU4X word dictionaries MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit icu_segmenter_data compiles the Thai, Lao, Khmer and Burmese word dictionaries (data/segmenter_dictionary_extended_v1.rs.data, 1,773,172 bytes by the file's own count) into the binary, so they get a [[bundled]] entry per AGENTS.md §1.3, and NOTICE carries the Unicode licence's copyright and permission notice, which it requires in the documentation. The line segmenter loads only these dictionaries; the Chinese and Japanese one is not linked. The data is under Unicode-3.0, which deny.toml already allows for code but the asset allowlist (AGENTS.md §1.2, xtask ALLOWED_LICENCES) did not list, so this adds it there. That is a policy change for the maintainers to accept; without it `cargo xtask assets` refuses the entry. Requested in FredApps' review of #3492. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_018cECVMvRnc9mHNFfVp7nXd --- AGENTS.md | 2 +- ATTRIBUTION.md | 3 ++- ATTRIBUTION.toml | 12 ++++++++++++ NOTICE | 28 ++++++++++++++++++++++++++++ xtask/src/assets.rs | 1 + 5 files changed, 44 insertions(+), 2 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index cd6c0e2ca5..8207f10693 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -30,7 +30,7 @@ An **asset** is any non-code material that the project includes, ships, embeds, ### 1.2 Only open, provable licences Every asset must be one of: -- **Open source:** ISC, MIT, BSD-2-Clause, BSD-3-Clause, Apache-2.0, OFL-1.1, Ubuntu-Font-1.0 or Bitstream-Vera. +- **Open source:** ISC, MIT, BSD-2-Clause, BSD-3-Clause, Apache-2.0, OFL-1.1, Ubuntu-Font-1.0, Bitstream-Vera or Unicode-3.0 (Unicode, Inc. data such as ICU4X's line-breaking dictionaries; already allowed for code in `deny.toml`). - **Creative Commons:** CC0-1.0, CC-BY-4.0 or CC-BY-SA-4.0. - **Public domain.** - **Contributor-original:** created, photographed or generated by a contributor who owns it and licenses it under the project licence (`MIT OR Apache-2.0`). diff --git a/ATTRIBUTION.md b/ATTRIBUTION.md index fc88e8b664..f7087817c9 100644 --- a/ATTRIBUTION.md +++ b/ATTRIBUTION.md @@ -298,7 +298,7 @@ Every asset PdfCraft includes, bundles or uses to build its published material, | `crates/ui-egui/src/i18n/th.tsv` | Thai interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-22 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed | Thai UI strings, compiled in by crates/ui-egui/src/i18n.rs | | `crates/ui-egui/src/i18n/ca.tsv` | Catalan interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, written from their meaning; no proprietary localisation resources; 2026-10-22 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 text-field-table labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed | Catalan UI strings, compiled in by crates/ui-egui/src/i18n.rs | -## Compiled in through dependencies (6) +## Compiled in through dependencies (7) | Crate | File | Title | Author | Licence | Source | Used for | |---|---|---|---|---|---|---| @@ -308,6 +308,7 @@ Every asset PdfCraft includes, bundles or uses to build its published material, | `epaint_default_fonts` 0.37.0 | `fonts/emoji-icon-font.ttf` | emoji-icon-font | John Slegers | MIT | https://github.com/jslegers/emoji-icon-font | egui monochrome fallback font (egui feature monochrome_emoji_fonts) | | `epaint_default_fonts` 0.37.0 | `fonts/egui-icons.ttf` | egui icons (monochrome emoji-icon-font subset) | John Slegers | MIT | https://github.com/jslegers/emoji-icon-font | Small special-icon subset embedded by egui default_fonts; retained in FontDefinitions::default(). Provenance: https://github.com/storytold/egui/blob/6d518fc799681571963842f95b55a6b68431b15a/crates/epaint_default_fonts/fonts/egui-icons.txt; complete MIT copyright and permission text in NOTICE. | | `hayro-interpret` 0.7.0 | `src/font/generated/metrics.rs` | Standard-14 font metrics and encodings (as Rust tables) (non-visual data, AGENTS.md §1.1) | Adobe (Core 14 AFM metrics, PDF specification encodings); tables by The Hayro Authors | MIT OR Apache-2.0 | ISO 32000-2 Annex D; Adobe Core14 AFM files (via vendored hayro-interpret) | Glyph widths and encodings for non-embedded standard fonts. Non-visual data, allowed by AGENTS.md §1.1 | +| `icu_segmenter_data` 2.3.0 | `data/segmenter_dictionary_extended_v1.rs.data` | Southeast Asian word dictionaries for line breaking (Thai, Lao, Khmer, Burmese) | The ICU4X Project Developers; word lists from the ICU project (Unicode, Inc.) | Unicode-3.0 | https://github.com/unicode-org/icu4x (via icu_segmenter_data) | Word boundaries for wrapping Thai text in Edit text and Add text (crates/edit/src/linebreak.rs). Non-visual data; the line segmenter loads only these dictionaries, not the Chinese and Japanese one | ## Downloaded at build time (22) diff --git a/ATTRIBUTION.toml b/ATTRIBUTION.toml index 76f4cf40a8..1dec544e09 100644 --- a/ATTRIBUTION.toml +++ b/ATTRIBUTION.toml @@ -2522,6 +2522,18 @@ adobe_data = true usage = "Glyph widths and encodings for non-embedded standard fonts. Non-visual data, allowed by AGENTS.md §1.1" sha256 = "2ec9e608573eda459de1e1d9bda3bb494315e36bee885acc88912a11fe0caa41" +[[bundled]] +crate = "icu_segmenter_data" +version = "2.3.0" +path = "data/segmenter_dictionary_extended_v1.rs.data" +title = "Southeast Asian word dictionaries for line breaking (Thai, Lao, Khmer, Burmese)" +author = "The ICU4X Project Developers; word lists from the ICU project (Unicode, Inc.)" +source = "https://github.com/unicode-org/icu4x (via icu_segmenter_data)" +licence = "Unicode-3.0" +kind = "data" +usage = "Word boundaries for wrapping Thai text in Edit text and Add text (crates/edit/src/linebreak.rs). Non-visual data; the line segmenter loads only these dictionaries, not the Chinese and Japanese one" +sha256 = "cad3821ca44fdb88ed856ff3336444aa200afe7b9d19e34ac28ae6a4459d4c6d" + [[fetched]] file = "PlayfairDisplay[wght].ttf" url = "https://raw.githubusercontent.com/google/fonts/9710da1eacb3be272583c3224dcb70f9da6eadbb/ofl/playfairdisplay/PlayfairDisplay%5Bwght%5D.ttf" diff --git a/NOTICE b/NOTICE index e8f31ade2f..eb9246a2b9 100644 --- a/NOTICE +++ b/NOTICE @@ -36,6 +36,8 @@ list (author, source, license) is in ATTRIBUTION.md. - egui default fonts (epaint_default_fonts, via eframe) — Ubuntu Light (Ubuntu Font Licence 1.0), Hack (MIT / Bitstream Vera licence), Noto Emoji (SIL OFL 1.1), emoji-icon-font (MIT). - Colour profile in hayro-interpret — CGATS001Compat-v2-micro.icc (CC0-1.0, Clinton Ingram). +- ICU4X line-breaking data (icu_segmenter_data, via crates.io) — Unicode-3.0. Its Thai, Lao, Khmer + and Burmese word dictionaries are compiled in for wrapping Thai text; notice below. Required notices: @@ -48,6 +50,32 @@ Required notices: (https://github.com/adobe-type-tools/cmap-resources/blob/master/LICENSE.txt). Neither the name of Adobe nor the names of its contributors are used to endorse or promote this product. + ICU4X segmenter data (bundled by icu_segmenter_data 2.3.0; word lists, not visual assets): + Copyright © 2020-2024 Unicode, Inc. Portions adapted from ICU4C and/or ICU4J: + ICU 1.8.1 to ICU 57.1 © 1995-2016 International Business Machines Corporation and others. + + Permission is hereby granted, free of charge, to any person obtaining a copy of data files and any + associated documentation (the "Data Files") or software and any associated documentation (the + "Software") to deal in the Data Files or Software without restriction, including without + limitation the rights to use, copy, modify, merge, publish, distribute, and/or sell copies of the + Data Files or Software, and to permit persons to whom the Data Files or Software are furnished to + do so, provided that either (a) this copyright and permission notice appear with all copies of + the Data Files or Software, or (b) this copyright and permission notice appear in associated + Documentation. + + THE DATA FILES AND SOFTWARE ARE PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR + PURPOSE AND NONINFRINGEMENT OF THIRD PARTY RIGHTS. + + IN NO EVENT SHALL THE COPYRIGHT HOLDER OR HOLDERS INCLUDED IN THIS NOTICE BE LIABLE FOR ANY CLAIM, + OR ANY SPECIAL INDIRECT OR CONSEQUENTIAL DAMAGES, OR ANY DAMAGES WHATSOEVER RESULTING FROM LOSS OF + USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, + ARISING OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THE DATA FILES OR SOFTWARE. + + Except as contained in this notice, the name of a copyright holder shall not be used in + advertising or otherwise to promote the sale, use or other dealings in these Data Files or + Software without prior written authorization of the copyright holder. + Showcase PDF (built by `cargo xtask demo-pdf`, not committed): fonts are downloaded at build time from google/fonts at a pinned commit, verified by SHA-256, all under the SIL Open Font License 1.1 — see the "Downloaded at build time" table in ATTRIBUTION.md. diff --git a/xtask/src/assets.rs b/xtask/src/assets.rs index ac836eae6d..c44c0f14f0 100644 --- a/xtask/src/assets.rs +++ b/xtask/src/assets.rs @@ -30,6 +30,7 @@ pub const ALLOWED_LICENCES: &[&str] = &[ "OFL-1.1", "Ubuntu-font-1.0", "Bitstream-Vera", + "Unicode-3.0", "CC0-1.0", "CC-BY-4.0", "CC-BY-SA-4.0", From 5031dd08271bb51eb123307227bdc2672eb9b432 Mon Sep 17 00:00:00 2001 From: Dew Date: Tue, 3 Nov 2026 20:19:25 +0700 Subject: [PATCH 08/92] M7: review follow-up, map the ICU4X licence for license-check The license-check gate (xtask ci) failed on two things this PR caused: - NOTICE changed, so its recorded hash in LICENSES.toml no longer matched. The ICU4X notice text was re-audited against the icu_segmenter_data 2.3.0 crate's LICENSE: the copyright line, the ICU portions line, and the permission, warranty and name paragraphs match it word for word. NOTICE now also points to the full licence; the hash is updated. - The bundled icu_segmenter_data dictionary had no licence-document mapping. licenses/ICU4X-LICENSE.txt is the crate's LICENSE, unchanged (Unicode-3.0, origin the crates.io archive), mapped as a [[document]] and from a [[bundled]] entry for the dictionary file. `cargo xtask license-check` now passes. Requested in FredApps' review of #3492. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_018cECVMvRnc9mHNFfVp7nXd --- LICENSES.toml | 15 ++++++++++++- NOTICE | 3 ++- licenses/ICU4X-LICENSE.txt | 46 ++++++++++++++++++++++++++++++++++++++ 3 files changed, 62 insertions(+), 2 deletions(-) create mode 100644 licenses/ICU4X-LICENSE.txt diff --git a/LICENSES.toml b/LICENSES.toml index d9ceb3180e..be6eb88b76 100644 --- a/LICENSES.toml +++ b/LICENSES.toml @@ -16,7 +16,7 @@ sha256 = "cd7032ea33b71427f188e34ffb4aee9d6868a57fd196037c5c8b2be03a2a0352" [[document]] source = "NOTICE" destination = "NOTICE" -sha256 = "9f854f730719af962cd840529324d089e05e68a30bd245c50e33f96e009b4314" +sha256 = "d9ba162ad2bc95d22ab9885065e253bb823bd6795068828e7b39858344b660eb" [[document]] source = "assets/app-icon/LICENSE-document.txt" @@ -60,6 +60,13 @@ destination = "licenses/CMap-LICENSE.txt" sha256 = "742665db9c8e1bc72603c6d319ca3e90b83bd6d95202f0cd4ef11068a07a9c29" origin = "https://static.crates.io/crates/hayro-cmap/hayro-cmap-0.1.0.crate#assets/LICENSE.txt" +[[document]] +source = "licenses/ICU4X-LICENSE.txt" +licences = ["Unicode-3.0"] +destination = "licenses/ICU4X-LICENSE.txt" +sha256 = "f367c1b8e1aa262435251e442901da4607b4650e0e63a026f5044473ecfb90f2" +origin = "https://static.crates.io/crates/icu_segmenter_data/icu_segmenter_data-2.3.0.crate#LICENSE" + [[document]] source = "licenses/egui-Hack-Regular.txt" licences = ["MIT", "Bitstream-Vera"] @@ -157,3 +164,9 @@ crate = "hayro-interpret" path = "src/font/generated/metrics.rs" licence = "MIT OR Apache-2.0" documents = ["vendor/hayro-interpret/LICENSE-MIT", "vendor/hayro-interpret/LICENSE-APACHE"] + +[[bundled]] +crate = "icu_segmenter_data" +path = "data/segmenter_dictionary_extended_v1.rs.data" +licence = "Unicode-3.0" +documents = ["licenses/ICU4X-LICENSE.txt"] diff --git a/NOTICE b/NOTICE index eb9246a2b9..eed2d654b0 100644 --- a/NOTICE +++ b/NOTICE @@ -37,7 +37,8 @@ list (author, source, license) is in ATTRIBUTION.md. Hack (MIT / Bitstream Vera licence), Noto Emoji (SIL OFL 1.1), emoji-icon-font (MIT). - Colour profile in hayro-interpret — CGATS001Compat-v2-micro.icc (CC0-1.0, Clinton Ingram). - ICU4X line-breaking data (icu_segmenter_data, via crates.io) — Unicode-3.0. Its Thai, Lao, Khmer - and Burmese word dictionaries are compiled in for wrapping Thai text; notice below. + and Burmese word dictionaries are compiled in for wrapping Thai text; notice below, full licence in + licenses/ICU4X-LICENSE.txt. Required notices: diff --git a/licenses/ICU4X-LICENSE.txt b/licenses/ICU4X-LICENSE.txt new file mode 100644 index 0000000000..c9be6012c5 --- /dev/null +++ b/licenses/ICU4X-LICENSE.txt @@ -0,0 +1,46 @@ +UNICODE LICENSE V3 + +COPYRIGHT AND PERMISSION NOTICE + +Copyright © 2020-2024 Unicode, Inc. + +NOTICE TO USER: Carefully read the following legal agreement. BY +DOWNLOADING, INSTALLING, COPYING OR OTHERWISE USING DATA FILES, AND/OR +SOFTWARE, YOU UNEQUIVOCALLY ACCEPT, AND AGREE TO BE BOUND BY, ALL OF THE +TERMS AND CONDITIONS OF THIS AGREEMENT. IF YOU DO NOT AGREE, DO NOT +DOWNLOAD, INSTALL, COPY, DISTRIBUTE OR USE THE DATA FILES OR SOFTWARE. + +Permission is hereby granted, free of charge, to any person obtaining a +copy of data files and any associated documentation (the "Data Files") or +software and any associated documentation (the "Software") to deal in the +Data Files or Software without restriction, including without limitation +the rights to use, copy, modify, merge, publish, distribute, and/or sell +copies of the Data Files or Software, and to permit persons to whom the +Data Files or Software are furnished to do so, provided that either (a) +this copyright and permission notice appear with all copies of the Data +Files or Software, or (b) this copyright and permission notice appear in +associated Documentation. + +THE DATA FILES AND SOFTWARE ARE PROVIDED "AS IS", WITHOUT WARRANTY OF ANY +KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT OF +THIRD PARTY RIGHTS. + +IN NO EVENT SHALL THE COPYRIGHT HOLDER OR HOLDERS INCLUDED IN THIS NOTICE +BE LIABLE FOR ANY CLAIM, OR ANY SPECIAL INDIRECT OR CONSEQUENTIAL DAMAGES, +OR ANY DAMAGES WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, +WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, +ARISING OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THE DATA +FILES OR SOFTWARE. + +Except as contained in this notice, the name of a copyright holder shall +not be used in advertising or otherwise to promote the sale, use or other +dealings in these Data Files or Software without prior written +authorization of the copyright holder. + +SPDX-License-Identifier: Unicode-3.0 + +— + +Portions of ICU4X may have been adapted from ICU4C and/or ICU4J. +ICU 1.8.1 to ICU 57.1 © 1995-2016 International Business Machines Corporation and others. From eb3cfd4ff78d350b6ce9c8d0668c3b23df6ccb20 Mon Sep 17 00:00:00 2001 From: NetterSilver <133535268+Silvertree2010@users.noreply.github.com> Date: Tue, 3 Nov 2026 16:08:30 +0100 Subject: [PATCH 09/92] Ignore dictionary key order when checking signed pages for changes Fixes #5955 Co-Authored-By: Claude Opus 5.5 --- crates/sign/src/pdf/changes.rs | 113 ++++++++++++++++++++++++++++++++- 1 file changed, 111 insertions(+), 2 deletions(-) diff --git a/crates/sign/src/pdf/changes.rs b/crates/sign/src/pdf/changes.rs index 3a10a74e41..28c96633cc 100644 --- a/crates/sign/src/pdf/changes.rs +++ b/crates/sign/src/pdf/changes.rs @@ -427,6 +427,35 @@ fn without(x: &Dict, keys: &[&[u8]]) -> Vec<(Vec, Object)> { v } +/// Whether two page dictionaries are the same apart from `/Annots`. Dictionaries are compared +/// without regard to key order at every depth: a writer may re-serialise a page's inline +/// resources in another order without changing what the page shows. +fn same_but_annots(d: &Dict, b: &Dict) -> bool { + same_entries(d, b, &[b"Annots"]) +} + +/// Whether two dictionaries hold the same entries apart from `skip`: both sorted by key, then +/// compared pairwise, so neither side can hide a key the other lacks. +fn same_entries(a: &Dict, b: &Dict, skip: &[&[u8]]) -> bool { + fn sorted<'a>(x: &'a Dict, skip: &[&[u8]]) -> Vec<(&'a [u8], &'a Object)> { + let mut v: Vec<(&[u8], &Object)> = x.iter().map(|(k, o)| (k.as_slice(), o)).filter(|(k, _)| !skip.contains(k)).collect(); + v.sort_by(|p, q| p.0.cmp(q.0)); + v + } + let (x, y) = (sorted(a, skip), sorted(b, skip)); + x.len() == y.len() && x.iter().zip(&y).all(|((k, v), (l, w))| k == l && same_object(v, w)) +} + +/// Object equality where dictionaries (and stream dictionaries) ignore key order; arrays keep it. +fn same_object(a: &Object, b: &Object) -> bool { + match (a, b) { + (Object::Dict(a), Object::Dict(b)) => same_entries(a, b, &[]), + (Object::Stream(a), Object::Stream(b)) => a.raw == b.raw && same_entries(&a.dict, &b.dict, &[]), + (Object::Array(a), Object::Array(b)) => a.len() == b.len() && a.iter().zip(b).all(|(v, w)| same_object(v, w)), + _ => a == b, + } +} + /// Whether a dictionary was a signature object: a signature or timestamp value, a signature /// reference or its transform parameters, or a signature form field. Used to decide from the /// signed revision, so a later relabel of some other object cannot pose as a signature change. @@ -521,7 +550,7 @@ fn signed_page_resources(old: &Document, pages: &HashSet) -> HashMap) -> &'static str { match before { - Some(b) if without(d, &[b"Annots"]) != without(b, &[b"Annots"]) => kind::PAGE_CONTENT, + Some(b) if !same_but_annots(d, b) => kind::PAGE_CONTENT, Some(b) => { let was = refs(old, b.get(b"Annots")); let added: Vec = refs(doc, d.get(b"Annots")).into_iter().filter(|r| !was.contains(r)).collect(); @@ -584,7 +613,7 @@ fn change_kind(doc: &Document, old: &Document, d: &Dict, before: Option<&Dict>) } Some(b"Page") => { return match before { - Some(b) if without(d, &[b"Annots"]) != without(b, &[b"Annots"]) => kind::PAGE_CONTENT, + Some(b) if !same_but_annots(d, b) => kind::PAGE_CONTENT, Some(b) => { let was = refs(old, b.get(b"Annots")); let added: Vec = refs(doc, d.get(b"Annots")).into_iter().filter(|r| !was.contains(r)).collect(); @@ -617,3 +646,83 @@ fn change_kind(doc: &Document, old: &Document, d: &Dict, before: Option<&Dict>) } kind::OTHER } + +#[cfg(test)] +mod tests { + use super::*; + + fn font(order: &[&str], base: &str) -> Object { + let mut d = Dict::new(); + for k in order { + match *k { + "Type" => d.set(b"Type".to_vec(), Object::name("Font")), + "Subtype" => d.set(b"Subtype".to_vec(), Object::name("Type1")), + "BaseFont" => d.set(b"BaseFont".to_vec(), Object::name(base)), + _ => { + let mut w = Dict::new(); + w.set(b"Differences".to_vec(), Object::Array(vec![Object::Int(32), Object::name("space")])); + w.set(b"Type".to_vec(), Object::name("Encoding")); + d.set(b"Encoding".to_vec(), Object::Dict(w)); + } + } + } + Object::Dict(d) + } + + fn page(fonts: Vec<(&str, Object)>, annots: Vec) -> Dict { + let mut f = Dict::new(); + fonts.into_iter().for_each(|(k, v)| f.set(k.as_bytes().to_vec(), v)); + let mut r = Dict::new(); + r.set(b"Font".to_vec(), Object::Dict(f)); + let mut p = Dict::new(); + p.set(b"Type".to_vec(), Object::name("Page")); + p.set(b"Resources".to_vec(), Object::Dict(r)); + p.set(b"Annots".to_vec(), Object::Array(annots)); + p + } + + #[test] + fn reordered_inline_resources_are_not_page_content() { + let doc = Document::new_empty(); + let order = ["Type", "Subtype", "BaseFont", "Encoding"]; + let before = page(vec![("F1", font(&order, "Times-Roman")), ("F2", font(&order, "Helvetica"))], vec![]); + let unchanged = page_change_kind(&doc, &doc, &before, Some(&before)); + let reordered = page( + vec![ + ("F2", font(&["Encoding", "BaseFont", "Type", "Subtype"], "Helvetica")), + ("F1", font(&["BaseFont", "Subtype", "Encoding", "Type"], "Times-Roman")), + ], + vec![], + ); + assert_eq!(page_change_kind(&doc, &doc, &reordered, Some(&before)), unchanged); + assert_eq!(change_kind(&doc, &doc, &reordered, Some(&before)), change_kind(&doc, &doc, &before, Some(&before))); + + // A changed value inside the nested dictionary still is a page content change. + let changed = page(vec![("F2", font(&order, "Helvetica")), ("F1", font(&order, "Courier"))], vec![]); + assert_eq!(page_change_kind(&doc, &doc, &changed, Some(&before)), kind::PAGE_CONTENT); + assert_eq!(change_kind(&doc, &doc, &changed, Some(&before)), kind::PAGE_CONTENT); + } + + #[test] + fn a_swapped_nested_key_is_page_content_both_ways() { + let doc = Document::new_empty(); + let order = ["Type", "Subtype", "BaseFont"]; + let signed = page(vec![("F1", font(&order, "Times-Roman"))], vec![]); + // Same entry count, but /Subtype was dropped and /Encoding added in its place. + let swapped = page(vec![("F1", font(&["Type", "Encoding", "BaseFont"], "Times-Roman"))], vec![]); + for (now, then) in [(&swapped, &signed), (&signed, &swapped)] { + assert_eq!(page_change_kind(&doc, &doc, now, Some(then)), kind::PAGE_CONTENT); + assert_eq!(change_kind(&doc, &doc, now, Some(then)), kind::PAGE_CONTENT); + } + } + + #[test] + fn reordered_arrays_are_still_page_content() { + let doc = Document::new_empty(); + let mut before = page(vec![], vec![]); + before.set(b"MediaBox".to_vec(), Object::Array(vec![Object::Int(0), Object::Int(0), Object::Int(612), Object::Int(792)])); + let mut after = before.clone(); + after.set(b"MediaBox".to_vec(), Object::Array(vec![Object::Int(0), Object::Int(0), Object::Int(792), Object::Int(612)])); + assert_eq!(page_change_kind(&doc, &doc, &after, Some(&before)), kind::PAGE_CONTENT); + } +} From 715c310f5b770e2621c6c2e0f969455dd481209f Mon Sep 17 00:00:00 2001 From: mtlcl410 Date: Sat, 7 Nov 2026 10:25:41 +0800 Subject: [PATCH 10/92] M9: a signed string respelled in hexadecimal is no change after signing (sign.changes-after-signing) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A string object is its bytes; literal and hexadecimal are two ways to write them (ISO 32000-2 §7.3.4.1-7.3.4.3). The modification check compared objects with the derived equality, which also compares how a string was written, so a later save that rewrote the catalog's /Lang from (en-US) to <656E2D5553> made an untouched signature read as a document structure change. same_object now compares strings by their bytes, and every comparison in changes.rs goes through it: the unchanged-object skip, the catalog, its /AcroForm and /Perms, developer extensions, signed widgets, page resource containers, the security store and the timestamps check. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Pq7HyDBXygTf7jcqdsjcfn --- crates/sign/src/pdf/changes.rs | 57 +++++++------- .../src/pdf/revision_classification_tests.rs | 77 +++++++++++++++++++ parity/acrobat-features.toml | 4 +- 3 files changed, 109 insertions(+), 29 deletions(-) diff --git a/crates/sign/src/pdf/changes.rs b/crates/sign/src/pdf/changes.rs index f6c5b286c4..b0d082a366 100644 --- a/crates/sign/src/pdf/changes.rs +++ b/crates/sign/src/pdf/changes.rs @@ -168,7 +168,7 @@ pub(super) fn classify_changes(doc: &Document, old: Option, p: Option< let r = ObjRef { num, generation: doc.generation(num) }; let new = doc.get(r); let before = old.try_get(num).ok().filter(|o| !matches!(**o, Object::Null)); - if before.as_deref() == Some(&*new) { + if before.as_deref().is_some_and(|b| same_object(b, &new)) { // The same content at a new place. That is true for the renderer as well only when // the entry locates the object itself: where this reader had to search for the // object, another reader may arrive at a different one. @@ -238,7 +238,7 @@ fn only_validation_data(allowed: &mut Vec<&'static str>, disallowed: &mut Vec<&' // Compared by content: an indirect /Perms rewritten in place keeps its reference (and, shared // with the trailer's /Info, would otherwise pass as a metadata change). let perms = |d: &Document| d.root().and_then(|r| d.get(r).as_dict().and_then(|c| c.get(b"Perms").map(|p| (*d.resolve(p)).clone()))); - if perms(doc) != perms(old) && !disallowed.contains(&kind::STRUCTURE) { + if !same_entry(perms(doc).as_ref(), perms(old).as_ref()) && !disallowed.contains(&kind::STRUCTURE) { disallowed.push(kind::STRUCTURE); } } @@ -269,7 +269,7 @@ fn only_invisible_timestamps_added(doc: &Document, old: &Document) -> bool { continue; } // Unchanged since signing: the certification already covered it. - if old.try_get(num).ok().filter(|o| !matches!(**o, Object::Null)).as_deref() == Some(&*new) { + if old.try_get(num).ok().filter(|o| !matches!(**o, Object::Null)).is_some_and(|o| same_object(&o, &new)) { continue; } let value = if is_value { Some(d.clone()) } else { d.get(b"V").map(|v| doc.resolve(v)).and_then(|v| v.as_dict().cloned()) }; @@ -330,7 +330,7 @@ impl ChangedObjectClassifier<'_> { } fn keeps_previous_entries(&self, dict: &Dict, before: Option<&Object>) -> bool { - before.and_then(Object::as_dict).is_some_and(|previous| previous.iter().all(|(key, value)| dict.get(key) == Some(value))) + before.and_then(Object::as_dict).is_some_and(|previous| previous.iter().all(|(key, value)| same_entry(dict.get(key), Some(value)))) } fn is_signed_annotation_array(&self, r: ObjRef, before: Option<&Object>) -> bool { @@ -520,14 +520,14 @@ pub(crate) fn only_grew(doc: &Document, old: &Document, before: &Object, now: &O let refs = |a: &[Object]| a.iter().map(Object::as_ref).collect::>>(); match (refs(b), refs(n)) { (Some(b), Some(n)) => b.is_subset(&n), - _ => b.len() <= MAX_COMPARED && n.len() <= MAX_COMPARED && b.iter().all(|x| n.contains(x)), + _ => b.len() <= MAX_COMPARED && n.len() <= MAX_COMPARED && b.iter().all(|x| n.iter().any(|y| same_object(x, y))), } } (Object::Dict(b), Object::Dict(n)) => b.iter().all(|(k, v)| match n.get(k) { - Some(nv) => nv == v || (depth < 4 && only_grew(doc, old, &old.resolve(v), &doc.resolve(nv), depth + 1)), + Some(nv) => same_object(nv, v) || (depth < 4 && only_grew(doc, old, &old.resolve(v), &doc.resolve(nv), depth + 1)), None => false, }), - _ => before == now, + _ => same_object(before, now), } } @@ -651,13 +651,7 @@ fn acroform_kind(doc: &Document, old: &Document, now: &Dict, before: Option<&Dic if lost_entries(doc, old, before.get(b"Fields"), now.get(b"Fields")) { return kind::OTHER; } - let strip = |d: &Dict| -> Vec<(Vec, Object)> { - let mut v: Vec<(Vec, Object)> = - d.iter().filter(|(k, _)| !matches!(k.as_slice(), b"Fields" | b"SigFlags")).map(|(k, o)| (k.to_vec(), o.clone())).collect(); - v.sort_by(|a, b| a.0.cmp(&b.0)); - v - }; - if strip(now) != strip(before) { + if !same_entries(now, before, &[b"Fields", b"SigFlags"]) { return kind::FORM_FILL; } let old_fields = refs(old, before.get(b"Fields")); @@ -665,13 +659,6 @@ fn acroform_kind(doc: &Document, old: &Document, now: &Dict, before: Option<&Dic if added.is_empty() || added_annots(doc, &added) == kind::SIGNATURE { kind::SIGNATURE } else { kind::FORM_FILL } } -/// Dictionary entries in a stable order, without `keys`. -fn without(x: &Dict, keys: &[&[u8]]) -> Vec<(Vec, Object)> { - let mut v: Vec<(Vec, Object)> = x.iter().filter(|(k, _)| !keys.contains(&k.as_slice())).map(|(k, o)| (k.to_vec(), o.clone())).collect(); - v.sort_by(|a, b| a.0.cmp(&b.0)); - v -} - /// Whether two page dictionaries are the same apart from `/Annots`. Dictionaries are compared /// without regard to key order at every depth: a writer may re-serialise a page's inline /// resources in another order without changing what the page shows. @@ -692,8 +679,13 @@ fn same_entries(a: &Dict, b: &Dict, skip: &[&[u8]]) -> bool { } /// Object equality where dictionaries (and stream dictionaries) ignore key order; arrays keep it. +/// A string is its bytes (ISO 32000-2 §7.3.4.1): written as a literal or as hexadecimal, it is the +/// same string, so a later save that respells one changes nothing it says. References compare by +/// object and generation, and stream data byte for byte as stored. The recursion follows the +/// parsed nesting, which the parser bounds. fn same_object(a: &Object, b: &Object) -> bool { match (a, b) { + (Object::String(a), Object::String(b)) => a.bytes == b.bytes, (Object::Dict(a), Object::Dict(b)) => same_entries(a, b, &[]), (Object::Stream(a), Object::Stream(b)) => a.raw == b.raw && same_entries(&a.dict, &b.dict, &[]), (Object::Array(a), Object::Array(b)) => a.len() == b.len() && a.iter().zip(b).all(|(v, w)| same_object(v, w)), @@ -701,6 +693,15 @@ fn same_object(a: &Object, b: &Object) -> bool { } } +/// [`same_object`] for an entry that may be absent: both absent, or both present and the same. +fn same_entry(a: Option<&Object>, b: Option<&Object>) -> bool { + match (a, b) { + (Some(a), Some(b)) => same_object(a, b), + (None, None) => true, + _ => false, + } +} + /// Whether a dictionary was a signature object: a signature or timestamp value, a signature /// reference or its transform parameters, or a signature form field. Used to decide from the /// signed revision, so a later relabel of some other object cannot pose as a signature change. @@ -798,7 +799,7 @@ fn extensions_kept(doc: &Document, old: &Document, d: &Dict, b: &Dict) -> bool { let was = b.get(b"Extensions").map(|e| old.resolve(e)).and_then(|e| e.as_dict().cloned()); match (was, now) { (None, _) => true, - (Some(was), Some(now)) => was.iter().all(|(k, v)| now.get(k) == Some(v)), + (Some(was), Some(now)) => was.iter().all(|(k, v)| same_entry(now.get(k), Some(v))), (Some(_), None) => false, } } @@ -806,18 +807,18 @@ fn extensions_kept(doc: &Document, old: &Document, d: &Dict, b: &Dict) -> bool { /// A change to the signed catalog `b`, now `d`. fn catalog_kind(doc: &Document, old: &Document, d: &Dict, b: &Dict) -> &'static str { let keys: &[&[u8]] = &[b"AcroForm", b"DSS", b"Perms", b"Metadata", b"NeedsRendering", b"Extensions"]; - if without(d, keys) != without(b, keys) { + if !same_entries(d, b, keys) { kind::STRUCTURE } else if !extensions_kept(doc, old, d, b) { // Developer extensions (ISO 32000-2 §7.12) only declare what the file uses. A writer // adding a /DSS also declares the ESIC extension it belongs to, so a declaration may be // added, but none that was signed may change or go. kind::STRUCTURE - } else if d.get(b"AcroForm") != b.get(b"AcroForm") { + } else if !same_entry(d.get(b"AcroForm"), b.get(b"AcroForm")) { // Compare the forms themselves, inline or not. let now = acroform(doc).unwrap_or_default(); acroform_kind(doc, old, &now, None) - } else if b.get(b"Perms") != d.get(b"Perms") { + } else if !same_entry(b.get(b"Perms"), d.get(b"Perms")) { kind::SIGNATURE } else { kind::METADATA @@ -869,7 +870,7 @@ fn change_kind(doc: &Document, old: &Document, d: &Dict, before: Option<&Dict>) // so relabelling the widget now does not change that. if before.is_some_and(|b| { b.name(b"Subtype") == Some(b"Widget") && b.name(b"FT") == Some(b"Sig") && b.get(b"V").is_some_and(|v| old.resolve(v).as_dict().is_some()) - }) && before != Some(d) + }) && !before.is_some_and(|b| same_entries(d, b, &[])) { return kind::PAGE_CONTENT; } @@ -883,7 +884,9 @@ fn change_kind(doc: &Document, old: &Document, d: &Dict, before: Option<&Dict>) } // Where a signed widget sits and whether it shows are what the page shows: filling the form // changes its value and appearance, never its rectangle or its flags. - if before.is_some_and(|b| b.name(b"Subtype") == Some(b"Widget") && (d.get(b"Rect") != b.get(b"Rect") || d.get(b"F") != b.get(b"F"))) { + if before.is_some_and(|b| { + b.name(b"Subtype") == Some(b"Widget") && (!same_entry(d.get(b"Rect"), b.get(b"Rect")) || !same_entry(d.get(b"F"), b.get(b"F"))) + }) { return kind::PAGE_CONTENT; } match ty { diff --git a/crates/sign/src/pdf/revision_classification_tests.rs b/crates/sign/src/pdf/revision_classification_tests.rs index b5e72fcae0..3b63953a0d 100644 --- a/crates/sign/src/pdf/revision_classification_tests.rs +++ b/crates/sign/src/pdf/revision_classification_tests.rs @@ -282,3 +282,80 @@ fn a_repaired_slot_rewritten_cleanly_is_judged_by_its_references() { let now = signed.clone().revision(&[(4, Row::Obj(0, 0, &filled))]); assert!(matches!(verdict(&now.open(), signed.open(), Some(2)), Modification::Disallowed(_))); } + +/// A signed catalog and text field whose strings a later save respells, given the catalog `extra` +/// entries and the field's `/T` as written in each revision. +fn respelled(signed_extra: &str, now_extra: &str, signed_t: &str, now_t: &str) -> (Document, Document) { + let catalog = |extra: &str| format!("<< /Type /Catalog /Pages 2 0 R /AcroForm << /Fields [5 0 R] /DA (/Helv 0 Tf 0 g) >> {extra} >>"); + let field = |t: &str| format!("<< /Type /Annot /Subtype /Widget /FT /Tx /T {t} /V (Ann) /Rect [20 20 120 40] /P 3 0 R >>"); + let signed = File::new().revision(&[ + (1, Row::Obj(0, 0, &catalog(signed_extra))), + (2, Row::Obj(0, 0, PAGES)), + (3, Row::Obj(0, 0, &page("/Annots [5 0 R]"))), + (4, Row::Obj(0, 0, &page("/Annots []"))), + (5, Row::Obj(0, 0, &field(signed_t))), + ]); + let now = signed.clone().revision(&[(1, Row::Obj(0, 0, &catalog(now_extra))), (5, Row::Obj(0, 0, &field(now_t)))]); + (now.open(), signed.open()) +} + +/// A string is its bytes (ISO 32000-2 §7.3.4.1): a later save that writes the catalog's /Lang in +/// hexadecimal instead of as a literal changes nothing, under every permission level. +#[test] +fn a_string_respelled_in_hex_is_no_change() { + for p in [Some(1), Some(2), Some(3), None] { + let (doc, old) = respelled("/Lang (en-US)", "/Lang <656E2D5553>", "(Name)", "(Name)"); + assert_eq!(verdict(&doc, old, p), Modification::Allowed(Vec::new()), "P={p:?}"); + } +} + +/// The same holds for strings nested in dictionaries and arrays (a name tree's keys), with the +/// entries also in another order, and for a signed field's /T. +#[test] +fn nested_strings_respelled_and_reordered_are_no_change() { + let (doc, old) = respelled( + "/Names << /Dests << /Names [(intro) [3 0 R /Fit]] >> >> /Lang (en-US)", + "/Lang <656e2d5553> /Names << /Dests << /Names [<696E74726F> [3 0 R /Fit]] >> >>", + "(Name)", + "<4E616D65>", + ); + assert_eq!(verdict(&doc, old, Some(1)), Modification::Allowed(Vec::new())); +} + +/// Different bytes are a different string however they are written: a catalog whose /Lang (or a +/// string deep in its name tree) now says something else is still a structure change. +#[test] +fn a_string_whose_bytes_change_is_still_a_change() { + for (signed, now) in [ + ("/Lang (en-US)", "/Lang <64652D4445>"), + ("/Names << /Dests << /Names [(intro) [3 0 R /Fit]] >> >>", "/Names << /Dests << /Names [<6F75747472> [3 0 R /Fit]] >> >>"), + ] { + let (doc, old) = respelled(signed, now, "(Name)", "(Name)"); + assert_eq!(verdict(&doc, old, Some(2)), Modification::Disallowed(vec![super::kind::STRUCTURE]), "{now}"); + } +} + +/// A save that adds a signature field and respells the catalog's and the form's strings on the +/// way is judged by the field it added: the respelled /Lang and /DA are not a structure or form +/// change. +#[test] +fn a_signature_added_with_respelled_strings_is_signing() { + let signed = File::new().revision(&[ + (1, Row::Obj(0, 0, "<< /Type /Catalog /Pages 2 0 R /Lang (en-US) /AcroForm << /Fields [5 0 R] /DA (/Helv 0 Tf 0 g) >> >>")), + (2, Row::Obj(0, 0, PAGES)), + (3, Row::Obj(0, 0, &page("/Annots [5 0 R]"))), + (4, Row::Obj(0, 0, &page("/Annots []"))), + (5, Row::Obj(0, 0, TEXT_FIELD)), + ]); + let catalog = + |lang: &str, da: &str| format!("<< /Type /Catalog /Pages 2 0 R /Lang {lang} /AcroForm << /DA {da} /Fields [5 0 R 6 0 R] /SigFlags 3 >> >>"); + let sig = "<< /Type /Annot /Subtype /Widget /FT /Sig /T (Sig1) /Rect [0 0 0 0] /P 4 0 R >>"; + let now = |lang: &str, da: &str| { + signed.clone().revision(&[(1, Row::Obj(0, 0, &catalog(lang, da))), (4, Row::Obj(0, 0, &page("/Annots [6 0 R]"))), (6, Row::Obj(0, 0, sig))]) + }; + let respelled = now("<656E2D5553>", "<2F48656C76203020546620302067>"); + assert_eq!(verdict(&respelled.open(), signed.open(), Some(2)), Modification::Allowed(vec![super::kind::SIGNATURE])); + // A /DA that now says something else is a form change, however it is spelled. + let restyled = now("<656E2D5553>", "<2F48656C7620313220546620302067>"); + assert_eq!(verdict(&restyled.open(), signed.open(), None), Modification::Allowed(vec![super::kind::FORM_FILL, super::kind::SIGNATURE])); +} diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index 10b040e09f..0bb7b7cf5d 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -6535,8 +6535,8 @@ milestone = "M9" status = "shipped" commands = ["sign.panel"] tools = ["sign_list"] -evidence = ["crates/sign/tests/it/pdf.rs::later_changes_are_classified_under_the_signature_permissions", "crates/sign/tests/it/pdf.rs::signing_an_existing_field_and_counter_signing", "crates/engine/src/tests.rs::signing_saving_trusting_and_commenting_afterwards", "crates/sign/tests/it/pdf.rs::validation_data_and_xmp_added_after_signing_are_not_tampering", "crates/sign/tests/it/pdf.rs::a_dss_entry_does_not_make_the_object_it_names_part_of_the_store", "crates/sign/tests/it/pdf.rs::an_existing_dictionary_is_not_a_store_part_because_the_store_names_it", "crates/sign/tests/it/pdf.rs::an_existing_array_stays_what_it_is_when_the_store_points_at_it", "crates/sign/tests/it/pdf.rs::a_signed_store_may_grow_but_not_lose_or_swap_entries", "crates/sign/tests/it/pdf.rs::a_dictionary_labelled_metadata_is_not_a_metadata_change", "crates/sign/tests/it/pdf.rs::a_stream_already_labelled_metadata_when_signed_is_not_metadata_unless_the_catalog_names_it", "crates/sign/tests/it/pdf.rs::the_catalogs_own_xmp_stream_may_still_be_rewritten_after_signing", "crates/sign/tests/it/pdf.rs::rewritten_page_resources_are_page_content_whatever_keys_they_carry", "crates/sign/tests/it/pdf.rs::rewriting_a_certified_forms_xfa_data_is_a_form_fill", "crates/sign/tests/it/pdf.rs::the_catalogs_perms_do_not_change_under_no_changes"] -notes = "revision diff classified as signing, form fill, comments, metadata, page content or structure. Later revisions that add DSS data (new /Certs, /CRLs, /OCSPs, /VRI objects, or ones of the signed store that only grew) or rewrite the catalog's XMP are permitted changes; a /DSS entry that names other objects, or a /Type /DSS or /Type /Metadata label on an existing dictionary or stream, does not excuse changing them (negative tests at every DocMDP level). A signed page's resources (fonts, XObjects, graphics states and what they reach) are page content by the role they held when signed, whatever keys a later revision writes into them (#1787). Rewriting the XFA `datasets` or `form` packet the signed revision named is form fill, as a Reader Extensions (UR3) save of a certified form does (#2759); the packets' roles come from the signed revision. Under P=1 a change to the catalog's /Perms breaks the certification." +evidence = ["crates/sign/tests/it/pdf.rs::later_changes_are_classified_under_the_signature_permissions", "crates/sign/tests/it/pdf.rs::signing_an_existing_field_and_counter_signing", "crates/engine/src/tests.rs::signing_saving_trusting_and_commenting_afterwards", "crates/sign/tests/it/pdf.rs::validation_data_and_xmp_added_after_signing_are_not_tampering", "crates/sign/tests/it/pdf.rs::a_dss_entry_does_not_make_the_object_it_names_part_of_the_store", "crates/sign/tests/it/pdf.rs::an_existing_dictionary_is_not_a_store_part_because_the_store_names_it", "crates/sign/tests/it/pdf.rs::an_existing_array_stays_what_it_is_when_the_store_points_at_it", "crates/sign/tests/it/pdf.rs::a_signed_store_may_grow_but_not_lose_or_swap_entries", "crates/sign/tests/it/pdf.rs::a_dictionary_labelled_metadata_is_not_a_metadata_change", "crates/sign/tests/it/pdf.rs::a_stream_already_labelled_metadata_when_signed_is_not_metadata_unless_the_catalog_names_it", "crates/sign/tests/it/pdf.rs::the_catalogs_own_xmp_stream_may_still_be_rewritten_after_signing", "crates/sign/tests/it/pdf.rs::rewritten_page_resources_are_page_content_whatever_keys_they_carry", "crates/sign/tests/it/pdf.rs::rewriting_a_certified_forms_xfa_data_is_a_form_fill", "crates/sign/tests/it/pdf.rs::the_catalogs_perms_do_not_change_under_no_changes", "crates/sign/src/pdf/revision_classification_tests.rs::a_string_respelled_in_hex_is_no_change", "crates/sign/src/pdf/revision_classification_tests.rs::a_string_whose_bytes_change_is_still_a_change"] +notes = "revision diff classified as signing, form fill, comments, metadata, page content or structure. Later revisions that add DSS data (new /Certs, /CRLs, /OCSPs, /VRI objects, or ones of the signed store that only grew) or rewrite the catalog's XMP are permitted changes; a /DSS entry that names other objects, or a /Type /DSS or /Type /Metadata label on an existing dictionary or stream, does not excuse changing them (negative tests at every DocMDP level). A signed page's resources (fonts, XObjects, graphics states and what they reach) are page content by the role they held when signed, whatever keys a later revision writes into them (#1787). Rewriting the XFA `datasets` or `form` packet the signed revision named is form fill, as a Reader Extensions (UR3) save of a certified form does (#2759); the packets' roles come from the signed revision. Under P=1 a change to the catalog's /Perms breaks the certification. Signed objects are compared by value: a string is its bytes, so one a later save rewrites from literal to hexadecimal (or back) is unchanged (ISO 32000-2 §7.3.4.1)." [[feature]] id = "sign.view-signed-version" From 44428b36d318cf910ab25dd29ac9f5ed8961ff11 Mon Sep 17 00:00:00 2001 From: mtlcl410 Date: Sat, 7 Nov 2026 12:46:10 +0800 Subject: [PATCH 11/92] M9: a signed number saved back as an integer is no change after signing (sign.changes-after-signing) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ISO 32000-2 §7.3.3: wherever a real number is expected, an integer may be used instead. PdfCraft's own writer writes a whole real as an integer, so a form fill that rewrites a signed widget turned /Rect [20.0 20.0 120.0 40.0] into [20 20 120 40] and was refused as a page content change. same_object now compares an integer and a real by value (exactly, in i128), for the widget /Rect and every other signed comparison, such as a page /MediaBox rewritten when a comment is added. Also states the reordered page's verdict as None explicitly, in a test of its own next to the one from #5962. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Pq7HyDBXygTf7jcqdsjcfn --- crates/sign/src/pdf/changes.rs | 61 ++++++++++++++++++- .../src/pdf/revision_classification_tests.rs | 38 ++++++++++++ parity/acrobat-features.toml | 4 +- 3 files changed, 98 insertions(+), 5 deletions(-) diff --git a/crates/sign/src/pdf/changes.rs b/crates/sign/src/pdf/changes.rs index b0d082a366..ce7349e2ad 100644 --- a/crates/sign/src/pdf/changes.rs +++ b/crates/sign/src/pdf/changes.rs @@ -680,12 +680,18 @@ fn same_entries(a: &Dict, b: &Dict, skip: &[&[u8]]) -> bool { /// Object equality where dictionaries (and stream dictionaries) ignore key order; arrays keep it. /// A string is its bytes (ISO 32000-2 §7.3.4.1): written as a literal or as hexadecimal, it is the -/// same string, so a later save that respells one changes nothing it says. References compare by -/// object and generation, and stream data byte for byte as stored. The recursion follows the -/// parsed nesting, which the parser bounds. +/// same string, so a later save that respells one changes nothing it says. Likewise a number is +/// its value (§7.3.3: "wherever a real number is expected, an integer may be used instead"), so a +/// signed `/Rect [20.0 20.0 120.0 40.0]` saved again as `[20 20 120 40]` is the same rectangle. +/// References compare by object and generation, and stream data byte for byte as stored. The +/// recursion follows the parsed nesting, which the parser bounds. fn same_object(a: &Object, b: &Object) -> bool { match (a, b) { (Object::String(a), Object::String(b)) => a.bytes == b.bytes, + // Compared in i128, where every i64 and every whole real near its range is exact (the cast + // saturates far beyond it): no fractional real, NaN or infinity (whose fract is NaN) + // equals an integer, nor does the real 2^63 pass for i64::MAX. + (Object::Int(i), Object::Real(r)) | (Object::Real(r), Object::Int(i)) => r.fract() == 0.0 && *r as i128 == i128::from(*i), (Object::Dict(a), Object::Dict(b)) => same_entries(a, b, &[]), (Object::Stream(a), Object::Stream(b)) => a.raw == b.raw && same_entries(&a.dict, &b.dict, &[]), (Object::Array(a), Object::Array(b)) => a.len() == b.len() && a.iter().zip(b).all(|(v, w)| same_object(v, w)), @@ -1023,6 +1029,55 @@ mod tests { assert_eq!(change_kind(&doc, &doc, &changed, Some(&before)), kind::PAGE_CONTENT); } + /// The page as signed and the same page with its inline resources reordered are each no + /// page change at all, stated as `None` rather than only equal to each other, and so is a page + /// whose numbers and strings are respelled as well (§7.3.3, §7.3.4.1). + #[test] + fn reordered_and_respelled_pages_are_no_page_change() { + let doc = Document::new_empty(); + let order = ["Type", "Subtype", "BaseFont", "Encoding"]; + let mut before = page(vec![("F1", font(&order, "Times-Roman")), ("F2", font(&order, "Helvetica"))], vec![]); + before.set(b"MediaBox".to_vec(), Object::Array(vec![Object::Int(0), Object::Int(0), Object::Real(612.0), Object::Real(792.0)])); + before.set( + b"PieceInfo".to_vec(), + Object::Dict(Dict::from_iter([(b"Note".to_vec(), Object::String(pdfcraft_cos::PdfString::literal("signed")))])), + ); + let mut reordered = page( + vec![ + ("F2", font(&["Encoding", "BaseFont", "Type", "Subtype"], "Helvetica")), + ("F1", font(&["BaseFont", "Subtype", "Encoding", "Type"], "Times-Roman")), + ], + vec![], + ); + reordered.set(b"MediaBox".to_vec(), Object::Array(vec![Object::Real(0.0), Object::Int(0), Object::Int(612), Object::Int(792)])); + reordered.set( + b"PieceInfo".to_vec(), + Object::Dict(Dict::from_iter([(b"Note".to_vec(), Object::String(pdfcraft_cos::PdfString { bytes: b"signed".to_vec(), hex: true }))])), + ); + assert_eq!(page_change_kind(&doc, &doc, &before, Some(&before)), None); + assert_eq!(page_change_kind(&doc, &doc, &reordered, Some(&before)), None); + assert_eq!(page_change_kind(&doc, &doc, &before, Some(&reordered)), None); + + // A real that is not the integer it replaced is a moved box. + let mut moved = reordered.clone(); + moved.set(b"MediaBox".to_vec(), Object::Array(vec![Object::Int(0), Object::Int(0), Object::Real(612.5), Object::Int(792)])); + assert_eq!(page_change_kind(&doc, &doc, &moved, Some(&before)), Some(kind::PAGE_CONTENT)); + } + + /// Numbers compare by value across integer and real, and only when the values are equal. + #[test] + fn integers_and_reals_compare_by_value() { + let same = |a: Object, b: Object| same_object(&a, &b) && same_object(&b, &a); + assert!(same(Object::Int(20), Object::Real(20.0))); + assert!(same(Object::Int(0), Object::Real(-0.0))); + assert!(same(Object::Int(-7), Object::Real(-7.0))); + assert!(!same(Object::Int(20), Object::Real(20.5))); + assert!(!same(Object::Int(20), Object::Real(f64::NAN))); + assert!(!same(Object::Int(i64::MAX), Object::Real(f64::INFINITY))); + assert!(!same(Object::Int(i64::MAX), Object::Real(9_223_372_036_854_775_808.0))); + assert!(!same(Object::Int(1), Object::Bool(true))); + } + #[test] fn a_swapped_nested_key_is_page_content_both_ways() { let doc = Document::new_empty(); diff --git a/crates/sign/src/pdf/revision_classification_tests.rs b/crates/sign/src/pdf/revision_classification_tests.rs index 3b63953a0d..ec95d1323b 100644 --- a/crates/sign/src/pdf/revision_classification_tests.rs +++ b/crates/sign/src/pdf/revision_classification_tests.rs @@ -359,3 +359,41 @@ fn a_signature_added_with_respelled_strings_is_signing() { let restyled = now("<656E2D5553>", "<2F48656C7620313220546620302067>"); assert_eq!(verdict(&restyled.open(), signed.open(), None), Modification::Allowed(vec![super::kind::FORM_FILL, super::kind::SIGNATURE])); } + +/// A signed widget whose /Rect was written with reals, filled and saved by PdfCraft's own writer, +/// which writes a whole real as an integer: the rectangle is the same numbers (ISO 32000-2 +/// §7.3.3, "wherever a real number is expected, an integer may be used instead"), so the save is a +/// form fill. A rectangle whose numbers did change is still a moved widget. +#[test] +fn a_widget_rect_saved_back_as_integers_is_a_form_fill() { + let field = "<< /Type /Annot /Subtype /Widget /FT /Tx /T (Name) /V (Ann) /Rect [20.0 20.0 120.0 40.0] /P 3 0 R >>"; + let signed = File::new().revision(&[ + (1, Row::Obj(0, 0, "<< /Type /Catalog /Pages 2 0 R /AcroForm << /Fields [5 0 R] >> >>")), + (2, Row::Obj(0, 0, PAGES)), + (3, Row::Obj(0, 0, &page("/Annots [5 0 R]"))), + (4, Row::Obj(0, 0, &page("/Annots []"))), + (5, Row::Obj(0, 0, field)), + ]); + let save = |rect: Option| { + let mut doc = signed.open(); + doc.update_dict(r(5, 0), |w| { + w.set(b"V".to_vec(), Object::String(pdfcraft_cos::PdfString::literal("Bob"))); + if let Some(x) = rect { + w.set(b"Rect".to_vec(), Object::Array(vec![Object::Real(x), Object::Real(20.0), Object::Real(120.0), Object::Real(40.0)])); + } + }) + .unwrap(); + let bytes = pdfcraft_cos::write_incremental(&doc, &pdfcraft_cos::SaveOptions { object_streams: false, ..Default::default() }).unwrap(); + Document::open(Arc::new(bytes)).unwrap() + }; + let filled = save(None); + let rect = filled.resolve(&Object::Ref(r(5, 0))).as_dict().and_then(|w| w.get(b"Rect").cloned()); + assert_eq!( + rect, + Some(Object::Array(vec![Object::Int(20), Object::Int(20), Object::Int(120), Object::Int(40)])), + "the writer respells whole reals" + ); + assert_eq!(verdict(&filled, signed.open(), Some(2)), Modification::Allowed(vec![super::kind::FORM_FILL])); + let moved = save(Some(20.5)); + assert_eq!(verdict(&moved, signed.open(), Some(2)), Modification::Disallowed(vec![super::kind::PAGE_CONTENT])); +} diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index 0bb7b7cf5d..58f49d3449 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -6535,8 +6535,8 @@ milestone = "M9" status = "shipped" commands = ["sign.panel"] tools = ["sign_list"] -evidence = ["crates/sign/tests/it/pdf.rs::later_changes_are_classified_under_the_signature_permissions", "crates/sign/tests/it/pdf.rs::signing_an_existing_field_and_counter_signing", "crates/engine/src/tests.rs::signing_saving_trusting_and_commenting_afterwards", "crates/sign/tests/it/pdf.rs::validation_data_and_xmp_added_after_signing_are_not_tampering", "crates/sign/tests/it/pdf.rs::a_dss_entry_does_not_make_the_object_it_names_part_of_the_store", "crates/sign/tests/it/pdf.rs::an_existing_dictionary_is_not_a_store_part_because_the_store_names_it", "crates/sign/tests/it/pdf.rs::an_existing_array_stays_what_it_is_when_the_store_points_at_it", "crates/sign/tests/it/pdf.rs::a_signed_store_may_grow_but_not_lose_or_swap_entries", "crates/sign/tests/it/pdf.rs::a_dictionary_labelled_metadata_is_not_a_metadata_change", "crates/sign/tests/it/pdf.rs::a_stream_already_labelled_metadata_when_signed_is_not_metadata_unless_the_catalog_names_it", "crates/sign/tests/it/pdf.rs::the_catalogs_own_xmp_stream_may_still_be_rewritten_after_signing", "crates/sign/tests/it/pdf.rs::rewritten_page_resources_are_page_content_whatever_keys_they_carry", "crates/sign/tests/it/pdf.rs::rewriting_a_certified_forms_xfa_data_is_a_form_fill", "crates/sign/tests/it/pdf.rs::the_catalogs_perms_do_not_change_under_no_changes", "crates/sign/src/pdf/revision_classification_tests.rs::a_string_respelled_in_hex_is_no_change", "crates/sign/src/pdf/revision_classification_tests.rs::a_string_whose_bytes_change_is_still_a_change"] -notes = "revision diff classified as signing, form fill, comments, metadata, page content or structure. Later revisions that add DSS data (new /Certs, /CRLs, /OCSPs, /VRI objects, or ones of the signed store that only grew) or rewrite the catalog's XMP are permitted changes; a /DSS entry that names other objects, or a /Type /DSS or /Type /Metadata label on an existing dictionary or stream, does not excuse changing them (negative tests at every DocMDP level). A signed page's resources (fonts, XObjects, graphics states and what they reach) are page content by the role they held when signed, whatever keys a later revision writes into them (#1787). Rewriting the XFA `datasets` or `form` packet the signed revision named is form fill, as a Reader Extensions (UR3) save of a certified form does (#2759); the packets' roles come from the signed revision. Under P=1 a change to the catalog's /Perms breaks the certification. Signed objects are compared by value: a string is its bytes, so one a later save rewrites from literal to hexadecimal (or back) is unchanged (ISO 32000-2 §7.3.4.1)." +evidence = ["crates/sign/tests/it/pdf.rs::later_changes_are_classified_under_the_signature_permissions", "crates/sign/tests/it/pdf.rs::signing_an_existing_field_and_counter_signing", "crates/engine/src/tests.rs::signing_saving_trusting_and_commenting_afterwards", "crates/sign/tests/it/pdf.rs::validation_data_and_xmp_added_after_signing_are_not_tampering", "crates/sign/tests/it/pdf.rs::a_dss_entry_does_not_make_the_object_it_names_part_of_the_store", "crates/sign/tests/it/pdf.rs::an_existing_dictionary_is_not_a_store_part_because_the_store_names_it", "crates/sign/tests/it/pdf.rs::an_existing_array_stays_what_it_is_when_the_store_points_at_it", "crates/sign/tests/it/pdf.rs::a_signed_store_may_grow_but_not_lose_or_swap_entries", "crates/sign/tests/it/pdf.rs::a_dictionary_labelled_metadata_is_not_a_metadata_change", "crates/sign/tests/it/pdf.rs::a_stream_already_labelled_metadata_when_signed_is_not_metadata_unless_the_catalog_names_it", "crates/sign/tests/it/pdf.rs::the_catalogs_own_xmp_stream_may_still_be_rewritten_after_signing", "crates/sign/tests/it/pdf.rs::rewritten_page_resources_are_page_content_whatever_keys_they_carry", "crates/sign/tests/it/pdf.rs::rewriting_a_certified_forms_xfa_data_is_a_form_fill", "crates/sign/tests/it/pdf.rs::the_catalogs_perms_do_not_change_under_no_changes", "crates/sign/src/pdf/revision_classification_tests.rs::a_string_respelled_in_hex_is_no_change", "crates/sign/src/pdf/revision_classification_tests.rs::a_string_whose_bytes_change_is_still_a_change", "crates/sign/src/pdf/revision_classification_tests.rs::a_widget_rect_saved_back_as_integers_is_a_form_fill"] +notes = "revision diff classified as signing, form fill, comments, metadata, page content or structure. Later revisions that add DSS data (new /Certs, /CRLs, /OCSPs, /VRI objects, or ones of the signed store that only grew) or rewrite the catalog's XMP are permitted changes; a /DSS entry that names other objects, or a /Type /DSS or /Type /Metadata label on an existing dictionary or stream, does not excuse changing them (negative tests at every DocMDP level). A signed page's resources (fonts, XObjects, graphics states and what they reach) are page content by the role they held when signed, whatever keys a later revision writes into them (#1787). Rewriting the XFA `datasets` or `form` packet the signed revision named is form fill, as a Reader Extensions (UR3) save of a certified form does (#2759); the packets' roles come from the signed revision. Under P=1 a change to the catalog's /Perms breaks the certification. Signed objects are compared by value: a string is its bytes, so one a later save rewrites from literal to hexadecimal (or back) is unchanged (ISO 32000-2 §7.3.4.1), and a number is its value, so a signed widget /Rect or page /MediaBox of whole reals saved back as integers is unchanged (§7.3.3)." [[feature]] id = "sign.view-signed-version" From ec1f9549086ffef03d81eed88be30fdd529e10ed Mon Sep 17 00:00:00 2001 From: bflatastic Date: Sun, 8 Nov 2026 23:53:42 +0700 Subject: [PATCH 12/92] House rules: copy craft-contrib's licence allowlist with Unicode-3.0 storytold/craft-contrib#40 (RFC-0026) added Unicode-3.0 to the shared list. --- AGENTS.md | 1 + 1 file changed, 1 insertion(+) diff --git a/AGENTS.md b/AGENTS.md index b68d8c8921..d2117eaa5b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -40,6 +40,7 @@ Dependencies and assets use SPDX identifiers. An expression is allowed when ever - `BSD-2-Clause` - `BSD-3-Clause` - `Apache-2.0` +- `Unicode-3.0` - `OFL-1.1` - `Ubuntu-font-1.0` - `Bitstream-Vera` From c7fc7560eb199fbe813a2ddea9efcde87875504c Mon Sep 17 00:00:00 2001 From: bflatastic Date: Mon, 9 Nov 2026 00:54:58 +0700 Subject: [PATCH 13/92] Test Thai word grouping and row filling without a Thai face thai_lines' row filling moves into wrap_words, so both it and thai_words are tested with synthetic advances where no Thai face is built in. --- crates/edit/src/added/layout.rs | 47 +++++++++++++++++++++++++++++++-- 1 file changed, 45 insertions(+), 2 deletions(-) diff --git a/crates/edit/src/added/layout.rs b/crates/edit/src/added/layout.rs index c76db6463a..e479897d84 100644 --- a/crates/edit/src/added/layout.rs +++ b/crates/edit/src/added/layout.rs @@ -91,7 +91,13 @@ fn thai_lines(t: &AddedText, para: &str, width: f64) -> Result, Edit })), } } - let words = thai_words(atoms); + Ok(wrap_words(thai_words(atoms), width)) +} + +/// `words` (each a list of text and advance, with the spaces that follow it) laid into rows no +/// wider than `width`: a word that doesn't fit what is left of a row starts the next one, and only +/// a word wider than a whole row is split, between its clusters. +fn wrap_words(words: Vec>, width: f64) -> Vec { let blank = |s: &str| s.chars().all(char::is_whitespace); let mut lines: Vec = Vec::new(); let (mut line, mut used) = (String::new(), 0.0); @@ -123,7 +129,7 @@ fn thai_lines(t: &AddedText, para: &str, width: f64) -> Result, Edit } } lines.push(line); - Ok(lines) + lines } /// The shaped clusters `atoms` (text and advance) grouped into words: a word starts after spaces, @@ -420,3 +426,40 @@ fn copy_key(c: &Content) -> Content { pub fn text_width(t: &AddedText) -> f64 { t.text.split('\n').map(|line| shaped_width(t, line, unicode_bidi::get_base_direction(line) == unicode_bidi::Direction::Rtl)).fold(0.0, f64::max) } + +#[cfg(test)] +mod thai_wrap_tests { + use super::{thai_words, wrap_words}; + + /// One atom per character, each `advance` wide: what shaping hands over, without a Thai face. + fn atoms(text: &str, advance: f64) -> Vec<(String, f64)> { + text.chars().map(|c| (c.to_string(), advance)).collect() + } + + fn texts(words: &[Vec<(String, f64)>]) -> Vec { + words.iter().map(|w| w.iter().map(|(s, _)| s.as_str()).collect()).collect() + } + + #[test] + fn thai_atoms_group_into_dictionary_words_with_their_spaces() { + assert_eq!(texts(&thai_words(atoms("น้ำประปาสะอาด", 1.0))), ["น้ำ", "ประปา", "สะอาด"]); + assert_eq!(texts(&thai_words(atoms("สวัสดี ครับ", 1.0))), ["สวัสดี ", "ครับ"]); + // A break the dictionary finds inside one shaped cluster is not a place to end a row. + assert_eq!(texts(&thai_words(vec![("น้ำประปา".into(), 8.0)])), ["น้ำประปา"]); + assert!(thai_words(Vec::new()).is_empty()); + } + + #[test] + fn words_wrap_whole_and_only_an_overlong_word_is_split() { + let words = || vec![vec![("ab".into(), 2.0), (" ".into(), 1.0)], vec![("cd".into(), 2.0)]]; + assert_eq!(wrap_words(words(), 10.0), ["ab cd"]); + // The space that ends a row hangs past it, and the next row doesn't begin with it. + assert_eq!(wrap_words(words(), 3.0), ["ab", "cd"]); + let long = vec![vec![("a".into(), 2.0), ("b".into(), 2.0), ("c".into(), 2.0)]]; + assert_eq!(wrap_words(long, 3.0), ["a", "b", "c"]); + // Spaces that open a paragraph stay, and a word too wide for the rest of their row is split. + let opening = vec![vec![(" ".into(), 1.0)], vec![("a".into(), 2.0), ("b".into(), 2.0)]]; + assert_eq!(wrap_words(opening, 3.0), [" a", "b"]); + assert_eq!(wrap_words(Vec::new(), 3.0), [""]); + } +} From ea239829e4ef104256642c0790e425a033f51f43 Mon Sep 17 00:00:00 2001 From: bflatastic Date: Mon, 9 Nov 2026 01:18:37 +0700 Subject: [PATCH 14/92] Export tests: a two-column page and a quoted poem keep their lines apart Review asked for the near misses of joining wrapped lines. A short line, a numbered item and a symbol-font bullet were already covered; these add a line beside another column and a poem between paragraphs. --- crates/export/src/tests.rs | 42 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 42 insertions(+) diff --git a/crates/export/src/tests.rs b/crates/export/src/tests.rs index 2da7e054ac..cb520d002e 100644 --- a/crates/export/src/tests.rs +++ b/crates/export/src/tests.rs @@ -1098,6 +1098,48 @@ fn a_paragraph_breaks_where_its_line_does_not_run_on() { } } +/// Two columns of text, the right one set half a line lower: a full line in one column never runs +/// on into the line of the other just below it, though the right column's lines reach the page's +/// right edge and the left column's start at its left. +#[test] +fn a_line_never_runs_on_into_the_other_column() { + let left = ["The tenant keeps the rooms and the", "stairs clean and in good repair for", "the whole of the term of the lease"]; + let right = ["The landlord keeps the roof and the", "outer walls sound and dry for the", "whole of the term of the lease too"]; + let mut blocks = Vec::new(); + for (n, (l, r)) in left.iter().zip(right).enumerate() { + let y = 700.0 - 14.0 * n as f64; + blocks.push(line(l, 72.0, 290.0, y)); + blocks.push(line(r, 322.0, 540.0, y - 7.0)); + } + let paragraphs = word_texts(blocks); + for line in left.iter().chain(&right) { + assert!(paragraphs.iter().any(|p| p.contains(line)), "{line}: {paragraphs:?}"); + } + for p in ¶graphs { + assert!(!(left.iter().any(|l| p.contains(l)) && right.iter().any(|r| p.contains(r))), "{p:?} mixes the columns"); + } +} + +/// A poem quoted between paragraphs keeps its lines: they stop well short of the right edge the +/// prose around it reaches, so none of them is a full line that wraps. +#[test] +fn a_poem_keeps_its_lines() { + let poem = [ + "The kettle sings beside the door", + "the cat is curled upon the chair", + "and rain runs down the window glass", + "while evening settles on the stair", + ]; + let prose = "The tenant may read aloud in the shared garden on summer evenings, as in the verse below"; + let mut blocks = vec![line(prose, 72.0, 540.0, 720.0)]; + for (n, (t, end)) in poem.iter().zip([236.0, 230.0, 248.0, 232.0]).enumerate() { + blocks.push(line(t, 90.0, end, 696.0 - 14.0 * n as f64)); + } + let mut expected = vec![prose]; + expected.extend(poem); + assert_eq!(word_texts(blocks), expected); +} + #[test] fn list_items_are_told_from_text() { for item in ["• item", "\u{F0B7} item", "- item", "\u{2013} item", "* item", "1. Term", "12) Rent", "3.", "a) rooms", "а) стаи"] { From 70fd3ec969ead1972a8421b0c94a57171bded081 Mon Sep 17 00:00:00 2001 From: bflatastic Date: Mon, 9 Nov 2026 01:18:57 +0700 Subject: [PATCH 15/92] Parity: Word export names the poem-only page among the joins it gets wrong A page whose only text is short unpunctuated lines takes its longest line for the right edge, so those lines join into one paragraph. --- parity/acrobat-features.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index 139729d4d4..e9ac6f9a0e 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -7204,7 +7204,7 @@ status = "partial" commands = ["export.docx"] tools = ["doc_export_office"] evidence = ["crates/export/src/tests.rs::word_table_cell_widths_follow_the_columns_they_span", "crates/export/src/tests.rs::table_spans_do_not_consume_text_they_do_not_emit", "crates/edit/src/tests.rs::reading_keeps_dotted_leader_entries_whole_and_in_order", "crates/engine/src/tests.rs::exporting_office_keeps_dotted_leader_entries_as_paragraphs", "crates/export/src/tests.rs::word_does_not_invent_borders_for_aligned_invoice_fields", "crates/export/src/tests.rs::word_overlap_does_not_consume_invoice_body_space", "crates/export/src/tests.rs::word_keeps_a_page_with_a_clear_image_and_big_title_in_flow", "crates/export/src/tests.rs::word_rotated_pages_keep_separate_page_sizes_and_unique_shapes", "crates/engine/src/tests.rs::office_export_positions_follow_the_displayed_crop_rotation_and_units", "crates/automation/tests/it/automation.rs::exporting_an_invoice_to_word_keeps_the_watermark_out_of_flow", "crates/export/src/tests.rs::docx_is_a_word_package", "crates/export/src/tests.rs::text_colour_survives_every_format", "crates/engine/src/tests.rs::exporting_office_files_keeps_text_colour", "crates/export/src/tests.rs::grid_text_becomes_a_real_table_in_every_format", "crates/export/src/tests.rs::a_link_inside_a_sentence_is_not_a_table_column", "crates/export/src/tests.rs::sentences_that_run_on_into_links_are_not_a_table", "crates/export/src/tests.rs::a_superscript_beside_a_number_keeps_its_table", "crates/export/src/tests.rs::word_opens_text_with_control_characters_and_long_pages", "crates/engine/src/tests.rs::exporting_office_files_keeps_images", "crates/automation/tests/it/automation.rs::exporting_to_word_html_and_rtf", "crates/ui-egui/tests/it/compare.rs::export_to_word_html_and_rtf", "crates/export/src/tests.rs::docx_keeps_a_shared_page_background", "crates/edit/src/tests.rs::a_page_painted_first_with_a_full_page_fill_has_that_background", "crates/engine/src/tests.rs::exporting_to_word_keeps_the_page_background", "crates/export/src/tests.rs::an_italic_word_stays_in_its_sentence", "crates/export/src/tests.rs::a_sentence_keeps_its_reading_order", "crates/export/src/tests.rs::runs_keep_the_spaces_the_source_had_between_them", "crates/export/src/tests.rs::a_paragraph_is_a_heading_only_when_its_text_is", "crates/automation/tests/it/automation.rs::exporting_to_word_keeps_an_italic_word_inside_its_sentence", "crates/export/src/tests.rs::word_and_html_take_the_documents_language", "crates/engine/src/tests.rs::office_exports_take_the_documents_language", "crates/export/src/tests.rs::a_sentence_that_wraps_after_a_style_change_is_one_paragraph", "crates/export/src/tests.rs::a_slanted_word_that_leans_into_the_next_stays_in_its_line", "crates/export/src/tests.rs::a_paragraph_breaks_where_its_line_does_not_run_on", "crates/automation/tests/it/automation.rs::exporting_to_word_keeps_a_wrapped_sentence_in_one_paragraph", "crates/export/src/tests.rs::a_bulleted_list_is_paragraphs_not_a_table", "crates/export/src/tests.rs::a_bullet_is_never_a_table_cell", "crates/export/src/tests.rs::a_long_bulleted_list_keeps_every_bullet_with_its_item", "crates/export/src/tests.rs::a_dash_in_a_table_row_stays_a_cell", "crates/automation/tests/it/automation.rs::exporting_to_word_keeps_a_bulleted_list_as_paragraphs"] -notes = "Text in slots covered by a preceding table-cell span remains in paragraph flow instead of being discarded. Flowing Word document: paragraphs, Heading 1/2 from larger text, bold/italic, text colour (fill as sRGB; gray/CMYK converted naively, colour spaces via cs/sc read as black), detected tables with spans/empty cells (borderless, except single borders along the grid lines the page draws, #5149) (cell widths sum the grid columns they occupy, including cells after spans, verified in the XML; a row whose blocks run on into each other, such as a link or superscript inside a sentence, or overlap, as a paragraph's lines do, is text and not a table row, #2806), per-page sections/sizes, the page colour (a full-page opaque fill painted first, such as Edit a PDF ▸ Background) as the document background when every page shares one non-white colour (Word has no per-page background, so differing colours are dropped; #2992; checked in the XML only, not yet opened in Word), and positioned editable text/artwork only for pages with rotated text or text overlapped by an image or watermark-sized text, other pages flowing with inline images (#773); synthetic invoice regression; dotted-leader contents entries keep the title, dots and page number together in separate paragraphs (#1757); the emitted package validates with Open XML SDK 2.20 (Office 2010 schema). Word/LibreOffice rendering of the positioned output remains unverified. Pictures on positioned pages are DrawingML anchors behind the text (a VML rectangle with an image showed no picture, #1936; not yet opened in Word); a Word file embeds at most 1000 pictures and 512 MiB of them. The earlier flowing output was checked with macOS textutil and opened in Microsoft Word (Windows). Characters XML forbids are dropped; the page size stays within Word's 22 in. A block that continues another on its line (an italic case name, a link, a superscript) is a run in its own size, bold/italic and colour inside that paragraph, keeping the source's word space between them, rather than a paragraph of its own; a lead-in in bold no longer makes its whole line a heading (#4158); a slanted run whose box leans into the next word by up to 0.3 of its size still runs on into it. A paragraph whose last line reaches the right edge of the page's text runs on into the line just below it that starts at the same left edge in the same size, so a sentence the reading blocks split at a style change stays one paragraph across its lines; headings, list items, a line that ends a sentence, an indented line and one after paragraph space keep their break (#4158). A list whose bullets are blocks of their own (a bullet in a symbol font a tab stop before the item's text, as Word writes it, U+F0B7 and U+F0A7 included) is one paragraph per item, the bullet first, and not a borderless two-column table: a bullet is never a table cell, and a dash or middle dot counts as a bullet only when nothing starts left of it on its line. The PDF's language (`/Lang`, when it is a well formed BCP 47 tag) is the default language of the text, in the attribute Word keeps for its script (East Asian, right to left or other), so Word checks the spelling in it rather than in English (checked in the XML only, not yet opened in Word). Not yet: Word list numbering (an item is a plain paragraph that starts with its bullet), a bullet drawn in the standard Symbol font without a ToUnicode map (it never reaches export, though text extraction reads it), a paragraph whose rows the reading blocks already grouped past a style change (the exporter does not see where such a block's last row ends), a wrapped line that ends in a full stop or colon, a first-line indent, lines with more than half their font size of space between them (as 1.5 or double line spacing leaves), text in columns, exact font matching, vector rules other than a table grid, arbitrary paint-order/transparency, general exact layout fidelity." +notes = "Text in slots covered by a preceding table-cell span remains in paragraph flow instead of being discarded. Flowing Word document: paragraphs, Heading 1/2 from larger text, bold/italic, text colour (fill as sRGB; gray/CMYK converted naively, colour spaces via cs/sc read as black), detected tables with spans/empty cells (borderless, except single borders along the grid lines the page draws, #5149) (cell widths sum the grid columns they occupy, including cells after spans, verified in the XML; a row whose blocks run on into each other, such as a link or superscript inside a sentence, or overlap, as a paragraph's lines do, is text and not a table row, #2806), per-page sections/sizes, the page colour (a full-page opaque fill painted first, such as Edit a PDF ▸ Background) as the document background when every page shares one non-white colour (Word has no per-page background, so differing colours are dropped; #2992; checked in the XML only, not yet opened in Word), and positioned editable text/artwork only for pages with rotated text or text overlapped by an image or watermark-sized text, other pages flowing with inline images (#773); synthetic invoice regression; dotted-leader contents entries keep the title, dots and page number together in separate paragraphs (#1757); the emitted package validates with Open XML SDK 2.20 (Office 2010 schema). Word/LibreOffice rendering of the positioned output remains unverified. Pictures on positioned pages are DrawingML anchors behind the text (a VML rectangle with an image showed no picture, #1936; not yet opened in Word); a Word file embeds at most 1000 pictures and 512 MiB of them. The earlier flowing output was checked with macOS textutil and opened in Microsoft Word (Windows). Characters XML forbids are dropped; the page size stays within Word's 22 in. A block that continues another on its line (an italic case name, a link, a superscript) is a run in its own size, bold/italic and colour inside that paragraph, keeping the source's word space between them, rather than a paragraph of its own; a lead-in in bold no longer makes its whole line a heading (#4158); a slanted run whose box leans into the next word by up to 0.3 of its size still runs on into it. A paragraph whose last line reaches the right edge of the page's text runs on into the line just below it that starts at the same left edge in the same size, so a sentence the reading blocks split at a style change stays one paragraph across its lines; headings, list items, a line that ends a sentence, an indented line and one after paragraph space keep their break (#4158). A list whose bullets are blocks of their own (a bullet in a symbol font a tab stop before the item's text, as Word writes it, U+F0B7 and U+F0A7 included) is one paragraph per item, the bullet first, and not a borderless two-column table: a bullet is never a table cell, and a dash or middle dot counts as a bullet only when nothing starts left of it on its line. The PDF's language (`/Lang`, when it is a well formed BCP 47 tag) is the default language of the text, in the attribute Word keeps for its script (East Asian, right to left or other), so Word checks the spelling in it rather than in English (checked in the XML only, not yet opened in Word). Not yet: Word list numbering (an item is a plain paragraph that starts with its bullet), a bullet drawn in the standard Symbol font without a ToUnicode map (it never reaches export, though text extraction reads it), a paragraph whose rows the reading blocks already grouped past a style change (the exporter does not see where such a block's last row ends), a wrapped line that ends in a full stop or colon, a page whose only text is short lines that end without punctuation, such as a poem (its longest line counts as full, so the lines join), a first-line indent, lines with more than half their font size of space between them (as 1.5 or double line spacing leaves), text in columns, exact font matching, vector rules other than a table grid, arbitrary paint-order/transparency, general exact layout fidelity." [[feature]] id = "create.export-rtf" From e0f90d5f2d25cd06068180caa1407149c0d82193 Mon Sep 17 00:00:00 2001 From: BFlat Date: Mon, 9 Nov 2026 02:32:50 +0800 Subject: [PATCH 16/92] Edit a PDF: select all, copy, cut and paste page objects within and between documents (#6639) Refs #5989. Ctrl/Cmd+A selects every object on the page; copied objects paste as page content that draws the same, within a document or into another, with organize's bounded deep copy across documents. Headless: object_copy, object_paste, object_move all. --- crates/automation/README.md | 5 +- crates/automation/src/content.rs | 75 +++++- crates/automation/src/dispatch.rs | 2 + crates/automation/src/lib.rs | 3 + crates/automation/src/tools.rs | 14 +- crates/automation/tests/it/automation.rs | 69 +++++ crates/edit/src/images.rs | 18 ++ crates/edit/src/lib.rs | 4 +- crates/edit/src/objects.rs | 3 + crates/edit/src/objects/clip.rs | 283 ++++++++++++++++++++ crates/edit/src/objects/clip_tests.rs | 228 ++++++++++++++++ crates/edit/src/text.rs | 2 +- crates/edit/src/text/rewrite.rs | 42 ++- crates/edit/src/ungroup.rs | 47 +++- crates/engine/src/edit.rs | 13 + crates/engine/src/edit/labels.rs | 4 +- crates/engine/src/edit/run.rs | 4 + crates/engine/src/history.rs | 2 + crates/engine/src/lib.rs | 2 + crates/engine/src/object_clip.rs | 211 +++++++++++++++ crates/engine/src/object_clip_tests.rs | 257 ++++++++++++++++++ crates/engine/src/session/editing.rs | 2 +- crates/organize/src/import.rs | 58 ++++ crates/organize/src/lib.rs | 4 +- crates/ui-egui/src/app.rs | 4 +- crates/ui-egui/src/app/clipboard.rs | 114 ++++++++ crates/ui-egui/src/app/keys.rs | 5 + crates/ui-egui/src/editing.rs | 4 +- crates/ui-egui/src/lib.rs | 4 + crates/ui-egui/src/menus.rs | 8 + crates/ui-egui/src/tools/object_ui.rs | 109 ++++---- crates/ui-egui/tests/it/object_selection.rs | 111 ++++++++ docs/editing-multiple-objects.md | 44 ++- parity/acrobat-features.toml | 12 +- 34 files changed, 1664 insertions(+), 103 deletions(-) create mode 100644 crates/edit/src/objects/clip.rs create mode 100644 crates/edit/src/objects/clip_tests.rs create mode 100644 crates/engine/src/object_clip.rs create mode 100644 crates/engine/src/object_clip_tests.rs diff --git a/crates/automation/README.md b/crates/automation/README.md index 937dfecb7d..921c0354e3 100644 --- a/crates/automation/README.md +++ b/crates/automation/README.md @@ -146,7 +146,10 @@ claude mcp add pdfcraft -- pdfcraft-cli mcp --root `object_list {doc, page}` inventories recognised paragraphs, Image/Form artwork and added items with generation-scoped kind/index references. `object_move {doc, page, objects, offset, generation?}` moves 1–1000 references atomically in displayed points (right/down), preserving source glyph bytes -and fonts. A refusal changes nothing; success is one undo step. See +and fonts. A refusal changes nothing; success is one undo step. `all: true` takes every object on the +page instead, as ⌘A does. `object_copy {doc, page, objects | all, cut?}` copies them with every +resource they use and `object_paste {doc, page}` pastes them as page content that draws the same, +into the same or another open document, stepped as the app steps a paste. See [the selection and movement guide](../../docs/editing-multiple-objects.md) for limits and examples. ## Accessibility review record diff --git a/crates/automation/src/content.rs b/crates/automation/src/content.rs index 1f26dcc26a..a77ef98b8b 100644 --- a/crates/automation/src/content.rs +++ b/crates/automation/src/content.rs @@ -354,14 +354,32 @@ impl Automation { Ok(json!({"page":page+1,"generation":doc.edit_generation(),"count":items.len(),"objects":items})) } - pub(crate) fn object_move(&mut self, a: &Args) -> Result { + /// The references `objects` names (from object_list), or with `all` every object on `page`, + /// as ⌘A selects them in Edit a PDF; refused when `generation` is not the document's. + fn object_targets(&self, a: &Args, page: usize) -> Result> { use pdfcraft_engine::{MAX_MOVE_OBJECTS, ObjectKind, ObjectTarget}; - let page = self.page(a)?; + let doc = self.doc(a)?; + if let Some(g) = a.get("generation") { + let g = g.as_u64().ok_or_else(|| bad("generation must be a non-negative integer from object_list"))?; + if g != doc.edit_generation() { + return Err(failed("the document changed; call object_list again")); + } + } + if a.opt_bool("all")?.unwrap_or(false) { + if a.get("objects").is_some() { + return Err(bad("give objects or all, not both")); + } + let all: Vec<_> = doc.editable_objects(page).map_err(failed)?.into_iter().map(|o| o.target).collect(); + if all.is_empty() || all.len() > MAX_MOVE_OBJECTS { + return Err(failed(format!("page {} has {} objects; select 1–1000", page + 1, all.len()))); + } + return Ok(all); + } let rows = a .get("objects") .and_then(Value::as_array) .filter(|r| !r.is_empty() && r.len() <= MAX_MOVE_OBJECTS) - .ok_or_else(|| bad("objects must contain 1–1000 references from object_list"))?; + .ok_or_else(|| bad("objects must contain 1–1000 references from object_list (or pass all: true)"))?; let mut targets = Vec::with_capacity(rows.len()); for row in rows { let row = row.as_object().ok_or_else(|| bad("each object must contain kind and index"))?; @@ -382,6 +400,12 @@ impl Automation { .ok_or_else(|| bad("object index must be a positive integer from object_list"))?; targets.push(ObjectTarget { kind, index }); } + Ok(targets) + } + + pub(crate) fn object_move(&mut self, a: &Args) -> Result { + let page = self.page(a)?; + let targets = self.object_targets(a, page)?; let delta = a .get("offset") .and_then(Value::as_array) @@ -389,17 +413,46 @@ impl Automation { .and_then(|v| Some([v.first()?.as_f64()?, v.get(1)?.as_f64()?])) .filter(|v| v.iter().all(|n| n.is_finite() && n.abs() <= 1e9)) .ok_or_else(|| bad("offset must contain two finite numbers [dx, dy] in displayed points"))?; - let doc = self.doc(a)?; - if let Some(g) = a.get("generation") { - let g = g.as_u64().ok_or_else(|| bad("generation must be a non-negative integer from object_list"))?; - if g != doc.edit_generation() { - return Err(failed("the document changed; call object_list again before moving")); - } - } - let offset = doc.object_move_offset(page, delta).map_err(failed)?; + let offset = self.doc(a)?.object_move_offset(page, delta).map_err(failed)?; let count = targets.len(); let mut result = self.apply(a, Edit::MoveObjects { page, objects: targets, offset })?; result["moved"] = json!(count); Ok(result) } + + /// Edit ▸ Copy (or with `cut`, Cut) of objects selected together, kept for object_paste into + /// any open document. A cut then deletes them as one undo step. + pub(crate) fn object_copy(&mut self, a: &Args) -> Result { + let page = self.page(a)?; + let targets = self.object_targets(a, page)?; + let cut = a.opt_bool("cut")?.unwrap_or(false); + let doc = self.doc(a)?; + if cut && !doc.allows_modification() { + return Err(failed(pdfcraft_engine::EditError::NotPermitted("changes"))); + } + let clip = doc.copy_objects(page, &targets).map_err(failed)?; + let out = json!({ "copied": clip.objects().len(), "text": clip.text() }); + self.objects_clipboard = Some(pdfcraft_engine::ObjectClipboard::new(clip)); + if !cut { + return Ok(out); + } + let delete = Edit::delete_objects(page, targets).ok_or_else(|| failed("nothing to cut"))?; + let mut result = self.apply(a, delete)?; + result["copied"] = out["copied"].clone(); + result["text"] = out["text"].clone(); + Ok(result) + } + + /// Edit ▸ Paste of the objects object_copy copied: onto `page`, where they were, or on the + /// page they came from (and on each further paste onto a page) 18 points down and right. + pub(crate) fn object_paste(&mut self, a: &Args) -> Result { + let page = self.page(a)?; + let mut clipboard = self.objects_clipboard.take().ok_or_else(|| failed("copy objects first (object_copy)"))?; + let count = clipboard.clip().objects().len(); + let edit = self.doc(a).and_then(|doc| clipboard.paste(doc, page).map_err(failed)); + self.objects_clipboard = Some(clipboard); + let mut result = self.apply(a, edit?)?; + result["pasted"] = json!(count); + Ok(result) + } } diff --git a/crates/automation/src/dispatch.rs b/crates/automation/src/dispatch.rs index a838241705..642c4b5b11 100644 --- a/crates/automation/src/dispatch.rs +++ b/crates/automation/src/dispatch.rs @@ -738,6 +738,8 @@ impl Automation { "links_remove" => self.links_remove(&a)?, "object_list" => self.object_list(&a)?, "object_move" => self.object_move(&a)?, + "object_copy" => self.object_copy(&a)?, + "object_paste" => self.object_paste(&a)?, "content_list" => self.content_list(&a)?, "page_add_text" => self.page_add_text(&a)?, "page_add_image" => self.page_add_image(&a)?, diff --git a/crates/automation/src/lib.rs b/crates/automation/src/lib.rs index 7e4443d244..6132ac7416 100644 --- a/crates/automation/src/lib.rs +++ b/crates/automation/src/lib.rs @@ -101,6 +101,8 @@ pub struct Automation { /// Review sessions held for `ocr_words` (a guard and the pages read), by document. ocr_reviews: HashMap, next_ocr_review: u64, + /// What object_copy copied, for object_paste into any open document. + objects_clipboard: Option, } impl Default for Automation { @@ -121,6 +123,7 @@ impl Automation { evicted: HashSet::new(), ocr_reviews: HashMap::new(), next_ocr_review: 0, + objects_clipboard: None, } } diff --git a/crates/automation/src/tools.rs b/crates/automation/src/tools.rs index feface85e4..4cdd58f3c3 100644 --- a/crates/automation/src/tools.rs +++ b/crates/automation/src/tools.rs @@ -204,12 +204,22 @@ pub fn tools() -> Vec { }), &["doc", "page"])), t("object_list", "List editable objects", "List existing paragraphs, Image/Form artwork and added content once, with source kind/index references, displayed rectangles and document generation. Indexes change when content changes; use generation in object_move to reject stale references.") .ro().with(schema(json!({"doc":doc(),"page":{"type":"integer","minimum":1}}), &["doc","page"])), - t("object_move", "Move objects together", "Atomically translate 1–1000 mixed references from object_list by [dx, dy] in displayed points (right/down). Preserves glyph codes, fonts, image data and relative spacing; success is one undo step. Text used as a clipping path and vertical text are refused. A bad/stale reference refuses the entire move.") + t("object_move", "Move objects together", "Atomically translate 1–1000 mixed references from object_list (or with all: true every object on the page) by [dx, dy] in displayed points (right/down). Preserves glyph codes, fonts, image data and relative spacing; success is one undo step. Text used as a clipping path and vertical text are refused. A bad/stale reference refuses the entire move.") .with(schema(json!({"doc":doc(),"page":{"type":"integer","minimum":1}, "objects":{"type":"array","minItems":1,"maxItems":1000,"uniqueItems":true,"items":{ "type":"object","properties":{"kind":{"type":"string","enum":["added","text","image"]},"index":{"type":"integer","minimum":1}},"required":["kind","index"],"additionalProperties":false}}, + "all":{"type":"boolean","description":"Every object on the page, instead of objects."}, "offset":{"type":"array","items":{"type":"number"},"minItems":2,"maxItems":2}, - "generation":{"type":"integer","minimum":0,"description":"Use generation returned by object_list to reject a stale selection."}}), &["doc","page","objects","offset"])), + "generation":{"type":"integer","minimum":0,"description":"Use generation returned by object_list to reject a stale selection."}}), &["doc","page","offset"])), + t("object_copy", "Copy or cut objects together", "Edit ▸ Copy (cut: Cut) of 1–1000 mixed references from object_list, or with all: true every object on the page, as ⌘A then ⌘C select and copy them in Edit a PDF. The copy keeps their content operators and every resource they use (fonts, images, ExtGStates, colour spaces, patterns, shadings) and is kept for object_paste into any open document, also after this one changes or closes. cut then deletes them as one undo step. Refused when the document doesn't allow copying (cut: or changes), or when text used as a clipping path is drawn before them. Returns the count copied and their text.") + .with(schema(json!({"doc":doc(),"page":{"type":"integer","minimum":1}, + "objects":{"type":"array","minItems":1,"maxItems":1000,"uniqueItems":true,"items":{ + "type":"object","properties":{"kind":{"type":"string","enum":["added","text","image"]},"index":{"type":"integer","minimum":1}},"required":["kind","index"],"additionalProperties":false}}, + "all":{"type":"boolean","description":"Every object on the page, instead of objects."}, + "cut":{"type":"boolean","description":"Delete them after copying (one undo step)."}, + "generation":{"type":"integer","minimum":0,"description":"Use generation returned by object_list to reject a stale selection."}}), &["doc","page"])), + t("object_paste", "Paste copied objects", "Edit ▸ Paste of what object_copy copied, onto page of doc as page content that draws the same: where it was, or on the page it came from 18 points down and to the right, and 18 more for each further paste onto the same page; centred when that would leave it off the page. Resources join the page's under names that don't clash; into another document the fonts, images and other objects they use are copied (each once, sharing what is there; refused past 20,000 objects or 256 MB). Pasted content is untagged and in no layer. One undo step.") + .with(schema(json!({"doc":doc(),"page":{"type":"integer","minimum":1}}), &["doc","page"])), t("text_word_count", "Word count", "Count Unicode words and characters on selected pages (default all), with totals and per-page counts. Table/code exclusions use PDF structure tags, not visual guesses; unclassified text is retained. Image exclusion removes Figure-tagged and invisible/OCR text. Does not run OCR. Copy permission is required. Fails rather than returning a partial total when a page cannot be read.") .ro() .cmd("file.word_count") diff --git a/crates/automation/tests/it/automation.rs b/crates/automation/tests/it/automation.rs index 4d0620addf..e1c596d74a 100644 --- a/crates/automation/tests/it/automation.rs +++ b/crates/automation/tests/it/automation.rs @@ -9503,6 +9503,75 @@ fn mixed_objects_move_through_tools_with_generation_and_one_undo() { assert!(page_text(&mut a, reopened)[0].contains("Added label")); } +/// Edit a PDF's ⌘A, ⌘C / ⌘X and ⌘V headlessly: every object of a page copied, pasted into +/// another document and onto its own page (stepped down and right), and cut as one undo step. +#[test] +fn objects_copy_cut_and_paste_through_tools_within_and_across_documents() { + let dir = workdir("object-copy-paste"); + let mut a = auto(&dir); + let doc = ok(&mut a, "doc_open", json!({"path":"a.pdf"}))["doc"].as_u64().unwrap(); + let other = ok(&mut a, "doc_open", json!({"path":"b.pdf"}))["doc"].as_u64().unwrap(); + ok(&mut a, "page_add_text", json!({"doc":doc,"page":1,"text":"Added label","rect":[20,20,130,45]})); + assert!(matches!(a.call("object_paste", &json!({"doc":doc,"page":1})), Err(ToolError::Failed(_))), "nothing copied yet"); + let listed = ok(&mut a, "object_list", json!({"doc":doc,"page":1})); + assert_eq!(listed["count"], 2); + let copied = ok(&mut a, "object_copy", json!({"doc":doc,"page":1,"all":true,"generation":listed["generation"]})); + assert_eq!(copied["copied"], 2); + let text = copied["text"].as_str().unwrap(); + assert!(text.contains("Added label") && text.contains("Page 1"), "{text}"); + assert_eq!(ok(&mut a, "object_list", json!({"doc":doc,"page":1})), listed, "a copy changes nothing"); + + // Into another document: the same objects, where they were, as its own page text. + let before = ok(&mut a, "object_list", json!({"doc":other,"page":2})); + assert_eq!(ok(&mut a, "object_paste", json!({"doc":other,"page":2}))["pasted"], 2); + let pasted = ok(&mut a, "object_list", json!({"doc":other,"page":2})); + assert_eq!(pasted["count"], 3); + let rect_of = + |list: &Value, text: &str| list["objects"].as_array().unwrap().iter().find(|o| o["text"] == text).map(|o| o["rect"].clone()).unwrap(); + assert_eq!(rect_of(&pasted, "Page 1"), rect_of(&listed, "Page 1")); + // The added text box comes as the page's own text, drawn inside the box it had. + let (label, box_) = (rect_of(&pasted, "Added label"), rect_of(&listed, "Added label")); + assert!((0..2).all(|i| label[i].as_f64() >= box_[i].as_f64()) && (2..4).all(|i| label[i].as_f64() <= box_[i].as_f64()), "{label} in {box_}"); + assert!(page_text(&mut a, other)[1].contains("Added label")); + ok(&mut a, "doc_save", json!({"doc":other,"path":"pasted.pdf"})); + let reopened = ok(&mut a, "doc_open", json!({"path":"pasted.pdf"}))["doc"].as_u64().unwrap(); + assert!(page_text(&mut a, reopened)[1].contains("Page 1")); + ok(&mut a, "edit_undo", json!({"doc":other})); + assert_eq!(ok(&mut a, "object_list", json!({"doc":other,"page":2}))["objects"], before["objects"], "one undo step"); + + // Onto its own page: 18 points down and to the right. + ok(&mut a, "object_paste", json!({"doc":doc,"page":1})); + let both = ok(&mut a, "object_list", json!({"doc":doc,"page":1})); + assert_eq!(both["count"], 4); + let original = rect_of(&listed, "Page 1"); + let moved = + both["objects"].as_array().unwrap().iter().filter(|o| o["text"] == "Page 1").map(|o| o["rect"].clone()).find(|r| *r != original).unwrap(); + for i in 0..4 { + assert!((moved[i].as_f64().unwrap() - original[i].as_f64().unwrap() - 18.0).abs() < 0.02, "{moved} {original}"); + } + + // Cut: copied, then deleted as one undo step. + let cut = ok(&mut a, "object_copy", json!({"doc":doc,"page":1,"objects":[{"kind":"text","index":1}],"cut":true})); + assert_eq!(cut["copied"], 1); + assert_eq!(ok(&mut a, "object_list", json!({"doc":doc,"page":1}))["count"], 3); + ok(&mut a, "edit_undo", json!({"doc":doc})); + assert_eq!(ok(&mut a, "object_list", json!({"doc":doc,"page":1}))["objects"], both["objects"]); + + for args in [ + json!({"doc":doc,"page":1,"all":true,"objects":[{"kind":"text","index":1}]}), + json!({"doc":doc,"page":1}), + json!({"doc":doc,"page":1,"objects":[{"kind":"text","index":99}]}), + json!({"doc":doc,"page":9,"all":true}), + ] { + assert!(a.call("object_copy", &args).is_err(), "{args}"); + } + assert!( + matches!(a.call("object_copy", &json!({"doc":doc,"page":1,"all":true,"generation":listed["generation"]})), Err(ToolError::Failed(_))), + "stale" + ); + assert!(a.call("object_paste", &json!({"doc":doc,"page":9})).is_err()); +} + #[test] fn object_move_rejects_invalid_nested_references_without_any_change() { let dir = workdir("object-move-refusals"); diff --git a/crates/edit/src/images.rs b/crates/edit/src/images.rs index 8ac77c9deb..c4a39bf689 100644 --- a/crates/edit/src/images.rs +++ b/crates/edit/src/images.rs @@ -1884,6 +1884,24 @@ fn crop_image_impl( Ok(()) } +/// The `Do` of each image `indexes` (from [`page_images`]) on `page`, as an index among the +/// page's joined operators: what Copy takes of them. +pub(crate) fn image_ops(doc: &Document, page: usize, indexes: &[usize]) -> Result, EditError> { + if indexes.is_empty() { + return Ok(Vec::new()); + } + let images = page_images(doc, page)?; + indexes + .iter() + .map(|i| { + images + .get(*i) + .map(|image| image.op) + .ok_or_else(|| EditError::Invalid("a selected image no longer exists; select the objects again".into())) + }) + .collect() +} + /// Plan all transforms before changing a stream; indexes refer to the original inventory. /// Splice only the chosen Do operators and preserve all other bytes and stream dictionaries. pub(crate) fn translate_images(doc: &mut Document, page: usize, indexes: &[usize], offset: [f64; 2]) -> Result<(), EditError> { diff --git a/crates/edit/src/lib.rs b/crates/edit/src/lib.rs index da51839047..39eeb1e5dc 100644 --- a/crates/edit/src/lib.rs +++ b/crates/edit/src/lib.rs @@ -46,7 +46,9 @@ pub use background::page_background; mod rules; pub use rules::page_rules; pub mod objects; -pub use objects::{EditableObject, MAX_MOVE_OBJECTS, ObjectKind, ObjectTarget, editable_objects, move_objects}; +pub use objects::{ + CopiedObjects, EditableObject, MAX_MOVE_OBJECTS, ObjectKind, ObjectTarget, copy_objects, editable_objects, move_objects, paste_objects, +}; mod flatten; pub use flatten::{flatten, flatten_fill_sign}; diff --git a/crates/edit/src/objects.rs b/crates/edit/src/objects.rs index 6a64e3cfce..5b1a36caef 100644 --- a/crates/edit/src/objects.rs +++ b/crates/edit/src/objects.rs @@ -9,6 +9,9 @@ use pdfcraft_cos::{DecodeBudget, Dict, Document, Object}; use crate::text::ContentStream; use crate::{Content, EditError}; +mod clip; +pub use clip::{CopiedObjects, copy_objects, paste_objects}; + pub const MAX_MOVE_OBJECTS: usize = 1_000; const MAX_PAGE_OBJECTS: usize = 10_000; const MAX_COORDINATE: f64 = 1e9; diff --git a/crates/edit/src/objects/clip.rs b/crates/edit/src/objects/clip.rs new file mode 100644 index 0000000000..4402667b76 --- /dev/null +++ b/crates/edit/src/objects/clip.rs @@ -0,0 +1,283 @@ +//! Edit a PDF ▸ Copy, Cut and Paste of objects selected together (#5989): paragraphs, images, +//! Form artwork and added content, pasted as page content that draws the same. +//! +//! A copy is a content stream of its own that draws the objects and nothing else, in the page's +//! user space, with the resources it names. It replays every state operator of the page up to +//! the last object (transformations, clips, colours, ExtGStates, text state), so each object is +//! drawn in the state it had, and leaves out what paints anything else: other text, images, +//! shadings and paths (a path that clips is kept, without its paint). Each copied text-showing +//! operator is placed by its own text matrix, which is where its glyphs start, so the text +//! positioning between operators is left out. Marked content is left out too: a paste is +//! untagged and in no layer. +//! +//! A paste draws that stream on top of the page through Ungroup's merge ([`ungroup_ops`]): the +//! copy is read as Form artwork whose resources join the page's under names that don't clash, +//! moved by the offset, with the patterns it paints moved along. Its resources must already be +//! the target document's: the engine copies them over first when the copy came from another +//! document. + +use std::collections::{BTreeMap, BTreeSet, HashMap}; + +use pdfcraft_content::{Matrix, Op, serialize_ops}; +use pdfcraft_cos::{Dict, Document, ObjRef, Object, Stream}; + +use super::{EditableObject, MAX_COORDINATE, MAX_MOVE_OBJECTS, ObjectKind, ObjectTarget, checked_streams, editable_objects, valid_rect}; +use crate::EditError; +use crate::ungroup::{CATEGORIES, DEFAULT_SPACES, MAX_FORM_BYTES, Ungrouped, page_blend_space, resource_operand, ungroup_ops}; + +/// How deep the copied graphics state may nest (`q`), as the page's own may. +const MAX_NESTING: usize = 1024; + +/// Objects copied from one page: what [`paste_objects`] draws again. +#[derive(Clone, Debug, PartialEq)] +pub struct CopiedObjects { + /// A content stream that draws the objects alone, in the source page's user space: its `q` + /// and `Q` balance and its text objects close. + pub content: Vec, + /// The resources `content` names, as the source page has them: references are into the + /// document the copy came from. + pub resources: Dict, + /// The union of the objects' boxes (user space of the source page). + pub rect: [f64; 4], + /// The objects as [`editable_objects`] listed them, in inventory order. + pub objects: Vec, +} + +impl CopiedObjects { + /// The text of the copied paragraphs and text boxes, one per line: what the system clipboard + /// gets for other applications. + pub fn text(&self) -> String { + self.objects.iter().filter_map(|o| o.text.as_deref()).filter(|t| !t.is_empty()).collect::>().join("\n") + } +} + +/// Copy `targets` (references from one [`editable_objects`] inventory of `page`). The document +/// is not changed. +pub fn copy_objects(doc: &Document, page: usize, targets: &[ObjectTarget]) -> Result { + if targets.is_empty() || targets.len() > MAX_MOVE_OBJECTS { + return Err(EditError::Invalid("select between 1 and 1000 objects to copy".into())); + } + let mut inventory: BTreeMap<_, _> = editable_objects(doc, page)?.into_iter().map(|o| (o.target, o)).collect(); + let mut objects = Vec::with_capacity(targets.len()); + for target in targets.iter().collect::>() { + objects + .push(inventory.remove(target).ok_or_else(|| EditError::Invalid("a selected object no longer exists; select the objects again".into()))?); + } + if objects.len() != targets.len() { + return Err(EditError::Invalid("an object was selected more than once".into())); + } + let indexes = |kind| objects.iter().filter(|o| o.target.kind == kind).map(|o| o.target.index).collect::>(); + let streams = checked_streams(doc, page)?; + let pieces = crate::text::joined(&streams); + let ops = pieces.parse().ops; + let mut keep = crate::text::paragraph_ops(doc, page, &indexes(ObjectKind::Text), false)?; + keep.extend(crate::images::image_ops(doc, page, &indexes(ObjectKind::Image))?); + // Added content owns its stream: everything that stream paints is the item. + let added: Vec<_> = crate::list_added(doc).into_iter().filter(|a| a.page == page).collect(); + let owned = indexes(ObjectKind::Added) + .into_iter() + .map(|i| { + added.get(i).map(|a| a.obj).ok_or_else(|| EditError::Invalid("a selected added item no longer exists; select the objects again".into())) + }) + .collect::, _>>()?; + if !owned.is_empty() { + for (i, op) in ops.iter().enumerate() { + let stream = streams.get(pieces.pieces_of(op).0).and_then(|s| s.entry.as_ref()); + if paints(op) && stream.is_some_and(|r| owned.contains(&r)) { + keep.insert(i); + } + } + } + let p = pdfcraft_model::pages(doc).into_iter().nth(page).ok_or(EditError::NoSuchPage(page))?; + let res = p.dict.get(b"Resources").map(|r| doc.resolve(r)).and_then(|r| r.as_dict().cloned()).unwrap_or_default(); + let shows = crate::text::show_starts(doc, &ops, &res)?; + let drawn = replay(&ops, &keep, &shows)?; + let rect = objects.iter().map(|o| o.rect).reduce(|a, b| [a[0].min(b[0]), a[1].min(b[1]), a[2].max(b[2]), a[3].max(b[3])]).unwrap_or_default(); + Ok(CopiedObjects { resources: used_resources(doc, &res, &drawn), content: serialize_ops(&drawn), rect, objects }) +} + +/// Whether `op` paints (rather than sets state or builds a path). +fn paints(op: &Op) -> bool { + matches!(op.op.as_slice(), b"S" | b"s" | b"f" | b"F" | b"f*" | b"B" | b"B*" | b"b" | b"b*" | b"Tj" | b"TJ" | b"'" | b"\"" | b"Do" | b"sh" | b"BI") +} + +/// The page's operators up to the last of `keep`, painting only `keep` (see the module docs). +/// `shows`: where each text-showing operator starts ([`crate::text::show_starts`]). +fn replay(ops: &[Op], keep: &BTreeSet, shows: &HashMap) -> Result, EditError> { + let Some(&last) = keep.last() else { return Err(EditError::Invalid("the selected objects draw nothing to copy".into())) }; + let mut out = Vec::new(); + let (mut depth, mut in_text) = (0usize, false); + let (mut path, mut clips) = (Vec::new(), false); + for (i, op) in ops.iter().enumerate().take(last.saturating_add(1)) { + let kept = keep.contains(&i); + match op.op.as_slice() { + b"q" => { + if depth >= MAX_NESTING { + return Err(EditError::Invalid("the page graphics state is nested too deeply".into())); + } + depth += 1; + out.push(op.clone()); + } + // An unmatched `Q` restores nothing; the renderer ignores it too. + b"Q" if depth > 0 => { + depth -= 1; + out.push(op.clone()); + } + b"BT" if !in_text => { + in_text = true; + out.push(op.clone()); + } + b"ET" if in_text => { + in_text = false; + out.push(op.clone()); + } + b"Q" | b"BT" | b"ET" => {} + b"m" | b"l" | b"c" | b"v" | b"y" | b"re" | b"h" => path.push(op.clone()), + b"W" | b"W*" => { + clips = true; + path.push(op.clone()); + } + b"n" | b"S" | b"s" | b"f" | b"F" | b"f*" | b"B" | b"B*" | b"b" | b"b*" => { + if kept || clips { + out.append(&mut path); + out.push(if kept { op.clone() } else { Op::new("n", Vec::new()) }); + } + path.clear(); + clips = false; + } + b"Tj" | b"TJ" | b"'" | b"\"" => { + // `"` sets the spacing for what follows, shown or not. + if op.is("\"") { + for (name, value) in ["Tw", "Tc"].into_iter().zip(op.operands.iter().take(2)) { + if value.as_f64().is_some() { + out.push(Op::new(name, vec![value.clone()])); + } + } + } + match shows.get(&i) { + Some((tm, _)) if kept => { + out.push(Op::new("Tm", tm.0.map(Object::Real).to_vec())); + out.push(if op.is("TJ") { op.clone() } else { Op::new("Tj", op.operands.last().cloned().into_iter().collect()) }); + } + // Its glyphs clip what follows, which leaving them out would not. + Some((_, mode)) if *mode >= 4 => { + return Err(EditError::Invalid( + "text used as a clipping path is drawn before the selected objects, so they can't be copied".into(), + )); + } + _ => {} + } + } + // Each copied show sets its own text matrix; `TD` also sets the leading. + b"TD" => { + if let Some([_, y]) = op.nums::<2>() { + out.push(Op::new("TL", vec![Object::Real(-y)])); + } + } + b"Td" | b"Tm" | b"T*" | b"BDC" | b"BMC" | b"EMC" | b"MP" | b"DP" => {} + b"Do" | b"sh" | b"BI" => { + if kept { + out.push(op.clone()); + } + } + _ => out.push(op.clone()), + } + } + if in_text { + out.push(Op::new("ET", Vec::new())); + } + out.extend(std::iter::repeat_with(|| Op::new("Q", Vec::new())).take(depth)); + Ok(out) +} + +/// The entries of the page's resources `res` that `ops` name, and the default colour spaces, +/// which remap the device colours they paint (ISO 32000-2 §8.6.5.6). +fn used_resources(doc: &Document, res: &Dict, ops: &[Op]) -> Dict { + let mut used: BTreeMap<&[u8], BTreeSet<&[u8]>> = BTreeMap::new(); + for op in ops { + if let Ok(Some((cat, i))) = resource_operand(op) + && let Some(Object::Name(name)) = op.operands.get(i) + { + used.entry(cat).or_default().insert(name.as_slice()); + } + } + let mut out = Dict::new(); + for cat in CATEGORIES { + let Some(all) = res.get(cat).map(|d| doc.resolve(d)).and_then(|d| d.as_dict().cloned()) else { continue }; + let mut mine = Dict::new(); + for (key, value) in all.iter() { + if used.get(cat).is_some_and(|u| u.contains(key.as_slice())) || (cat == b"ColorSpace" && DEFAULT_SPACES.contains(&key.as_slice())) { + mine.set(key.clone(), value.clone()); + } + } + if !mine.is_empty() { + out.set(cat.to_vec(), Object::Dict(mine)); + } + } + out +} + +/// Paste `copied` on top of `page`, moved by `offset` (user space), as one change: its content +/// and the page's resources merged with its own. Its resources must be this document's. +pub fn paste_objects(doc: &mut Document, page: usize, copied: &CopiedObjects, offset: [f64; 2]) -> Result<(), EditError> { + let r = copied.rect; + if !offset.iter().all(|v| v.is_finite() && v.abs() <= MAX_COORDINATE) + || !valid_rect([r[0] + offset[0], r[1] + offset[1], r[2] + offset[0], r[3] + offset[1]]) + { + return Err(EditError::Invalid("the paste position is out of range".into())); + } + let p = pdfcraft_model::pages(doc).into_iter().nth(page).ok_or(EditError::NoSuchPage(page))?; + crate::check_on_top(doc, &p)?; + let res = p.dict.get(b"Resources").map(|r| doc.resolve(r)).and_then(|r| r.as_dict().cloned()).unwrap_or_default(); + let mut xobjects = res.get(b"XObject").map(|x| doc.resolve(x)).and_then(|x| x.as_dict().cloned()).unwrap_or_default(); + let name = (1..=xobjects.len().saturating_add(1)) + .map(|n| format!("PCPaste{n}").into_bytes()) + .find(|n| xobjects.get(n).is_none()) + .ok_or_else(|| EditError::Invalid("the page has too many resources".into()))?; + // The copy as Form artwork, to draw inline as Ungroup does: its resources merge into the + // page's, and patterns move with it. The form itself is never added to the document. + let number = |v: f64| Object::Real(v); + let mut form = Dict::new(); + form.set(b"Type".to_vec(), Object::name("XObject")); + form.set(b"Subtype".to_vec(), Object::name("Form")); + // Nothing of the copy is clipped: its glyphs and strokes may reach past its objects' boxes. + form.set(b"BBox".to_vec(), Object::Array([-MAX_COORDINATE, -MAX_COORDINATE, MAX_COORDINATE, MAX_COORDINATE].map(number).to_vec())); + form.set(b"Matrix".to_vec(), Object::Array(Matrix::translate(offset[0], offset[1]).0.map(number).to_vec())); + form.set(b"Resources".to_vec(), Object::Dict(copied.resources.clone())); + xobjects.set(name.clone(), Object::Stream(Stream::from_raw(form, copied.content.clone()))); + let mut with_copy = res; + with_copy.set(b"XObject".to_vec(), Object::Dict(xobjects)); + let space = page_blend_space(doc, &p.dict); + // Drawn by a `Do` of its own: not translucent, and in no path or text object. + let draw = [Op::new("Do", vec![Object::Name(name.clone())])]; + let mut budget = pdfcraft_cos::DecodeBudget::new(MAX_FORM_BYTES, 0); + let Ungrouped { ops, resources: mut merged, patterns, .. } = + ungroup_ops(doc, &with_copy, &draw, 0, space.as_ref(), Matrix::translate(offset[0], offset[1]), &mut budget).map_err(|r| match r.error { + EditError::Invalid(why) => { + EditError::Invalid(why.replacen("this artwork can't be ungrouped", "the copied objects can't be pasted here", 1)) + } + other => other, + })?; + let mut work = doc.clone(); + if let Some(mut x) = merged.get(b"XObject").and_then(Object::as_dict).cloned() { + x.remove(&name); + merged.set(b"XObject".to_vec(), Object::Dict(x)); + } + if !patterns.is_empty() { + let mut table = merged.get(b"Pattern").map(|x| work.resolve(x)).and_then(|x| x.as_dict().cloned()).unwrap_or_default(); + for (key, pattern) in patterns { + table.set(key, Object::Ref(work.add(pattern))); + } + merged.set(b"Pattern".to_vec(), Object::Dict(table)); + } + work.update_dict(p.obj, |d| d.set(b"Resources".to_vec(), Object::Dict(merged)))?; + crate::place_tagged(&mut work, &p, "Pasted", serialize_ops(&ops), false)?; + // Keep the page's objects selectable together, as a move does. + editable_objects(&work, page)?; + *doc = work; + Ok(()) +} + +#[cfg(test)] +#[path = "clip_tests.rs"] +mod tests; diff --git a/crates/edit/src/objects/clip_tests.rs b/crates/edit/src/objects/clip_tests.rs new file mode 100644 index 0000000000..980bb9ba4c --- /dev/null +++ b/crates/edit/src/objects/clip_tests.rs @@ -0,0 +1,228 @@ +use std::sync::Arc; + +use pdfcraft_cos::{Document, SaveOptions, write_incremental}; + +use super::*; + +/// A document of `objs` (object 1 the catalog), numbered in order. +fn pdf(objs: &[String]) -> Document { + let mut out = b"%PDF-1.7\n".to_vec(); + let mut offs = Vec::new(); + for (i, o) in objs.iter().enumerate() { + offs.push(out.len()); + out.extend_from_slice(format!("{} 0 obj\n{o}\nendobj\n", i + 1).as_bytes()); + } + let x = out.len(); + out.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objs.len() + 1).as_bytes()); + for o in offs { + out.extend_from_slice(format!("{o:010} 00000 n \n").as_bytes()); + } + out.extend_from_slice(format!("trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{x}\n%%EOF\n", objs.len() + 1).as_bytes()); + Document::open(Arc::new(out)).unwrap() +} + +fn stream(content: &str) -> String { + format!("<< /Length {} >>\nstream\n{content}\nendstream", content.len()) +} + +/// Two 300 × 300 pages. The first draws `source` with Helvetica as /F1, a 4 × 2 RGB image /Im0, +/// a half-opaque ExtGState /GS0 and Courier as /F2; the second draws `target` with Courier +/// under the same name /F1, so a paste from the first must rename the font it brings. +fn two_pages(source: &str, target: &str) -> Document { + pdf(&[ + "<< /Type /Catalog /Pages 2 0 R >>".into(), + "<< /Type /Pages /Kids [3 0 R 4 0 R] /Count 2 /MediaBox [0 0 300 300] >>".into(), + "<< /Type /Page /Parent 2 0 R /Contents 5 0 R /Resources << /Font << /F1 7 0 R /F2 8 0 R >> /XObject << /Im0 9 0 R >> /ExtGState << /GS0 << /ca 0.5 /CA 0.5 >> >> >> >>" + .into(), + "<< /Type /Page /Parent 2 0 R /Contents 6 0 R /Resources << /Font << /F1 8 0 R >> >> >>".into(), + stream(source), + stream(target), + "<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding /WinAnsiEncoding >>".into(), + "<< /Type /Font /Subtype /Type1 /BaseFont /Courier /Encoding /WinAnsiEncoding >>".into(), + "<< /Type /XObject /Subtype /Image /Width 4 /Height 2 /ColorSpace /DeviceRGB /BitsPerComponent 8 /Length 24 >>\nstream\n\u{7f}\u{0}\u{0}\u{0}\u{7f}\u{0}\u{0}\u{0}\u{7f}\u{7f}\u{7f}\u{0}\u{0}\u{7f}\u{7f}\u{7f}\u{0}\u{7f}@@@\u{0}\u{0}\u{0}\nendstream".into(), + ]) +} + +/// What the page state, the copied paragraph and picture and the objects left out are drawn with. +const SOURCE: &str = "q 1 0 0 1 10 10 cm 0 0 270 270 re W n 0 0 1 rg /GS0 gs \ + BT /F2 10 Tf 200 20 Td (Skip) Tj ET \ + BT /F1 24 Tf 1.5 Tc 20 170 Td (Copied) Tj 0 -30 Td (Too) Tj ET \ + q 1 0 0 rg 150 230 100 40 re f Q \ + q 80 0 0 40 20 60 cm /Im0 Do Q Q \ + BT /F1 12 Tf 200 250 Td (After) Tj ET"; + +fn target(doc: &Document, page: usize, want: impl Fn(&EditableObject) -> bool) -> ObjectTarget { + editable_objects(doc, page).unwrap().into_iter().find(|o| want(o)).unwrap().target +} + +fn render(doc: &Document, page: usize) -> pdfcraft_render::RenderedPage { + let bytes = write_incremental(doc, &SaveOptions::default()).unwrap(); + let mut renderer = pdfcraft_render::PageRenderer::new(Arc::new(bytes), pdfcraft_render::RenderConfig::default()); + renderer.render(pdfcraft_render::RenderRequest { page, kind: pdfcraft_render::RequestKind::Pixels, scale: 1.0, ..Default::default() }) +} + +/// The pixels of `page` in user-space box `r` (a page 300 high at scale 1), shifted by `by`. +fn pixels(page: &pdfcraft_render::RenderedPage, r: [f64; 4], by: [f64; 2]) -> Vec { + let (x0, x1) = ((r[0] + by[0]) as u32, (r[2] + by[0]) as u32); + let (y0, y1) = ((300.0 - r[3] - by[1]) as u32, (300.0 - r[1] - by[1]) as u32); + let mut out = Vec::new(); + for y in y0..y1 { + for x in x0..x1 { + let i = ((y * page.width + x) * 4) as usize; + out.extend_from_slice(&page.rgba[i..i + 4]); + } + } + out +} + +/// The paragraph "Copied" / "Too" (one or two of them, as the lines group) and the picture. +fn copied_paragraph_and_picture(doc: &Document) -> CopiedObjects { + let targets: Vec<_> = editable_objects(doc, 0) + .unwrap() + .into_iter() + .filter(|o| o.target.kind == ObjectKind::Image || o.text.as_deref().is_some_and(|t| t.contains("Copied") || t.contains("Too"))) + .map(|o| o.target) + .collect(); + copy_objects(doc, 0, &targets).unwrap() +} + +#[test] +fn a_copy_takes_the_selected_objects_their_state_and_only_the_resources_they_use() { + let doc = two_pages(SOURCE, ""); + let copied = copied_paragraph_and_picture(&doc); + let content = String::from_utf8_lossy(&copied.content).into_owned(); + assert!(content.contains("(Copied)") && content.contains("(Too)") && content.contains("/Im0 Do"), "{content}"); + // The page's clip and its colour and transparency still apply; nothing else is painted. + assert!(content.contains("W\nn") && content.contains("/GS0 gs") && content.contains("rg"), "{content}"); + assert!(!content.contains("Skip") && !content.contains("After") && !content.contains("\nf\n"), "{content}"); + let names = |cat: &[u8]| copied.resources.get(cat).and_then(Object::as_dict).map(|d| d.iter().map(|(k, _)| k.clone()).collect::>()); + assert_eq!(names(b"Font"), Some(vec![b"F1".to_vec(), b"F2".to_vec()]), "the font of the skipped text stays selected for what follows"); + assert_eq!(names(b"XObject"), Some(vec![b"Im0".to_vec()])); + assert_eq!(names(b"ExtGState"), Some(vec![b"GS0".to_vec()])); + assert!(copied.text().contains("Copied") && copied.text().contains("Too") && !copied.text().contains("Skip"), "{}", copied.text()); + assert_eq!(pdfcraft_content::parse(&copied.content).ops.iter().filter(|o| o.is("q")).count(), 3, "balanced: {content}"); +} + +#[test] +fn pasted_objects_draw_the_same_on_another_page_under_renamed_resources() { + let mut doc = two_pages(SOURCE, "BT /F1 10 Tf 5 5 Td (x) Tj ET"); + let copied = copied_paragraph_and_picture(&doc); + paste_objects(&mut doc, 1, &copied, [0.0, 0.0]).unwrap(); + let (source, pasted) = (render(&doc, 0), render(&doc, 1)); + let area = [copied.rect[0] - 4.0, copied.rect[1] - 4.0, copied.rect[2] + 4.0, copied.rect[3] + 4.0]; + let (before, after) = (pixels(&source, area, [0.0, 0.0]), pixels(&pasted, area, [0.0, 0.0])); + assert!(before.chunks(4).any(|p| p != [255, 255, 255, 255]), "the copied objects are drawn"); + assert_eq!(before, after, "the paste draws the same pixels"); + let red = pixels(&pasted, [160.0, 240.0, 260.0, 280.0], [0.0, 0.0]); + assert!(red.chunks(4).all(|p| p == [255, 255, 255, 255]), "what wasn't selected isn't pasted"); + // Its own /F1 (Courier) is left alone; the copy's Helvetica comes under another name. + let fonts = + pdfcraft_model::pages(&doc)[1].dict.get(b"Resources").map(|r| doc.resolve(r)).unwrap().as_dict().unwrap().get(b"Font").cloned().unwrap(); + let fonts = doc.resolve(&fonts).as_dict().cloned().unwrap(); + assert_eq!(fonts.get(b"F1"), Some(&Object::Ref(pdfcraft_cos::ObjRef::new(8, 0)))); + assert!(fonts.iter().any(|(k, v)| k != b"F1" && *v == Object::Ref(pdfcraft_cos::ObjRef::new(7, 0))), "{fonts:?}"); + let texts: Vec<_> = editable_objects(&doc, 1).unwrap().into_iter().filter_map(|o| o.text).collect(); + assert!(texts.iter().any(|t| t.contains("Copied")) && !texts.iter().any(|t| t.contains("Skip")), "{texts:?}"); + assert_eq!(crate::images::page_images(&doc, 1).unwrap().len(), 1); +} + +#[test] +fn a_paste_onto_its_own_page_moves_by_the_offset_and_draws_the_same() { + let mut doc = two_pages(SOURCE, ""); + let copied = copied_paragraph_and_picture(&doc); + let before = render(&doc, 0); + paste_objects(&mut doc, 0, &copied, [150.0, 0.0]).unwrap(); + let after = render(&doc, 0); + let area = [copied.rect[0] - 2.0, copied.rect[1] - 2.0, copied.rect[2] + 2.0, copied.rect[3] + 2.0]; + assert!(area[2] + 150.0 < 300.0, "{area:?}"); + // The copy (and the clip it is drawn in) lands 150 pt to the right, the original stays. + // Antialiasing elsewhere on the page may round an edge pixel one step differently. + let (moved, original) = (pixels(&after, area, [150.0, 0.0]), pixels(&before, area, [0.0, 0.0])); + assert_eq!(moved.len(), original.len()); + assert!(original.iter().any(|v| *v != 255), "the copied objects are drawn"); + assert!(moved.iter().zip(&original).all(|(a, b)| a.abs_diff(*b) <= 2), "the paste draws the same pixels, moved"); + assert_eq!(pixels(&after, area, [0.0, 0.0]), original); + // Within the document and the page, the resources are the page's own, under their names. + let res = pdfcraft_model::pages(&doc)[0].dict.get(b"Resources").map(|r| doc.resolve(r)).unwrap(); + let fonts = res.as_dict().unwrap().get(b"Font").and_then(Object::as_dict).cloned().unwrap(); + assert_eq!(fonts.len(), 2, "{fonts:?}"); + assert_eq!(editable_objects(&doc, 0).unwrap().iter().filter(|o| o.target.kind == ObjectKind::Image).count(), 2); +} + +/// Artwork without resources of its own takes the names of the page drawing it. Pasted onto a +/// page whose names differ, artwork that names nothing still draws the same; artwork that names +/// a font would find another one, so the paste is refused. +#[test] +fn artwork_inheriting_resources_pastes_only_when_it_names_none() { + for (art, pastes) in [("0 0 1 rg 0 0 60 30 re f", true), ("BT /F1 9 Tf (n) Tj ET", false)] { + let mut doc = pdf(&[ + "<< /Type /Catalog /Pages 2 0 R >>".into(), + "<< /Type /Pages /Kids [3 0 R 4 0 R] /Count 2 /MediaBox [0 0 300 300] >>".into(), + "<< /Type /Page /Parent 2 0 R /Contents 5 0 R /Resources << /Font << /F1 7 0 R >> /XObject << /Art 8 0 R >> >> >>".into(), + "<< /Type /Page /Parent 2 0 R /Contents 6 0 R /Resources << /Font << /F1 9 0 R >> >> >>".into(), + stream("BT /F1 12 Tf 20 250 Td (Title) Tj ET q 1 0 0 1 20 100 cm /Art Do Q"), + stream(""), + "<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>".into(), + format!("<< /Type /XObject /Subtype /Form /BBox [0 0 60 30] /Length {} >>\nstream\n{art}\nendstream", art.len()), + "<< /Type /Font /Subtype /Type1 /BaseFont /Courier >>".into(), + ]); + let all: Vec<_> = editable_objects(&doc, 0).unwrap().into_iter().map(|o| o.target).collect(); + let copied = copy_objects(&doc, 0, &all).unwrap(); + let pasted = paste_objects(&mut doc, 1, &copied, [0.0, 0.0]); + assert_eq!(pasted.is_ok(), pastes, "{art}: {pasted:?}"); + if let Err(EditError::Invalid(why)) = pasted { + assert!(why.starts_with("the copied objects can't be pasted here") && why.contains("uses its resources"), "{why}"); + } + } +} + +#[test] +fn added_content_is_copied_with_everything_its_stream_paints() { + let mut doc = two_pages(SOURCE, ""); + let item = crate::Content::Text(crate::AddedText { + rect: [20.0, 20.0, 200.0, 60.0], + text: "Added box".into(), + size: 12.0, + ..crate::AddedText::default() + }); + crate::add_content(&mut doc, 0, &item).unwrap(); + let added = target(&doc, 0, |o| o.target.kind == ObjectKind::Added); + let copied = copy_objects(&doc, 0, &[added]).unwrap(); + assert_eq!(copied.text(), "Added box"); + paste_objects(&mut doc, 1, &copied, [0.0, 0.0]).unwrap(); + let texts: Vec<_> = editable_objects(&doc, 1).unwrap().into_iter().filter_map(|o| o.text).collect(); + assert_eq!(texts, ["Added box"], "pasted as the page's own text"); + assert!(crate::list_added(&doc).iter().all(|a| a.page == 0)); +} + +#[test] +fn copying_and_pasting_refuse_what_they_cannot_do_and_change_nothing() { + let doc = two_pages(SOURCE, ""); + let text = target(&doc, 0, |o| o.text.as_deref().is_some_and(|t| t.contains("Copied"))); + assert!(copy_objects(&doc, 0, &[]).is_err()); + assert!(copy_objects(&doc, 0, &[text, text]).is_err(), "selected twice"); + assert!(copy_objects(&doc, 0, &[ObjectTarget { kind: ObjectKind::Image, index: 9 }]).is_err(), "stale"); + assert!(copy_objects(&doc, 5, &[text]).is_err(), "no such page"); + let copied = copy_objects(&doc, 0, &[text]).unwrap(); + for offset in [[f64::NAN, 0.0], [f64::INFINITY, 0.0], [2e9, 0.0]] { + let mut work = doc.clone(); + assert!(paste_objects(&mut work, 1, &copied, offset).is_err(), "{offset:?}"); + assert!(!work.is_modified(), "a refused paste changes nothing"); + } + assert!(paste_objects(&mut doc.clone(), 7, &copied, [0.0, 0.0]).is_err()); + + // Text drawn as a clipping path before the selection would clip it: refused, not dropped. + let clipped = two_pages("BT /F1 20 Tf 7 Tr 20 20 Td (Mask) Tj ET 0 Tr BT /F1 20 Tf 20 200 Td (Shown) Tj ET", ""); + let shown = target(&clipped, 0, |o| o.text.as_deref() == Some("Shown")); + assert!(copy_objects(&clipped, 0, &[shown]).is_err()); + + // Stray `Q`, `ET` and an unfinished path or text object: the copy is still balanced. + let stray = two_pages("Q Q ET 10 10 m 20 20 l BT /F1 20 Tf 20 200 Td (Stray) Tj", ""); + let shown = target(&stray, 0, |o| o.text.as_deref() == Some("Stray")); + let copied = copy_objects(&stray, 0, &[shown]).unwrap(); + let ops = pdfcraft_content::parse(&copied.content).ops; + assert_eq!(ops.iter().filter(|o| o.is("q")).count(), ops.iter().filter(|o| o.is("Q")).count()); + assert_eq!(ops.iter().filter(|o| o.is("BT")).count(), ops.iter().filter(|o| o.is("ET")).count()); + let mut work = stray.clone(); + paste_objects(&mut work, 1, &copied, [0.0, 0.0]).unwrap(); +} diff --git a/crates/edit/src/text.rs b/crates/edit/src/text.rs index be230d8db7..2e73a5a075 100644 --- a/crates/edit/src/text.rs +++ b/crates/edit/src/text.rs @@ -63,9 +63,9 @@ pub(crate) use lines::{ pub use lines::{MAX_FORM_DEPTH, MAX_FORM_VISITS, glyph_text_lines, text_lines}; pub use rewrite::{LineEdit, replace_line, rewrite_block}; use rewrite::{coincident_ops, rewrite_block_from}; +pub(crate) use rewrite::{paragraph_ops, reveal_ops, selectable_blocks, show_starts, translate_blocks}; #[cfg(test)] pub(crate) use rewrite::{replace_line_with_limit, rewrite_block_with_limit}; -pub(crate) use rewrite::{reveal_ops, selectable_blocks, translate_blocks}; /// One line of existing text. #[derive(Clone, Debug, PartialEq)] diff --git a/crates/edit/src/text/rewrite.rs b/crates/edit/src/text/rewrite.rs index 8741888c4e..4816271e30 100644 --- a/crates/edit/src/text/rewrite.rs +++ b/crates/edit/src/text/rewrite.rs @@ -1117,6 +1117,36 @@ fn vertical_encoding(doc: &Document, font: &Dict) -> Result { Err(EditError::Invalid("the text encoding is nested too deeply".into())) } +/// The text-showing operators of paragraphs `indexes` (from [`group_blocks`] of the page's +/// lines), as indexes among the page's joined operators. `editable`: refuse a line that can't be +/// edited (moving rewrites its operators; copying takes them as they are). +pub(crate) fn paragraph_ops(doc: &Document, page: usize, indexes: &[usize], editable: bool) -> Result, EditError> { + let mut selected = std::collections::BTreeSet::new(); + if indexes.is_empty() { + return Ok(selected); + } + let lines = text_lines(doc, page)?; + let blocks = group_blocks(&lines); + for index in indexes { + let b = blocks.get(*index).ok_or_else(|| EditError::Invalid("a selected paragraph no longer exists".into()))?; + for line in &b.lines { + let l = lines.get(*line).ok_or_else(|| EditError::Invalid("a selected text line no longer exists".into()))?; + if editable { + l.ensure_editable()?; + } + selected.extend(l.ops.iter().copied()); + } + } + Ok(selected) +} + +/// Where each text-showing operator of `ops` (a page's joined operators, drawn with `resources`) +/// starts its glyphs: the text matrix there, and its rendering mode (`Tr`). +pub(crate) fn show_starts(doc: &Document, ops: &[Op], resources: &Dict) -> Result, EditError> { + let shown = interpret(doc, ops, resources, &mut HashMap::new(), &mut Carry::new(), None, None)?; + Ok(shown.into_iter().map(|s| (s.op, (s.tm, s.state.render_mode))).collect()) +} + /// Translate paragraphs together without re-encoding glyphs, changing fonts or reflowing. /// Numeric TJ offsets and text rise temporarily move each selected show operation, then /// restore both advances and rise. The PDF reader advances the original glyph bytes itself: @@ -1127,17 +1157,7 @@ pub(crate) fn translate_blocks(doc: &mut Document, page: usize, indexes: &[usize } let p = page_dict(doc, page)?; let streams = content_streams(doc, &p.dict)?; - let lines = text_lines(doc, page)?; - let blocks = group_blocks(&lines); - let mut selected = std::collections::BTreeSet::new(); - for index in indexes { - let b = blocks.get(*index).ok_or_else(|| EditError::Invalid("a selected paragraph no longer exists".into()))?; - for line in &b.lines { - let l = lines.get(*line).ok_or_else(|| EditError::Invalid("a selected text line no longer exists".into()))?; - l.ensure_editable()?; - selected.extend(l.ops.iter().copied()); - } - } + let selected = paragraph_ops(doc, page, indexes, true)?; let res = p.dict.get(b"Resources").map(|r| doc.resolve(r)).and_then(|r| r.as_dict().cloned()).unwrap_or_default(); let fonts = res.get(b"Font").map(|r| doc.resolve(r)).and_then(|r| r.as_dict().cloned()).unwrap_or_default(); let pieces = joined(&streams); diff --git a/crates/edit/src/ungroup.rs b/crates/edit/src/ungroup.rs index 1b1916a1c8..c8b0d2f4d4 100644 --- a/crates/edit/src/ungroup.rs +++ b/crates/edit/src/ungroup.rs @@ -41,11 +41,11 @@ use crate::EditError; pub(crate) const MAX_FORM_BYTES: usize = 64 << 20; /// Resource categories a content stream refers to by name (ISO 32000-2 §7.8.3, Table 34). -const CATEGORIES: [&[u8]; 7] = [b"Font", b"XObject", b"ExtGState", b"ColorSpace", b"Pattern", b"Shading", b"Properties"]; +pub(crate) const CATEGORIES: [&[u8]; 7] = [b"Font", b"XObject", b"ExtGState", b"ColorSpace", b"Pattern", b"Shading", b"Properties"]; /// The `/ColorSpace` entries that remap device colours painted with the resources they are in /// (ISO 32000-2 §8.6.5.6). -const DEFAULT_SPACES: [&[u8]; 3] = [b"DefaultGray", b"DefaultRGB", b"DefaultCMYK"]; +pub(crate) const DEFAULT_SPACES: [&[u8]; 3] = [b"DefaultGray", b"DefaultRGB", b"DefaultCMYK"]; /// The most distinct patterns one ungrouped form may select; each becomes a new object. const MAX_MOVED_PATTERNS: usize = 256; @@ -258,7 +258,7 @@ pub(crate) fn ungroup_ops( // up in the form's (the renderer chains them to whatever draws it). Drawn from the page, it // would find the page's, which differ wherever a name was renamed (every form resource that // isn't already the page's, the nested item's own entry included). - if !renames.is_empty() && ops.iter().any(|op| inherits_resources(doc, &form_res, op)) { + if !renames.is_empty() && ops.iter().any(|op| inherits_resources(doc, &form_res, op, budget)) { return Err(Refusal { renaming: true, ..refuse("it draws a nested form, Type 3 font or pattern that uses its resources") }); } let mut depth = 0usize; @@ -505,7 +505,7 @@ fn shows_text(op: &Op) -> bool { } /// An operator whose operands the renderer can't read as it expects, so it skips the operator. -struct Unreadable; +pub(crate) struct Unreadable; /// Which operand of `op` names a resource, and from which category, read as the renderer reads /// it: operands are taken from the end, so a surplus operand before them is ignored. `Do`, `gs`, @@ -513,7 +513,7 @@ struct Unreadable; /// a name or an inline dictionary; `scn` and `SCN` numbers and the last name among them. /// `Ok(None)` when `op` names no resource; [`Unreadable`] when the renderer would skip it, which /// [`ungroup_ops`] refuses rather than guess at. -fn resource_operand(op: &Op) -> Result, Unreadable> { +pub(crate) fn resource_operand(op: &Op) -> Result, Unreadable> { let operands = &op.operands; let name_at = |i: Option| i.is_some_and(|i| matches!(operands.get(i), Some(Object::Name(_)))); let last = operands.len().checked_sub(1); @@ -553,25 +553,44 @@ fn resource_operand(op: &Op) -> Result, Unreadabl } /// Whether `op` draws a Form XObject (`Do`), Type 3 font (`Tf`) or tiling pattern (`scn`/`SCN`) from -/// `resources` that has no `/Resources` of its own, and so inherits the ones it is drawn with. -fn inherits_resources(doc: &Document, resources: &Dict, op: &Op) -> bool { +/// `resources` that has no `/Resources` of its own, and so inherits the ones it is drawn with. A +/// form or pattern whose content names no resource inherits nothing (a filled shape, say), so it +/// needs no renaming; its content is read within `budget`. A Type 3 font's glyphs are not read, +/// and always count. +fn inherits_resources(doc: &Document, resources: &Dict, op: &Op, budget: &mut DecodeBudget) -> bool { let Ok(Some((cat @ (b"XObject" | b"Font" | b"Pattern"), i))) = resource_operand(op) else { return false }; let Some(Object::Name(name)) = op.operands.get(i) else { return false }; let Some(entry) = resources.get(cat).map(|d| doc.resolve(d)).and_then(|d| d.as_dict().and_then(|d| d.get(name).cloned())) else { return false; }; - let dict = match &*doc.resolve(&entry) { - Object::Stream(s) => s.dict.clone(), - Object::Dict(d) => d.clone(), + let entry = doc.resolve(&entry); + let (dict, stream) = match &*entry { + Object::Stream(s) => (&s.dict, Some(s)), + Object::Dict(d) => (d, None), _ => return false, }; let drawn = match cat { - b"XObject" => is_name(doc, &dict, b"Subtype", b"Form"), - b"Font" => is_name(doc, &dict, b"Subtype", b"Type3"), + b"XObject" => is_name(doc, dict, b"Subtype", b"Form"), + b"Font" => is_name(doc, dict, b"Subtype", b"Type3"), // A shading pattern has no content of its own. - _ => int_of(doc, &dict, b"PatternType") == Some(1), + _ => int_of(doc, dict, b"PatternType") == Some(1), }; - drawn && dict.get(b"Resources").is_none() + drawn && dict.get(b"Resources").is_none() && (cat == b"Font" || stream.is_none_or(|s| names_resources(doc, s, budget))) +} + +/// Whether content stream `s` names a resource (or can't be read within `budget` to tell): device +/// colour spaces are not resources; an inline image counts, as its colour space may be one. +fn names_resources(doc: &Document, s: &pdfcraft_cos::Stream, budget: &mut DecodeBudget) -> bool { + let Ok(data) = budget.decode_strict(doc, s) else { return true }; + let parsed = parse(&data); + parsed.skipped > 0 + || parsed.ops.iter().any(|op| match resource_operand(op) { + Ok(None) => op.inline.is_some(), + Ok(Some((b"ColorSpace", i))) => { + !matches!(op.operands.get(i), Some(Object::Name(n)) if [&b"DeviceGray"[..], b"DeviceRGB", b"DeviceCMYK", b"Pattern"].contains(&n.as_slice())) + } + _ => true, + }) } /// Whether `ops` (drawn with `resources`) blend with something other than Normal, which counts diff --git a/crates/engine/src/edit.rs b/crates/engine/src/edit.rs index f1a99a2387..6ead3ca09c 100644 --- a/crates/engine/src/edit.rs +++ b/crates/engine/src/edit.rs @@ -457,6 +457,12 @@ pub enum Edit { objects: Vec, offset: [f64; 2], }, + /// Delete mixed existing and added objects together (references from one inventory of + /// `page`), as one step: what Delete and Cut do to objects selected together. + DeleteObjects { + page: usize, + objects: Vec, + }, /// Edit text in a paragraph box: replace paragraph `block` (from `Document::text_blocks`), /// rewrapped to the box's width. EditTextBlock { @@ -549,6 +555,13 @@ pub enum Edit { content: AddedContent, file: Option<(String, Arc>)>, }, + /// Edit ▸ Paste of objects copied together in Edit a PDF ([`Document::copy_objects`](crate::Document::copy_objects)), in this + /// document or another: drawn on top of `page` as they were, moved by `offset` (user space). + PasteObjects { + page: usize, + clip: Arc, + offset: [f64; 2], + }, /// Apply redaction marks (all, or those on `pages`): remove what they cover for good. ApplyRedactions { pages: Option>, diff --git a/crates/engine/src/edit/labels.rs b/crates/engine/src/edit/labels.rs index da7d718811..762e8ced2f 100644 --- a/crates/engine/src/edit/labels.rs +++ b/crates/engine/src/edit/labels.rs @@ -302,9 +302,9 @@ impl Edit { Edit::AddText { .. } => Label::of("Add text"), Edit::AddImage { .. } => Label::of("Add image"), Edit::UpdateContent { .. } => Label::of("Edit content"), - Edit::DeleteContent { .. } => Label::of("Delete content"), + Edit::DeleteContent { .. } | Edit::DeleteObjects { .. } => Label::of("Delete content"), Edit::ReplaceImage { .. } => Label::of("Replace image"), - Edit::PasteContent { .. } => Label::of("Paste"), + Edit::PasteContent { .. } | Edit::PasteObjects { .. } => Label::of("Paste"), Edit::ApplyRedactions { .. } => Label::of("Apply redactions"), Edit::ClearRedactions => Label::of("Remove redaction marks"), Edit::RemoveHidden { .. } => Label::of("Remove hidden information"), diff --git a/crates/engine/src/edit/run.rs b/crates/engine/src/edit/run.rs index 82a302d769..1d4a799397 100644 --- a/crates/engine/src/edit/run.rs +++ b/crates/engine/src/edit/run.rs @@ -111,9 +111,11 @@ pub(crate) fn check_permission(edit: &Edit, p: &pdfcraft_cos::Permissions) -> Re | Edit::AddImage { .. } | Edit::UpdateContent { .. } | Edit::MoveObjects { .. } + | Edit::DeleteObjects { .. } | Edit::DeleteContent { .. } | Edit::ReplaceImage { .. } | Edit::PasteContent { .. } + | Edit::PasteObjects { .. } | Edit::ClearRedactions | Edit::RemoveHidden { .. } | Edit::ImportData { .. } @@ -729,6 +731,8 @@ pub(crate) fn run_edit(doc: &mut pdfcraft_cos::Document, edit: &Edit, cx: &mut E Edit::EnhanceScan { images } => crate::scan_clean::write_back(doc, images)?, Edit::EditPageImage { page, index, change } => run_edit_page_image(doc, *page, *index, change)?, Edit::MoveObjects { page, objects, offset } => pdfcraft_edit::move_objects(doc, *page, objects, *offset)?, + Edit::DeleteObjects { page, objects } => crate::object_clip::delete(doc, *page, objects, |doc, edit| run_edit(doc, edit, cx))?, + Edit::PasteObjects { page, clip, offset } => crate::object_clip::paste(doc, *page, clip, *offset)?, Edit::EditTextBlock { page, block, text, style } => { pdfcraft_edit::rewrite_block(doc, *page, *block, Some(text), style)?; } diff --git a/crates/engine/src/history.rs b/crates/engine/src/history.rs index 4997f64efe..6b204ec372 100644 --- a/crates/engine/src/history.rs +++ b/crates/engine/src/history.rs @@ -79,6 +79,7 @@ pub(crate) fn scope_of(edit: &Edit) -> Scope { // Page content, resources and added content/marks only: the page tree, annotations, // fields, links and document metadata are untouched (see `refresh_content`). Edit::MoveObjects { .. } + | Edit::DeleteObjects { .. } | Edit::EditTextLine { .. } | Edit::EditTextBlock { .. } | Edit::PrepareText { .. } @@ -90,6 +91,7 @@ pub(crate) fn scope_of(edit: &Edit) -> Scope { | Edit::DeleteContent { .. } | Edit::ReplaceImage { .. } | Edit::PasteContent { .. } + | Edit::PasteObjects { .. } | Edit::AddHeaderFooter { .. } | Edit::AddWatermark { .. } | Edit::AddBackground { .. } diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index fbd1cacc1f..baa06b02fd 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -36,6 +36,7 @@ pub mod js; mod layers; pub mod links; pub mod model3d; +mod object_clip; pub mod ocr; mod open; pub mod optimizer; @@ -108,6 +109,7 @@ pub use pdfcraft_edit::{Backdrop, BlockKey, BlockStyle, PageImage, RangeStyle, T pub use pdfcraft_measure as measure; pub use pdfcraft_xfa::Report as XfaLayout; +pub use object_clip::{ObjectClip, ObjectClipboard}; pub use pdfcraft_edit::{EditableObject, MAX_MOVE_OBJECTS, ObjectKind, ObjectTarget}; pub use pdfcraft_fonts::{MAX_SIGNATURE_CHARS, ScriptOutline, script_missing, script_outline, undrawable_message}; diff --git a/crates/engine/src/object_clip.rs b/crates/engine/src/object_clip.rs new file mode 100644 index 0000000000..3a2f0915a1 --- /dev/null +++ b/crates/engine/src/object_clip.rs @@ -0,0 +1,211 @@ +//! Edit ▸ Copy, Cut and Paste of objects selected together in Edit a PDF (#5989). +//! +//! A copy ([`ObjectClip`]) holds what the objects draw and the resources that drawing names +//! ([`pdfcraft_edit::copy_objects`]), with a snapshot of the document they came from, so it can +//! be pasted into any open document, also after the original changes or closes. Within the same +//! document, while the objects it refers to are unchanged, a paste draws them again from the same +//! fonts and images; otherwise it first copies them over ([`pdfcraft_organize::import_value`]): +//! each once, through cycles, sharing what the target already holds, and within a size limit. + +use std::sync::Arc; + +use pdfcraft_cos::Object; + +use crate::{DocId, Document, Edit, EditError, EditableObject, ObjectTarget}; + +/// The most objects a paste copies into another document. +const MAX_IMPORT_OBJECTS: usize = 20_000; +/// The most stream data (fonts, images…) a paste copies into another document. +const MAX_IMPORT_BYTES: usize = 256 << 20; +/// How far each further paste onto the same page steps down and to the right, in points, as +/// pasted added content steps ([`pdfcraft_edit::added::PASTE_STEP`]). +const STEP: f64 = pdfcraft_edit::added::PASTE_STEP; + +/// Objects copied from one page of a document. +#[derive(Clone)] +pub struct ObjectClip { + copied: pdfcraft_edit::CopiedObjects, + /// The document as it was when copied: what the copy's resources refer to. + source: pdfcraft_cos::Document, + doc: DocId, + page: usize, +} + +impl PartialEq for ObjectClip { + fn eq(&self, other: &Self) -> bool { + self.copied == other.copied + && self.doc == other.doc + && self.page == other.page + && self.source.source_identity() == other.source.source_identity() + } +} + +impl std::fmt::Debug for ObjectClip { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("ObjectClip").field("doc", &self.doc).field("page", &self.page).field("objects", &self.copied.objects.len()).finish() + } +} + +impl ObjectClip { + /// The objects as they were listed when copied ([`Document::editable_objects`]). + pub fn objects(&self) -> &[EditableObject] { + &self.copied.objects + } + + /// The text of the copied paragraphs and text boxes, one per line. + pub fn text(&self) -> String { + self.copied.text() + } + + /// The union of the objects' boxes, in the user space of the page they came from. + pub fn rect(&self) -> [f64; 4] { + self.copied.rect + } + + /// The document and page the objects were copied from. + pub fn source(&self) -> (DocId, usize) { + (self.doc, self.page) + } +} + +impl Document { + /// Edit ▸ Copy of `targets` (references from one [`Document::editable_objects`] inventory of + /// `page`). Refused when the document doesn't allow copying. Changes nothing. + pub fn copy_objects(&self, page: usize, targets: &[ObjectTarget]) -> Result { + self.check_copying()?; + let editor = self.editor.as_ref().ok_or_else(|| EditError::ReadOnly("the document can't be read".into()))?; + let copied = pdfcraft_edit::copy_objects(&editor.cos, page, targets)?; + Ok(ObjectClip { copied, source: editor.cos.clone(), doc: self.id, page }) + } + + /// Where Paste puts `clip` on `page`: the user-space offset that moves it `steps` × 18 pt down + /// and to the right as displayed, or, when that leaves nothing of it on the page's crop box, + /// the offset that centres it there. + pub fn object_paste_offset(&self, page: usize, clip: &ObjectClip, steps: u32) -> Result<[f64; 2], String> { + let step = STEP * f64::from(steps); + let offset = self.object_move_offset(page, [step, step])?; + let p = self.info.pages.get(page).ok_or("the page no longer exists")?; + let crop = p.crop.map(f64::from); + let r = clip.rect(); + let moved = [r[0] + offset[0], r[1] + offset[1], r[2] + offset[0], r[3] + offset[1]]; + if moved[0] < crop[2] && moved[2] > crop[0] && moved[1] < crop[3] && moved[3] > crop[1] { + return Ok(offset); + } + let centre = |a: f64, b: f64| (a + b) / 2.0; + Ok([centre(crop[0], crop[2]) - centre(r[0], r[2]), centre(crop[1], crop[3]) - centre(r[1], r[3])]) + } +} + +/// The objects copied in an application or a script, and how many times each page has had them +/// pasted, which sets how far the next paste there steps ([`Document::object_paste_offset`]). +#[derive(Clone, Debug)] +pub struct ObjectClipboard { + clip: Arc, + pastes: Vec<(DocId, usize, u32)>, +} + +impl ObjectClipboard { + pub fn new(clip: ObjectClip) -> Self { + ObjectClipboard { clip: Arc::new(clip), pastes: Vec::new() } + } + + pub fn clip(&self) -> &ObjectClip { + &self.clip + } + + /// The edit that pastes the copy onto `page` of `doc`: where it was, or on the page it came + /// from one step down and to the right (as Acrobat offsets a paste onto the same page), and + /// one step further for each earlier paste onto that page. + pub fn paste(&mut self, doc: &Document, page: usize) -> Result { + let at = self.pastes.iter().position(|(d, p, _)| *d == doc.id && *p == page); + let earlier = at.and_then(|i| self.pastes.get(i)).map_or(0, |(_, _, n)| *n); + let own = u32::from(self.clip.source() == (doc.id, page)); + let offset = doc.object_paste_offset(page, &self.clip, earlier.saturating_add(own))?; + match at.and_then(|i| self.pastes.get_mut(i)) { + Some(entry) => entry.2 = entry.2.saturating_add(1), + None => self.pastes.push((doc.id, page, 1)), + } + Ok(Edit::PasteObjects { page, clip: Arc::clone(&self.clip), offset }) + } +} + +impl Edit { + /// The edit that deletes `targets` (indexes into one inventory of `page`) as one undo step: + /// what Delete and Cut do to objects selected together. One object is deleted by its own + /// edit, several by [`Edit::DeleteObjects`]; none is no edit. + pub fn delete_objects(page: usize, mut targets: Vec) -> Option { + targets.sort(); + targets.dedup(); + match targets.as_slice() { + [] => None, + [t] => Some(delete_one(page, *t)), + _ => Some(Edit::DeleteObjects { page, objects: targets }), + } + } +} + +fn delete_one(page: usize, t: ObjectTarget) -> Edit { + use crate::ObjectKind; + match t.kind { + ObjectKind::Text => Edit::EditTextBlock { page, block: t.index, text: String::new(), style: crate::BlockStyle::default() }, + ObjectKind::Image => Edit::EditPageImage { page, index: t.index, change: crate::ImageEdit::Delete }, + ObjectKind::Added => Edit::DeleteContent { page, index: t.index }, + } +} + +/// [`Edit::DeleteObjects`], each object by its own edit (`run`). A deletion renumbers what follows +/// it in its own inventory, so pictures go from the last to the first, and added content, which +/// the other inventories leave out, after them. Deleting a paragraph also removes a copy drawn +/// exactly over it (fake bold, a paste onto itself), which renumbers paragraphs before it too: so +/// each paragraph is found again by its text and box, and one already gone with another is done. +pub(crate) fn delete( + doc: &mut pdfcraft_cos::Document, + page: usize, + objects: &[ObjectTarget], + mut run: impl FnMut(&mut pdfcraft_cos::Document, &Edit) -> Result<(), EditError>, +) -> Result<(), EditError> { + use crate::ObjectKind; + let mut targets = objects.to_vec(); + targets.sort_by_key(|t| (t.kind == ObjectKind::Added, std::cmp::Reverse(t.index))); + targets.dedup(); + let blocks = pdfcraft_edit::text_blocks(doc, page)?; + for t in targets { + let edit = match t.kind { + ObjectKind::Text => { + let key = blocks.get(t.index).map(crate::BlockKey::of).ok_or_else(|| { + EditError::Edit(pdfcraft_edit::EditError::Invalid("a selected paragraph no longer exists; select the objects again".into())) + })?; + let Some(block) = key.find(&pdfcraft_edit::text_blocks(doc, page)?, t.index) else { continue }; + delete_one(page, ObjectTarget { kind: t.kind, index: block }) + } + _ => delete_one(page, t), + }; + run(doc, &edit)?; + } + Ok(()) +} + +/// [`Edit::PasteObjects`]: the copy's resources as `doc` holds them, then the paste. +pub(crate) fn paste(doc: &mut pdfcraft_cos::Document, page: usize, clip: &ObjectClip, offset: [f64; 2]) -> Result<(), EditError> { + let resources = Object::Dict(clip.copied.resources.clone()); + let refused = + |e: pdfcraft_organize::OrganizeError| EditError::Edit(pdfcraft_edit::EditError::Invalid(format!("the copied objects can't be pasted: {e}"))); + let used = pdfcraft_organize::reachable(&clip.source, &resources, MAX_IMPORT_OBJECTS, MAX_IMPORT_BYTES).map_err(refused)?; + // Still the objects the copy was made from: the same document, and each of them unchanged. + let same = doc.source_identity() == clip.source.source_identity() + && used.iter().all(|r| { + let (here, there) = (doc.get(*r), clip.source.get(*r)); + Arc::ptr_eq(&here, &there) || here == there + }); + let resources = if same { resources } else { pdfcraft_organize::import_value(doc, &clip.source, &resources).map_err(refused)? }; + let Object::Dict(resources) = resources else { + return Err(EditError::Edit(pdfcraft_edit::EditError::Invalid("the copied objects' resources could not be copied".into()))); + }; + let copied = pdfcraft_edit::CopiedObjects { resources, ..clip.copied.clone() }; + pdfcraft_edit::paste_objects(doc, page, &copied, offset)?; + Ok(()) +} + +#[cfg(test)] +#[path = "object_clip_tests.rs"] +mod tests; diff --git a/crates/engine/src/object_clip_tests.rs b/crates/engine/src/object_clip_tests.rs new file mode 100644 index 0000000000..49baae69b8 --- /dev/null +++ b/crates/engine/src/object_clip_tests.rs @@ -0,0 +1,257 @@ +use super::*; +use crate::{ObjectKind, Session}; + +/// A PDF of 300 × 300 pages, each drawing its `content` with `resources` and objects `extra` +/// (numbered after the pages' content streams). +fn pdf(pages: &[(&str, &str)], extra: &[String]) -> Arc> { + let n = pages.len(); + let kids: Vec = (0..n).map(|i| format!("{} 0 R", 3 + i)).collect(); + let mut objs = vec![ + "<< /Type /Catalog /Pages 2 0 R >>".to_string(), + format!("<< /Type /Pages /Kids [{}] /Count {n} /MediaBox [0 0 300 300] >>", kids.join(" ")), + ]; + for (i, (_, resources)) in pages.iter().enumerate() { + objs.push(format!("<< /Type /Page /Parent 2 0 R /Contents {} 0 R /Resources {resources} >>", 3 + n + i)); + } + for (content, _) in pages { + objs.push(format!("<< /Length {} >>\nstream\n{content}\nendstream", content.len())); + } + objs.extend(extra.iter().cloned()); + let mut out = b"%PDF-1.7\n".to_vec(); + let mut offs = Vec::new(); + for (i, o) in objs.iter().enumerate() { + offs.push(out.len()); + out.extend_from_slice(format!("{} 0 obj\n{o}\nendobj\n", i + 1).as_bytes()); + } + let x = out.len(); + out.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objs.len() + 1).as_bytes()); + for o in offs { + out.extend_from_slice(format!("{o:010} 00000 n \n").as_bytes()); + } + out.extend_from_slice(format!("trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{x}\n%%EOF\n", objs.len() + 1).as_bytes()); + Arc::new(out) +} + +const HELVETICA: &str = "<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding /WinAnsiEncoding >>"; +const COURIER: &str = "<< /Type /Font /Subtype /Type1 /BaseFont /Courier /Encoding /WinAnsiEncoding >>"; +/// A 2 × 2 RGB picture of the twelve bytes `data` (ASCII, so the file's lengths are its bytes). +fn picture(data: &str) -> String { + assert!(data.is_ascii() && data.len() == 12); + format!( + "<< /Type /XObject /Subtype /Image /Width 2 /Height 2 /ColorSpace /DeviceRGB /BitsPerComponent 8 /Length 12 >>\nstream\n{data}\nendstream" + ) +} + +/// A one-page document whose paragraph "Copied" (blue, half opaque, Helvetica /F1 at objects 5) +/// and picture /Im0 (object 6) are copied; "Left" is not. +fn source() -> Arc> { + let content = "0 0 1 rg /GS0 gs BT /F1 24 Tf 20 200 Td (Copied) Tj ET q 60 0 0 40 20 100 cm /Im0 Do Q BT /F1 12 Tf 200 40 Td (Left) Tj ET"; + pdf( + &[(content, "<< /Font << /F1 5 0 R >> /XObject << /Im0 6 0 R >> /ExtGState << /GS0 << /ca 0.5 >> >> >>")], + &[HELVETICA.into(), picture("\u{7f}\u{0}\u{0}\u{0}\u{7f}\u{0}\u{0}\u{0}\u{7f}\u{7f}\u{7f}\u{0}")], + ) +} + +/// A one-page document that already draws with Courier as /F1 and has a picture of its own as /Im0. +fn target() -> Arc> { + pdf( + &[("BT /F1 10 Tf 250 280 Td (x) Tj ET q 10 0 0 10 280 5 cm /Im0 Do Q", "<< /Font << /F1 5 0 R >> /XObject << /Im0 6 0 R >> >>")], + &[COURIER.into(), picture("@@@@@@@@@@@@")], + ) +} + +fn copy_all_but_left(s: &Session, id: DocId) -> ObjectClip { + let doc = s.get(id).unwrap(); + let targets: Vec<_> = doc.editable_objects(0).unwrap().into_iter().filter(|o| o.text.as_deref() != Some("Left")).map(|o| o.target).collect(); + assert_eq!(targets.len(), 2); + doc.copy_objects(0, &targets).unwrap() +} + +fn render(s: &Session, id: DocId) -> pdfcraft_render::RenderedPage { + let doc = s.get(id).unwrap(); + let mut renderer = pdfcraft_render::PageRenderer::new(doc.bytes.clone(), doc.config.clone()); + renderer.render(pdfcraft_render::RenderRequest { page: 0, scale: 1.0, ..Default::default() }) +} + +/// The pixels in user-space box `r` of a 300 pt page rendered at scale 1. +fn pixels(page: &pdfcraft_render::RenderedPage, r: [f64; 4]) -> Vec { + let mut out = Vec::new(); + for y in (300.0 - r[3]) as u32..(300.0 - r[1]) as u32 { + for x in r[0] as u32..r[2] as u32 { + let i = ((y * page.width + x) * 4) as usize; + out.extend_from_slice(&page.rgba[i..i + 4]); + } + } + out +} + +fn images(s: &Session, id: DocId) -> Vec> { + s.get(id).unwrap().page_images(0).into_iter().map(|i| i.object).collect() +} + +#[test] +fn objects_copied_from_one_document_paste_into_another_and_draw_the_same() { + let mut s = Session::new().with_clock(|| 1_700_000_000); + let a = s.open("a.pdf", None, source(), None).unwrap(); + let b = s.open("b.pdf", None, target(), None).unwrap(); + let clip = copy_all_but_left(&s, a); + assert_eq!(clip.text(), "Copied"); + let mut clipboard = ObjectClipboard::new(clip); + // The source can change, or close: the copy is a snapshot. + s.apply(a, Edit::delete_objects(0, vec![ObjectTarget { kind: ObjectKind::Image, index: 0 }]).unwrap()).unwrap(); + s.close(a); + let a = s.open("a.pdf", None, source(), None).unwrap(); + + let paste = clipboard.paste(s.get(b).unwrap(), 0).unwrap(); + assert!(matches!(&paste, Edit::PasteObjects { offset, .. } if *offset == [0.0, 0.0]), "another document: where it was"); + s.apply(b, paste).unwrap(); + assert_eq!(s.get(b).unwrap().can_undo(), Some("Paste")); + let area = clipboard.clip().rect().map(|v| v.round()); + let area = [area[0] - 3.0, area[1] - 3.0, area[2] + 3.0, area[3] + 3.0]; + let (original, pasted) = (pixels(&render(&s, a), area), pixels(&render(&s, b), area)); + assert!(original.chunks(4).any(|p| p[2] > 200 && p[0] < 200), "the blue paragraph is drawn"); + assert_eq!(original, pasted, "the pasted paragraph and picture draw the same pixels"); + + // Its own /F1 and /Im0 are left alone; what the copy uses came over under other names. + let doc = s.get(b).unwrap(); + let texts: Vec<_> = doc.editable_objects(0).unwrap().into_iter().filter_map(|o| o.text).collect(); + assert!(texts.contains(&"Copied".to_string()) && !texts.contains(&"Left".to_string()), "{texts:?}"); + let pictures = images(&s, b); + assert_eq!(pictures.len(), 2); + assert_ne!(pictures[0], pictures[1]); + + // A second paste steps down and to the right, and shares the fonts and pictures the first + // brought instead of copying them again. + let paste = clipboard.paste(s.get(b).unwrap(), 0).unwrap(); + assert!(matches!(&paste, Edit::PasteObjects { offset, .. } if *offset == [18.0, -18.0]), "{paste:?}"); + s.apply(b, paste).unwrap(); + let pictures = images(&s, b); + assert_eq!(pictures.len(), 3); + assert_eq!(pictures[1], pictures[2], "one picture object for both pastes"); + + // Saved and reopened, the pastes are there; undone, the page is as it was. + let saved = s.save_bytes(b).unwrap(); + let mut again = Session::new(); + let reopened = again.open("b.pdf", None, saved, None).unwrap(); + assert_eq!(again.get(reopened).unwrap().page_images(0).len(), 3); + s.undo(b).unwrap(); + s.undo(b).unwrap(); + assert_eq!(images(&s, b).len(), 1); + assert_eq!(s.get(b).unwrap().can_undo(), None); +} + +#[test] +fn a_paste_onto_the_page_it_came_from_steps_and_draws_from_the_same_objects() { + let mut s = Session::new().with_clock(|| 1_700_000_000); + let a = s.open("a.pdf", None, source(), None).unwrap(); + let mut clipboard = ObjectClipboard::new(copy_all_but_left(&s, a)); + let paste = clipboard.paste(s.get(a).unwrap(), 0).unwrap(); + assert!(matches!(&paste, Edit::PasteObjects { offset, .. } if *offset == [18.0, -18.0]), "one step down and right: {paste:?}"); + s.apply(a, paste).unwrap(); + let pictures = images(&s, a); + assert_eq!(pictures.len(), 2); + assert_eq!(pictures[0], pictures[1], "the same picture object, not a copy"); + // Far off the page (a copy from a larger page), a paste is centred instead. + let doc = s.get(a).unwrap(); + let mut far = clipboard.clip().clone(); + far.copied.rect = [1000.0, 1000.0, 1100.0, 1050.0]; + assert_eq!(doc.object_paste_offset(0, &far, 0).unwrap(), [150.0 - 1050.0, 150.0 - 1025.0]); +} + +#[test] +fn pasting_into_another_document_follows_cycles_and_refuses_past_its_limit() { + let mut s = Session::new().with_clock(|| 1_700_000_000); + // A font that refers to itself and to an object that refers back. + let cyclic = pdf( + &[("BT /F1 20 Tf 20 200 Td (Loop) Tj ET", "<< /Font << /F1 5 0 R >> >>")], + &[ + "<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding /WinAnsiEncoding /Self 5 0 R /Next [6 0 R] >>".into(), + "<< /Back 5 0 R /Again 6 0 R >>".into(), + ], + ); + let a = s.open("cyclic.pdf", None, cyclic, None).unwrap(); + let b = s.open("b.pdf", None, target(), None).unwrap(); + let all: Vec<_> = s.get(a).unwrap().editable_objects(0).unwrap().into_iter().map(|o| o.target).collect(); + let mut clipboard = ObjectClipboard::new(s.get(a).unwrap().copy_objects(0, &all).unwrap()); + s.apply(b, clipboard.paste(s.get(b).unwrap(), 0).unwrap()).unwrap(); + assert!(s.get(b).unwrap().editable_objects(0).unwrap().iter().any(|o| o.text.as_deref() == Some("Loop"))); + + // An ExtGState that drags in more objects than a paste copies is refused with a reason. + let many = 20_001; + let refs: Vec = (0..many).map(|i| format!("{} 0 R", 6 + i)).collect(); + let mut extra = vec![format!("<< /ca 0.5 /Junk [{}] >>", refs.join(" "))]; + extra.extend((0..many).map(|i| format!("<< /N {i} >>"))); + let resources = format!("<< /Font << /F1 {HELVETICA} >> /ExtGState << /GS0 5 0 R >> >>"); + let big = pdf(&[("/GS0 gs BT /F1 20 Tf 20 200 Td (Big) Tj ET", &resources)], &extra); + let c = s.open("big.pdf", None, big, None).unwrap(); + let all: Vec<_> = s.get(c).unwrap().editable_objects(0).unwrap().into_iter().map(|o| o.target).collect(); + assert_eq!(all.len(), 1); + let mut clipboard = ObjectClipboard::new(s.get(c).unwrap().copy_objects(0, &all).unwrap()); + let before = s.get(b).unwrap().can_undo().map(str::to_string); + let err = s.apply(b, clipboard.paste(s.get(b).unwrap(), 0).unwrap()).unwrap_err().to_string(); + assert!(err.contains("copy fewer objects"), "{err}"); + assert_eq!(s.get(b).unwrap().can_undo().map(str::to_string), before, "a refused paste changes nothing"); +} + +#[test] +fn copying_refuses_a_stale_selection_and_deleting_several_objects_is_one_step() { + let mut s = Session::new().with_clock(|| 1_700_000_000); + let a = s.open("a.pdf", None, source(), None).unwrap(); + let doc = s.get(a).unwrap(); + assert!(doc.copy_objects(0, &[ObjectTarget { kind: ObjectKind::Text, index: 7 }]).is_err()); + assert!(doc.copy_objects(3, &[ObjectTarget { kind: ObjectKind::Text, index: 0 }]).is_err()); + let delete = Edit::delete_objects(0, vec![ObjectTarget { kind: ObjectKind::Text, index: 0 }, ObjectTarget { kind: ObjectKind::Image, index: 0 }]); + assert!(matches!(delete, Some(Edit::DeleteObjects { .. }))); + assert!(Edit::delete_objects(0, Vec::new()).is_none()); + s.apply(a, delete.unwrap()).unwrap(); + assert_eq!(s.get(a).unwrap().can_undo(), Some("Delete content")); + assert!(s.get(a).unwrap().editable_objects(0).unwrap().iter().all(|o| o.text.as_deref() == Some("Left"))); +} + +/// Two pastes of a paragraph and a picture exactly over each other: deleting a paragraph also +/// removes a copy drawn exactly over it, so deleting all four objects together found the second +/// paragraph's number gone and refused the whole delete. Both copies go, in one step. +#[test] +fn objects_pasted_exactly_over_each_other_delete_together_in_one_step() { + let mut s = Session::new().with_clock(|| 1_700_000_000); + let a = s.open("a.pdf", None, source(), None).unwrap(); + let blank = s.create_blank(300.0, 300.0, 1).unwrap(); + let b = s.open_new("b.pdf", blank).unwrap(); + let mut clipboard = ObjectClipboard::new(copy_all_but_left(&s, a)); + let Edit::PasteObjects { clip, .. } = clipboard.paste(s.get(b).unwrap(), 0).unwrap() else { panic!("a paste") }; + for _ in 0..2 { + s.apply(b, Edit::PasteObjects { page: 0, clip: Arc::clone(&clip), offset: [0.0, 0.0] }).unwrap(); + } + let pasted = s.get(b).unwrap().editable_objects(0).unwrap(); + assert_eq!(pasted.iter().filter(|o| o.text.as_deref() == Some("Copied")).count(), 2, "{pasted:?}"); + assert_eq!(pasted.len(), 4); + let delete = Edit::delete_objects(0, pasted.iter().map(|o| o.target).collect()).unwrap(); + s.apply(b, delete).unwrap(); + assert!(s.get(b).unwrap().editable_objects(0).unwrap().is_empty()); + assert_eq!(s.get(b).unwrap().can_undo(), Some("Delete content")); + s.undo(b).unwrap(); + assert_eq!(s.get(b).unwrap().editable_objects(0).unwrap(), pasted, "one undo brings both copies back"); +} + +/// Deleting renumbers what follows in the same inventory, so paragraphs and pictures go from +/// the last to the first, and added content (outside those inventories) after them. +#[test] +fn several_objects_delete_last_first() { + let content = + "BT /F1 12 Tf 20 250 Td (A) Tj ET BT /F1 12 Tf 20 180 Td (B) Tj ET BT /F1 12 Tf 20 110 Td (C) Tj ET BT /F1 12 Tf 20 40 Td (D) Tj ET"; + let mut doc = pdfcraft_cos::Document::open(pdf(&[(content, "<< /Font << /F1 5 0 R >> >>")], &[HELVETICA.into()])).unwrap(); + let t = |kind, index| ObjectTarget { kind, index }; + let targets = [t(ObjectKind::Added, 0), t(ObjectKind::Text, 1), t(ObjectKind::Image, 0), t(ObjectKind::Text, 3), t(ObjectKind::Added, 2)]; + let mut order = Vec::new(); + delete(&mut doc, 0, &targets, |_, e| { + order.push(match e { + Edit::EditTextBlock { block, text, .. } if text.is_empty() => ('t', *block), + Edit::EditPageImage { index, change: crate::ImageEdit::Delete, .. } => ('i', *index), + Edit::DeleteContent { index, .. } => ('a', *index), + other => panic!("unexpected {other:?}"), + }); + Ok(()) + }) + .unwrap(); + assert_eq!(order, [('t', 3), ('t', 1), ('i', 0), ('a', 2), ('a', 0)]); +} diff --git a/crates/engine/src/session/editing.rs b/crates/engine/src/session/editing.rs index ae0a83a8f8..753f8dd0bd 100644 --- a/crates/engine/src/session/editing.rs +++ b/crates/engine/src/session/editing.rs @@ -434,7 +434,7 @@ impl Session { | Edit::ReplaceImage { .. } | Edit::PasteContent { .. } | Edit::EditPageImage { .. } - ) || matches!(e, Edit::MoveObjects { objects, .. } if objects.iter().any(|o| o.kind == ObjectKind::Added)) + ) || matches!(e, Edit::MoveObjects { objects, .. } | Edit::DeleteObjects { objects, .. } if objects.iter().any(|o| o.kind == ObjectKind::Added)) }); let touches_marks = edit.is_none_or(|e| { matches!(e, Edit::AddHeaderFooter { .. } | Edit::AddWatermark { .. } | Edit::AddBackground { .. } | Edit::RemoveMarks { .. }) diff --git a/crates/organize/src/import.rs b/crates/organize/src/import.rs index b52c9175b3..636ed61991 100644 --- a/crates/organize/src/import.rs +++ b/crates/organize/src/import.rs @@ -480,6 +480,64 @@ pub(crate) fn register_layers(dst: &mut Document, src: &Document, ocgs: &[(ObjRe Ok(()) } +/// The objects `value` (of `src`) refers to, directly or through others, each once whatever the +/// cycles: what [`import_value`] copies. Refused past `max_objects` objects or `max_bytes` bytes +/// of stream data, with a message that says how much there is. +pub fn reachable(src: &Document, value: &Object, max_objects: usize, max_bytes: usize) -> Result, OrganizeError> { + let mut seen = HashSet::new(); + let mut found = Vec::new(); + let mut bytes = 0usize; + // The values still to look into: owned, or an object of `src`. + let mut pending: Vec> = vec![std::sync::Arc::new(value.clone())]; + while let Some(next) = pending.pop() { + let mut refs = Vec::new(); + let mut inside: Vec<&Object> = vec![&next]; + while let Some(o) = inside.pop() { + match o { + Object::Ref(r) => refs.push(*r), + Object::Array(a) => inside.extend(a.iter()), + Object::Dict(d) => inside.extend(d.iter().map(|(_, v)| v)), + Object::Stream(s) => { + bytes = bytes.saturating_add(s.raw.len()); + inside.extend(s.dict.iter().map(|(_, v)| v)); + } + _ => {} + } + } + for r in refs { + if seen.insert(r) { + found.push(r); + pending.push(src.get(r)); + } + } + if found.len() > max_objects || bytes > max_bytes { + return Err(OrganizeError::Invalid(format!( + "it uses more than {max_objects} objects or {} MB of fonts, images and other data; copy fewer objects at a time", + max_bytes >> 20 + ))); + } + } + Ok(found) +} + +/// Copy `value` from `src` into `dst` with every object it refers to, each once (cycles end), as +/// Insert Pages copies a page's resources; what equals an object already in `dst` (the same font +/// or image) is shared rather than stored twice. Returns `value` as `dst` refers to it. A chain +/// nested more than 256 levels deep is refused; [`reachable`] bounds the size first. +pub fn import_value(dst: &mut Document, src: &Document, value: &Object) -> Result { + let before: HashSet = dst.object_numbers().into_iter().collect(); + let mut copier = Copier::new(src, HashMap::new(), HashMap::new()); + let copied = copier.copy_value(dst, value, None)?; + // Held in an array object while duplicates merge (an array is never a resource that merges + // itself away), so the references in it follow them. + let holder = dst.add(Object::Array(vec![copied])); + let created = added_objects(dst, &before); + crate::dedupe::dedupe_resources(dst, &created, true); + let held = dst.get(holder).as_array().and_then(|a| a.first().cloned()).unwrap_or(Object::Null); + dst.free(holder); + Ok(held) +} + /// Page `page` of `src` as a form XObject in `dst` (its content and resources, no annotations), /// for backgrounds and watermarks taken from a PDF. The form's `/BBox` is the page's crop box /// and its `/Matrix` undoes the page rotation, so it draws upright as displayed. Returns the diff --git a/crates/organize/src/lib.rs b/crates/organize/src/lib.rs index 3b3d56cd83..d85cd15645 100644 --- a/crates/organize/src/lib.rs +++ b/crates/organize/src/lib.rs @@ -33,8 +33,8 @@ pub use comments::import_comments; pub use dedupe::dedupe_resources; pub use import::{ CombineOutcome, Combiner, FieldConflicts, ImportOutcome, Run, SplitBy, combine, combine_field_conflicts, combine_grouped, combine_grouped_with, - combine_selected, combine_selected_with, combine_with, extract_pages, field_name_conflicts, import_pages, import_pages_with, insert_file_pages, - page_as_form, split, split_ranges, + combine_selected, combine_selected_with, combine_with, extract_pages, field_name_conflicts, import_pages, import_pages_with, import_value, + insert_file_pages, page_as_form, reachable, split, split_ranges, }; pub use labels::{LabelRange, LabelStyle, PageLabels, number_pages, page_label_ranges, page_labels, set_page_label_ranges}; pub use outline::{ diff --git a/crates/ui-egui/src/app.rs b/crates/ui-egui/src/app.rs index a8d8f80390..2c9d6b4579 100644 --- a/crates/ui-egui/src/app.rs +++ b/crates/ui-egui/src/app.rs @@ -18,7 +18,7 @@ mod links; mod prefs; mod window; -pub(crate) use clipboard::{copy_refused, print_refused}; +pub(crate) use clipboard::{ObjectClipMark, copy_refused, print_refused}; #[cfg(not(target_arch = "wasm32"))] pub(crate) use files::same_file; use frame::migrate_panel_state; @@ -245,6 +245,8 @@ impl PdfCraftApp { progress_notice: None, page_clipboard: None, comment_clipboard: None, + object_clipboard: None, + object_clip_mark: Default::default(), grid_drop: None, last_snapshot: None, last_copied_image: None, diff --git a/crates/ui-egui/src/app/clipboard.rs b/crates/ui-egui/src/app/clipboard.rs index 155ffa7538..3de7164f49 100644 --- a/crates/ui-egui/src/app/clipboard.rs +++ b/crates/ui-egui/src/app/clipboard.rs @@ -15,6 +15,24 @@ enum ClipboardPaste { Image(std::sync::Arc), } +/// What objects copied in Edit a PDF put on the system clipboard for other applications, so ⌘V +/// can tell whether it still holds that copy or something copied since: their text, or else the +/// first picture's pixels (a hash of them), or nothing. +#[derive(Clone, Debug, Default, PartialEq)] +pub(crate) enum ObjectClipMark { + Text(String), + Picture(u64), + #[default] + None, +} + +fn picture_hash(width: usize, height: usize, rgba: &[u8]) -> u64 { + use std::hash::{Hash, Hasher}; + let mut h = std::collections::hash_map::DefaultHasher::new(); + (width, height, rgba).hash(&mut h); + h.finish() +} + impl PdfCraftApp { /// Whether text and images may be copied out of document `id`; says why not when they may /// not. Reading aloud and accessibility tools don't ask (assistive technology may read @@ -53,8 +71,14 @@ impl PdfCraftApp { || ctx.input_mut(|input| input.consume_shortcut(&KeyboardShortcut::new(Modifiers::COMMAND, Key::X))); let paste = ctx.input_mut(|input| input.consume_shortcut(&KeyboardShortcut::new(Modifiers::COMMAND, Key::V))); + // Objects selected together in Edit a PDF take ⌘C and ⌘X. + let objects = (copy || cut) && self.quick_tool == QuickTool::EditText && self.views.get(i).is_some_and(|v| v.objects.count() > 0); + if objects { + self.copy_objects(i, ctx, cut); + } let text_selected = copy && self.views.get(i).and_then(|v| v.selected_text()).is_some_and(|t| !t.is_empty()); if copy + && !objects && (text_selected || !self.copy_selected_comments(i)) && !self.copy_selected_text(i, ctx) && let Some(sel) = self.views.get(i).and_then(|v| v.image_selection.clone()) @@ -64,12 +88,18 @@ impl PdfCraftApp { self.copy_page_image(sel.page, sel.index); } if cut + && !objects && let Some(sel) = self.views.get(i).and_then(|v| v.image_selection.clone()) && let Some(id) = self.views.get(i).map(|v| v.id) && self.may_copy(id) { self.cut_page_image(sel.page, sel.index); } + if (paste || !pastes.is_empty()) && self.quick_tool == QuickTool::EditText && self.object_clipboard.is_some() && self.pastes_objects(&pastes) + { + self.paste_objects(i); + return; + } if !pastes.is_empty() { self.receive_pastes(i, pastes, true); return; @@ -92,6 +122,90 @@ impl PdfCraftApp { } } + /// ⌘C / ⌘X in Edit a PDF with objects selected together: copy them for ⌘V in any open + /// document, and put their text (or else their first picture) on the system clipboard for + /// other applications. A cut then deletes them, as one undo step. + pub(crate) fn copy_objects(&mut self, i: usize, ctx: &egui::Context, cut: bool) -> bool { + let Some((id, Some((page, targets)))) = self.views.get(i).map(|v| (v.id, v.objects.selection())) else { return false }; + if !self.may_copy(id) { + return false; + } + let Some(doc) = self.session.get(id) else { return false }; + if cut && !doc.allows_modification() { + self.notify_error(tl!("The document's security settings don't allow this change")); + return false; + } + let clip = match doc.copy_objects(page, &targets) { + Ok(clip) => clip, + Err(e) => { + self.notify_error(e); + return false; + } + }; + // A newer copy wins over a whole-document copy still reading its pages. + self.document_copy = None; + let text = clip.text(); + let picture = + clip.objects().iter().find(|o| o.target.kind == pdfcraft_engine::ObjectKind::Image && o.content_type == "image").map(|o| o.target.index); + self.object_clip_mark = if !text.is_empty() { + ctx.copy_text(text.clone()); + ObjectClipMark::Text(text) + } else if let Some(index) = picture.filter(|_| self.system_clipboard && cfg!(not(target_arch = "wasm32"))) + && self.copy_page_image(page, index) + && let Some((w, h, rgba)) = &self.last_copied_image + { + ObjectClipMark::Picture(picture_hash(*w as usize, *h as usize, rgba)) + } else { + ObjectClipMark::None + }; + self.object_clipboard = Some(pdfcraft_engine::ObjectClipboard::new(clip)); + if cut && let Some(delete) = pdfcraft_engine::Edit::delete_objects(page, targets) { + self.apply_edit(delete); + } + true + } + + /// Whether ⌘V's `pastes` (what the system clipboard gave, if anything) are the objects + /// copied in Edit a PDF, rather than something copied since, here or in another application. + fn pastes_objects(&self, pastes: &[ClipboardPaste]) -> bool { + pastes.iter().all(|paste| match (paste, &self.object_clip_mark) { + (ClipboardPaste::Text(text), ObjectClipMark::Text(copied)) => text.replace("\r\n", "\n").trim_end() == copied.trim_end(), + (ClipboardPaste::Text(text), ObjectClipMark::None) => !pdfcraft_engine::is_xfdf_comments(text), + (ClipboardPaste::Image(image), ObjectClipMark::Picture(hash)) => { + let rgba: Vec = image.pixels.iter().flat_map(egui::Color32::to_srgba_unmultiplied).collect(); + picture_hash(image.size[0], image.size[1], &rgba) == *hash + } + (ClipboardPaste::Image(_), ObjectClipMark::None) => true, + _ => false, + }) + } + + /// ⌘V in Edit a PDF: the copied objects onto the current page of tab `i`, as page content + /// that draws the same (one undo step), and selected. + pub(crate) fn paste_objects(&mut self, i: usize) -> bool { + let Some((id, page)) = self.views.get(i).map(|v| (v.id, v.current)) else { return false }; + let (Some(doc), Some(clipboard)) = (self.session.get(id), self.object_clipboard.as_mut()) else { return false }; + if !doc.allows_modification() { + self.notify_error(tl!("The document's security settings don't allow this change")); + return false; + } + let before = doc.editable_objects(page).unwrap_or_default(); + let edit = match clipboard.paste(doc, page) { + Ok(edit) => edit, + Err(e) => { + self.notify_error(e); + return false; + } + }; + if !self.apply_edit(edit) { + return false; + } + if let (Some(Ok(after)), Some(view)) = (self.session.get(id).map(|d| d.editable_objects(page)), self.views.get_mut(i)) { + view.objects.select_pasted(page, &before, after); + } + true + } + /// `on_canvas`: the paste is the canvas's (no text field, dialog or page grid has it), so /// pictures and copied comments land on the page. fn receive_pastes(&mut self, view: usize, pastes: Vec, on_canvas: bool) { diff --git a/crates/ui-egui/src/app/keys.rs b/crates/ui-egui/src/app/keys.rs index c014453806..419f7a9ed5 100644 --- a/crates/ui-egui/src/app/keys.rs +++ b/crates/ui-egui/src/app/keys.rs @@ -50,8 +50,13 @@ impl PdfCraftApp { && (pages_owns_keyboard || !ctx.egui_wants_keyboard_input()) && ctx.input_mut(|input| input.consume_shortcut(&egui::KeyboardShortcut::new(egui::Modifiers::COMMAND, Key::A))) { + let view = &self.views[i]; + // A paragraph or text box open for typing keeps ⌘A for its own text. + let edit_objects = self.quick_tool == QuickTool::EditText && view.line_editor.is_none() && view.content.draft.is_none(); if pages_owns_keyboard { self.views[i].select_all_pages(); + } else if let Some(doc) = self.session.get(view.id).filter(|d| edit_objects && d.allows_modification()) { + object_ui::select_all(&mut self.views[i], doc); } else if self.is_preparing() { if let Some(doc) = self.session.get(self.views[i].id) { prepare::select_all(&mut self.views[i], &doc.form); diff --git a/crates/ui-egui/src/editing.rs b/crates/ui-egui/src/editing.rs index 29528db146..c205eb377c 100644 --- a/crates/ui-egui/src/editing.rs +++ b/crates/ui-egui/src/editing.rs @@ -917,6 +917,7 @@ fn re_resolve_image_selection( fn content_page(edit: &Edit) -> Option { match edit { Edit::MoveObjects { page, .. } + | Edit::DeleteObjects { page, .. } | Edit::EditTextLine { page, .. } | Edit::EditTextBlock { page, .. } | Edit::EditPageImage { page, .. } @@ -924,7 +925,8 @@ fn content_page(edit: &Edit) -> Option { | Edit::AddImage { page, .. } | Edit::UpdateContent { page, .. } | Edit::DeleteContent { page, .. } - | Edit::ReplaceImage { page, .. } => Some(*page), + | Edit::ReplaceImage { page, .. } + | Edit::PasteObjects { page, .. } => Some(*page), _ => None, } } diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 66256416c7..1663c3be73 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -603,6 +603,10 @@ pub struct PdfCraftApp { pub page_clipboard: Option, /// Copied comments (XFDF), to paste into this or another document (M14). pub comment_clipboard: Option, + /// Objects copied together in Edit a PDF, to paste into this or another document (#5989). + pub object_clipboard: Option, + /// What copying them put on the system clipboard (see [`app::ObjectClipMark`]). + pub(crate) object_clip_mark: app::ObjectClipMark, /// Files dropped on the page grid, waiting for the pointer to say which gap they go to. grid_drop: Option, /// The last snapshot (width, height, RGBA); `system_clipboard` also puts it on the diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index 369ad5933d..02c8de225f 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -475,6 +475,10 @@ impl PdfCraftApp { let enabled_id = |id: &str| commands::command(id).is_some_and(|s| self.command_enabled(s)); let index = param_index(command); let text = ctx.text_edit_focused() && matches!(command.id.as_str(), "edit.undo" | "edit.redo" | "edit.copy" | CUT | PASTE | SELECT_ALL); + // Objects selected together in Edit a PDF are what Copy and Cut take, and Paste brings + // back objects copied there. + let edit_objects = self.quick_tool == crate::QuickTool::EditText && !organize; + let doc = self.active_ids().and_then(|(_, id)| self.session.get(id)); let enabled = text || match command.id.as_str() { NO_RECENT => false, @@ -487,6 +491,10 @@ impl PdfCraftApp { "edit.copy" if view.is_some_and(|v| v.picked_image.is_some()) => { self.active_ids().and_then(|(_, id)| self.session.get(id)).is_some_and(|d| d.allows_copying()) } + "edit.copy" | CUT if edit_objects && view.is_some_and(|v| v.objects.count() > 0) => { + doc.is_some_and(|d| d.allows_copying() && (command.id != CUT || d.allows_modification())) + } + PASTE if edit_objects && self.object_clipboard.is_some() => doc.is_some_and(pdfcraft_engine::Document::allows_modification), CUT => organize && enabled_id("page.cut"), PASTE => organize && self.page_clipboard.is_some() && enabled_id("page.paste"), SELECT_ALL | DELETE => has_doc, diff --git a/crates/ui-egui/src/tools/object_ui.rs b/crates/ui-egui/src/tools/object_ui.rs index 6ba22764ec..43013bae58 100644 --- a/crates/ui-egui/src/tools/object_ui.rs +++ b/crates/ui-egui/src/tools/object_ui.rs @@ -48,6 +48,39 @@ impl ObjectSelection { self.selected.len() } + /// The page and references of the objects selected together, if any: what Copy and Cut take. + pub(crate) fn selection(&self) -> Option<(usize, Vec)> { + self.page.filter(|_| !self.selected.is_empty()).map(|page| (page, self.selected.iter().map(|o| o.target).collect())) + } + + /// Select what a paste added to `page`, as Acrobat selects pasted objects: the objects of + /// `after` (the page's inventory with the paste) that `before` (without it) didn't have. They + /// are taken at the next `prepare`, which sees the new document generation. + pub(crate) fn select_pasted(&mut self, page: usize, before: &[EditableObject], after: Vec) { + let same = |a: &EditableObject, b: &EditableObject| { + a.target.kind == b.target.kind + && a.content_type == b.content_type + && a.text == b.text + && a.rect.iter().zip(b.rect).all(|(x, y)| (x - y).abs() < 0.01) + }; + let mut old: Vec<&EditableObject> = before.iter().collect(); + let added: Vec<_> = after + .into_iter() + .filter(|o| match old.iter().position(|b| same(b, o)) { + Some(i) => { + old.swap_remove(i); + false + } + None => true, + }) + .collect(); + self.clear(); + if !added.is_empty() && added.len() <= MAX_MOVE_OBJECTS { + self.page = Some(page); + self.pending = Some(added); + } + } + /// Edit text's input is blocked: a dialog, the palette, another window or another tab has it. pub(crate) fn input_blocked(&self) -> bool { self.input_blocked @@ -188,6 +221,25 @@ fn leave_single(view: &mut DocView) { view.block_drag = None; } +/// ⌘A in Edit a PDF: every object on the current page selected together, as Acrobat selects +/// them (paragraphs, pictures and added content), instead of the page's text. A page whose +/// objects can't be selected together, or has too many, says so and keeps what was selected. +pub(crate) fn select_all(view: &mut DocView, doc: &Document) { + let page = view.current; + match view.objects.inventory(doc, page) { + Ok(objects) if objects.len() > MAX_MOVE_OBJECTS => view.objects.notice = Some(tl!("Select at most 1000 objects at a time.").into()), + Ok(objects) => { + view.objects.clear(); + if !objects.is_empty() { + leave_single(view); + view.objects.page = Some(page); + view.objects.selected = objects; + } + } + Err(_) => view.objects.notice = Some(tl!("This page contains objects that cannot be selected together.").into()), + } +} + /// What of the objects lies under `p` on `page`: the objects selected together (a drag moves /// them), or the frame around the paragraph open for typing (`open`: its box on screen), which /// takes hold of the box as in Acrobat: a click selects it, a drag moves it, its text keeps the @@ -288,7 +340,7 @@ pub(crate) fn page_input(cx: PageToolContext<'_>, press: PagePress, view: &mut D let deleted = ui.input_mut(|i| i.consume_key(egui::Modifiers::NONE, egui::Key::Delete) || i.consume_key(egui::Modifiers::NONE, egui::Key::Backspace)); if deleted { - view.pending_edit = delete_edit(page, view.objects.selected.iter().map(|o| o.target).collect()); + view.pending_edit = Edit::delete_objects(page, view.objects.selected.iter().map(|o| o.target).collect()); return true; } } @@ -503,30 +555,7 @@ pub(crate) fn delete_keys(ctx: &egui::Context, view: &mut DocView) { } // The selection stays until the edit applies: `prepare` clears it when the document changes, // and a refused delete (text used as a clipping path) leaves it selected. - view.pending_edit = delete_edit(page, view.objects.selected.iter().map(|o| o.target).collect()); -} - -/// The edit that deletes `targets` (indexes into one inventory of `page`) as one undo step. -/// Each deletion renumbers only what follows it in its own inventory, so paragraphs and images -/// go from the last to the first, and added content, which the source inventories leave out, -/// after them. -fn delete_edit(page: usize, mut targets: Vec) -> Option { - targets.sort_by_key(|t| (t.kind == ObjectKind::Added, std::cmp::Reverse(t.index))); - targets.dedup(); - let mut edits: Vec = targets - .into_iter() - .map(|t| match t.kind { - ObjectKind::Text => Edit::EditTextBlock { page, block: t.index, text: String::new(), style: pdfcraft_engine::BlockStyle::default() }, - ObjectKind::Image => Edit::EditPageImage { page, index: t.index, change: pdfcraft_engine::ImageEdit::Delete }, - ObjectKind::Added => Edit::DeleteContent { page, index: t.index }, - }) - .collect(); - if edits.len() > 1 { - // "Delete content" is an edit label already, so the history reads the same in every language. - Some(Edit::Batch { label: "Delete content".into(), edits }) - } else { - edits.pop() - } + view.pending_edit = Edit::delete_objects(page, view.objects.selected.iter().map(|o| o.target).collect()); } pub(crate) fn paint(painter: &egui::Painter, xf: &PageXform, page: usize, info: &DocInfo, selection: &ObjectSelection) { @@ -569,32 +598,20 @@ mod tests { ObjectTarget { kind, index } } - /// Deleting renumbers what follows in the same inventory, so the batch takes each inventory - /// from its last item to its first, and added content (outside the source inventories) last. + /// Several objects are one step, each object once (the engine orders the deletions). #[test] - fn several_objects_delete_last_first_in_one_step() { - let targets = vec![t(ObjectKind::Added, 0), t(ObjectKind::Text, 1), t(ObjectKind::Image, 0), t(ObjectKind::Text, 3), t(ObjectKind::Added, 2)]; - let Some(Edit::Batch { label, edits }) = delete_edit(0, targets) else { panic!("several objects make one batch") }; - assert_eq!(label, "Delete content"); - let order: Vec<(char, usize)> = edits - .iter() - .map(|e| match e { - Edit::EditTextBlock { block, text, .. } => { - assert!(text.is_empty()); - ('t', *block) - } - Edit::EditPageImage { index, change: pdfcraft_engine::ImageEdit::Delete, .. } => ('i', *index), - Edit::DeleteContent { index, .. } => ('a', *index), - other => panic!("unexpected {}", other.label().wording), - }) - .collect(); - assert_eq!(order, [('t', 3), ('t', 1), ('i', 0), ('a', 2), ('a', 0)]); + fn several_objects_delete_in_one_step() { + let targets = vec![t(ObjectKind::Added, 0), t(ObjectKind::Text, 1), t(ObjectKind::Image, 0), t(ObjectKind::Text, 1)]; + let edit = Edit::delete_objects(0, targets).unwrap(); + let once = [t(ObjectKind::Added, 0), t(ObjectKind::Text, 1), t(ObjectKind::Image, 0)]; + assert!(matches!(&edit, Edit::DeleteObjects { page: 0, objects } if *objects == once), "{edit:?}"); + assert_eq!(edit.label().wording, "Delete content"); } #[test] fn one_object_deletes_with_its_own_edit_and_none_with_nothing() { - assert!(matches!(delete_edit(2, vec![t(ObjectKind::Image, 4)]), Some(Edit::EditPageImage { page: 2, index: 4, .. }))); - assert!(delete_edit(0, Vec::new()).is_none()); + assert!(matches!(Edit::delete_objects(2, vec![t(ObjectKind::Image, 4)]), Some(Edit::EditPageImage { page: 2, index: 4, .. }))); + assert!(Edit::delete_objects(0, Vec::new()).is_none()); } /// Selecting content objects on a page and pressing Delete must queue one edit that removes diff --git a/crates/ui-egui/tests/it/object_selection.rs b/crates/ui-egui/tests/it/object_selection.rs index 21ebc90b69..2f5619c81a 100644 --- a/crates/ui-egui/tests/it/object_selection.rs +++ b/crates/ui-egui/tests/it/object_selection.rs @@ -504,3 +504,114 @@ fn a_double_click_on_a_grouped_paragraph_opens_it() { h.run_steps(2); assert!(h.state().views[0].line_editor.is_some(), "the paragraph is open for typing"); } + +fn texts(h: &Harness<'static, PdfCraftApp>, view: usize) -> Vec { + let app = h.state(); + app.session.get(app.views[view].id).unwrap().editable_objects(0).unwrap().into_iter().filter_map(|o| o.text).collect() +} + +fn can_undo(h: &Harness<'static, PdfCraftApp>, view: usize) -> Option { + let app = h.state(); + app.session.get(app.views[view].id).unwrap().can_undo().map(str::to_string) +} + +fn keys(h: &mut Harness<'static, PdfCraftApp>, modifiers: Modifiers, key: Key) { + h.key_press_modifiers(modifiers, key); + h.run_steps(4); +} + +/// ⌘A in Edit a PDF selects every object on the page, as Acrobat does: paragraphs, artwork and +/// added content. The arrow keys then move them all and Delete removes them all, each as one step. +#[test] +fn command_a_selects_every_object_on_the_page_to_move_and_delete_together() { + let mut h = harness(); + let before = objects(&h); + assert_eq!(before.len(), 4); + keys(&mut h, Modifiers::COMMAND, Key::A); + h.get_by_label("4 objects selected"); + assert!(h.state().views[0].selected_text().is_none_or(|t| t.is_empty()), "objects, not the page's text"); + keys(&mut h, Modifiers::SHIFT, Key::ArrowRight); + assert_eq!(can_undo(&h, 0).as_deref(), Some("Move objects")); + for (old, new) in before.iter().zip(objects(&h)) { + assert!((new.rect[0] - old.rect[0] - 10.0).abs() < 0.01, "{old:?} -> {new:?}"); + } + h.get_by_label("4 objects selected"); + keys(&mut h, Modifiers::NONE, Key::Delete); + assert!(objects(&h).is_empty(), "{:?}", objects(&h)); + assert_eq!(can_undo(&h, 0).as_deref(), Some("Delete content")); + keys(&mut h, Modifiers::COMMAND, Key::Z); + assert_eq!(objects(&h).len(), 4, "one undo brings every object back"); +} + +/// In a paragraph open for typing, ⌘A selects its text, as before. +#[test] +fn command_a_in_an_open_paragraph_selects_its_text_not_the_objects() { + let mut h = harness(); + let p = at(&h, 55.0, 70.0); + click(&mut h, p, Modifiers::NONE); + assert!(h.state().views[0].line_editor.is_some()); + keys(&mut h, Modifiers::COMMAND, Key::A); + assert!(h.query_all_by_label_contains("objects selected").next().is_none()); + assert!(h.state().views[0].line_editor.is_some(), "the paragraph stays open"); +} + +/// ⌘C, ⌘X and ⌘V take the objects selected together: a paste onto their own page lands 18 pt down +/// and right, selected; a paste into another document draws them there; a cut is one step. +#[test] +fn objects_copy_cut_and_paste_with_the_keys_on_the_page_and_into_another_document() { + let mut h = harness(); + h.state_mut().system_clipboard = false; + let unchanged = can_undo(&h, 0); + keys(&mut h, Modifiers::COMMAND, Key::A); + keys(&mut h, Modifiers::COMMAND, Key::C); + assert_eq!(can_undo(&h, 0), unchanged, "a copy changes nothing"); + assert!(h.state().object_clipboard.is_some()); + keys(&mut h, Modifiers::COMMAND, Key::V); + assert_eq!(can_undo(&h, 0).as_deref(), Some("Paste")); + let pasted = texts(&h, 0); + for text in ["Group headline", "Keep here", "Added note"] { + assert_eq!(pasted.iter().filter(|t| t.contains(text)).count(), 2, "{text}: {pasted:?}"); + } + assert!(h.query_all_by_label_contains("objects selected").next().is_some(), "the pasted objects are selected"); + let moved = objects(&h).into_iter().filter(|o| o.text.as_deref() == Some("Keep here")).map(|o| o.rect).collect::>(); + assert!((moved[1][0] - moved[0][0] - 18.0).abs() < 0.01 && (moved[0][1] - moved[1][1] - 18.0).abs() < 0.01, "{moved:?}"); + + // Into another open document, where it was: exactly over its own copy of the same page. + h.state_mut().open_bytes("other.pdf", None, fixture(false)).unwrap(); + h.run_steps(4); + let other = h.state().active.unwrap(); + assert_eq!(other, 1); + h.state_mut().quick_tool = pdfcraft_ui_egui::QuickTool::EditText; + h.run_steps(2); + keys(&mut h, Modifiers::COMMAND, Key::V); + assert_eq!(can_undo(&h, other).as_deref(), Some("Paste")); + let there = texts(&h, other); + assert!(["Group headline", "Keep here", "Added note"].iter().all(|t| there.iter().any(|x| x.contains(t))), "{there:?}"); + + // Cut: copied, then deleted in one step; a paste brings it back. + keys(&mut h, Modifiers::COMMAND, Key::A); + keys(&mut h, Modifiers::COMMAND, Key::X); + assert!(texts(&h, other).is_empty(), "{:?}", texts(&h, other)); + assert_eq!(can_undo(&h, other).as_deref(), Some("Delete content")); + keys(&mut h, Modifiers::COMMAND, Key::V); + assert!(texts(&h, other).iter().any(|t| t.contains("Added note"))); +} + +/// What another application (or another copy) put on the clipboard since wins ⌘V: the objects +/// paste only while it still holds the text their copy put there. +#[test] +fn a_newer_copy_elsewhere_wins_over_the_copied_objects() { + let mut h = harness(); + h.state_mut().system_clipboard = false; + keys(&mut h, Modifiers::COMMAND, Key::A); + keys(&mut h, Modifiers::COMMAND, Key::C); + let copied = h.state().object_clipboard.as_ref().unwrap().clip().text(); + assert!(copied.contains("Group headline") && copied.contains("Added note"), "{copied}"); + let before = can_undo(&h, 0); + h.event(egui::Event::Paste("a word copied in another application".into())); + h.run_steps(4); + assert_eq!(can_undo(&h, 0), before, "the newer text is not the objects"); + h.event(egui::Event::Paste(copied.replace('\n', "\r\n"))); + h.run_steps(4); + assert_eq!(can_undo(&h, 0).as_deref(), Some("Paste")); +} diff --git a/docs/editing-multiple-objects.md b/docs/editing-multiple-objects.md index 7881be6a7c..3cf4a6b167 100644 --- a/docs/editing-multiple-objects.md +++ b/docs/editing-multiple-objects.md @@ -13,6 +13,30 @@ In **Edit a PDF → Edit text & images**, select several items on the same page: - Modifier-clicks prefer added items and text over background artwork, so labels remain selectable. - Clear the selection or double-click an item to return to ordinary text editing and image resizing. Finish or discard a text draft before starting a multi-selection. +- **⌘A / Ctrl+A** selects every item on the current page (paragraphs, artwork and added items), + as Acrobat's Edit PDF does, instead of the page's text. Inside a paragraph or text box open for + typing it still selects that text. + +## Copy, cut and paste + +With items selected together, **⌘C / Ctrl+C** (Edit ▸ Copy) copies them and **⌘X / Ctrl+X** +(Edit ▸ Cut) copies them, then deletes them as one undo step. **⌘V / Ctrl+V** (Edit ▸ Paste) pastes +them onto the current page of any open document, also after the original changed or closed, as +one undo step, and selects what it pasted. Onto the page they came from they land 18 points down +and to the right, as Acrobat offsets a paste onto the same page; elsewhere where they were; each +further paste onto a page steps another 18 points; a copy that would land off the page is centred. + +A paste draws the same: it copies the items' content operators with the graphics state they were +drawn in (transformation, clip, colours, transparency, text state) and every resource they use +(fonts, images and Form artwork, ExtGStates, colour spaces, patterns, shadings), merged into the +target page's resources under names that don't clash, as Ungroup merges them. Within a document the +same font and image objects are drawn again. Into another document they are copied with what they +refer to, each once whatever the cycles, sharing objects it already holds; a paste whose resources +take more than 20,000 objects or 256 MB is refused with that reason. Pasted items are ordinary page +content: an added text box comes as page text, untagged and in no layer. The system clipboard gets +the copied text (or, without text, the first picture) for other applications; a newer copy there, +here or in another application, wins ⌘V. Text drawn as a clipping path before the selection is +refused, as is artwork without resources of its own that names one, where a paste would change it. The supported items are the existing paragraph boxes, raster Image XObjects, whole Form artwork, and text/images added by PdfCraft. A Form remains one item, including its nested artwork and @@ -75,6 +99,17 @@ session. For a CLI script, include `doc_open` in that script before the move: ] ``` +`{"all": true}` instead of `objects` takes every item on the page, as ⌘A does. `object_copy` takes +the same references (with `cut: true` it deletes them afterwards, one undo step) and `object_paste +{doc, page}` pastes them, into the same or another open document, as the app does: + +```json +[ + {"tool":"object_copy","args":{"doc":1,"page":1,"all":true}}, + {"tool":"object_paste","args":{"doc":2,"page":3}} +] +``` + Use the actual generation and references from your document, rather than assuming these example values. `offset` is in displayed points: positive x moves right, positive y moves down. The optional generation guard rejects a changed document before interpreting references. Re-list after any edit, @@ -82,6 +117,9 @@ undo, redo, or save; there is no claim that an index persists across generations `pdfcraft-cli run --script steps.json --root DIR` or call these tools through the opt-in MCP server. Regression coverage: `crates/edit/src/tests.rs` (glyph bytes, neighbours, split streams, shared Forms, -rotated/cropped/UserUnit pages and refusal), `crates/engine/src/tests.rs` (permissions, history, -rendering and save/reopen), `crates/automation/tests/it/automation.rs` (generation guards and hostile -references), and `crates/ui-egui/tests/it/object_selection.rs` (real-shell selection and gestures). +rotated/cropped/UserUnit pages and refusal), `crates/edit/src/objects/clip_tests.rs` (copies that +render the same pixels, renamed resources, hostile content), `crates/engine/src/tests.rs` and +`crates/engine/src/object_clip_tests.rs` (permissions, history, rendering, save/reopen, pastes into +another document, cycles and the size limit), `crates/automation/tests/it/automation.rs` (generation +guards, hostile references, copy/cut/paste tools), and `crates/ui-egui/tests/it/object_selection.rs` +(real-shell selection, gestures, ⌘A and the clipboard keys). diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index 0e8ff0681c..d6038c856d 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -1561,9 +1561,9 @@ tier = "P1" milestone = "M3" status = "shipped" commands = [] -tools = [] -evidence = ["crates/ui-egui/tests/it/viewer.rs::view_history_select_all_and_find_options", "crates/ui-egui/src/canvas/tests.rs::select_all_copies_the_whole_document_while_scrolling_continuously", "crates/ui-egui/src/canvas/tests.rs::select_all_of_the_document_only_while_scrolling_through_several_pages"] -notes = "⌘A: the current page one page at a time; the whole document scrolling continuously (#2639), copied whole" +tools = ["object_copy", "object_move"] +evidence = ["crates/ui-egui/tests/it/viewer.rs::view_history_select_all_and_find_options", "crates/ui-egui/src/canvas/tests.rs::select_all_copies_the_whole_document_while_scrolling_continuously", "crates/ui-egui/src/canvas/tests.rs::select_all_of_the_document_only_while_scrolling_through_several_pages", "crates/ui-egui/tests/it/object_selection.rs::command_a_selects_every_object_on_the_page_to_move_and_delete_together", "crates/ui-egui/tests/it/object_selection.rs::command_a_in_an_open_paragraph_selects_its_text_not_the_objects", "crates/automation/tests/it/automation.rs::objects_copy_cut_and_paste_through_tools_within_and_across_documents"] +notes = "⌘A: the current page one page at a time; the whole document scrolling continuously (#2639), copied whole. In Edit text & images (Acrobat's Edit PDF) ⌘A selects every object on the current page together instead (paragraphs, artwork, added content), to move with the arrows or a drag, delete or copy as one step; inside a paragraph open for typing it selects that text. Headless: `all: true` in object_move and object_copy. Not observed against Acrobat: whether its ⌘A there also reaches paths and inline images (not selectable here)." [[feature]] id = "view.copy-with-layout" @@ -2117,9 +2117,9 @@ area = "C" tier = "P1" milestone = "M7" status = "partial" -tools = ["content_copy"] -evidence = ["crates/automation/tests/it/automation.rs::copying_added_content_through_tools", "crates/engine/src/tests.rs::pasted_added_content_keeps_its_style_and_picture", "crates/engine/src/tests.rs::an_added_jpeg_2000_image_is_copied_as_its_file", "crates/edit/src/tests.rs::a_paste_lands_where_the_copy_was_offset_per_paste_and_stays_on_the_page", "crates/edit/src/tests.rs::a_paste_steps_past_copies_of_itself_only"] -notes = "Issue #2636, engine and automation part. Edit::PasteContent pastes an added text box or image copied in the same document or another, as one undo step, and `content_copy` drives it headlessly. A copy keeps a text box's text, font, size, colour, alignment and box, and an image's picture, box, turns, flips and crop. It lands where the item was, stepped 18 pt down and to the right past each copy already on the page, or at a given point, and stays on the page. Within a document a pasted image draws the same image object; into another document its picture goes as its JPEG or JPEG 2000 file, or else a PNG, so a colour profile is dropped and CMYK colours in a PNG are turned to RGB, and later pastes there reuse the first one's picture. The copy permission is honoured. Missing: the desktop app's ⌘X / ⌘C / ⌘V, Edit ▸ Cut and Paste and the right-click menu (follow-up PRs), the original document's paragraphs and images (open PRs #1625 and #2251), several items at once, and a check of Acrobat's own clipboard format (not observed)." +tools = ["content_copy", "object_copy", "object_paste"] +evidence = ["crates/ui-egui/tests/it/object_selection.rs::objects_copy_cut_and_paste_with_the_keys_on_the_page_and_into_another_document", "crates/ui-egui/tests/it/object_selection.rs::a_newer_copy_elsewhere_wins_over_the_copied_objects", "crates/automation/tests/it/automation.rs::objects_copy_cut_and_paste_through_tools_within_and_across_documents", "crates/engine/src/object_clip_tests.rs::objects_copied_from_one_document_paste_into_another_and_draw_the_same", "crates/engine/src/object_clip_tests.rs::pasting_into_another_document_follows_cycles_and_refuses_past_its_limit", "crates/edit/src/objects/clip_tests.rs::pasted_objects_draw_the_same_on_another_page_under_renamed_resources", "crates/edit/src/objects/clip_tests.rs::a_paste_onto_its_own_page_moves_by_the_offset_and_draws_the_same", "crates/automation/tests/it/automation.rs::copying_added_content_through_tools", "crates/engine/src/tests.rs::pasted_added_content_keeps_its_style_and_picture", "crates/engine/src/tests.rs::an_added_jpeg_2000_image_is_copied_as_its_file", "crates/edit/src/tests.rs::a_paste_lands_where_the_copy_was_offset_per_paste_and_stays_on_the_page", "crates/edit/src/tests.rs::a_paste_steps_past_copies_of_itself_only"] +notes = "Existing page objects (#5989): in Edit text & images, ⌘C / ⌘X / ⌘V and Edit ▸ Copy, Cut and Paste act on the objects selected together (⌘A, a marquee, modifier-clicks, a paragraph's frame): paragraphs, Image and Form artwork and added content. Edit::PasteObjects pastes them onto the current page of any open document as one undo step, selected: onto their own page 18 pt down and right, elsewhere where they were, each further paste a step more, centred when off the page. The paste draws the same pixels (raster-compared in tests): their content operators with the graphics state they had, and every resource they use (fonts, XObjects, ExtGStates, colour spaces, patterns, shadings) merged into the page's under names that don't clash, as Ungroup merges them; into another document the objects they refer to are copied once each, through cycles, sharing what is there, refused past 20,000 objects or 256 MB. Cut deletes as one step. The system clipboard gets their text (or the first picture) for other applications, and a newer copy wins ⌘V. Headless: object_copy (all, cut) and object_paste. Limits: pasted content is untagged and in no layer, an added text box pastes as page text, text clipping what follows and artwork that inherits resources it names are refused, no right-click Cut/Copy/Paste for these objects yet, and Acrobat's own clipboard format was not observed. Issue #2636, engine and automation part. Edit::PasteContent pastes an added text box or image copied in the same document or another, as one undo step, and `content_copy` drives it headlessly. A copy keeps a text box's text, font, size, colour, alignment and box, and an image's picture, box, turns, flips and crop. It lands where the item was, stepped 18 pt down and to the right past each copy already on the page, or at a given point, and stays on the page. Within a document a pasted image draws the same image object; into another document its picture goes as its JPEG or JPEG 2000 file, or else a PNG, so a colour profile is dropped and CMYK colours in a PNG are turned to RGB, and later pastes there reuse the first one's picture. The copy permission is honoured. Missing: the desktop app's ⌘X / ⌘C / ⌘V, Edit ▸ Cut and Paste and the right-click menu (follow-up PRs), the original document's paragraphs and images (open PRs #1625 and #2251), several items at once, and a check of Acrobat's own clipboard format (not observed)." [[feature]] id = "edit.image-replace" From 43f24aa1692c7ee39ae8117482896f5264ad994a Mon Sep 17 00:00:00 2001 From: BFlat Date: Mon, 9 Nov 2026 02:56:34 +0800 Subject: [PATCH 17/92] Draw Thai in Fill & Sign, text boxes, form fields, headers, footers and watermarks (#6632) Refs #2354. Thai typed into these tools is shaped and drawn from the Thai face through Add text's writer (now pdfcraft_fonts::shaped_text), searchable and copyable, instead of being written as ?????; a build without a Thai face refuses it with a message. --- crates/annot/README.md | 6 +- crates/annot/src/appearance.rs | 68 ++- crates/annot/src/create.rs | 28 +- crates/annot/src/draw.rs | 4 +- crates/annot/src/modify.rs | 16 +- crates/annot/src/tests.rs | 63 +++ crates/automation/src/tools.rs | 10 +- crates/automation/tests/it/automation.rs | 44 ++ crates/edit/src/added.rs | 8 +- crates/edit/src/added/draw.rs | 201 ++------- crates/edit/src/added/layout.rs | 31 +- crates/edit/src/apply.rs | 55 ++- crates/edit/src/lib.rs | 2 +- crates/edit/src/marks.rs | 92 +++- crates/edit/src/place.rs | 24 +- crates/edit/src/tests.rs | 49 +++ crates/fonts/README.md | 7 +- crates/fonts/src/lib.rs | 31 +- crates/fonts/src/shaped_text.rs | 515 +++++++++++++++++++++++ crates/forms/README.md | 5 +- crates/forms/src/appearance.rs | 113 +++-- crates/forms/src/appearances.rs | 3 + crates/forms/src/author/look.rs | 4 +- crates/forms/src/tests.rs | 55 +++ crates/forms/src/values.rs | 9 +- parity/acrobat-features.toml | 20 +- 26 files changed, 1155 insertions(+), 308 deletions(-) create mode 100644 crates/fonts/src/shaped_text.rs diff --git a/crates/annot/README.md b/crates/annot/README.md index 999baec20a..5c62eece5a 100644 --- a/crates/annot/README.md +++ b/crates/annot/README.md @@ -12,7 +12,11 @@ Comments (annotations), ISO 32000-2 §12.5. Layer L3, depends on `pdfcraft-cos`, A line's leader lines (`/LL`, `/LLE`, `/LLO`) and caption (`/Cap`, `/CP`, `/CO`) are drawn as ISO 32000-2 Table 178 places them, the caption in black Helvetica 10. Note icons are PdfCraft's own drawings. Text boxes use standard Helvetica (WinAnsi), with - line breaking by approximate Helvetica proportions (no font program is bundled). Text it can't + line breaking by Helvetica's standard-14 metrics. Thai in a text box, callout or Fill & Sign + text is shaped and drawn from the craft-fonts Thai face with Add text's writer + (`pdfcraft_fonts::shaped_text`: Type 3 glyphs with a ToUnicode map), the box sized to the ink + of its stacked marks and descenders; a build without a Thai face refuses it + (`AnnotError::Undrawable`) instead of drawing `?`. Other text it can't draw (in a text box, callout, Fill & Sign text, line caption, measurement label or dynamic stamp's by-line; `appearance::undrawable_text`) is kept as typed and drawn as `?`, and the comment is marked with the first such character (`pdfcraft_fonts::UNDRAWN_MARK`) so the engine reports the edit and diff --git a/crates/annot/src/appearance.rs b/crates/annot/src/appearance.rs index fcb3cb22b5..dafc4b01b1 100644 --- a/crates/annot/src/appearance.rs +++ b/crates/annot/src/appearance.rs @@ -7,12 +7,39 @@ //! endings), so callers never replace an appearance with a worse one. //! //! The note icons are PdfCraft's own drawings (AGENTS.md §1). Text boxes use the standard -//! Helvetica font with WinAnsi encoding; line breaking uses [`text_width`], an approximation of -//! Helvetica's proportions by character class (no font program or metrics file is bundled). +//! Helvetica font with WinAnsi encoding, and draw Thai from the craft-fonts Thai face as Type 3 +//! glyphs (`pdfcraft_fonts::shaped_text`, the writer Add text uses); line breaking uses +//! [`text_width`], the standard-14 metrics and the Thai face's shaped advances. use pdfcraft_cos::{Dict, Object, PdfString, Stream}; -use pdfcraft_fonts::{first_undrawable, literal, win_ansi}; -pub use pdfcraft_fonts::{helvetica_width as text_width, wrap}; +use pdfcraft_fonts::shaped_text; +use pdfcraft_fonts::{first_undrawable, helvetica_width, literal, win_ansi, wrap_with}; + +/// The width of `text` at `size` as appearances draw it: Helvetica, with Thai shaped in the Thai +/// face that text boxes draw it with. +pub fn text_width(text: &str, size: f64) -> f64 { + shaped_text::thai_width(text, size, helvetica_width) +} + +/// `text` wrapped `width` wide as [`text_width`] measures it; a Thai mark stays on its letter. +pub fn wrap(text: &str, size: f64, width: f64) -> Vec { + wrap_with(text, size, width, text_width) +} + +/// How far a text box's `lines` reach, in em: above the first baseline (0.9 of the 1.2 em line) +/// and below the last (0.3), or further where Thai stacked marks or descenders reach, so the box +/// is sized to their ink and doesn't clip them. +fn reach(lines: &[String]) -> [f64; 2] { + let ink = |line: Option<&String>| line.and_then(|l| shaped_text::thai_reach(l)); + [ink(lines.first()).map_or(0.9, |r| r[1].max(0.9)), ink(lines.last()).map_or(0.3, |r| (-r[0]).max(0.3))] +} + +/// The height a text box's `lines` take at `size`, inside its padding: 1.2 em a line, and more +/// for Thai that reaches further ([`reach`]). +pub fn text_lines_height(lines: &[String], size: f64) -> f64 { + let [up, down] = reach(lines); + lines.len().max(1) as f64 * size * 1.2 + (up - 0.9 + down - 0.3) * size +} use crate::{Document, NOTE_SIZE, Rgb, n}; @@ -392,13 +419,15 @@ pub(crate) fn inset(d: &Dict) -> Option<[f64; 4]> { /// The first text [`build`] would draw in the standard fonts (Helvetica, WinAnsiEncoding) that /// they can't, which would come out as `?`: what it is (for the message) and the character. The -/// text of a text box, callout or Fill & Sign text, a line's caption, a measurement's label, a +/// text of a text box, callout or Fill & Sign text (whose Thai the Thai face draws, where this +/// build has one), a line's caption, a measurement's label, a /// dynamic stamp's "By …" line, and a redaction mark's overlay text (drawn when it is applied). `None` when all of /// it can be drawn. pub fn undrawable_text(d: &Dict) -> Option<(&'static str, char)> { let text = |key: &[u8]| d.get(key).and_then(Object::as_string).map(PdfString::to_text); let (what, text) = match d.name(b"Subtype")? { - b"FreeText" => ("the comment's text", text(b"Contents")?), + // A text box draws Thai too. + b"FreeText" => return Some(("the comment's text", shaped_text::first_undrawable(&text(b"Contents")?)?)), b"Line" if !d.contains(b"PCMeasureValue") => ("the line's caption", line_caption(d)?), b"Line" | b"PolyLine" | b"Polygon" => ("the measurement label", text(b"PCMeasureValue")?), b"Stamp" if matches!(d.get(b"PCStamp"), Some(Object::Bool(true))) => ("the stamp's \"By\" line", text(b"PCByLine")?), @@ -411,6 +440,11 @@ pub fn undrawable_text(d: &Dict) -> Option<(&'static str, char)> { /// `/Font` resources naming `/Helv`: standard Helvetica with WinAnsiEncoding. pub(crate) fn helvetica_resource() -> Object { + Object::Dict(helvetica_fonts()) +} + +/// [`helvetica_resource`]'s dictionary. +fn helvetica_fonts() -> Dict { let mut font = Dict::new(); font.set(b"Type".to_vec(), Object::name("Font")); font.set(b"Subtype".to_vec(), Object::name("Type1")); @@ -418,7 +452,7 @@ pub(crate) fn helvetica_resource() -> Object { font.set(b"Encoding".to_vec(), Object::name("WinAnsiEncoding")); let mut fonts = Dict::new(); fonts.set(b"Helv".to_vec(), Object::Dict(font)); - Object::Dict(fonts) + fonts } /// Where a caret and its paragraph symbol go within `rect`: the box the caret is drawn in, and for @@ -1102,27 +1136,31 @@ fn draw_free_text(context: &AppearanceContext<'_>, mut drawing: AppearanceDrawin )); let mut out = c.into_bytes(); let lines = wrap(&text, size, width); - let mut y = rect[3] - pad - size * 0.9; + let mut y = rect[3] - pad - size * reach(&lines)[0]; if round { - y -= ((rect[3] - rect[1] - 2.0 * pad - lines.len() as f64 * size * 1.2) / 2.0).max(0.0); + y -= ((rect[3] - rect[1] - 2.0 * pad - text_lines_height(&lines, size)) / 2.0).max(0.0); } - for line in lines { + // Thai is shaped and drawn from the Thai face (Type 3 fonts in the form's resources, whose + // streams `apply_appearance` stores in the document); the rest, and Thai it can't draw (as + // `?`, which `undrawable_text` reports), in Helvetica. + let mut fonts = helvetica_fonts(); + let (pieces, shaped) = shaped_text::appearance_lines(&lines, &mut fonts); + let mut current = None; + for pieces in &pieces { if y < rect[1] - size { break; } - let lw = text_width(&line, size); + let lw = shaped_text::pieces_width(pieces, size, 0.0, helvetica_width); let x = match q { 1 => rect[0] + pad + (width - lw) / 2.0, 2 => rect[2] - pad - lw, _ => rect[0] + pad, }; - out.extend(format!("1 0 0 1 {} {} Tm ", n(x), n(y)).bytes()); - out.extend(literal(&win_ansi(&line))); - out.extend_from_slice(b" Tj\n"); + shaped.show_line(&mut out, pieces, x, y, size, "Helv", win_ansi, |s| helvetica_width(s, size), &mut current); y -= size * 1.2; } out.extend_from_slice(b"ET\n"); - res.set(b"Font".to_vec(), helvetica_resource()); + res.set(b"Font".to_vec(), Object::Dict(fonts)); let mut stream = form(full, &out, res); if let Some(m) = matrix { stream.dict.set(b"Matrix".to_vec(), Object::Array(m.iter().copied().map(Object::Real).collect())); diff --git a/crates/annot/src/create.rs b/crates/annot/src/create.rs index 6376655ce9..6dbb7842e6 100644 --- a/crates/annot/src/create.rs +++ b/crates/annot/src/create.rs @@ -1,6 +1,6 @@ use super::draw::draw_appearance; use super::lookup::{annot_dict, annot_ref, annots, fill_sign_shape, page_ref, set_annots}; -use super::modify::{free_text_da, n, rich_text, set_part_keys, shape_text_box}; +use super::modify::{free_text_da, n, refuse_undrawable_thai, rich_text, set_part_keys, shape_text_box}; use super::types::{ FLAG_NO_ROTATE, FLAG_NO_ZOOM, FLAG_PRINT, LineEnding, Markup, Meta, NOTE_SIZE, NewAnnotation, OverlayLook, Rgb, Shape, StampGroup, Style, }; @@ -398,6 +398,7 @@ pub fn add_annotation(doc: &mut Document, new: &NewAnnotation, meta: &Meta) -> R if matches!(turn, 90 | 180 | 270) { d.set(b"PCTextRotate".to_vec(), Object::Int(turn)); } + let rect = fit_thai_ink(rect, turn, &new.contents, size, 2.0 + appearance::border_width(&d)); // An oval or a circle goes around the text box, its text wrapped as displayed: // after `/PCTextRotate`, which swaps the box's sides on a quarter-turned page. shape_text_box(&mut d, rect, style.border_shape, style.width); @@ -417,6 +418,7 @@ pub fn add_annotation(doc: &mut Document, new: &NewAnnotation, meta: &Meta) -> R { return Err(AnnotError::Undrawable(pdfcraft_fonts::undrawable_message(what, c))); } + refuse_undrawable_thai(d.name(b"Subtype") == Some(b"FreeText"), &new.contents)?; let r = doc.add(Object::Dict(d.clone())); draw_appearance(doc, r)?; let mut list = annots(doc, page); @@ -427,6 +429,30 @@ pub fn add_annotation(doc: &mut Document, new: &NewAnnotation, meta: &Meta) -> R Ok(index) } +/// Text box `tb` (user space; its text upright on a page turned by `turn`), grown down as displayed +/// until the ink of `text`'s Thai (stacked marks above its first line, descenders below its last) +/// fits inside the padding `pad`, as the appearance lays it out at `size`. Other text, and a box +/// tall enough already, keep the box they were given. +fn fit_thai_ink(tb: [f64; 4], turn: i64, text: &str, size: f64, pad: f64) -> [f64; 4] { + if !text.chars().any(pdfcraft_fonts::shaped_text::is_thai) { + return tb; + } + let [x0, y0, x1, y1] = normalize(tb); + let (w, h) = if matches!(turn, 90 | 270) { (y1 - y0, x1 - x0) } else { (x1 - x0, y1 - y0) }; + let need = appearance::text_lines_height(&appearance::wrap(text, size, (w - 2.0 * pad).max(1.0)), size) + 2.0 * pad + 2.0; + let by = need - h; + if !(by.is_finite() && by > 0.0) { + return tb; + } + // Displayed down is user -y unturned, +x on a page turned 90°, +y at 180° and -x at 270°. + match turn { + 90 => [x0, y0, x1 + by, y1], + 180 => [x0, y0, x1, y1 + by], + 270 => [x0 - by, y0, x1, y1], + _ => [x0, y0 - by, x1, y1], + } +} + fn validate_style(style: &Style) -> Result<(), AnnotError> { if !finite(&style.color) || style.border_color.is_some_and(|c| !finite(&c)) || !style.opacity.is_finite() || !style.width.is_finite() { return Err(AnnotError::Invalid("invalid style".into())); diff --git a/crates/annot/src/draw.rs b/crates/annot/src/draw.rs index eac1f09fea..5c9701ce81 100644 --- a/crates/annot/src/draw.rs +++ b/crates/annot/src/draw.rs @@ -57,9 +57,11 @@ fn build_appearance(doc: &Document, d: &Dict) -> Option { } /// Store a built appearance on the annotation: the cheap half of [`set_appearance`]. -fn apply_appearance(doc: &mut Document, r: ObjRef, d: &Dict, stream: Stream) -> Result<(), AnnotError> { +fn apply_appearance(doc: &mut Document, r: ObjRef, d: &Dict, mut stream: Stream) -> Result<(), AnnotError> { // Characters the standard fonts can't draw came out as `?`: mark the annotation with the first. let missing = appearance::undrawable_text(d).map(|(_, c)| c); + // A text box's Thai glyphs are streams of their own. + pdfcraft_fonts::shaped_text::store_streams(doc, &mut stream.dict); let ap = doc.add(Object::Stream(stream)); // A copy (a shared /AP is left alone) that keeps unknown entries; the old down and rollover // appearances would show the previous look on press or hover, so they go with the old /N. diff --git a/crates/annot/src/modify.rs b/crates/annot/src/modify.rs index 70abf987aa..60e97f6a72 100644 --- a/crates/annot/src/modify.rs +++ b/crates/annot/src/modify.rs @@ -25,6 +25,15 @@ pub(crate) fn refuse_foreign_text(doc: &Document, d: &Dict) -> Result<(), AnnotE Ok(()) } +/// Thai typed into a text box (`free_text`) that this build can't draw (it has no Thai face): refused +/// with what to do, rather than drawn as `?`. +pub(crate) fn refuse_undrawable_thai(free_text: bool, text: &str) -> Result<(), AnnotError> { + match free_text.then(|| pdfcraft_fonts::shaped_text::thai_refusal(text)).flatten() { + Some(why) => Err(AnnotError::Undrawable(why)), + None => Ok(()), + } +} + /// Format a number for content streams and DA strings. pub(crate) fn n(v: f64) -> String { let s = format!("{:.3}", if v.abs() < 5e-4 { 0.0 } else { v }); @@ -165,6 +174,7 @@ pub fn set_contents(doc: &mut Document, page: usize, index: usize, text: &str, m let (_, r) = annot_ref(doc, page, index)?; let d = annot_dict(doc, r); let free_text = d.name(b"Subtype") == Some(b"FreeText"); + refuse_undrawable_thai(free_text, text)?; let snug = free_text && is_snug(&d); doc.update_dict(r, |d| { d.set(b"Contents".to_vec(), PdfString::text(text)); @@ -224,8 +234,8 @@ fn fitted_box(doc: &Document, r: ObjRef, text: &str) -> Option<[f64; 4]> { let longest = text.lines().map(|l| appearance::text_width(l, size)).fold(0.0, f64::max); (longest + 2.0 * pad + 4.0).clamp(40.0, 300.0) }; - let lines = appearance::wrap(text, size, (w - 2.0 * pad).max(1.0)).len().max(1) as f64; - Some([x0, top - lines * size * 1.2 - 2.0 * pad - 2.0, x0 + w, top]) + let height = appearance::text_lines_height(&appearance::wrap(text, size, (w - 2.0 * pad).max(1.0)), size); + Some([x0, top - height - 2.0 * pad - 2.0, x0 + w, top]) } /// Reply to a comment; returns the reply's index in the page's `/Annots`. @@ -459,7 +469,7 @@ fn text_extent(d: &Dict, wrap: f64) -> [f64; 2] { let (_, size) = appearance::parse_da(d); let lines = appearance::wrap(&text, size, wrap.max(1.0)); let widest = lines.iter().map(|l| appearance::text_width(l, size)).fold(0.0, f64::max); - [widest, lines.len().max(1) as f64 * size * 1.2] + [widest, appearance::text_lines_height(&lines, size)] } /// The width and height of FreeText `d`'s text box `tb` as displayed: swapped on a page turned by diff --git a/crates/annot/src/tests.rs b/crates/annot/src/tests.rs index e14838aac8..0c9ac7ed5a 100644 --- a/crates/annot/src/tests.rs +++ b/crates/annot/src/tests.rs @@ -2786,6 +2786,69 @@ fn indirect_typewriter_names_keep_outer_whitespace_in_summaries() { check(&reopen(&doc)); } +/// The `y` of every `1 0 0 1 x y Tm` in `content`: where each piece of text sits. +fn baselines(content: &str) -> Vec { + content.split(" Tm").filter_map(|s| s.rsplit(' ').next()?.parse().ok()).collect() +} + +/// Thai typed into Fill & Sign text or a text box is shaped and drawn from the Thai face, as Add +/// text draws it: Type 3 glyphs (their streams objects of their own) with a ToUnicode map, never +/// `?`. The box grows to hold the ink of its stacked marks and descenders, which reach past the +/// 1.2 em lines boxes are sized by. A build without a Thai face refuses the text, saying why. +#[test] +fn thai_in_fill_sign_text_and_text_boxes_is_drawn_from_the_thai_face() { + let mut doc = fixture(); + // Each line has the face's highest stack (น้ำ) and deepest descender (ฎู). + let thai = "น้ำฎู บุญ\nปั้นน้ำฎู"; + let item = |shape: Shape| NewAnnotation { page: 0, style: Style::default_for(&shape), shape, contents: thai.into(), author: "Ada".into() }; + let shapes = [ + Shape::Typewriter { rect: [100.0, 700.0, 300.0, 716.0], font_size: 24.0 }, + Shape::TextBox { rect: [100.0, 400.0, 400.0, 430.0], font_size: 30.0 }, + ]; + if pdfcraft_fonts::document_thai_font().is_none() { + for shape in shapes { + let err = add_annotation(&mut doc, &item(shape), &meta("t")).unwrap_err(); + assert!(matches!(&err, AnnotError::Undrawable(m) if m.contains("Thai") && m.contains("CRAFT_FONTS_DIR")), "{err:?}"); + } + assert_eq!(list(&doc, 0).len(), 1, "nothing was added"); + let t = add_annotation(&mut doc, &new(0, Shape::TextBox { rect: [100.0, 600.0, 300.0, 650.0], font_size: 12.0 }), &meta("b")).unwrap(); + assert!(matches!(set_contents(&mut doc, 0, t, thai, &meta("")), Err(AnnotError::Undrawable(_)))); + assert_eq!(text(&list(&doc, 0)[t], b"Contents"), "hello"); + return; + } + let reach = pdfcraft_fonts::shaped_text::thai_reach; + let (lo, hi) = (reach("ฎู").unwrap()[0], reach("น้ำ").unwrap()[1]); + for (shape, size) in shapes.into_iter().zip([24.0, 30.0]) { + let t = add_annotation(&mut doc, &item(shape), &meta("t")).unwrap(); + let doc = reopen(&doc); + let d = &list(&doc, 0)[t]; + assert_eq!((text(d, b"Contents").as_str(), pdfcraft_fonts::undrawn_mark(d)), (thai, None)); + let content = ap_content(&doc, d); + assert!(!content.contains('?') && content.contains("/PCTh1 "), "{content}"); + // The glyphs and the text they stand for are objects of the saved file. + let n = d.get(b"AP").and_then(|a| a.as_dict()).and_then(|a| a.reference(b"N")).unwrap(); + let ap = doc.get(n); + let fonts = ap.as_dict().and_then(|s| s.get(b"Resources")).and_then(|r| r.as_dict()).and_then(|r| r.get(b"Font")).and_then(|f| f.as_dict()); + let font = fonts.and_then(|f| f.get(b"PCTh1")).and_then(|f| f.as_dict()).unwrap().clone(); + assert_eq!(font.name(b"Subtype"), Some(&b"Type3"[..])); + let cmap = doc.get(font.reference(b"ToUnicode").unwrap()); + let Object::Stream(cmap) = &*cmap else { panic!("ToUnicode is a stream object") }; + let cmap = String::from_utf8_lossy(&cmap.decoded().unwrap()).into_owned(); + assert!(cmap.contains("<0E190E490E33>"), "น้ำ copies as typed: {cmap}"); + let procs = font.get(b"CharProcs").and_then(|p| p.as_dict()).unwrap(); + assert!(procs.iter().all(|(_, g)| matches!(g, Object::Ref(_))), "glyph programs are stream objects"); + // Every line's ink lies inside the box: the appearance's BBox and its clip. + let (r, ys) = (rect(d), baselines(&content)); + let (top, bottom) = (ys.iter().copied().fold(f64::MIN, f64::max), ys.iter().copied().fold(f64::MAX, f64::min)); + assert!(top + hi * size <= r[3] + 1e-3 && bottom + lo * size >= r[1] - 1e-3, "{ys:?} at {size} in {r:?}"); + } + // New text refits the box to its ink too: the stack on its first line, the descender on its last. + set_contents(&mut doc, 0, 1, "น้ำ\nฎู", &meta("")).unwrap(); + let d = &list(&doc, 0)[1]; + let (r, ys) = (rect(d), baselines(&ap_content(&doc, d))); + assert!(ys[0] + hi * 24.0 <= r[3] + 1e-3 && ys[ys.len() - 1] + lo * 24.0 >= r[1] - 1e-3, "{ys:?} in {r:?}"); +} + const QUAD: [f64; 8] = [20.0, 110.0, 40.0, 110.0, 20.0, 95.0, 40.0, 95.0]; fn reals(v: &[f64]) -> Vec { diff --git a/crates/automation/src/tools.rs b/crates/automation/src/tools.rs index 4cdd58f3c3..16371c6a70 100644 --- a/crates/automation/src/tools.rs +++ b/crates/automation/src/tools.rs @@ -493,7 +493,7 @@ pub fn tools() -> Vec { t( "form_fill", "Fill in form fields", - "Set several fields at once (one undo step). values maps field names (from form_fields) to: a string for text fields, combo boxes and radio groups (an option), true/false for check boxes, an array of strings for multi-select lists. Appearances are regenerated so every viewer shows the values. A value whose characters the field's font can't draw (Chinese in a Helvetica field) is still saved; `warnings` in the result names the field and character: PdfCraft draws `?` there and asks other viewers to redraw the field from its value.", + "Set several fields at once (one undo step). values maps field names (from form_fields) to: a string for text fields, combo boxes and radio groups (an option), true/false for check boxes, an array of strings for multi-select lists. Appearances are regenerated so every viewer shows the values. Thai the field's font can't hold is drawn from PdfCraft's built-in Thai face (a build without one refuses it). Any other value whose characters the field's font can't draw (Chinese in a Helvetica field) is still saved; `warnings` in the result names the field and character: PdfCraft draws `?` there and asks other viewers to redraw the field from its value.", ) .with(schema(json!({ "doc": doc(), "values": { "type": "object", "description": "Field name → value." } }), &["doc", "values"])), t("form_reset", "Clear form", "Reset fields to their default values: all of them, or only those listed. Undoable.") @@ -864,7 +864,7 @@ pub fn tools() -> Vec { "Add a comment as Acrobat's commenting tools do. Geometry is in points with the origin at the top-left of the displayed page, y down (as in page_render images at 72 dpi and text_find rects). \ note: `at` [x, y] (icon top-left). stamp: `at` [x, y] (its centre) and `stamp`: approved, completed, confidential, draft, final, for comment, for public release, information only, not approved, not for public release, preliminary results, void, accepted, initial here, rejected, sign here, witness; dynamic: true for the dynamic approved/confidential/received/reviewed/revised stamps with a By … at … line. highlight/underline/strikeout/squiggly: `find` (text on the page to mark; every match with all: true) or `quads`. \ rectangle/oval/textbox: `rect` [x0, y0, x1, y1]. line/arrow: `from`, `to`. Optional `endings` (two names: start, end) for a line, arrow or polyline, or one name for a callout: None, Square, Circle, Diamond, OpenArrow, ClosedArrow, Butt, ROpenArrow, RClosedArrow, Slash. An arrow without endings is None then OpenArrow. ink: `strokes` [[[x, y], …], …]. \ - polygon/cloud/polyline (connected lines): `points` [[x, y], …]. callout: `rect` (its text box), `to` (the point the arrow touches), optional `knee`. caret (inserted text): `at`, the insertion point on the baseline. replace (Replace Text): `find` or `quads` like highlight, `contents` the replacement. attachment: `path` (the file), `at`, optional `icon` (PushPin, Paperclip, Graph, Tag). Undoable. Text a text box, callout or stamp draws in the standard fonts that they can't draw (Chinese, Greek) is saved as typed and drawn as `?`; `warnings` in the result says so.", + polygon/cloud/polyline (connected lines): `points` [[x, y], …]. callout: `rect` (its text box), `to` (the point the arrow touches), optional `knee`. caret (inserted text): `at`, the insertion point on the baseline. replace (Replace Text): `find` or `quads` like highlight, `contents` the replacement. attachment: `path` (the file), `at`, optional `icon` (PushPin, Paperclip, Graph, Tag). Undoable. Thai in a text box or callout is drawn from PdfCraft's built-in Thai face (a build without one refuses it). Other text a text box, callout or stamp draws in the standard fonts that they can't draw (Chinese, Greek) is saved as typed and drawn as `?`; `warnings` in the result says so.", ) .with(schema( json!({ @@ -1135,7 +1135,7 @@ pub fn tools() -> Vec { t( "doc_header_footer", "Add header and footer", - "Add a header and/or footer to pages. Text boxes: header_left/center/right, footer_left/center/right. Tokens: <<1>> page number, <> page count, <<1 of n>>, <>, <<1/n>>, dates <> <> <> <> (and more), Bates <>. shrink: true scales the page content down evenly into the room the header and footer leave free, so they don't cover it. font: Helvetica (default), Times or Courier. replace: true swaps out existing ones (Update). Undoable.", + "Add a header and/or footer to pages. Text boxes: header_left/center/right, footer_left/center/right. Tokens: <<1>> page number, <> page count, <<1 of n>>, <>, <<1/n>>, dates <> <> <> <> (and more), Bates <>. shrink: true scales the page content down evenly into the room the header and footer leave free, so they don't cover it. font: Helvetica (default), Times or Courier; Thai is drawn from PdfCraft's built-in Thai face beside it (a build without one refuses it). replace: true swaps out existing ones (Update). Undoable.", ) .cmd("edit.header_footer") .with(schema( @@ -1154,7 +1154,7 @@ pub fn tools() -> Vec { }), &["doc"], )), - t("doc_watermark", "Add watermark", "Add a watermark to pages: text, or a picture from `file` (an image, or page `file_page` of a PDF) at `scale` of the page (default 0.5); rotated (degrees counter-clockwise, default 45), semi-transparent (opacity 0–1, default 0.5), text fitted to the page unless font_size is given, on top unless behind: true; moved from the page centre by `offset_x` (right) and `offset_y` (up) points (default 0). replace: true swaps out existing ones. Undoable.") + t("doc_watermark", "Add watermark", "Add a watermark to pages: text, or a picture from `file` (an image, or page `file_page` of a PDF) at `scale` of the page (default 0.5); rotated (degrees counter-clockwise, default 45), semi-transparent (opacity 0–1, default 0.5), text fitted to the page unless font_size is given (Thai drawn from PdfCraft's built-in Thai face; a build without one refuses it), on top unless behind: true; moved from the page centre by `offset_x` (right) and `offset_y` (up) points (default 0). replace: true swaps out existing ones. Undoable.") .cmd("edit.watermark") .with(schema( json!({ @@ -1489,7 +1489,7 @@ pub fn tools() -> Vec { t( "fill_sign_add", "Fill & Sign: type text or place a mark", - "Fill in a form that has no fields, as Acrobat's Fill & Sign does: type text (`text`, default 10 pt; `font_size` 4–300 pt; optional `color` as #RRGGBB for text/date, default black; `border` 0–12 pt around it, default none, in its colour or `border_color`), place a check, cross, dot or line, today's date (in Preferences ▸ Date format, see fill_sign_date_format, or `format`; `font_size` also applies), or a signature or initials (either `text` drawn in a script font, or `path` to a local PNG/JPEG image up to 4 MiB and 32 megapixels, scaled down to 4 megapixels, preserving transparency; with `scan: true`, `path` is instead a photo of a signature on paper, within the same limits: the signature is found, and the paper, shadows and table around it are removed, with `sensitivity` 0–100, default 50, picking up fainter ink when higher). `at` [x, y] is in points from the top-left of the page: text's top-left, mark's centre, signature's left edge centred vertically. Image signatures fit within 150 pt wide and 32 pt tall (24 pt for initials). Creates movable, undoable annotations. Typed text the standard fonts can't draw is saved and drawn as `?` (`warnings` in the result says so); a typed signature neither the script font nor its fallback can draw is refused (pass `path` instead).", + "Fill in a form that has no fields, as Acrobat's Fill & Sign does: type text (`text`, default 10 pt; `font_size` 4–300 pt; optional `color` as #RRGGBB for text/date, default black; `border` 0–12 pt around it, default none, in its colour or `border_color`), place a check, cross, dot or line, today's date (in Preferences ▸ Date format, see fill_sign_date_format, or `format`; `font_size` also applies), or a signature or initials (either `text` drawn in a script font, or `path` to a local PNG/JPEG image up to 4 MiB and 32 megapixels, scaled down to 4 megapixels, preserving transparency; with `scan: true`, `path` is instead a photo of a signature on paper, within the same limits: the signature is found, and the paper, shadows and table around it are removed, with `sensitivity` 0–100, default 50, picking up fainter ink when higher). `at` [x, y] is in points from the top-left of the page: text's top-left, mark's centre, signature's left edge centred vertically. Image signatures fit within 150 pt wide and 32 pt tall (24 pt for initials). Creates movable, undoable annotations. Typed Thai is drawn from PdfCraft's built-in Thai face (a build without one refuses it); other typed text the standard fonts can't draw is saved and drawn as `?` (`warnings` in the result says so); a typed signature neither the script font nor its fallback can draw is refused (pass `path` instead).", ) .cmd("sign.fill.text") .with(schema( diff --git a/crates/automation/tests/it/automation.rs b/crates/automation/tests/it/automation.rs index e1c596d74a..0909021951 100644 --- a/crates/automation/tests/it/automation.rs +++ b/crates/automation/tests/it/automation.rs @@ -9445,6 +9445,50 @@ fn thai_text_through_tools_saves_reopens_extracts_and_renders() { assert!(page_text(&mut a, reopened)[0].contains("แก้ไข น้ำ")); } +/// Thai typed into Fill & Sign text, a text box, a form field, a footer and a watermark (#2354) is +/// drawn from PdfCraft's Thai face, as Add text draws it: no tool warns of `?`, and after a save +/// each one's text is found as typed and the page renders. A build without a Thai face refuses +/// the text in each, saying what to do, rather than writing `?????`. +#[test] +fn thai_in_fill_sign_text_comments_fields_and_page_marks_is_drawn_and_found() { + let dir = workdir("thai-everywhere"); + let mut a = auto(&dir); + let doc = ok(&mut a, "doc_open", json!({"path": "a.pdf"}))["doc"].as_u64().unwrap(); + let thai = "บุญฤทธิ์ น้ำ ปั้น"; + let field = ok(&mut a, "form_add_field", json!({"doc": doc, "page": 1, "type": "text", "rect": [20, 120, 220, 142]}))["field"] + .as_str() + .unwrap() + .to_string(); + let calls = [ + ("fill_sign_add", json!({"doc": doc, "page": 1, "type": "text", "at": [20, 40], "text": thai, "font_size": 14})), + ("comment_add", json!({"doc": doc, "page": 1, "type": "textbox", "rect": [20, 160, 200, 190], "contents": thai})), + ("form_fill", json!({"doc": doc, "values": {field.as_str(): thai}})), + ("doc_header_footer", json!({"doc": doc, "pages": [1], "footer_center": format!("{thai} <<1>>")})), + ("doc_watermark", json!({"doc": doc, "pages": [1], "text": thai})), + ]; + if pdfcraft_fonts::document_thai_font().is_none() { + for (tool, args) in calls { + let err = a.call(tool, &args).unwrap_err().to_string(); + assert!(err.contains("Thai") && err.contains("CRAFT_FONTS_DIR"), "{tool}: {err}"); + } + assert_eq!(ok(&mut a, "comment_list", json!({"doc": doc}))["comments"].as_array().map(Vec::len), Some(0)); + return; + } + for (tool, args) in calls { + let r = ok(&mut a, tool, args); + assert!(r.get("warnings").is_none(), "{tool}: {r}"); + } + ok(&mut a, "doc_save", json!({"doc": doc, "path": "thai-marks.pdf"})); + let reopened = ok(&mut a, "doc_open", json!({"path": "thai-marks.pdf"}))["doc"].as_u64().unwrap(); + let found = ok(&mut a, "text_find", json!({"doc": reopened, "query": "บุญฤทธิ์"})); + assert_eq!(found["count"], 5, "{found}"); + let rendered = a.call("page_render", &json!({"doc": reopened, "page": 1, "dpi": 72})).unwrap(); + assert!(rendered.iter().any(|c| matches!(c, Content::Png { data, .. } if !data.is_empty()))); + // Flattened, every one of them is page text, as typed. + ok(&mut a, "doc_flatten", json!({"doc": reopened})); + assert_eq!(page_text(&mut a, reopened)[0].matches(thai).count(), 5, "{:?}", page_text(&mut a, reopened)); +} + #[test] fn hebrew_text_through_tools_saves_reopens_extracts_and_renders() { let dir = workdir("hebrew-content"); diff --git a/crates/edit/src/added.rs b/crates/edit/src/added.rs index adab347908..98b428ed92 100644 --- a/crates/edit/src/added.rs +++ b/crates/edit/src/added.rs @@ -8,12 +8,8 @@ //! after `/UserUnit` and `/Rotate`), so items stay upright on rotated pages; an item records the //! `/UserUnit` it was written for. -use std::collections::{HashMap, HashSet}; - use pdfcraft_cos::{Dict, Document, ObjRef, Object, PdfString, Stream}; -use pdfcraft_fonts::{ - GlyphError, GlyphOutline, ShapedCluster, ShapedScript, Std14, literal, shape_arabic, shape_hebrew, shape_thai, shaped_glyph, win_ansi, -}; +use pdfcraft_fonts::{GlyphError, GlyphOutline, ShapedScript, Std14, literal, shape_arabic, shape_hebrew, shape_thai, shaped_glyph, win_ansi}; use unicode_bidi::{Level, ParagraphBidiInfo}; use crate::{EditError, MarkKind, check, contents, matrix_number, n, page_list, place_tagged}; @@ -33,6 +29,8 @@ pub(crate) use layout::WRAP_CALLS; #[cfg(test)] pub(crate) use layout::shaped_width; pub use layout::{PASTE_STEP, first_undrawable, lines, paste_place, paste_rect, text_rect, text_width}; +#[cfg(test)] +use pdfcraft_fonts::ShapedCluster; pub use store::{add_content, delete_content, isolate_content, list_added, scan_markers, update_content}; pub use types::{Added, AddedImage, AddedText, Align, CHAR_SPACING_LIMIT, Family, Ink, MarkerScan}; diff --git a/crates/edit/src/added/draw.rs b/crates/edit/src/added/draw.rs index 90c9e6877b..8e0ec75791 100644 --- a/crates/edit/src/added/draw.rs +++ b/crates/edit/src/added/draw.rs @@ -3,17 +3,17 @@ use super::layout::{ }; use super::types::{AddedText, Align, Family, Ink}; use super::{ - Content, Dict, Document, EditError, GlyphError, GlyphOutline, HashMap, HashSet, ObjRef, Object, PdfString, ShapedCluster, ShapedScript, Stream, - align_code, literal, matrix_number, n, shaped_glyph, win_ansi, + Content, Dict, Document, EditError, GlyphError, GlyphOutline, Object, PdfString, ShapedScript, align_code, literal, matrix_number, n, + shaped_glyph, win_ansi, }; +use pdfcraft_fonts::shaped_text::{self, ShapedFonts}; fn font_name(base: &str) -> String { format!("PCF{}", base.replace('-', "")) } -/// Most glyphs in one Type 3 font: its codes are single bytes, 1–240. An item gets one such font -/// per 240 distinct glyphs it needs. -pub(super) const GLYPHS_PER_FONT: usize = 240; +#[cfg(test)] +pub(super) use pdfcraft_fonts::shaped_text::GLYPHS_PER_FONT; /// A line of a shaped-text item after layout: its text, whether its paragraph runs right to left, /// its paragraph, its pieces in drawing order and its width at the item's size. @@ -61,50 +61,15 @@ pub(super) fn draw_laid_shaped( ) -> Result<(), EditError> { let r = text_rect(t); let latin = font_name(t.family.base_font(t.bold, t.italic)); - // Every distinct cluster (a letter with its dots and the text it stands for) is one glyph of - // one of the fonts, so copy and search get each character exactly once. - let mut keys: Vec<&ShapedCluster> = Vec::new(); - let mut index: HashMap = HashMap::new(); - for c in laid.iter().flat_map(|(_, _, _, pieces, _)| pieces).flat_map(|p| match p { - Piece::Shaped(clusters) => clusters.as_slice(), - Piece::Latin(_) => &[], - }) { - index.entry(cluster_key(c)).or_insert_with(|| { - keys.push(c); - keys.len() - 1 - }); - } - - // Every outline is read before anything is written, so a failure leaves the document as it was. - let mut outlines = HashMap::new(); - for c in &keys { - for (id, _) in &c.glyphs { - if let std::collections::hash_map::Entry::Vacant(entry) = outlines.entry((c.script, *id)) { - entry.insert(outline(c.script, *id).map_err(|e| match c.script { - ShapedScript::Arabic => arabic_error(e, &c.text), - ShapedScript::Hebrew => hebrew_error(e, &c.text), - ShapedScript::Thai => thai_error(e, &c.text), - })?); - } - } - } - // The names the page and this item already use: a new font never takes one of them. - let mut taken_names: HashSet> = taken.iter().chain(fonts.iter()).map(|(name, _)| name.clone()).collect(); - let mut names = Vec::new(); - for chunk in keys.chunks(GLYPHS_PER_FONT) { - let font = type3_shaped(doc, chunk, &outlines); - // A name no font on the page has, so items never replace each other's fonts (object - // numbers alone don't do: a full save renumbers objects but keeps resource names). - let mut name = format!("PCAr{}", font.num); - let mut suffix = 0usize; - while taken_names.contains(name.as_bytes()) { - suffix = suffix.saturating_add(1); - name = format!("PCAr{}_{suffix}", font.num); - } - taken_names.insert(name.clone().into_bytes()); - fonts.set(name.clone().into_bytes(), Object::Ref(font)); - names.push(name); - } + let shaped = + ShapedFonts::new(shaped_text::clusters(laid.iter().map(|(_, _, _, pieces, _)| pieces.as_slice())), outline, taken, fonts, "PCAr", &mut |o| { + Object::Ref(doc.add(o)) + }) + .map_err(|(e, c)| match c.script { + ShapedScript::Arabic => arabic_error(e, &c.text), + ShapedScript::Hebrew => hebrew_error(e, &c.text), + ShapedScript::Thai => thai_error(e, &c.text), + })?; out.extend(format!("BT {}\n", t.fill_op()).bytes()); if t.char_spacing != 0.0 { out.extend(format!("{} Tc\n", n(t.char_spacing)).bytes()); @@ -119,32 +84,17 @@ pub(super) fn draw_laid_shaped( let (x, tw) = placed(t, r, ¶graphs, i, *w, spaces, *rtl); let y = r[3] - (i as f64 * 1.2 + 0.95) * t.size; let mut pen = x; - let mut current: Option<&str> = None; + let mut current = None; for piece in pieces { match piece { Piece::Latin(s) => { out.extend(format!("/{latin} {} Tf {} Tw 1 0 0 1 {} {} Tm ", n(t.size), n(tw), n(pen), n(y)).bytes()); out.extend(literal(&win_ansi(s))); out.extend_from_slice(b" Tj\n"); - current = Some(latin.as_str()); + current = None; pen += latin_width(t, s) + tw * s.matches(' ').count() as f64; } - Piece::Shaped(clusters) => { - for c in clusters { - let Some(&k) = index.get(&cluster_key(c)) else { continue }; - let Some(name) = names.get(k / GLYPHS_PER_FONT) else { continue }; - // Codes start at 1; k % 240 + 1 is at most 240. - let code = u8::try_from(k % GLYPHS_PER_FONT + 1).unwrap_or(1); - if current != Some(name.as_str()) { - out.extend(format!("/{name} {} Tf ", n(t.size)).bytes()); - current = Some(name.as_str()); - } - out.extend(format!("1 0 0 1 {} {} Tm ", n(pen), n(y)).bytes()); - out.extend(literal(&[code])); - out.extend_from_slice(b" Tj\n"); - pen += c.advance * t.size + t.char_spacing + if c.text == " " { tw } else { 0.0 }; - } - } + Piece::Shaped(clusters) => pen = shaped.show(out, clusters, pen, y, t.size, t.char_spacing, tw, &mut current), } } out.extend_from_slice(b"EMC\n"); @@ -153,104 +103,6 @@ pub(super) fn draw_laid_shaped( Ok(()) } -/// What makes two clusters the same Type 3 glyph: text, glyphs, offsets and advance (bit for bit; -/// equal clusters come from the same font units). -type ClusterKey<'a> = (ShapedScript, &'a str, Vec<(u32, u64, u64)>, u64); - -fn cluster_key(c: &ShapedCluster) -> ClusterKey<'_> { - (c.script, c.text.as_str(), c.glyphs.iter().map(|(id, [x, y])| (*id, x.to_bits(), y.to_bits())).collect(), c.advance.to_bits()) -} - -/// A Type 3 font with `clusters` as codes 1…, each drawn from its script face's glyph `outlines`. -fn type3_shaped(doc: &mut Document, clusters: &[&ShapedCluster], outlines: &HashMap<(ShapedScript, u32), GlyphOutline>) -> ObjRef { - let mut charprocs = Dict::new(); - let mut differences = vec![Object::Int(1)]; - let mut widths = Vec::with_capacity(clusters.len()); - let mut bbox = [0.0f64; 4]; - let mut to_unicode = Vec::new(); - for (i, c) in clusters.iter().enumerate() { - let code = i + 1; - // Glyph space is 1000 units per em. - let mut paths = Vec::new(); - let mut ink: Option<[f64; 4]> = None; - for (id, [dx, dy]) in &c.glyphs { - let Some(g) = outlines.get(&(c.script, *id)) else { continue }; - for contour in &g.contours { - for (j, p) in contour.iter().enumerate() { - paths.extend(format!("{} {} {}\n", n((p[0] + dx) * 1000.0), n((p[1] + dy) * 1000.0), if j == 0 { "m" } else { "l" }).bytes()); - } - paths.extend_from_slice(b"h\n"); - let b = [g.bbox[0] + dx, g.bbox[1] + dy, g.bbox[2] + dx, g.bbox[3] + dy]; - ink = Some(ink.map_or(b, |a| [a[0].min(b[0]), a[1].min(b[1]), a[2].max(b[2]), a[3].max(b[3])])); - } - } - // Rounded outwards so no ink is clipped. - let [x0, y0, x1, y1] = - ink.map_or([0.0; 4], |b| [(b[0] * 1000.0).floor(), (b[1] * 1000.0).floor(), (b[2] * 1000.0).ceil(), (b[3] * 1000.0).ceil()]); - bbox = [bbox[0].min(x0), bbox[1].min(y0), bbox[2].max(x1), bbox[3].max(y1)]; - let mut proc = format!("{} 0 {} {} {} {} d1\n", n(c.advance * 1000.0), n(x0), n(y0), n(x1), n(y1)).into_bytes(); - if !paths.is_empty() { - proc.extend(paths); - proc.extend_from_slice(b"f\n"); - } - let glyph = format!("g{code:02X}"); - charprocs.set(glyph.clone().into_bytes(), Object::Ref(doc.add(Object::Stream(Stream::flate(Dict::new(), &proc))))); - differences.push(Object::name(&glyph)); - widths.push(Object::Real((c.advance * 1000.0).round())); - if !c.text.is_empty() { - let hex: String = c.text.encode_utf16().map(|u| format!("{u:04X}")).collect(); - to_unicode.push(format!("<{code:02X}> <{hex}>\n")); - } - } - let mut cmap = String::from( - "/CIDInit /ProcSet findresource begin\n12 dict begin\nbegincmap\n/CMapType 2 def\n1 begincodespacerange\n<01> \nendcodespacerange\n", - ); - // A CMap block holds at most 100 entries. - for block in to_unicode.chunks(100) { - cmap.push_str(&format!("{} beginbfchar\n{}endbfchar\n", block.len(), block.concat())); - } - cmap.push_str("endcmap\nCMapName currentdict /CMap defineresource pop\nend\nend\n"); - let cmap = doc.add(Object::Stream(Stream::flate(Dict::new(), cmap.as_bytes()))); - // The face's family when every glyph comes from one script; without the face, the script's - // name (as Arabic items were always named). - let family = match clusters.first().map(|c| c.script).filter(|s| clusters.iter().all(|c| c.script == *s)) { - Some(script) => script.font().map_or( - match script { - ShapedScript::Arabic => "Arabic", - ShapedScript::Hebrew => "Hebrew", - ShapedScript::Thai => "Thai", - }, - |f| f.family, - ), - None => "PdfCraft Shaped Text", - }; - let mut descriptor = Dict::new(); - descriptor.set(b"Type".to_vec(), Object::name("FontDescriptor")); - descriptor.set(b"FontName".to_vec(), Object::name(&family.replace(' ', ""))); - descriptor.set(b"FontFamily".to_vec(), Object::String(PdfString::literal(family.as_bytes().to_vec()))); - descriptor.set(b"Flags".to_vec(), Object::Int(4)); - descriptor.set(b"ItalicAngle".to_vec(), Object::Int(0)); - let mut encoding = Dict::new(); - encoding.set(b"Type".to_vec(), Object::name("Encoding")); - encoding.set(b"Differences".to_vec(), Object::Array(differences)); - let mut font = Dict::new(); - font.set(b"Type".to_vec(), Object::name("Font")); - font.set(b"Subtype".to_vec(), Object::name("Type3")); - font.set(b"FontDescriptor".to_vec(), Object::Ref(doc.add(Object::Dict(descriptor)))); - font.set(b"FontBBox".to_vec(), Object::Array(bbox.iter().map(|v| Object::Real(*v)).collect())); - font.set( - b"FontMatrix".to_vec(), - Object::Array(vec![Object::Real(0.001), Object::Int(0), Object::Int(0), Object::Real(0.001), Object::Int(0), Object::Int(0)]), - ); - font.set(b"FirstChar".to_vec(), Object::Int(1)); - font.set(b"LastChar".to_vec(), Object::Int(clusters.len() as i64)); - font.set(b"Widths".to_vec(), Object::Array(widths)); - font.set(b"Encoding".to_vec(), Object::Dict(encoding)); - font.set(b"CharProcs".to_vec(), Object::Dict(charprocs)); - font.set(b"ToUnicode".to_vec(), Object::Ref(cmap)); - doc.add(Object::Dict(font)) -} - /// Content and resources for an item; `view` maps display space to user space. `taken` holds the /// page's font names. `existing`: the item is being replaced, so a build without the Arabic or /// Hebrew face keeps drawing that text as `?`, as before it was supported, rather than refusing to @@ -401,9 +253,9 @@ pub(crate) fn thai_glyph_resources( if shaped.glyphs.len() > 65_534 { return Err(EditError::Invalid("shaped row exceeds its geometry budget".into())); } - let mut outlines = HashMap::new(); + let mut outlines = std::collections::HashMap::new(); let mut clusters = Vec::new(); - let mut index = HashMap::new(); + let mut index = std::collections::HashMap::new(); let mut indices = Vec::new(); for glyph in &shaped.glyphs { let id = glyph.id.to_u32(); @@ -414,7 +266,12 @@ pub(crate) fn thai_glyph_resources( let at = *index.entry((id, glyph.text.as_str())).or_insert_with(|| { let at = clusters.len(); let width = outlines.get(&key).map_or(0.0, |g| g.width); - clusters.push(ShapedCluster { script: ShapedScript::Thai, glyphs: vec![(id, [0.0, 0.0])], advance: width, text: glyph.text.clone() }); + clusters.push(pdfcraft_fonts::ShapedCluster { + script: ShapedScript::Thai, + glyphs: vec![(id, [0.0, 0.0])], + advance: width, + text: glyph.text.clone(), + }); at }); indices.push(at); @@ -422,9 +279,9 @@ pub(crate) fn thai_glyph_resources( // Validate all outlines before adding resources. Type 3 character codes are single bytes; // shard long rows and return the appropriate resource/code for every positioned glyph. let mut names = Vec::new(); - for chunk in clusters.chunks(GLYPHS_PER_FONT) { + for chunk in clusters.chunks(shaped_text::GLYPHS_PER_FONT) { let refs: Vec<_> = chunk.iter().collect(); - let font = type3_shaped(doc, &refs, &outlines); + let font = shaped_text::add_type3_font(doc, &refs, &outlines); doc.update_dict(font, |f| f.set(b"Name".to_vec(), Object::name("PCThai")))?; let mut name = format!("PCThai{}", font.num); while fonts.contains(name.as_bytes()) { @@ -436,8 +293,8 @@ pub(crate) fn thai_glyph_resources( indices .into_iter() .map(|at| { - let name = names.get(at / GLYPHS_PER_FONT).ok_or_else(|| EditError::Invalid("Thai font resource is missing".into()))?; - let code = u8::try_from(at % GLYPHS_PER_FONT + 1).map_err(|_| EditError::Invalid("Thai glyph limit".into()))?; + let name = names.get(at / shaped_text::GLYPHS_PER_FONT).ok_or_else(|| EditError::Invalid("Thai font resource is missing".into()))?; + let code = u8::try_from(at % shaped_text::GLYPHS_PER_FONT + 1).map_err(|_| EditError::Invalid("Thai glyph limit".into()))?; Ok((name.clone(), vec![code])) }) .collect() diff --git a/crates/edit/src/added/layout.rs b/crates/edit/src/added/layout.rs index e479897d84..d1be9ef521 100644 --- a/crates/edit/src/added/layout.rs +++ b/crates/edit/src/added/layout.rs @@ -1,7 +1,6 @@ use super::types::{AddedImage, AddedText}; use super::{ - Content, Document, EditError, GlyphError, Level, ObjRef, Object, ParagraphBidiInfo, ShapedCluster, ShapedScript, shape_arabic, shape_hebrew, - shape_thai, + Content, Document, EditError, GlyphError, Level, ObjRef, Object, ParagraphBidiInfo, ShapedScript, shape_arabic, shape_hebrew, shape_thai, }; pub(super) fn nums(doc: &Document, o: Option<&Object>) -> Vec { @@ -171,9 +170,7 @@ fn thai_words(atoms: Vec<(String, f64)>) -> Vec> { words } -pub(super) fn is_thai(c: char) -> bool { - matches!(u32::from(c), 0x0E00..=0x0E7F) -} +pub(super) use pdfcraft_fonts::shaped_text::{Piece, is_thai}; /// Arabic letters, marks, digits and punctuation: an item with any of them is shaped with the /// craft-fonts Arabic face. @@ -253,13 +250,6 @@ fn mirrored(c: char) -> char { } } -/// A piece of a line with shaped text, in drawing order. -pub(super) enum Piece { - /// Shown in the item's own standard font. - Latin(String), - Shaped(Vec), -} - /// A line of a shaped-text item in drawing order (the Unicode bidirectional algorithm, UAX #9), and /// its width at the item's size. `rtl` is its paragraph's direction. pub(super) fn shaped_layout(t: &AddedText, line: &str, rtl: bool) -> Result<(Vec, f64), EditError> { @@ -313,14 +303,7 @@ pub(super) fn shaped_layout(t: &AddedText, line: &str, rtl: bool) -> Result<(Vec }); } } - let width = pieces - .iter() - .map(|p| match p { - Piece::Latin(s) => latin_width(t, s), - // Each cluster is shown on its own, so the spacing follows each once. - Piece::Shaped(clusters) => clusters.iter().map(|c| c.advance * t.size + t.char_spacing).sum::(), - }) - .sum(); + let width = pdfcraft_fonts::shaped_text::pieces_width(&pieces, t.size, t.char_spacing, |s, _| latin_width(t, s)); Ok((pieces, width)) } @@ -351,13 +334,7 @@ pub(super) fn hebrew_error(e: GlyphError, text: &str) -> EditError { } pub(super) fn thai_error(e: GlyphError, text: &str) -> EditError { - EditError::Invalid(match e { - GlyphError::NoFont => { - "Thai text needs a Thai font in this build; rebuild with CRAFT_FONTS_DIR pointing to craft-fonts with a Thai face".into() - } - GlyphError::Missing => format!("the Thai font can't show \"{text}\""), - GlyphError::TooComplex => "the Thai glyphs are too complex; use a shorter text box".into(), - }) + EditError::Invalid(pdfcraft_fonts::shaped_text::thai_message(e, text)) } /// The box a text item occupies (height from its lines). diff --git a/crates/edit/src/apply.rs b/crates/edit/src/apply.rs index 0d0213904b..8b52c81c76 100644 --- a/crates/edit/src/apply.rs +++ b/crates/edit/src/apply.rs @@ -1,9 +1,12 @@ +use pdfcraft_fonts::shaped_text::{is_thai, thai_refusal, thai_width}; + use super::marks::{ - Background, Context, HeaderFooter, MarkKind, Watermark, add_picture_resource, bates_start, expand, matrix_number, n, picture, rgb, shrink_matrix, + Background, Context, HeaderFooter, MarkKind, MarkText, Watermark, add_picture_resource, bates_start, expand, matrix_number, n, picture, rgb, + shrink_matrix, }; use super::place::{ END, Isolation, add_page_resources, add_resources, begin, check, check_on_top, contents, free_font_name, isolation, page_fonts, page_list, place, - place_inside_wrapper, stream_tag, text_op, undrawable, wrapper_role, + place_inside_wrapper, stream_tag, undrawable, wrapper_role, }; use super::{Document, EditError, Object, added, backdrop, helvetica_width}; @@ -31,6 +34,9 @@ fn add_header_footer_in_place(doc: &mut Document, pages: &[usize], hf: &HeaderFo if hf.text.iter().all(|t| t.trim().is_empty()) { return Err(EditError::Invalid("type the header or footer text first".into())); } + if let Some(why) = hf.text.iter().find_map(|t| thai_refusal(t)) { + return Err(EditError::Invalid(why)); + } if let Some(c) = hf.first_undrawable() { return Err(undrawable(c)); } @@ -73,12 +79,23 @@ fn add_header_footer_in_place(doc: &mut Document, pages: &[usize], hf: &HeaderFo } let all = page_list(doc); let count = all.len(); + let number_of = |k: usize| { + u32::try_from(u64::from(hf.start_number) + k as u64).map_err(|_| EditError::Invalid("the header page numbers are too large".into())) + }; + let bates_of = |k: usize| bates0.checked_add(k as u64).ok_or_else(|| EditError::Invalid("the Bates numbers are too large".into())); + // Every Thai line drawn on any page (a Bates prefix may join a template's Thai), for its fonts. + let mut thai_lines = std::collections::BTreeSet::new(); + if hf.text.iter().any(|t| t.chars().any(is_thai)) { + for k in 0..pages.len() { + let (number, bates) = (number_of(k)?, bates_of(k)?); + thai_lines.extend(hf.text.iter().flat_map(|t| expand(t, number, count, bates, cx).lines().map(str::to_owned).collect::>())); + } + } + let thai = MarkText::new(doc, &all, pages, thai_lines.iter().map(String::as_str))?; for (k, &i) in pages.iter().enumerate() { let page = &all[i]; let (w, h) = page.display_size(doc); - let number = - u32::try_from(u64::from(hf.start_number) + k as u64).map_err(|_| EditError::Invalid("the header page numbers are too large".into()))?; - let bates = bates0.checked_add(k as u64).ok_or_else(|| EditError::Invalid("the Bates numbers are too large".into()))?; + let (number, bates) = (number_of(k)?, bates_of(k)?); let size = hf.font_size; let [top, bottom, left, right] = hf.margins; // The page resource the face is added under: the usual name, unless the page already @@ -91,6 +108,7 @@ fn add_header_footer_in_place(doc: &mut Document, pages: &[usize], hf: &HeaderFo let base = hf.font.base_font(false, false); let resource = free_font_name(doc, &page_fonts(doc, page), preferred, base); let mut body: Vec = format!("BT\n/{resource} {} Tf\n{}\n", n(size), rgb(hf.color)).into_bytes(); + let mut current = None; let mut underlines = String::new(); for (slot, template) in hf.text.iter().enumerate() { if template.trim().is_empty() { @@ -101,7 +119,8 @@ fn add_header_footer_in_place(doc: &mut Document, pages: &[usize], hf: &HeaderFo let line_h = size * 1.2; for (li, line) in lines.iter().enumerate() { // Centred and right-aligned text is placed by its width in the chosen face. - let tw = hf.font.width(line, size, false, false); + let face = |s: &str, size: f64| hf.font.width(s, size, false, false); + let tw = thai_width(line, size, face); let x = match slot % 3 { 0 => left, 1 => (w - tw) / 2.0, @@ -109,7 +128,7 @@ fn add_header_footer_in_place(doc: &mut Document, pages: &[usize], hf: &HeaderFo }; // Headers hang below the top margin; footers sit on the bottom margin. let y = if header { h - top - size * 0.8 - li as f64 * line_h } else { bottom + (lines.len() - 1 - li) as f64 * line_h }; - body.extend(text_op(x, y, line)); + thai.show(&mut body, line, x, y, size, &resource, |s| face(s, size), &mut current); if hf.underline { underlines.push_str(&format!("{} {} {} {} re f\n", n(x), n(y - size * 0.15), n(tw), n((size * 0.06).max(0.4)))); } @@ -126,6 +145,7 @@ fn add_header_footer_in_place(doc: &mut Document, pages: &[usize], hf: &HeaderFo if !page_fonts(doc, page).contains(resource.as_bytes()) { add_page_resources(doc, page, None, &[(resource.into_bytes(), base)])?; } + thai.add_to(doc, &page_list(doc)[i])?; let page = &page_list(doc)[i]; place(doc, page, MarkKind::HeaderFooter, content, false)?; if let Some(Some(m)) = shrinks.get(k) { @@ -150,6 +170,9 @@ fn add_watermark_in_place(doc: &mut Document, pages: &[usize], wm: &Watermark, r if text.is_empty() && wm.source.is_none() { return Err(EditError::Invalid("type the watermark text first".into())); } + if let Some(why) = wm.source.is_none().then(|| thai_refusal(text)).flatten() { + return Err(EditError::Invalid(why)); + } if let Some(c) = wm.first_undrawable() { return Err(undrawable(c)); } @@ -171,6 +194,7 @@ fn add_watermark_in_place(doc: &mut Document, pages: &[usize], wm: &Watermark, r } let opacity = wm.opacity.clamp(0.0, 1.0); let lines: Vec<&str> = text.lines().collect(); + let thai = if wm.source.is_none() { MarkText::new(doc, &page_list(doc), pages, lines.iter().copied())? } else { MarkText::default() }; for &i in pages { let page = page_list(doc)[i].clone(); let (w, h) = page.display_size(doc); @@ -185,7 +209,7 @@ fn add_watermark_in_place(doc: &mut Document, pages: &[usize], wm: &Watermark, r place(doc, page, MarkKind::Watermark, content.into_bytes(), wm.behind)?; continue; } - let widest = lines.iter().map(|l| helvetica_width(l, 1.0)).fold(0.0, f64::max).max(0.01); + let widest = lines.iter().map(|l| thai_width(l, 1.0, helvetica_width)).fold(0.0, f64::max).max(0.01); let size = if wm.font_size > 0.0 { wm.font_size } else { ((w * w + h * h).sqrt() * 0.5 / widest).clamp(6.0, 300.0) }; let (s, c) = wm.rotation.to_radians().sin_cos(); let (cx, cy) = (w / 2.0 + wm.offset[0], h / 2.0 + wm.offset[1]); @@ -207,13 +231,24 @@ fn add_watermark_in_place(doc: &mut Document, pages: &[usize], wm: &Watermark, r ); let line_h = size * 1.15; let total = line_h * (lines.len() as f64 - 1.0); + let mut current = None; for (li, line) in lines.iter().enumerate() { - let tw = helvetica_width(line, size); - content.extend(text_op(-tw / 2.0, total / 2.0 - li as f64 * line_h - size * 0.35, line)); + let tw = thai_width(line, size, helvetica_width); + thai.show( + &mut content, + line, + -tw / 2.0, + total / 2.0 - li as f64 * line_h - size * 0.35, + size, + "PCHelv", + |s| helvetica_width(s, size), + &mut current, + ); } content.extend_from_slice(b"ET\n"); content.extend_from_slice(END.as_bytes()); add_resources(doc, &page, Some(opacity), None)?; + thai.add_to(doc, &page_list(doc)[i])?; let page = &page_list(doc)[i]; place(doc, page, MarkKind::Watermark, content, wm.behind)?; } diff --git a/crates/edit/src/lib.rs b/crates/edit/src/lib.rs index 39eeb1e5dc..dda38c7c08 100644 --- a/crates/edit/src/lib.rs +++ b/crates/edit/src/lib.rs @@ -23,7 +23,7 @@ #![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] use pdfcraft_cos::{Dict, Document, ObjRef, Object, Stream}; -use pdfcraft_fonts::{helvetica_width, literal, win_ansi}; +use pdfcraft_fonts::helvetica_width; #[derive(Debug, thiserror::Error, PartialEq)] pub enum EditError { diff --git a/crates/edit/src/marks.rs b/crates/edit/src/marks.rs index 344c76250f..6c88fb2bf0 100644 --- a/crates/edit/src/marks.rs +++ b/crates/edit/src/marks.rs @@ -1,4 +1,75 @@ -use super::{Document, EditError, Object, Rgb, added}; +use pdfcraft_fonts::shaped_text::{Piece, ShapedFonts, clusters, first_undrawable_in_line, is_thai, thai_message, thai_pieces}; + +use super::{Dict, Document, EditError, Object, Rgb, added}; + +/// The Thai of a header, footer or text watermark, drawn from the Thai face as Add text draws it: +/// Type 3 fonts added to the document once and to each page the mark goes on, beside the page +/// font that draws the rest. +#[derive(Default)] +pub(crate) struct MarkText { + shaped: ShapedFonts, + /// The fonts, by the resource names the marked pages give them. + fonts: Dict, +} + +impl MarkText { + /// The fonts for the Thai in `lines` (every line the mark draws, on any page), named so that + /// none of `pages` (of `all`) has the name already. Thai this build can't draw (it has no Thai + /// face, or the face lacks a character) is refused, saying what to do. + pub(crate) fn new<'a>( + doc: &mut Document, + all: &[pdfcraft_model::Page], + pages: &[usize], + lines: impl IntoIterator, + ) -> Result { + let thai = |line: &str| thai_pieces(line).map_err(|e| EditError::Invalid(thai_message(e, line))); + let pieces = lines.into_iter().filter(|l| l.chars().any(is_thai)).map(thai).collect::, _>>()?; + if pieces.is_empty() { + return Ok(Self::default()); + } + let mut taken = Dict::new(); + for page in pages.iter().filter_map(|&i| all.get(i)) { + for (name, font) in crate::place::page_fonts(doc, page).iter() { + taken.set(name.clone(), font.clone()); + } + } + let mut fonts = Dict::new(); + let shaped = + ShapedFonts::new(clusters(pieces.iter().map(Vec::as_slice)), pdfcraft_fonts::shaped_glyph, &taken, &mut fonts, "PCTh", &mut |o| { + Object::Ref(doc.add(o)) + }) + .map_err(|(e, c)| EditError::Invalid(thai_message(e, &c.text)))?; + Ok(Self { shaped, fonts }) + } + + /// Show `line` inside `BT … ET` from `x` on baseline `y` at `size`: its Thai from the fonts, + /// the rest in the page font `latin` (selected when `current` is `None`), `width` wide. + #[allow(clippy::too_many_arguments)] + pub(crate) fn show( + &self, + out: &mut Vec, + line: &str, + x: f64, + y: f64, + size: f64, + latin: &str, + width: impl Fn(&str) -> f64, + current: &mut Option, + ) { + let plain = || vec![Piece::Latin(line.into())]; + // `new` shaped every line drawn, so this shapes. + let pieces = if self.fonts.is_empty() || !line.chars().any(is_thai) { plain() } else { thai_pieces(line).unwrap_or_else(|_| plain()) }; + self.shaped.show_line(out, &pieces, x, y, size, latin, pdfcraft_fonts::win_ansi, width, current); + } + + /// Add the fonts to `page`'s own resources. + pub(crate) fn add_to(&self, doc: &mut Document, page: &pdfcraft_model::Page) -> Result<(), EditError> { + if self.fonts.is_empty() { + return Ok(()); + } + crate::place::add_page_fonts(doc, page, &self.fonts) + } +} /// The kinds of page marks. #[derive(Clone, Copy, Debug, PartialEq, Eq)] @@ -61,13 +132,13 @@ impl Default for HeaderFooter { } impl HeaderFooter { - /// The first character [`add_header_footer`] would refuse, because the standard fonts can't - /// draw it (#125). Tokens only add ASCII (Bates prefixes and suffixes are part of the - /// template), so checking the templates, split as `expand`'s output is, covers every page - /// (and a `\r` that would be drawn is refused too). The dialog asks this too, so its preview - /// never offers text that applying refuses (#2013). + /// The first character [`add_header_footer`] would refuse, because neither the standard + /// fonts nor (for Thai) the Thai face can draw it (#125). Tokens only add ASCII (Bates + /// prefixes and suffixes are part of the template), so checking the templates, split as + /// `expand`'s output is, covers every page (and a `\r` that would be drawn is refused too). + /// The dialog asks this too, so its preview never offers text that applying refuses (#2013). pub fn first_undrawable(&self) -> Option { - self.text.iter().flat_map(|t| t.lines()).find_map(pdfcraft_fonts::first_non_win_ansi) + self.text.iter().flat_map(|t| t.lines()).find_map(first_undrawable_in_line) } /// The room the header and the footer take at the top and the bottom of a page, in points: @@ -139,12 +210,13 @@ pub struct Watermark { } impl Watermark { - /// The first character of a text watermark [`add_watermark`] would refuse, because the - /// standard fonts can't draw it (#125). A picture watermark draws no text. + /// The first character of a text watermark [`add_watermark`] would refuse, because neither + /// the standard fonts nor (for Thai) the Thai face can draw it (#125). A picture watermark + /// draws no text. pub fn first_undrawable(&self) -> Option { match self.source { Some(_) => None, - None => self.text.trim().lines().find_map(pdfcraft_fonts::first_non_win_ansi), + None => self.text.trim().lines().find_map(first_undrawable_in_line), } } } diff --git a/crates/edit/src/place.rs b/crates/edit/src/place.rs index 6460aab066..ae2faf8386 100644 --- a/crates/edit/src/place.rs +++ b/crates/edit/src/place.rs @@ -1,5 +1,5 @@ -use super::marks::{MarkKind, matrix_number, n}; -use super::{Dict, Document, EditError, ObjRef, Object, Stream, literal, win_ansi}; +use super::marks::{MarkKind, matrix_number}; +use super::{Dict, Document, EditError, ObjRef, Object, Stream}; pub(crate) fn page_list(doc: &Document) -> Vec { pdfcraft_model::pages(doc) @@ -68,6 +68,19 @@ pub(crate) fn free_font_name(doc: &Document, fonts: &Dict, preferred: &str, base (1..=fonts.len().saturating_add(1)).map(|k| format!("{preferred}_{k}")).find(|name| usable(name)).unwrap_or_else(|| preferred.to_string()) } +/// Add `fonts` (resource name, font) to the page's own resources. +pub(crate) fn add_page_fonts(doc: &mut Document, page: &pdfcraft_model::Page, fonts: &Dict) -> Result<(), EditError> { + let mut res = page.dict.get(b"Resources").map(|r| doc.resolve(r)).and_then(|r| r.as_dict().cloned()).unwrap_or_default(); + let mut all = res.get(b"Font").map(|f| doc.resolve(f)).and_then(|f| f.as_dict().cloned()).unwrap_or_default(); + for (name, font) in fonts.iter() { + all.set(name.clone(), font.clone()); + } + res.set(b"Font".to_vec(), Object::Dict(all)); + // The page gets its own (possibly copied) resources; shared dictionaries stay untouched. + doc.update_dict(page.obj, |d| d.set(b"Resources".to_vec(), Object::Dict(res)))?; + Ok(()) +} + /// Add the standard `faces` (resource name, base font) and an opacity state to the page's own /// resources. pub(crate) fn add_page_resources( @@ -575,10 +588,3 @@ pub(crate) fn undrawable(c: char) -> EditError { pub fn undrawable_message(c: char) -> String { format!("the standard fonts can't draw \"{c}\" (U+{:04X}); only Western European characters can be added as text for now", u32::from(c)) } - -pub(crate) fn text_op(x: f64, y: f64, text: &str) -> Vec { - let mut v = format!("1 0 0 1 {} {} Tm ", n(x), n(y)).into_bytes(); - v.extend(literal(&win_ansi(text))); - v.extend_from_slice(b" Tj\n"); - v -} diff --git a/crates/edit/src/tests.rs b/crates/edit/src/tests.rs index b22d697fe1..1c4829b418 100644 --- a/crates/edit/src/tests.rs +++ b/crates/edit/src/tests.rs @@ -6199,6 +6199,55 @@ fn header_and_watermark_drawability_matches_what_applying_refuses() { } } +/// Thai in a header, footer or text watermark is drawn from the Thai face as Add text draws it: +/// Type 3 glyphs with a ToUnicode map, one set of fonts shared by every page the mark goes on, +/// beside the mark's standard face. A build without a Thai face refuses it, saying what to do. +#[test] +fn thai_headers_footers_and_watermarks_are_drawn_from_the_thai_face() { + let blank = String::new; + let footer = HeaderFooter { + text: [blank(), "หน้า <<1>> บุญฤทธิ์".into(), blank(), blank(), blank(), "น้ำ <>".into()], + ..HeaderFooter::default() + }; + let wm = Watermark { text: "ลับ ปั้น\nDRAFT".into(), ..Watermark::default() }; + if pdfcraft_fonts::document_thai_font().is_none() { + let mut doc = fixture(); + for err in [add_header_footer(&mut doc, &[0], &footer, false, &cx()).unwrap_err(), add_watermark(&mut doc, &[0], &wm, false).unwrap_err()] { + assert!(matches!(&err, EditError::Invalid(m) if m.contains("Thai") && m.contains("CRAFT_FONTS_DIR")), "{err:?}"); + } + assert!(!doc.is_modified(), "nothing is written when the font is missing"); + assert_eq!((footer.first_undrawable(), wm.first_undrawable()), (Some('ห'), Some('ล'))); + return; + } + assert_eq!((footer.first_undrawable(), wm.first_undrawable()), (None, None)); + let mut doc = fixture(); + add_header_footer(&mut doc, &[0, 1], &footer, false, &cx()).unwrap(); + add_watermark(&mut doc, &[0, 1], &wm, false).unwrap(); + let doc = reopen(&doc); + let mut shared = Vec::new(); + for page in 0..2 { + let drawn = streams(&doc, page).concat(); + assert!(drawn.contains("/PCTh") && drawn.contains("(DRAFT) Tj") && !drawn.contains("(?"), "{drawn}"); + let p = &pdfcraft_model::pages(&doc)[page]; + let res = doc.resolve(p.dict.get(b"Resources").unwrap()); + let fonts = doc.resolve(res.as_dict().unwrap().get(b"Font").unwrap()).as_dict().unwrap().clone(); + let thai: Vec<_> = fonts.iter().filter(|(k, _)| k.starts_with(b"PCTh")).map(|(k, v)| (k.clone(), v.clone())).collect(); + assert_eq!(thai.len(), 2, "the footer's fonts and the watermark's"); + let mut mapped = String::new(); + for (_, f) in &thai { + let f = doc.resolve(f).as_dict().unwrap().clone(); + assert_eq!(f.name(b"Subtype"), Some(&b"Type3"[..])); + mapped += &String::from_utf8_lossy(&stream_bytes(&doc, f.get(b"ToUnicode").unwrap()).unwrap()); + } + // น้ำ, ก (the Bates prefix) and ปั้น copy as typed. + for text in ["<0E190E490E33>", "<0E01>", "<0E1B0E310E49>"] { + assert!(mapped.contains(text), "{text} in {mapped}"); + } + shared.push(thai); + } + assert_eq!(shared[0], shared[1], "every page draws with the same fonts"); +} + /// #125: page-content tools refuse text the standard fonts can't draw instead of writing `?`, /// and leave the document untouched. #[test] diff --git a/crates/fonts/README.md b/crates/fonts/README.md index 963ee889b9..c3c0ea5021 100644 --- a/crates/fonts/README.md +++ b/crates/fonts/README.md @@ -56,9 +56,12 @@ set). Without that variable `CRAFT_FONTS` is empty and everything below copes: editor can use the first one that has all of the replacement's glyphs (the faces differ in coverage, e.g. of Cyrillic). - `ui_thai_fonts` / `document_thai_font` / `shape_thai`: the `Thai` faces for the interface - and shaped Thai Add Text content. Harfrust positions stacked vowel/tone marks and decomposes + and shaped Thai content. Harfrust positions stacked vowel/tone marks and decomposes Sara Am; cluster ToUnicode entries preserve the original text. The shared Type 3 writer - embeds outlines, so reopening does not depend on installed fonts. + (`shaped_text`) embeds outlines, so reopening does not depend on installed fonts: Add text, + text boxes, Fill & Sign text, form fields, headers, footers and watermarks all write with it + (`shaped_text::appearance_lines` for appearances, whose glyph streams `store_streams` moves into + the document). `wrap_with` never breaks a line before a Thai mark. - `craft_glyph(face, ch)` and `face_has(bytes, ch)`: bounded outlines and glyph coverage for any bundled face. - `SHIPPORI_MINCHO`: Shippori Mincho's bytes, or `None`. - `TextPlan::for_words`: how the OCR output writes text beyond WinAnsi — per word the first diff --git a/crates/fonts/src/lib.rs b/crates/fonts/src/lib.rs index 4ebe181a75..253246a79e 100644 --- a/crates/fonts/src/lib.rs +++ b/crates/fonts/src/lib.rs @@ -16,6 +16,7 @@ pub mod fallback; pub mod pdf; pub mod plan; mod script; +pub mod shaped_text; mod shaping; pub mod source_shaping; mod std14; @@ -99,7 +100,6 @@ pub fn wrap(text: &str, size: f64, width: f64) -> Vec { pub fn wrap_with(text: &str, size: f64, width: f64, measure: impl Fn(&str, f64) -> f64) -> Vec { let mut lines = Vec::new(); let space = measure(" ", size); - let mut buf = [0u8; 4]; // A CRLF pair is one break, a lone CR or LF is one break each. for para in text.replace("\r\n", "\n").split(['\n', '\r']) { let mut line = String::new(); @@ -125,14 +125,24 @@ pub fn wrap_with(text: &str, size: f64, width: f64, measure: impl Fn(&str, f64) lines.push(std::mem::take(&mut line)); } line_width = 0.0; - for ch in word.chars() { - let ch_width = measure(ch.encode_utf8(&mut buf), size); - if !line.is_empty() && line_width + ch_width > width { + // Broken between characters, but never before a Thai mark: it stays on its letter. + let mut start = 0; + let mut units = Vec::new(); + for (i, ch) in word.char_indices().skip(1) { + if !shaped_text::joins_previous(ch) { + units.extend(word.get(start..i)); + start = i; + } + } + units.extend(word.get(start..).filter(|u| !u.is_empty())); + for unit in units { + let unit_width = measure(unit, size); + if !line.is_empty() && line_width + unit_width > width { lines.push(std::mem::take(&mut line)); line_width = 0.0; } - line.push(ch); - line_width += ch_width; + line.push_str(unit); + line_width += unit_width; } fresh = line.is_empty(); } @@ -320,6 +330,15 @@ mod tests { assert_eq!(broken, wrap(" abcdefghijklmnopqrstuvwxyz", 11.0, 40.0)); } + #[test] + fn a_broken_thai_word_keeps_its_marks_on_their_letters() { + // Every character one point wide: "น้ำ" (three characters) is one unit, never split. + let lines = wrap_with("น้ำปั้นบุญฤทธิ์", 1.0, 2.0, |s, _| s.chars().count() as f64); + assert_eq!(lines.concat(), "น้ำปั้นบุญฤทธิ์"); + assert_eq!(lines, ["น้ำ", "ปั้", "น", "บุ", "ญฤ", "ท", "ธิ์"]); + assert!(lines.iter().all(|l| l.chars().next().is_some_and(|c| !shaped_text::joins_previous(c))), "{lines:?}"); + } + #[test] fn crlf_is_one_line_break() { assert_eq!(wrap("a\r\nb", 12.0, 100.0), ["a", "b"]); diff --git a/crates/fonts/src/shaped_text.rs b/crates/fonts/src/shaped_text.rs new file mode 100644 index 0000000000..c85de92f64 --- /dev/null +++ b/crates/fonts/src/shaped_text.rs @@ -0,0 +1,515 @@ +//! Shaped text written into a PDF: each distinct shaped cluster (a letter with its marks, and the +//! text it stands for) becomes one glyph of a Type 3 font drawn from the craft-fonts face's +//! outlines, with a `/ToUnicode` map so copy and search get each character once. No font file is +//! embedded. Add text writes Arabic, Hebrew and Thai with it; text boxes, Fill & Sign text, form +//! fields, headers, footers and watermarks write Thai with it, beside the standard font that +//! draws the rest ([`thai_pieces`]). + +use std::collections::{HashMap, HashSet}; + +use pdfcraft_cos::{Dict, Document, Object, PdfString, Stream}; + +use crate::{GlyphError, GlyphOutline, ShapedCluster, ShapedScript, literal, shape_thai, shaped_glyph}; + +/// Most glyphs in one Type 3 font: its codes are single bytes, 1–240. Text gets one such font per +/// 240 distinct clusters it needs. +pub const GLYPHS_PER_FONT: usize = 240; + +/// The Thai block (U+0E00–U+0E7F): letters, vowels, tone marks, digits and signs. +pub fn is_thai(c: char) -> bool { + matches!(u32::from(c), 0x0E00..=0x0E7F) +} + +/// Thai vowel signs and tone marks drawn on the consonant before them, and Sara Am, which the +/// face splits over it: a line never breaks before one, so a mark stays on its letter. +pub fn joins_previous(c: char) -> bool { + matches!(u32::from(c), 0x0E31 | 0x0E33..=0x0E3A | 0x0E47..=0x0E4E) +} + +/// Why Thai text can't be drawn, for a message an agent or user can act on. +pub fn thai_message(e: GlyphError, text: &str) -> String { + match e { + GlyphError::NoFont => "Thai text needs a Thai font, and this build has none; rebuild with CRAFT_FONTS_DIR pointing to a craft-fonts \ + checkout with a Thai face" + .into(), + GlyphError::Missing => format!("the Thai font can't show \"{text}\""), + GlyphError::TooComplex => "the Thai glyphs are too complex; use shorter text".into(), + } +} + +/// A piece of a line of shaped text, in drawing order. +#[derive(Clone, Debug, PartialEq)] +pub enum Piece { + /// Shown in the text's own standard font. + Latin(String), + Shaped(Vec), +} + +/// `line` split into its Thai runs, shaped with the Thai face, and the text between them, left to +/// the standard font. Thai runs left to right, so the pieces are in typing order. +/// [`GlyphError::NoFont`] when there's Thai and this build has no Thai face. +pub fn thai_pieces(line: &str) -> Result, GlyphError> { + let mut runs: Vec<(bool, String)> = Vec::new(); + for c in line.chars() { + match runs.last_mut() { + Some((thai, s)) if *thai == is_thai(c) => s.push(c), + _ => runs.push((is_thai(c), c.to_string())), + } + } + runs.into_iter().map(|(thai, s)| if thai { shape_thai(&s).map(Piece::Shaped) } else { Ok(Piece::Latin(s)) }).collect() +} + +/// The width of `text` at `size`: its Thai shaped, the rest measured by `latin`. Text the Thai +/// face can't shape is measured by `latin` as a whole (drawing then reports why). +pub fn thai_width(text: &str, size: f64, latin: impl Fn(&str, f64) -> f64) -> f64 { + if !text.chars().any(is_thai) { + return latin(text, size); + } + match thai_pieces(text) { + Ok(pieces) => pieces_width(&pieces, size, 0.0, latin), + Err(_) => latin(text, size), + } +} + +/// The width of `pieces` at `size`, with `spacing` after each shaped cluster (as `Tc` moves the +/// pen; the caller measures it into `latin`'s widths). +pub fn pieces_width(pieces: &[Piece], size: f64, spacing: f64, latin: impl Fn(&str, f64) -> f64) -> f64 { + pieces + .iter() + .map(|p| match p { + Piece::Latin(s) => latin(s, size), + // Each cluster is shown on its own, so the spacing follows each once. + Piece::Shaped(clusters) => clusters.iter().map(|c| c.advance * size + spacing).sum::(), + }) + .sum() +} + +/// The shaped clusters of `lines`' pieces, for [`ShapedFonts::new`]. +pub fn clusters<'a>(lines: impl IntoIterator) -> impl Iterator { + lines.into_iter().flatten().flat_map(|p| match p { + Piece::Shaped(clusters) => clusters.as_slice(), + Piece::Latin(_) => &[], + }) +} + +/// What makes two clusters the same Type 3 glyph: text, glyphs, offsets and advance (bit for bit; +/// equal clusters come from the same font units). +type ClusterKey = (ShapedScript, String, Vec<(u32, u64, u64)>, u64); + +fn cluster_key(c: &ShapedCluster) -> ClusterKey { + (c.script, c.text.clone(), c.glyphs.iter().map(|(id, [x, y])| (*id, x.to_bits(), y.to_bits())).collect(), c.advance.to_bits()) +} + +/// The Type 3 fonts that draw a set of shaped clusters, one glyph per distinct cluster. The +/// default has none (for text with nothing shaped). +#[derive(Default)] +pub struct ShapedFonts { + index: HashMap, + /// Each font's resource name. + names: Vec, +} + +impl ShapedFonts { + /// Build the fonts for `clusters` into `fonts`, under names starting `prefix` that neither + /// `taken` (the page's fonts) nor `fonts` already has. `outline` gives a glyph's outline in its + /// script's face (the craft-fonts faces, or a stand-in in tests). `store` keeps each new + /// object and returns what refers to it: `doc.add` as a reference, or the object itself for + /// an appearance that isn't in a document yet, whose streams [`store_streams`] then moves in. + /// + /// Every outline is read before anything is stored, so a failure (and the cluster it came + /// from) leaves the document as it was. + pub fn new<'a>( + clusters: impl IntoIterator, + outline: impl Fn(ShapedScript, u32) -> Result, + taken: &Dict, + fonts: &mut Dict, + prefix: &str, + store: &mut dyn FnMut(Object) -> Object, + ) -> Result { + let mut keys: Vec<&ShapedCluster> = Vec::new(); + let mut index: HashMap = HashMap::new(); + for c in clusters { + index.entry(cluster_key(c)).or_insert_with(|| { + keys.push(c); + keys.len() - 1 + }); + } + let mut outlines = HashMap::new(); + for c in &keys { + for (id, _) in &c.glyphs { + if let std::collections::hash_map::Entry::Vacant(entry) = outlines.entry((c.script, *id)) { + entry.insert(outline(c.script, *id).map_err(|e| (e, *c))?); + } + } + } + // A name no font on the page has, so items never replace each other's fonts (object + // numbers alone don't do: a full save renumbers objects but keeps resource names). + let mut taken_names: HashSet> = taken.iter().chain(fonts.iter()).map(|(name, _)| name.clone()).collect(); + let mut names = Vec::new(); + for (i, chunk) in keys.chunks(GLYPHS_PER_FONT).enumerate() { + let font = type3_font(chunk, &outlines, store); + let font = store(Object::Dict(font)); + let number = font.as_ref().map_or(i + 1, |r| r.num as usize); + let mut name = format!("{prefix}{number}"); + let mut suffix = 0usize; + while taken_names.contains(name.as_bytes()) { + suffix = suffix.saturating_add(1); + name = format!("{prefix}{number}_{suffix}"); + } + taken_names.insert(name.clone().into_bytes()); + fonts.set(name.clone().into_bytes(), font); + names.push(name); + } + Ok(ShapedFonts { index, names }) + } + + /// The font resource name and code that show `c`, if it was one of the clusters. + pub fn code(&self, c: &ShapedCluster) -> Option<(&str, u8)> { + let &k = self.index.get(&cluster_key(c))?; + let name = self.names.get(k / GLYPHS_PER_FONT)?; + // Codes start at 1; k % 240 + 1 is at most 240. + Some((name.as_str(), u8::try_from(k % GLYPHS_PER_FONT + 1).ok()?)) + } + + /// Show `clusters` inside `BT … ET` from `pen` on baseline `y` at `size`, each placed with its + /// own `Tm`, `spacing` after each and `word_spacing` more after a space. `current` is the + /// shaped font the text state has selected (`None` after another font); returns the pen + /// after the clusters. + #[allow(clippy::too_many_arguments)] + pub fn show( + &self, + out: &mut Vec, + clusters: &[ShapedCluster], + mut pen: f64, + y: f64, + size: f64, + spacing: f64, + word_spacing: f64, + current: &mut Option, + ) -> f64 { + for c in clusters { + let Some(&k) = self.index.get(&cluster_key(c)) else { continue }; + let Some(name) = self.names.get(k / GLYPHS_PER_FONT) else { continue }; + let code = u8::try_from(k % GLYPHS_PER_FONT + 1).unwrap_or(1); + if *current != Some(k / GLYPHS_PER_FONT) { + out.extend(format!("/{name} {} Tf ", n(size)).bytes()); + *current = Some(k / GLYPHS_PER_FONT); + } + out.extend(format!("1 0 0 1 {} {} Tm ", n(pen), n(y)).bytes()); + out.extend(literal(&[code])); + out.extend_from_slice(b" Tj\n"); + pen += c.advance * size + spacing + if c.text == " " { word_spacing } else { 0.0 }; + } + pen + } + + /// Show a line's `pieces` inside `BT … ET` from `x` on baseline `y` at `size`: shaped clusters + /// in their Type 3 fonts, the rest in the font `latin` (which the text state has selected when + /// `current` is `None`), written by `encode` and `measure`d. `current` is as for [`Self::show`]. + #[allow(clippy::too_many_arguments)] + pub fn show_line( + &self, + out: &mut Vec, + pieces: &[Piece], + x: f64, + y: f64, + size: f64, + latin: &str, + encode: impl Fn(&str) -> Vec, + measure: impl Fn(&str) -> f64, + current: &mut Option, + ) { + let mut pen = x; + for p in pieces { + match p { + Piece::Latin(s) => { + if current.take().is_some() { + out.extend(format!("/{latin} {} Tf ", n(size)).bytes()); + } + out.extend(format!("1 0 0 1 {} {} Tm ", n(pen), n(y)).bytes()); + out.extend(literal(&encode(s))); + out.extend_from_slice(b" Tj\n"); + pen += measure(s); + } + Piece::Shaped(clusters) => pen = self.show(out, clusters, pen, y, size, 0.0, 0.0, current), + } + } + } +} + +/// `lines` as a generated appearance not yet in a document draws them: each line's pieces, its +/// Thai shaped, and the Type 3 fonts for that Thai built into `fonts` (the form's font resources) +/// as direct objects, whose streams [`store_streams`] moves into the document with the +/// appearance. Thai the face can't draw, and every line when a glyph can't be read, is left to the +/// standard font, whose encoder writes `?` for it; [`first_undrawable`] reports that. +pub fn appearance_lines(lines: &[String], fonts: &mut Dict) -> (Vec>, ShapedFonts) { + let plain = |l: &String| vec![Piece::Latin(l.clone())]; + let pieces: Vec> = + lines.iter().map(|l| if l.chars().any(is_thai) { thai_pieces(l).unwrap_or_else(|_| plain(l)) } else { plain(l) }).collect(); + match ShapedFonts::new(clusters(pieces.iter().map(Vec::as_slice)), shaped_glyph, &Dict::new(), fonts, "PCTh", &mut |o| o).map_err(|(e, _)| e) { + Ok(shaped) => (pieces, shaped), + Err(_) => (lines.iter().map(plain).collect(), ShapedFonts::default()), + } +} + +/// How far the Thai glyphs of `line` reach below and above its baseline, in em units; `None` +/// without Thai, or with Thai the face can't draw. +pub fn thai_reach(line: &str) -> Option<[f64; 2]> { + if !line.chars().any(is_thai) { + return None; + } + let pieces = thai_pieces(line).ok()?; + let mut reach: Option<[f64; 2]> = None; + for c in clusters([pieces.as_slice()]) { + for (id, [_, dy]) in &c.glyphs { + let g = shaped_glyph(c.script, *id).ok()?; + if !g.contours.is_empty() { + let b = [g.bbox[1] + dy, g.bbox[3] + dy]; + reach = Some(reach.map_or(b, |r| [r[0].min(b[0]), r[1].max(b[1])])); + } + } + } + reach +} + +/// The first character of `s` a generated appearance would draw as `?`: one the standard fonts +/// (WinAnsiEncoding) can't draw and that isn't Thai, or Thai on a line the Thai face can't draw +/// (this build has none, or it lacks a character). Line breaks don't count. +pub fn first_undrawable(s: &str) -> Option { + s.split(['\n', '\r']).find_map(first_undrawable_in_line) +} + +/// [`first_undrawable`] for one line drawn as it is: a line break in it counts. +pub fn first_undrawable_in_line(line: &str) -> Option { + let thai_fails = line.chars().any(is_thai) && thai_pieces(line).is_err(); + line.chars().find(|c| if is_thai(*c) { thai_fails } else { !crate::win_ansi_encodable(*c) }) +} + +/// Why the Thai in `text` can't be drawn ([`thai_message`]): this build has no Thai face, or the +/// face lacks a character. For tools that refuse such text rather than write `?`; `None` when all +/// of its Thai draws, or it has none. +pub fn thai_refusal(text: &str) -> Option { + text.split(['\n', '\r']).filter(|l| l.chars().any(is_thai)).find_map(|l| thai_pieces(l).err().map(|e| thai_message(e, l))) +} + +/// [`type3_font`] written into `doc`, with its glyphs, descriptor and ToUnicode as objects of their +/// own: for content that draws `clusters` (at most [`GLYPHS_PER_FONT`]) as codes 1… directly. +pub fn add_type3_font( + doc: &mut Document, + clusters: &[&ShapedCluster], + outlines: &HashMap<(ShapedScript, u32), GlyphOutline>, +) -> pdfcraft_cos::ObjRef { + let font = type3_font(clusters, outlines, &mut |o| Object::Ref(doc.add(o))); + doc.add(Object::Dict(font)) +} + +/// A Type 3 font with `clusters` as codes 1…, each drawn from its script face's glyph `outlines`. +fn type3_font(clusters: &[&ShapedCluster], outlines: &HashMap<(ShapedScript, u32), GlyphOutline>, store: &mut dyn FnMut(Object) -> Object) -> Dict { + let mut charprocs = Dict::new(); + let mut differences = vec![Object::Int(1)]; + let mut widths = Vec::with_capacity(clusters.len()); + let mut bbox = [0.0f64; 4]; + let mut to_unicode = Vec::new(); + for (i, c) in clusters.iter().enumerate() { + let code = i + 1; + // Glyph space is 1000 units per em. + let mut paths = Vec::new(); + let mut ink: Option<[f64; 4]> = None; + for (id, [dx, dy]) in &c.glyphs { + let Some(g) = outlines.get(&(c.script, *id)) else { continue }; + for contour in &g.contours { + for (j, p) in contour.iter().enumerate() { + paths.extend(format!("{} {} {}\n", n((p[0] + dx) * 1000.0), n((p[1] + dy) * 1000.0), if j == 0 { "m" } else { "l" }).bytes()); + } + paths.extend_from_slice(b"h\n"); + let b = [g.bbox[0] + dx, g.bbox[1] + dy, g.bbox[2] + dx, g.bbox[3] + dy]; + ink = Some(ink.map_or(b, |a| [a[0].min(b[0]), a[1].min(b[1]), a[2].max(b[2]), a[3].max(b[3])])); + } + } + // Rounded outwards so no ink is clipped. + let [x0, y0, x1, y1] = + ink.map_or([0.0; 4], |b| [(b[0] * 1000.0).floor(), (b[1] * 1000.0).floor(), (b[2] * 1000.0).ceil(), (b[3] * 1000.0).ceil()]); + bbox = [bbox[0].min(x0), bbox[1].min(y0), bbox[2].max(x1), bbox[3].max(y1)]; + let mut proc = format!("{} 0 {} {} {} {} d1\n", n(c.advance * 1000.0), n(x0), n(y0), n(x1), n(y1)).into_bytes(); + if !paths.is_empty() { + proc.extend(paths); + proc.extend_from_slice(b"f\n"); + } + let glyph = format!("g{code:02X}"); + charprocs.set(glyph.clone().into_bytes(), store(Object::Stream(Stream::flate(Dict::new(), &proc)))); + differences.push(Object::name(&glyph)); + widths.push(Object::Real((c.advance * 1000.0).round())); + if !c.text.is_empty() { + let hex: String = c.text.encode_utf16().map(|u| format!("{u:04X}")).collect(); + to_unicode.push(format!("<{code:02X}> <{hex}>\n")); + } + } + let mut cmap = String::from( + "/CIDInit /ProcSet findresource begin\n12 dict begin\nbegincmap\n/CMapType 2 def\n1 begincodespacerange\n<01> \nendcodespacerange\n", + ); + // A CMap block holds at most 100 entries. + for block in to_unicode.chunks(100) { + cmap.push_str(&format!("{} beginbfchar\n{}endbfchar\n", block.len(), block.concat())); + } + cmap.push_str("endcmap\nCMapName currentdict /CMap defineresource pop\nend\nend\n"); + let cmap = store(Object::Stream(Stream::flate(Dict::new(), cmap.as_bytes()))); + // The face's family when every glyph comes from one script; without the face, the script's + // name (as Arabic items were always named). + let family = match clusters.first().map(|c| c.script).filter(|s| clusters.iter().all(|c| c.script == *s)) { + Some(script) => script.font().map_or( + match script { + ShapedScript::Arabic => "Arabic", + ShapedScript::Hebrew => "Hebrew", + ShapedScript::Thai => "Thai", + }, + |f| f.family, + ), + None => "PdfCraft Shaped Text", + }; + let mut descriptor = Dict::new(); + descriptor.set(b"Type".to_vec(), Object::name("FontDescriptor")); + descriptor.set(b"FontName".to_vec(), Object::name(&family.replace(' ', ""))); + descriptor.set(b"FontFamily".to_vec(), Object::String(PdfString::literal(family.as_bytes().to_vec()))); + descriptor.set(b"Flags".to_vec(), Object::Int(4)); + descriptor.set(b"ItalicAngle".to_vec(), Object::Int(0)); + let mut encoding = Dict::new(); + encoding.set(b"Type".to_vec(), Object::name("Encoding")); + encoding.set(b"Differences".to_vec(), Object::Array(differences)); + let mut font = Dict::new(); + font.set(b"Type".to_vec(), Object::name("Font")); + font.set(b"Subtype".to_vec(), Object::name("Type3")); + font.set(b"FontDescriptor".to_vec(), store(Object::Dict(descriptor))); + font.set(b"FontBBox".to_vec(), Object::Array(bbox.iter().map(|v| Object::Real(*v)).collect())); + font.set( + b"FontMatrix".to_vec(), + Object::Array(vec![Object::Real(0.001), Object::Int(0), Object::Int(0), Object::Real(0.001), Object::Int(0), Object::Int(0)]), + ); + font.set(b"FirstChar".to_vec(), Object::Int(1)); + font.set(b"LastChar".to_vec(), Object::Int(clusters.len() as i64)); + font.set(b"Widths".to_vec(), Object::Array(widths)); + font.set(b"Encoding".to_vec(), Object::Dict(encoding)); + font.set(b"CharProcs".to_vec(), Object::Dict(charprocs)); + font.set(b"ToUnicode".to_vec(), cmap); + font +} + +/// Most levels of dictionaries and arrays [`store_streams`] looks into: a Type 3 font in a form's +/// resources is four deep (`/Font` → font → `/CharProcs` → glyph). +const STREAM_DEPTH: usize = 8; + +/// Move the streams written into `d` (the fonts [`ShapedFonts::new`] built for an appearance not +/// yet in a document) into `doc` as objects of their own, leaving references: a stream is never a +/// direct object (ISO 32000-2 §7.3.8). +pub fn store_streams(doc: &mut Document, d: &mut Dict) { + fn walk(doc: &mut Document, o: &mut Object, depth: usize) { + if depth > STREAM_DEPTH { + return; + } + match o { + Object::Stream(_) => { + let Object::Stream(mut s) = std::mem::replace(o, Object::Null) else { return }; + walk_dict(doc, &mut s.dict, depth + 1); + *o = Object::Ref(doc.add(Object::Stream(s))); + } + Object::Dict(d) => walk_dict(doc, d, depth + 1), + Object::Array(a) => a.iter_mut().for_each(|o| walk(doc, o, depth + 1)), + _ => {} + } + } + fn walk_dict(doc: &mut Document, d: &mut Dict, depth: usize) { + let keys: Vec> = + d.iter().filter(|(_, v)| matches!(v, Object::Stream(_) | Object::Dict(_) | Object::Array(_))).map(|(k, _)| k.clone()).collect(); + for k in keys { + if let Some(v) = d.get_mut(&k) { + walk(doc, v, depth); + } + } + } + walk_dict(doc, d, 0); +} + +/// Format a number for content streams (up to three decimals). +fn n(v: f64) -> String { + let s = format!("{:.3}", if v.abs() < 5e-4 { 0.0 } else { v }); + let s = s.trim_end_matches('0').trim_end_matches('.'); + if s == "-0" { "0".into() } else { s.into() } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn boxed(width: f64) -> GlyphOutline { + GlyphOutline { contours: vec![vec![[0.0, -0.4], [width, -0.4], [width, 1.1], [0.0, 1.1]]], width, bbox: [0.0, -0.4, width, 1.1] } + } + + fn cluster(text: &str, id: u32) -> ShapedCluster { + ShapedCluster { script: ShapedScript::Thai, glyphs: vec![(id, [0.0, 0.0])], advance: 0.5, text: text.into() } + } + + #[test] + fn appearance_fonts_keep_their_streams_until_stored_then_refer_to_them() { + let clusters = [cluster("น้ำ", 1), cluster("ก", 2), cluster("น้ำ", 1)]; + let mut fonts = Dict::new(); + let made = ShapedFonts::new(&clusters, |_, _| Ok(boxed(0.5)), &Dict::new(), &mut fonts, "PCTh", &mut |o| o).unwrap(); + assert_eq!(made.code(&clusters[0]), Some(("PCTh1", 1))); + assert_eq!(made.code(&clusters[1]), Some(("PCTh1", 2)), "a repeated cluster is one glyph"); + let mut line = Vec::new(); + let pieces = [Piece::Latin("a".into()), Piece::Shaped(clusters[..1].to_vec()), Piece::Latin("b".into())]; + made.show_line(&mut line, &pieces, 0.0, 0.0, 10.0, "Helv", |s| s.as_bytes().to_vec(), |_| 1.0, &mut None); + let shown = String::from_utf8_lossy(&line).into_owned(); + assert_eq!(shown, "1 0 0 1 0 0 Tm (a) Tj\n/PCTh1 10 Tf 1 0 0 1 1 0 Tm (\u{1}) Tj\n/Helv 10 Tf 1 0 0 1 6 0 Tm (b) Tj\n"); + let mut out = Vec::new(); + let mut current = None; + let pen = made.show(&mut out, &clusters, 10.0, 20.0, 10.0, 0.0, 0.0, &mut current); + assert_eq!(pen, 25.0); + assert_eq!(String::from_utf8_lossy(&out).matches(" Tf").count(), 1, "one font selection for one font"); + let mut doc = Document::new_empty(); + let mut res = Dict::new(); + res.set(b"Font".to_vec(), Object::Dict(fonts)); + store_streams(&mut doc, &mut res); + let font = res.get(b"Font").and_then(Object::as_dict).and_then(|f| f.get(b"PCTh1")).and_then(Object::as_dict).unwrap(); + assert!(matches!(font.get(b"ToUnicode"), Some(Object::Ref(_)))); + let procs = font.get(b"CharProcs").and_then(Object::as_dict).unwrap(); + assert!(procs.iter().count() == 2 && procs.iter().all(|(_, v)| matches!(v, Object::Ref(_)))); + } + + #[test] + fn a_missing_glyph_names_its_cluster_and_writes_nothing() { + let clusters = [cluster("ก", 2)]; + let mut fonts = Dict::new(); + let mut stored = 0; + let made = ShapedFonts::new(&clusters, |_, _| Err(GlyphError::Missing), &Dict::new(), &mut fonts, "PCTh", &mut |o| { + stored += 1; + o + }); + assert!(matches!(made, Err((GlyphError::Missing, c)) if c.text == "ก")); + assert_eq!((stored, fonts.iter().count()), (0, 0)); + } + + #[test] + fn thai_runs_are_shaped_and_the_rest_left_to_the_standard_font() { + assert_eq!(thai_pieces("Hello").unwrap(), [Piece::Latin("Hello".into())]); + assert!(joins_previous('\u{0E49}') && joins_previous('\u{0E33}') && !joins_previous('ก') && !joins_previous('a')); + assert_eq!((first_undrawable("Café\nпривет"), thai_refusal("Café"), thai_reach("Hg")), (Some('п'), None, None)); + if crate::document_thai_font().is_none() { + assert_eq!(thai_pieces("น้ำ 1"), Err(GlyphError::NoFont)); + assert!(thai_message(GlyphError::NoFont, "").contains("this build has none")); + assert_eq!(thai_width("น้ำ", 10.0, |s, size| s.chars().count() as f64 * size), 30.0, "measured by the standard font"); + assert_eq!(first_undrawable("ok\nชื่อ น้ำ"), Some('ช')); + assert!(thai_refusal("ok\nน้ำ").is_some_and(|m| m.contains("CRAFT_FONTS_DIR"))); + return; + } + assert_eq!((first_undrawable("ชื่อ: น้ำ\nOK"), thai_refusal("ชื่อ: น้ำ")), (None, None)); + assert_eq!(first_undrawable("น้ำ ☃"), Some('☃')); + // Stacked marks reach above the em box's 0.9, a descender below its 0.3. + let [_, top] = thai_reach("น้ำ").unwrap(); + let [bottom, _] = thai_reach("ฎู").unwrap(); + assert!(top > 0.95 && bottom < -0.35, "{top} {bottom}"); + let pieces = thai_pieces("ชื่อ: บุญฤทธิ์ 12").unwrap(); + assert!(matches!(&pieces[..], [Piece::Shaped(_), Piece::Latin(a), Piece::Shaped(_), Piece::Latin(b)] if a == ": " && b == " 12")); + let text: String = clusters([pieces.as_slice()]).map(|c| c.text.as_str()).collect(); + assert_eq!(text, "ชื่อบุญฤทธิ์"); + assert!(thai_width("น้ำ", 10.0, |_, _| 0.0) > 0.0); + } +} diff --git a/crates/forms/README.md b/crates/forms/README.md index 0c8f5ad9b9..6126dc1cbd 100644 --- a/crates/forms/README.md +++ b/crates/forms/README.md @@ -45,7 +45,10 @@ let cells: Vec = grid.cells_for(names)?; // "Table1_1", "Table (cells parted in the border colour), multiline wrapping, password masking, quadding, `/MK` background and border, list-box selection. The `/DA` font is used when `/DR` has it as a simple font or a composite font with a predefined - Unicode CMap; otherwise Helvetica (WinAnsi). A value or button caption the font can't hold + Unicode CMap; otherwise Helvetica (WinAnsi). Thai in a text field or combo box that the font + can't hold is drawn from the craft-fonts Thai face with Add text's writer + (`pdfcraft_fonts::shaped_text`), beside the field's font, keeping `/DA`; a build without a Thai + face refuses it. Any other value or button caption the font can't hold (Chinese in a Helvetica field, a character beyond U+FFFF in a UCS-2 CMap) is kept, as Acrobat keeps it: `/V` is stored as typed, the appearance draws `?` for those characters, the widget is marked with the first one (`pdfcraft_fonts::UNDRAWN_MARK`, cleared once all of it can be drawn) diff --git a/crates/forms/src/appearance.rs b/crates/forms/src/appearance.rs index 8403b729d9..91cc352701 100644 --- a/crates/forms/src/appearance.rs +++ b/crates/forms/src/appearance.rs @@ -23,6 +23,7 @@ use pdfcraft_cos::{Dict, Document, Object, Stream}; use pdfcraft_fonts::pdf::Metrics; +use pdfcraft_fonts::shaped_text::{self, Piece, ShapedFonts}; use pdfcraft_fonts::{UnicodeCMap, cjk_width, helvetica_width, literal, win_ansi, wrap_with}; use crate::{Field, FieldKind, Widget, acroform, flags}; @@ -747,6 +748,21 @@ fn draws(doc: &Document, f: &Field, wd: &Dict, text: &str) -> bool { enc.first_unencodable(text).is_none() } +/// Why text field `f` can't take `text`: it has Thai that a widget's font can't hold and this +/// build can't draw (it has no Thai face, or the face lacks a character), which would come out +/// as `?`. `None` for text it can draw, and for a password field, which shows `*`. +pub(crate) fn thai_refusal(doc: &Document, f: &Field, text: &str) -> Option { + if f.has(flags::PASSWORD) || !text.chars().any(shaped_text::is_thai) { + return None; + } + let held = |w: &Widget| { + let wd = doc.get(w.obj).as_dict().cloned().unwrap_or_default(); + let da = parse_da(wd.get(b"DA").and_then(|o| doc.resolve(o).as_string().map(|s| s.to_text())).as_deref().unwrap_or(&f.da)); + dr_font(doc, &da.font).is_some_and(|(_, enc)| matches!(enc, FontText::Simple(_)) && text.split(['\n', '\r']).all(|l| enc.can_encode(l))) + }; + if f.widgets.iter().all(held) { None } else { shaped_text::thai_refusal(text) } +} + /// [`field_appearance_drawn`], and whether the text drawn is cut off by the box. fn field_appearance_fit(doc: &Document, f: &Field, w: &Widget, values: &[String], format: bool) -> (Stream, Option, Option) { let wobj = doc.get(w.obj); @@ -778,36 +794,43 @@ fn field_appearance_fit(doc: &Document, f: &Field, w: &Widget, values: &[String] values.first().cloned().unwrap_or_default() }; let text = if f.has(flags::PASSWORD) { "*".repeat(text.chars().count()) } else { text }; - let can_show = |enc: &FontText| { + let can_show = |enc: &FontText, text: &str| { if f.kind == FieldKind::List { f.options.iter().all(|(_, display)| enc.can_encode(display)) } else if f.kind == FieldKind::Text && f.has(flags::MULTILINE) { text.split(['\n', '\r']).all(|line| enc.can_encode(line)) } else { - enc.can_encode(&text) + enc.can_encode(text) } }; - let (font_name, font_obj, enc) = match dr_font(doc, &da.font).filter(|(_, enc)| can_show(enc)) { + let dr = dr_font(doc, &da.font); + // Thai the form's font can't hold (it isn't a simple font encoding it) is drawn from the Thai + // face, as Add text draws it: Type 3 glyphs beside the form's font, which draws the rest. One + // line of a text field or combo box at a time; the `/DA` is kept. + let thai = matches!(f.kind, FieldKind::Text | FieldKind::Combo) + && !f.is_comb() + && text.chars().any(shaped_text::is_thai) + && !dr.as_ref().is_some_and(|(_, enc)| matches!(enc, FontText::Simple(_)) && can_show(enc, &text)) + && shaped_text::thai_refusal(&text).is_none(); + let latin_text: String = if thai { text.chars().filter(|c| !shaped_text::is_thai(*c)).collect() } else { text.clone() }; + let (font_name, font_obj, enc) = match dr.filter(|(_, enc)| can_show(enc, &latin_text)) { Some((o, enc)) => (da.font.clone(), o, enc), None => ("Helv".to_string(), helvetica(), FontText::WinAnsi), }; - let width_of = |text: &str, size: f64| enc.width(text, size); + let width_of = + |text: &str, size: f64| if thai { shaped_text::thai_width(text, size, |s, size| enc.width(s, size)) } else { enc.width(text, size) }; + // How far a line's ink reaches below and above its baseline (em), where Thai stacks or + // descenders reach past the form font's (Helvetica's) -0.207 and 0.718; `None` otherwise. + let thai_ink = |line: &str| thai.then(|| shaped_text::thai_reach(line)).flatten().map(|[lo, hi]| [lo.min(-0.207), hi.max(0.718)]); let (mut c, bw) = frame(doc, &wd, width, height); let pad = 2.0 + bw; let inner_w = (width - 2.0 * pad).max(1.0); let q = wd.get(b"Q").and_then(|o| doc.resolve(o).as_int()).unwrap_or(f.quadding); - let mut body: Vec = Vec::new(); - // The first character drawn that the font can't hold (drawn as `?`), for the caller to report. - let mut unencodable: Option = None; - let mut show = |body: &mut Vec, x: f64, y: f64, text: &str| { + // The lines to draw (where, and what), drawn once all are known: their Thai shares fonts. + let mut shown: Vec<(f64, f64, String)> = Vec::new(); + let mut show = |x: f64, y: f64, text: &str| { // A line break in a single-line value is a space, as in viewers (multiline text is split). - let text: String = text.chars().map(|c| if matches!(c, '\n' | '\r') { ' ' } else { c }).collect(); - if unencodable.is_none() { - unencodable = enc.first_unencodable(&text); - } - body.extend(format!("1 0 0 1 {} {} Tm ", n(x), n(y)).bytes()); - body.extend(literal(&enc.encode(&text))); - body.extend_from_slice(b" Tj\n"); + shown.push((x, y, text.chars().map(|c| if matches!(c, '\n' | '\r') { ' ' } else { c }).collect())); }; let x_for = |text: &str, size: f64| -> f64 { let tw = width_of(text, size); @@ -834,7 +857,7 @@ fn field_appearance_fit(doc: &Document, f: &Field, w: &Widget, values: &[String] if values.contains(export) { c.push_str(&format!("0.6 0.75 0.86 rg\n{} {} {} {} re f\n", n(bw), n(y - line), n(width - 2.0 * bw), n(line))); } - show(&mut body, pad, y - line + size * 0.25, display); + show(pad, y - line + size * 0.25, display); y -= line; } } @@ -844,18 +867,20 @@ fn field_appearance_fit(doc: &Document, f: &Field, w: &Widget, values: &[String] if size == 0.0 { size = auto_text_size(&text, inner_w, height - 2.0 * pad, true, width_of); } - let mut y = height - pad - size * 0.85; let lines = wrap_with(&text, size, inner_w, width_of); + // The first line hangs from the top by its ascent: higher for stacked Thai marks. + let mut y = height - pad - size * lines.first().and_then(|l| thai_ink(l)).map_or(0.85, |[_, hi]| hi.max(0.85)); // The lines whose descent stays inside the border; the rest are cut off. let mut room = 0; for line in &lines { if y < -size { break; } - if y - 0.207 * size >= bw - 0.01 { + let descent = thai_ink(line).map_or(0.207, |[lo, _]| -lo); + if y - descent * size >= bw - 0.01 { room += 1; } - show(&mut body, x_for(line, size), y, line); + show(x_for(line, size), y, line); y -= size * 1.15; } if room < lines.len() { @@ -872,12 +897,22 @@ fn field_appearance_fit(doc: &Document, f: &Field, w: &Widget, values: &[String] auto_text_size(&text, inner_w, height - 2.0 * pad, false, width_of) }; } - // Centre Helvetica's ascent (0.718) and descent (0.207) vertically. - let y = (height - 0.925 * size) / 2.0 + 0.207 * size; + // Centre Helvetica's ascent (0.718) and descent (0.207) vertically, or the reach of + // Thai stacks and descenders, which an automatic size also keeps inside the padding. + let reach = thai_ink(&text); + if let Some([lo, hi]) = reach + && da.size == 0.0 + { + size = size.min((height - 2.0 * pad) / (hi - lo)).max(4.0); + } + let (y, ink) = match reach { + Some([lo, hi]) => ((height - (hi - lo) * size) / 2.0 - lo * size, (hi - lo) * size), + None => ((height - 0.925 * size) / 2.0 + 0.207 * size, 0.925 * size), + }; // The clip box is inside the border; a line taller than it is cut at the top and bottom. let room_h = height - 2.0 * bw; - if 0.925 * size > room_h + 0.01 { - overflow = Some(Overflow::High { height: 0.925 * size, room: room_h, size }); + if ink > room_h + 0.01 { + overflow = Some(Overflow::High { height: ink, room: room_h, size }); } if comb { let cells = f.max_len.unwrap_or(1).max(1); @@ -885,18 +920,19 @@ fn field_appearance_fit(doc: &Document, f: &Field, w: &Widget, values: &[String] c.push_str(&comb_dividers(doc, &wd, width, height, cells)); for (i, ch) in text.chars().take(cells).enumerate() { let s = ch.to_string(); - show(&mut body, cell * i as f64 + (cell - width_of(&s, size)) / 2.0, y, &s); + show(cell * i as f64 + (cell - width_of(&s, size)) / 2.0, y, &s); } } else { let tw = width_of(&text.replace(['\n', '\r'], " "), size); if overflow.is_none() && tw > inner_w + 0.01 { overflow = Some(Overflow::Wide { width: tw, room: inner_w, size }); } - show(&mut body, x_for(&text, size), y, &text); + show(x_for(&text, size), y, &text); } } } } + let (body, fonts, unencodable) = draw_lines(&shown, size, &font_name, font_obj, &enc, thai); // Acrobat regenerates only what is inside /Tx BMC when it redraws a field (e.g. on focus // changes), discarding what came before it (issues #1613, #633). The background and border // therefore go inside the marked-content block too, so a redraw keeps them with the text. @@ -913,8 +949,6 @@ fn field_appearance_fit(doc: &Document, f: &Field, w: &Widget, values: &[String] .into_bytes(); content.extend(body); content.extend_from_slice(b"ET\nQ\nEMC\n"); - let mut fonts = Dict::new(); - fonts.set(font_name.into_bytes(), font_obj); let mut res = Dict::new(); res.set(b"Font".to_vec(), Object::Dict(fonts)); let mut d = Dict::new(); @@ -927,6 +961,31 @@ fn field_appearance_fit(doc: &Document, f: &Field, w: &Widget, values: &[String] (Stream::flate(d, &content), unencodable, overflow) } +/// The text operators for the `shown` lines (where each starts, and what it says) at `size`, in +/// the field's font `font_name` (`font`, written with `enc`) and, with `thai`, their Thai from the +/// Thai face; the font resources they use, and the first character drawn as `?` because the +/// font can't hold it, for the caller to report. +fn draw_lines(shown: &[(f64, f64, String)], size: f64, font_name: &str, font: Object, enc: &FontText, thai: bool) -> (Vec, Dict, Option) { + let mut fonts = Dict::new(); + fonts.set(font_name.as_bytes().to_vec(), font); + let lines: Vec = shown.iter().map(|(_, _, text)| text.clone()).collect(); + let (pieces, shaped) = if thai { + shaped_text::appearance_lines(&lines, &mut fonts) + } else { + (lines.into_iter().map(|l| vec![Piece::Latin(l)]).collect(), ShapedFonts::default()) + }; + let mut body = Vec::new(); + let mut current = None; + for ((x, y, _), pieces) in shown.iter().zip(&pieces) { + shaped.show_line(&mut body, pieces, *x, *y, size, font_name, |s| enc.encode(s), |s| enc.width(s, size), &mut current); + } + let unencodable = pieces.iter().flatten().find_map(|p| match p { + Piece::Latin(s) => enc.first_unencodable(s), + Piece::Shaped(_) => None, + }); + (body, fonts, unencodable) +} + /// On/Off appearances for a check box or radio button that has none (a check mark or a dot, /// drawn as paths, so no symbol font is needed). /// The mark is drawn in the colour of the widget's `/DA`, else of `f_da` (the field's): the diff --git a/crates/forms/src/appearances.rs b/crates/forms/src/appearances.rs index 02c1eec5bc..0db605be6b 100644 --- a/crates/forms/src/appearances.rs +++ b/crates/forms/src/appearances.rs @@ -75,6 +75,9 @@ pub fn with_field_appearances_cached(doc: &Document, cache: &mut FieldAppearance } }; let target = copy.get_or_insert_with(|| doc.clone()); + let mut stream = stream; + // Thai glyphs are streams of their own. + pdfcraft_fonts::shaped_text::store_streams(target, &mut stream.dict); let ap = target.add(Object::Stream(stream)); let mut fresh = Dict::new(); match &state { diff --git a/crates/forms/src/author/look.rs b/crates/forms/src/author/look.rs index 23661ed70a..325a1ec3ad 100644 --- a/crates/forms/src/author/look.rs +++ b/crates/forms/src/author/look.rs @@ -323,8 +323,10 @@ pub(super) fn redraw_widgets(doc: &mut Document, name: &str, only: Option let mut missing = None; let ap = match f.kind { FieldKind::Text | FieldKind::Combo | FieldKind::List => { - let (s, m) = appearance::field_appearance_drawn(doc, &f, w, &f.value, true); + let (mut s, m) = appearance::field_appearance_drawn(doc, &f, w, &f.value, true); missing = m; + // Thai glyphs are streams of their own. + pdfcraft_fonts::shaped_text::store_streams(doc, &mut s.dict); let r = doc.add(Object::Stream(s)); let mut d = Dict::new(); d.set(b"N".to_vec(), Object::Ref(r)); diff --git a/crates/forms/src/tests.rs b/crates/forms/src/tests.rs index 3948e06e83..353545f2b3 100644 --- a/crates/forms/src/tests.rs +++ b/crates/forms/src/tests.rs @@ -967,6 +967,61 @@ fn text_a_field_font_cannot_hold_is_kept_marked_and_redrawn_elsewhere() { assert!(need_appearances(&doc)); } +/// Thai typed into a field whose `/DA` font (Helvetica, WinAnsi) can't hold it is drawn from the +/// Thai face as Add text draws it: Type 3 glyphs (stream objects of their own, with a ToUnicode +/// map) beside the field's font, which draws the rest, so every viewer shows the same glyphs. The +/// `/DA` is kept, nothing is marked and other viewers aren't asked to redraw. The ink of stacked +/// marks and descenders stays inside the border, an automatic size shrinking to keep it there. +/// A build without a Thai face refuses the text, saying why, and keeps the old value. +#[test] +fn thai_a_field_font_cannot_hold_is_drawn_from_the_thai_face() { + let objs: Vec = vec![ + "<< /Type /Catalog /Pages 2 0 R /AcroForm 4 0 R >>".into(), + "<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 600 800] >>".into(), + "<< /Type /Page /Parent 2 0 R /Annots [6 0 R 7 0 R 8 0 R] >>".into(), + "<< /Fields [6 0 R 7 0 R 8 0 R] /DA (/Helv 0 Tf 0 g) /DR << /Font << /Helv 5 0 R >> >> >>".into(), + "<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding /WinAnsiEncoding >>".into(), + "<< /Type /Annot /Subtype /Widget /FT /Tx /T (name) /DA (/Helv 0 Tf 0 g) /V (Ada) /Rect [50 700 250 720] /P 3 0 R >>".into(), + "<< /Type /Annot /Subtype /Widget /FT /Tx /T (big) /DA (/Helv 16 Tf 0 g) /Rect [50 600 350 640] /P 3 0 R >>".into(), + "<< /Type /Annot /Subtype /Widget /FT /Tx /T (note) /Ff 4096 /DA (/Helv 12 Tf 0 g) /Rect [50 400 350 500] /P 3 0 R >>".into(), + ]; + let mut doc = document(&objs); + let thai = "ชื่อ: บุญฤทธิ์ น้ำฎู"; + if pdfcraft_fonts::document_thai_font().is_none() { + let err = set_value(&mut doc, "name", &FieldValue::Text(thai.into())).unwrap_err(); + assert!(matches!(&err, FormError::Invalid(m) if m.contains("Thai") && m.contains("CRAFT_FONTS_DIR")), "{err}"); + assert_eq!(field(&fields(&doc), "name").value, ["Ada"]); + assert!(!need_appearances(&doc) && undrawn_fields(&doc).is_empty()); + return; + } + set_value(&mut doc, "name", &FieldValue::Text(thai.into())).unwrap(); + set_value(&mut doc, "big", &FieldValue::Text(thai.into())).unwrap(); + set_value(&mut doc, "note", &FieldValue::Text(format!("น้ำฎู {thai}\nปั้น ฎู"))).unwrap(); + let doc = reopen(&doc); + assert!(undrawn_fields(&doc).is_empty() && !need_appearances(&doc)); + let (lo, hi) = (pdfcraft_fonts::shaped_text::thai_reach("ฎู").unwrap()[0], pdfcraft_fonts::shaped_text::thai_reach("น้ำ").unwrap()[1]); + for (name, height) in [("name", 20.0), ("big", 40.0), ("note", 100.0)] { + let all = fields(&doc); + let w = &field(&all, name).widgets[0]; + let wd = doc.get(w.obj).as_dict().unwrap().clone(); + assert!(wd.get(b"DA").and_then(|o| o.as_string()).is_some_and(|s| s.to_text().starts_with("/Helv ")), "the /DA is kept"); + let n = wd.get(b"AP").unwrap().as_dict().unwrap().reference(b"N").unwrap(); + let Object::Stream(s) = &*doc.get(n) else { panic!() }; + let ap = String::from_utf8_lossy(&s.decoded().unwrap()).into_owned(); + assert!(!ap.contains('?') && ap.contains("/PCTh1 ") && ap.contains("(: ) Tj"), "{ap}"); + let fonts = s.dict.get(b"Resources").and_then(|r| r.as_dict()).and_then(|r| r.get(b"Font")).and_then(|f| f.as_dict()).unwrap(); + let font = fonts.get(b"PCTh1").and_then(|f| f.as_dict()).unwrap(); + assert_eq!(font.name(b"Subtype"), Some(&b"Type3"[..])); + let Object::Stream(cmap) = &*doc.get(font.reference(b"ToUnicode").unwrap()) else { panic!("ToUnicode is a stream object") }; + assert!(String::from_utf8_lossy(&cmap.decoded().unwrap()).contains("<0E190E490E33>"), "น้ำ copies as typed"); + // The size each line is drawn at, and where: every line's ink is inside the box. + let size: f64 = ap.split(" Tf").next().and_then(|s| s.rsplit(' ').next()).and_then(|s| s.parse().ok()).unwrap(); + let ys: Vec = ap.split(" Tm").filter_map(|s| s.rsplit(' ').next()?.parse().ok()).collect(); + let (top, bottom) = (ys.iter().copied().fold(f64::MIN, f64::max), ys.iter().copied().fold(f64::MAX, f64::min)); + assert!(top + hi * size <= height + 1e-3 && bottom + lo * size >= -1e-3, "{name}: {ys:?} at {size} in {height}"); + } +} + /// A document that declares PDF/A can't ask other viewers to redraw its fields (PDF/A forbids /// `/NeedAppearances`): the value is still kept and its widget marked, and the flag stays off. #[test] diff --git a/crates/forms/src/values.rs b/crates/forms/src/values.rs index 9d670e62db..f2176e7fd5 100644 --- a/crates/forms/src/values.rs +++ b/crates/forms/src/values.rs @@ -206,6 +206,11 @@ pub(crate) fn write_value(doc: &mut Document, f: &Field, value: &FieldValue, scr { return invalid(format!("{:?} takes at most {max} characters", f.name)); } + // Thai this build can't draw is refused (before any script runs), rather than stored + // and drawn as `?`. + if let Some(why) = appearance::thai_refusal(doc, f, t) { + return invalid(format!("{:?} can't take this text: {why}", f.name)); + } // Keystroke (on commit) and Validate, as Acrobat runs them before accepting a value. let t = &af::keystroke(&f.actions.format, &f.name, t).map_err(FormError::Format)?; af::validate(&f.actions.validate, t).map_err(FormError::Invalid)?; @@ -430,7 +435,7 @@ fn redraw(doc: &mut Document, f: &Field, values: &[String], scripts: &mut dyn Sc // fonts can't draw the date language's (#5303). let mut english: Option> = None; for w in &f.widgets { - let (stream, missing) = match &shown { + let (mut stream, missing) = match &shown { Some(s) => { let drawn = appearance::field_appearance_drawn(doc, f, w, std::slice::from_ref(s), false); // Taken when the English run happened and draws whole, even where it is the @@ -447,6 +452,8 @@ fn redraw(doc: &mut Document, f: &Field, values: &[String], scripts: &mut dyn Sc None => appearance::field_appearance_drawn(doc, f, w, values, true), }; undrawn |= missing.is_some(); + // Thai glyphs are streams of their own. + pdfcraft_fonts::shaped_text::store_streams(doc, &mut stream.dict); let existing_ap = in_place .then(|| { doc.get(w.obj) diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index d6038c856d..c1e851c150 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -2454,8 +2454,8 @@ milestone = "M7" status = "shipped" commands = ["edit.header_footer"] tools = ["doc_header_footer"] -evidence = ["crates/ui-egui/tests/it/marks.rs::header_and_footer_dialog_inserts_tokens_and_applies_to_a_subset", "crates/edit/src/tests.rs::header_and_footer_are_drawn_in_display_space_and_wrap_the_original_content", "crates/engine/src/tests.rs::headers_footers_watermarks_and_backgrounds_show_update_and_remove", "crates/ui-egui/tests/it/marks.rs::the_preview_shows_the_page_the_range_selects_under_the_marks", "crates/ui-egui/tests/it/marks.rs::update_previews_the_page_without_the_marks_it_replaces", "crates/engine/src/tests.rs::update_previews_on_the_page_without_the_marks_it_replaces"] -notes = "Standard faces only: Helvetica, Times or Courier (#3754; no bold, italic or embedded fonts yet); drawn in display space on rotated pages; artifact-tagged; the dialog previews the text over the page's own thumbnail (the current page, or the first one the range selects), in the interface font and with tokens unexpanded; Update previews it on the page without the headers and footers it replaces" +evidence = ["crates/ui-egui/tests/it/marks.rs::header_and_footer_dialog_inserts_tokens_and_applies_to_a_subset", "crates/edit/src/tests.rs::header_and_footer_are_drawn_in_display_space_and_wrap_the_original_content", "crates/engine/src/tests.rs::headers_footers_watermarks_and_backgrounds_show_update_and_remove", "crates/ui-egui/tests/it/marks.rs::the_preview_shows_the_page_the_range_selects_under_the_marks", "crates/ui-egui/tests/it/marks.rs::update_previews_the_page_without_the_marks_it_replaces", "crates/engine/src/tests.rs::update_previews_on_the_page_without_the_marks_it_replaces", "crates/edit/src/tests.rs::thai_headers_footers_and_watermarks_are_drawn_from_the_thai_face", "crates/automation/tests/it/automation.rs::thai_in_fill_sign_text_comments_fields_and_page_marks_is_drawn_and_found"] +notes = "Standard faces only: Helvetica, Times or Courier (#3754; no bold, italic or embedded fonts yet), with Thai drawn beside them from the craft-fonts Thai face as Add text draws it (shaped, Type 3 glyphs with a ToUnicode map, one set of fonts shared by every page; refused, saying what to do, in a build without a Thai face; #2354); drawn in display space on rotated pages; artifact-tagged; the dialog previews the text over the page's own thumbnail (the current page, or the first one the range selects), in the interface font and with tokens unexpanded; Update previews it on the page without the headers and footers it replaces" [[feature]] id = "edit.header-footer-update-remove" @@ -2514,8 +2514,8 @@ milestone = "M7" status = "shipped" commands = ["edit.watermark"] tools = ["doc_watermark"] -evidence = ["crates/edit/src/tests.rs::watermarks_rotate_fade_and_can_go_behind", "crates/ui-egui/tests/it/marks.rs::watermark_and_background_dialogs", "crates/automation/tests/it/automation.rs::headers_watermarks_and_backgrounds_through_tools", "crates/ui-egui/tests/it/marks.rs::the_preview_shows_the_page_the_range_selects_under_the_marks", "crates/ui-egui/tests/it/marks.rs::update_previews_the_page_without_the_marks_it_replaces", "crates/engine/src/tests.rs::update_previews_on_the_page_without_the_marks_it_replaces"] -notes = "Helvetica; fit-to-page or a size; the dialog previews it over the page's own thumbnail, always on top (a watermark behind the page is not drawn under its content there); Update previews on the page without the watermarks it replaces" +evidence = ["crates/edit/src/tests.rs::watermarks_rotate_fade_and_can_go_behind", "crates/ui-egui/tests/it/marks.rs::watermark_and_background_dialogs", "crates/automation/tests/it/automation.rs::headers_watermarks_and_backgrounds_through_tools", "crates/ui-egui/tests/it/marks.rs::the_preview_shows_the_page_the_range_selects_under_the_marks", "crates/ui-egui/tests/it/marks.rs::update_previews_the_page_without_the_marks_it_replaces", "crates/engine/src/tests.rs::update_previews_on_the_page_without_the_marks_it_replaces", "crates/edit/src/tests.rs::thai_headers_footers_and_watermarks_are_drawn_from_the_thai_face", "crates/automation/tests/it/automation.rs::thai_in_fill_sign_text_comments_fields_and_page_marks_is_drawn_and_found"] +notes = "Helvetica, with Thai drawn from the craft-fonts Thai face as Add text draws it (refused in a build without one; #2354); fit-to-page or a size; the dialog previews it over the page's own thumbnail, always on top (a watermark behind the page is not drawn under its content there); Update previews on the page without the watermarks it replaces" [[feature]] id = "edit.watermark-add-file" @@ -3782,8 +3782,8 @@ milestone = "M5" status = "partial" commands = ["comment.freetext"] tools = ["comment_add"] -evidence = ["crates/ui-egui/tests/it/comments.rs::sticky_note_composer_posts_and_returns_to_select", "crates/annot/src/tests.rs::text_box_text_and_colour_changes_are_drawn"] -notes = "Helvetica text with colour, size and alignment; /RC (XHTML) and /DS are written and kept in step with edits; per-run formatting (bold/italic spans) is not yet" +evidence = ["crates/ui-egui/tests/it/comments.rs::sticky_note_composer_posts_and_returns_to_select", "crates/annot/src/tests.rs::text_box_text_and_colour_changes_are_drawn", "crates/annot/src/tests.rs::thai_in_fill_sign_text_and_text_boxes_is_drawn_from_the_thai_face", "crates/automation/tests/it/automation.rs::thai_in_fill_sign_text_comments_fields_and_page_marks_is_drawn_and_found"] +notes = "Helvetica text with colour, size and alignment; Thai is shaped and drawn from the craft-fonts Thai face as Add text draws it (Type 3 glyphs with a ToUnicode map, in the appearance's resources), the box growing to hold the ink of its stacked marks and descenders, and refused, saying what to do, in a build without a Thai face (#2354); /RC (XHTML) and /DS are written and kept in step with edits; per-run formatting (bold/italic spans) is not yet" [[feature]] id = "comment.callout" @@ -4626,8 +4626,8 @@ milestone = "M6" status = "shipped" commands = ["form.fields"] tools = ["form_fill", "form_fields"] -evidence = ["crates/ui-egui/tests/it/forms.rs::typing_into_a_text_field_and_tabbing_on", "crates/ui-egui/tests/it/forms.rs::auto_size_fields_shrink_and_wrap_typed_text_to_fit_their_box", "crates/forms/src/tests.rs::text_fields_get_new_appearances", "crates/engine/src/tests.rs::filling_a_form_shows_saves_and_undoes", "crates/automation/tests/it/automation.rs::orphan_form_fields_through_tools", "crates/forms/src/tests.rs::fields_sharing_a_name_take_the_same_value", "crates/ui-egui/tests/it/forms.rs::hovering_a_field_shows_its_tooltip", "crates/forms/src/tests.rs::zero_max_len_is_unlimited_and_overrides_inherited_limit", "crates/engine/src/tests.rs::password_field_values_are_kept_in_memory_but_written_to_no_file", "crates/forms/src/tests.rs::a_cleared_password_field_returns_to_its_default_value", "crates/forms/src/tests.rs::a_text_field_says_what_fits_it_as_its_appearance_draws_it", "crates/forms/src/tests.rs::an_edit_to_a_full_field_keeps_what_fits", "crates/ui-egui/tests/it/forms.rs::a_field_that_doesnt_scroll_stops_taking_text_when_full", "crates/ui-egui/tests/it/forms.rs::a_multi_line_auto_sized_field_that_doesnt_scroll_stops_when_full", "crates/engine/src/tests.rs::an_xfa_field_that_doesnt_scroll_opens_with_do_not_scroll_and_a_text_box", "crates/xfa/src/tests.rs::fields_that_dont_scroll_are_written_with_do_not_scroll", "crates/forms/src/tests.rs::comb_fields_show_their_cells"] -notes = "in-place editor; Enter or clicking away commits, Escape cancels; auto-size (0 Tf) fields shrink, and multiline ones wrap, the text being typed to fit the box, on screen as on the page (the screen measures in the interface font, so its size can be a little smaller than the page's Helvetica fit); fixed sizes are kept and clip, as in Acrobat; a field that doesn't scroll (DoNotScroll, ISO 32000-2 Table 228, and XFA's hScrollPolicy/vScrollPolicy=\"off\", written as it) takes no more text than its appearance shows, measured in the field's own font, padding and size (the smallest automatic size, 4 pt, for 0 Tf): typing into a full field adds nothing, and a paste or a replaced selection keeps what fits (form_fill isn't interactive and still takes any value); no format/keystroke JavaScript yet (M6.4); resting the pointer on any field shows its tooltip (/TU), as Acrobat does (#3854); imported MaxLen 0 permits text input and overrides inherited limits; positive limits remain enforced. A text field with the Password flag is saved as Acrobat saves it (#3926, black-box test): the typed value is written to no file (Save, Save As, Reduce File Size, recovery, Extract/Split/Combine, signing); the field gets back its default value (/DV) or is left empty, and its asterisks are redrawn from that value. /DV and the field's scripts stay. Known limits, which Acrobat shares: a script that copies the password into an ordinary field saves it there (Acrobat recalculates a Calculate copy on save, PdfCraft does not), and an incremental save leaves a password stored by an earlier revision in that revision. A comb field with a border colour shows its MaxLen cells as lines in that colour, in the appearances PdfCraft saves and in display-only ones (up to 500 cells)." +evidence = ["crates/ui-egui/tests/it/forms.rs::typing_into_a_text_field_and_tabbing_on", "crates/ui-egui/tests/it/forms.rs::auto_size_fields_shrink_and_wrap_typed_text_to_fit_their_box", "crates/forms/src/tests.rs::text_fields_get_new_appearances", "crates/engine/src/tests.rs::filling_a_form_shows_saves_and_undoes", "crates/automation/tests/it/automation.rs::orphan_form_fields_through_tools", "crates/forms/src/tests.rs::fields_sharing_a_name_take_the_same_value", "crates/ui-egui/tests/it/forms.rs::hovering_a_field_shows_its_tooltip", "crates/forms/src/tests.rs::zero_max_len_is_unlimited_and_overrides_inherited_limit", "crates/engine/src/tests.rs::password_field_values_are_kept_in_memory_but_written_to_no_file", "crates/forms/src/tests.rs::a_cleared_password_field_returns_to_its_default_value", "crates/forms/src/tests.rs::a_text_field_says_what_fits_it_as_its_appearance_draws_it", "crates/forms/src/tests.rs::an_edit_to_a_full_field_keeps_what_fits", "crates/ui-egui/tests/it/forms.rs::a_field_that_doesnt_scroll_stops_taking_text_when_full", "crates/ui-egui/tests/it/forms.rs::a_multi_line_auto_sized_field_that_doesnt_scroll_stops_when_full", "crates/engine/src/tests.rs::an_xfa_field_that_doesnt_scroll_opens_with_do_not_scroll_and_a_text_box", "crates/xfa/src/tests.rs::fields_that_dont_scroll_are_written_with_do_not_scroll", "crates/forms/src/tests.rs::comb_fields_show_their_cells", "crates/forms/src/tests.rs::thai_a_field_font_cannot_hold_is_drawn_from_the_thai_face", "crates/automation/tests/it/automation.rs::thai_in_fill_sign_text_comments_fields_and_page_marks_is_drawn_and_found"] +notes = "in-place editor; Thai the field's /DA font can't hold is drawn from the craft-fonts Thai face as Add text draws it (Type 3 glyphs with a ToUnicode map beside the field's font, /DA kept, no /NeedAppearances asked for; the ink of stacked marks and descenders kept inside the border, an automatic size shrinking for it), and refused, saying what to do, in a build without a Thai face (#2354); comb and list fields still draw Thai as `?`, marked; Enter or clicking away commits, Escape cancels; auto-size (0 Tf) fields shrink, and multiline ones wrap, the text being typed to fit the box, on screen as on the page (the screen measures in the interface font, so its size can be a little smaller than the page's Helvetica fit); fixed sizes are kept and clip, as in Acrobat; a field that doesn't scroll (DoNotScroll, ISO 32000-2 Table 228, and XFA's hScrollPolicy/vScrollPolicy=\"off\", written as it) takes no more text than its appearance shows, measured in the field's own font, padding and size (the smallest automatic size, 4 pt, for 0 Tf): typing into a full field adds nothing, and a paste or a replaced selection keeps what fits (form_fill isn't interactive and still takes any value); no format/keystroke JavaScript yet (M6.4); resting the pointer on any field shows its tooltip (/TU), as Acrobat does (#3854); imported MaxLen 0 permits text input and overrides inherited limits; positive limits remain enforced. A text field with the Password flag is saved as Acrobat saves it (#3926, black-box test): the typed value is written to no file (Save, Save As, Reduce File Size, recovery, Extract/Split/Combine, signing); the field gets back its default value (/DV) or is left empty, and its asterisks are redrawn from that value. /DV and the field's scripts stay. Known limits, which Acrobat shares: a script that copies the password into an ordinary field saves it there (Acrobat recalculates a Calculate copy on save, PdfCraft does not), and an incremental save leaves a password stored by an earlier revision in that revision. A comb field with a border colour shows its MaxLen cells as lines in that colour, in the appearances PdfCraft saves and in display-only ones (up to 500 cells)." [[feature]] id = "form.fill-checkbox-radio" @@ -5483,8 +5483,8 @@ milestone = "M5" status = "shipped" commands = ["sign.fill.text"] tools = ["fill_sign_add"] -evidence = ["crates/annot/src/tests.rs::indirect_typewriter_names_keep_outer_whitespace_in_summaries", "crates/ui-egui/tests/it/comments.rs::typewriter_edit_composer_keeps_trailing_blank_lines", "crates/ui-egui/tests/it/comments.rs::other_edit_composers_keep_trimming_trailing_whitespace", "crates/ui-egui/tests/it/comments.rs::moved_fill_and_sign_text_is_drawn_where_it_goes_before_release", "crates/engine/src/tests.rs::fill_sign_multiline_text_survives_engine_save_reopen_and_render", "crates/ui-egui/tests/it/fill_sign.rs::fill_sign_enter_adds_lines_outside_click_commits_and_escape_cancels", "crates/automation/tests/it/automation.rs::multiline_fill_sign_engine_round_trip_preserves_lines_and_appearance", "crates/ui-egui/tests/it/fill_sign.rs::text_marks_and_date", "crates/annot/src/tests.rs::fill_and_sign_items_are_drawn", "crates/automation/tests/it/automation.rs::fill_and_sign_through_tools", "crates/engine/src/tests.rs::typed_text_reads_across_as_displayed_on_rotated_pages", "crates/ui-egui/tests/it/fill_sign.rs::fill_text_colour_survives_preview_commit_settings_and_save", "crates/automation/tests/it/automation.rs::fill_sign_text_and_date_colours_are_saved_and_validated", "crates/ui-egui/tests/it/fill_sign.rs::escape_leaves_the_fill_and_sign_tools"] -notes = "Indirect IT is resolved when recognizing typewriter contents and Fill & Sign marks; comment listing and properties retain direct Subtype as before; composer edits preserve the typewriter text as written, while other comments retain their existing trimming. Enter inserts a newline; the editor grows with its lines, outside clicks commit and Escape cancels. Nonempty text preserves leading/internal/trailing line breaks, including after save/reopen; fill_sign_add sizes all wrapped rows for the saved appearance (#5850). typewriter FreeText, 10 pt Helvetica. Escape closes the open box, and a second Escape leaves the Fill & Sign tool for Select, as it leaves a comment tool. Reads across as displayed on pages with /Rotate (sideways-stored scans): the box follows the displayed axes and the appearance is turned back (PdfCraft key /PCTextRotate); other apps that regenerate the appearance from /DA draw it unturned. A later page rotation does not re-turn it. Field detection does not work on scanned (image-only) forms yet; typed fractional font sizes commit once; Fill & Sign text/date colour is remembered and preserved in preview and saved appearances (#2444)" +evidence = ["crates/annot/src/tests.rs::indirect_typewriter_names_keep_outer_whitespace_in_summaries", "crates/ui-egui/tests/it/comments.rs::typewriter_edit_composer_keeps_trailing_blank_lines", "crates/ui-egui/tests/it/comments.rs::other_edit_composers_keep_trimming_trailing_whitespace", "crates/ui-egui/tests/it/comments.rs::moved_fill_and_sign_text_is_drawn_where_it_goes_before_release", "crates/engine/src/tests.rs::fill_sign_multiline_text_survives_engine_save_reopen_and_render", "crates/ui-egui/tests/it/fill_sign.rs::fill_sign_enter_adds_lines_outside_click_commits_and_escape_cancels", "crates/automation/tests/it/automation.rs::multiline_fill_sign_engine_round_trip_preserves_lines_and_appearance", "crates/ui-egui/tests/it/fill_sign.rs::text_marks_and_date", "crates/annot/src/tests.rs::fill_and_sign_items_are_drawn", "crates/automation/tests/it/automation.rs::fill_and_sign_through_tools", "crates/engine/src/tests.rs::typed_text_reads_across_as_displayed_on_rotated_pages", "crates/ui-egui/tests/it/fill_sign.rs::fill_text_colour_survives_preview_commit_settings_and_save", "crates/automation/tests/it/automation.rs::fill_sign_text_and_date_colours_are_saved_and_validated", "crates/ui-egui/tests/it/fill_sign.rs::escape_leaves_the_fill_and_sign_tools", "crates/annot/src/tests.rs::thai_in_fill_sign_text_and_text_boxes_is_drawn_from_the_thai_face", "crates/automation/tests/it/automation.rs::thai_in_fill_sign_text_comments_fields_and_page_marks_is_drawn_and_found"] +notes = "Thai is shaped and drawn from the craft-fonts Thai face as Add text draws it, the box sized to its ink, and refused in a build without a Thai face (#2354). Indirect IT is resolved when recognizing typewriter contents and Fill & Sign marks; comment listing and properties retain direct Subtype as before; composer edits preserve the typewriter text as written, while other comments retain their existing trimming. Enter inserts a newline; the editor grows with its lines, outside clicks commit and Escape cancels. Nonempty text preserves leading/internal/trailing line breaks, including after save/reopen; fill_sign_add sizes all wrapped rows for the saved appearance (#5850). typewriter FreeText, 10 pt Helvetica. Escape closes the open box, and a second Escape leaves the Fill & Sign tool for Select, as it leaves a comment tool. Reads across as displayed on pages with /Rotate (sideways-stored scans): the box follows the displayed axes and the appearance is turned back (PdfCraft key /PCTextRotate); other apps that regenerate the appearance from /DA draw it unturned. A later page rotation does not re-turn it. Field detection does not work on scanned (image-only) forms yet; typed fractional font sizes commit once; Fill & Sign text/date colour is remembered and preserved in preview and saved appearances (#2444)" [[feature]] id = "form.fill-sign-marks" From 0f274278d7e9f1f9ef2889082b7199a1d6f49b73 Mon Sep 17 00:00:00 2001 From: BFlat Date: Mon, 9 Nov 2026 04:09:13 +0800 Subject: [PATCH 18/92] Fill & Sign test checks Escape writes nothing by edits, not saved bytes (#6646) The test compared two saves' bytes, which carry the save time, so it failed when the saves landed in different seconds (seen on CI). It now checks that the edit generation, the unsaved state and the undo step are unchanged and that the one annotation is all there is. --- crates/ui-egui/tests/it/fill_sign.rs | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/crates/ui-egui/tests/it/fill_sign.rs b/crates/ui-egui/tests/it/fill_sign.rs index b5bb52cfa7..fe65314fa4 100644 --- a/crates/ui-egui/tests/it/fill_sign.rs +++ b/crates/ui-egui/tests/it/fill_sign.rs @@ -2527,10 +2527,17 @@ fn fill_sign_enter_adds_lines_outside_click_commits_and_escape_cancels() { let annot = &session.get(reopened).unwrap().info.annotations[0]; assert_eq!(annot.contents.as_deref(), Some(text)); assert!(annot.rect[3] - annot.rect[1] >= 5.0 * 10.0 * 1.2, "the saved box keeps all five lines"); + // Compared as edits, not saved bytes: a save stamps its time, so two saves a second apart differ. + let unchanged = |h: &Harness<'static, PdfCraftApp>| { + let doc = h.state().session.get(id).unwrap(); + (doc.edit_generation(), doc.dirty, doc.can_undo().map(str::to_owned)) + }; + let before = unchanged(&h); click(&mut h, 50.0, 180.0); fill_input(&mut h, [egui::Event::Text("Cancel me".into())]); fill_key(&mut h, egui::Key::Enter); fill_key(&mut h, egui::Key::Escape); assert!(h.state().views[0].fill_text.is_none()); - assert_eq!(h.state().session.save_bytes(id).unwrap(), saved, "Escape cancels without writing"); + assert_eq!(unchanged(&h), before, "Escape cancels without writing"); + assert_eq!(items(&h), [("FreeText".to_owned(), Some(text.to_owned()))]); } From 8d5d39ef5e87abc4012e3a224148fa5958ea1389 Mon Sep 17 00:00:00 2001 From: BFlat Date: Mon, 9 Nov 2026 04:10:08 +0800 Subject: [PATCH 19/92] Combine files: a drop is one undo step, in order, and names what it can't take (#6645) * Combine files: add a drop as one step, in order, and name what it can't take Files dropped on the Combine files tab are handled as one batch: they join the end of the list in the order dropped, as one undo step, from their path or, when a drop carries only bytes, from those. A folder still adds the files in it and its subfolders. Files of a kind Combine doesn't take are left out and named in a notice, instead of joining the list as files that can't be read. In a browser the dropped files are read one after another, so they keep their order too. * Combine files: let the web build use Incoming for dropped files * Parity: Combine files takes drops in order, as one step, and names what it leaves out --- crates/ui-egui/src/app.rs | 10 +-- crates/ui-egui/src/app/input.rs | 6 +- crates/ui-egui/src/workflows/combine_ui.rs | 1 - .../ui-egui/src/workflows/combine_ui/files.rs | 7 ++ .../ui-egui/src/workflows/combine_ui/parts.rs | 6 ++ .../src/workflows/combine_ui/session.rs | 20 ++++- crates/ui-egui/src/workflows/files.rs | 76 ++++++++++++------ crates/ui-egui/tests/it/ui.rs | 80 +++++++++++++++++++ parity/acrobat-features.toml | 4 +- 9 files changed, 172 insertions(+), 38 deletions(-) diff --git a/crates/ui-egui/src/app.rs b/crates/ui-egui/src/app.rs index 2c9d6b4579..91bad575c4 100644 --- a/crates/ui-egui/src/app.rs +++ b/crates/ui-egui/src/app.rs @@ -520,11 +520,11 @@ impl eframe::App for PdfCraftApp { let dropped = self.drop_on_signature_pad(dropped); #[cfg(not(target_arch = "wasm32"))] let dropped = self.drop_on_grid(dropped, ctx); - for f in dropped { - // Files dropped on the Combine files tab join its list instead of opening. - if self.combine_showing() { - self.drop_into_combine(f, ctx); - } else { + // Files dropped on the Combine files tab join its list, all at once, instead of opening. + if self.combine_showing() && !dropped.is_empty() { + self.drop_into_combine(dropped, ctx); + } else { + for f in dropped { self.open_dropped(f, ctx); } } diff --git a/crates/ui-egui/src/app/input.rs b/crates/ui-egui/src/app/input.rs index cda282e9ed..4d6742cf70 100644 --- a/crates/ui-egui/src/app/input.rs +++ b/crates/ui-egui/src/app/input.rs @@ -19,7 +19,7 @@ impl PdfCraftApp { self.open_path(&p); return; } - let name = f.path().file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_else(|| "dropped.pdf".into()); + let name = crate::files::dropped_name(&f); match f.bytes() { Ok(bytes) => { if self.ctx.is_some() && !self.run_inline { @@ -76,7 +76,7 @@ impl PdfCraftApp { }; let mut files = Vec::new(); for f in &dropped { - let name = f.path().file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_else(|| "dropped.pdf".into()); + let name = crate::files::dropped_name(f); if f.path().is_absolute() { files.push((name, Err(f.path().to_path_buf()))); continue; @@ -130,7 +130,7 @@ impl PdfCraftApp { let inbox = self.inbox.clone(); let ctx = ctx.clone(); wasm_bindgen_futures::spawn_local(async move { - let name = f.path().file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_else(|| "dropped.pdf".into()); + let name = crate::files::dropped_name(&f); if let Ok(bytes) = f.bytes_async().await && let Ok(mut q) = inbox.lock() { diff --git a/crates/ui-egui/src/workflows/combine_ui.rs b/crates/ui-egui/src/workflows/combine_ui.rs index c31ede9ca1..59d0c234e6 100644 --- a/crates/ui-egui/src/workflows/combine_ui.rs +++ b/crates/ui-egui/src/workflows/combine_ui.rs @@ -23,7 +23,6 @@ mod session; mod table; mod view; -#[cfg(not(target_arch = "wasm32"))] pub(crate) use files::Incoming; pub use files::{Columns, CombineFile, CombineView, ConflictPreview, Lock, SortKey}; pub(crate) use files::{Preview, RowAction}; diff --git a/crates/ui-egui/src/workflows/combine_ui/files.rs b/crates/ui-egui/src/workflows/combine_ui/files.rs index 4947e4f8b1..85634c2aff 100644 --- a/crates/ui-egui/src/workflows/combine_ui/files.rs +++ b/crates/ui-egui/src/workflows/combine_ui/files.rs @@ -274,6 +274,13 @@ pub(crate) struct Incoming { pub open_doc: Option, } +impl Incoming { + /// A file known only by its name and bytes. + pub(crate) fn new(name: String, bytes: Vec) -> Self { + Incoming { name, bytes: Arc::new(bytes), modified: None, note: None, open_doc: None } + } +} + /// A new value for [`CombineFile::auth`]: counted for the whole run, outside the undo history. fn next_auth() -> u64 { static NEXT: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(1); diff --git a/crates/ui-egui/src/workflows/combine_ui/parts.rs b/crates/ui-egui/src/workflows/combine_ui/parts.rs index 5cba711a03..a64d0eeb34 100644 --- a/crates/ui-egui/src/workflows/combine_ui/parts.rs +++ b/crates/ui-egui/src/workflows/combine_ui/parts.rs @@ -38,6 +38,12 @@ fn combinable(ext: &str) -> bool { std::iter::once("pdf").chain(pdfcraft_engine::CONVERTIBLE).any(|c| ext.eq_ignore_ascii_case(c)) } +/// Whether Combine takes a file by what it is: a PDF (by its header or its name) or an image or +/// text file it converts, as `Session::as_pdf` tells them apart. +pub(super) fn takes(name: &str, bytes: &[u8]) -> bool { + pdfcraft_engine::source_kind(name, bytes).is_some() || name.to_ascii_lowercase().ends_with(".pdf") +} + /// A file's bookmark in the combined PDF: its name without ".pdf", or without the extension of /// the image or text file it was made from. pub(super) fn title_of(name: &str) -> &str { diff --git a/crates/ui-egui/src/workflows/combine_ui/session.rs b/crates/ui-egui/src/workflows/combine_ui/session.rs index 594b5f745a..7ef50d5ffc 100644 --- a/crates/ui-egui/src/workflows/combine_ui/session.rs +++ b/crates/ui-egui/src/workflows/combine_ui/session.rs @@ -3,7 +3,7 @@ use super::MAX_FOLDER_FILES; use super::files::{AssessError, Assessed, CombineFile, Incoming, Secret, Snapshot, SortKey, UnlockPrompt, assess}; #[cfg(not(target_arch = "wasm32"))] use super::parts::combinable_in; -use super::parts::{Part, items_of, moved_order, natural, parts_of, range_of, title_of}; +use super::parts::{Part, items_of, moved_order, natural, parts_of, range_of, takes, title_of}; use super::{Arc, BTreeSet, CombineTab, HISTORY, Modifiers, PdfCraftApp}; impl PdfCraftApp { @@ -246,9 +246,21 @@ impl PdfCraftApp { /// Add picked files to the Combine files list (and show it). pub(crate) fn stage_combine(&mut self, files: Vec<(String, Vec)>) { - let incoming = - files.into_iter().map(|(name, bytes)| Incoming { name, bytes: Arc::new(bytes), modified: None, note: None, open_doc: None }).collect(); - self.stage_combine_with(incoming); + self.stage_combine_with(files.into_iter().map(|(name, bytes)| Incoming::new(name, bytes)).collect()); + } + + /// Files dropped on the Combine files tab join the end of its list in the order dropped, as + /// one undo step. A drop isn't filtered like a pick, so files of a kind Combine doesn't take + /// are left out, and named. + pub(crate) fn add_dropped_to_combine(&mut self, files: Vec) { + let (taken, refused): (Vec<_>, Vec<_>) = files.into_iter().partition(|f| takes(&f.name, &f.bytes)); + if !refused.is_empty() { + let names: Vec<&str> = refused.iter().map(|f| f.name.as_str()).collect(); + self.notify_fmt("Not added: {names}. Combine takes PDFs, images and text files.", &[("names", &names.join(", "))]); + } + if !taken.is_empty() { + self.stage_combine_with(taken); + } } /// Add files to the Combine files list, with what can be known about them up front. diff --git a/crates/ui-egui/src/workflows/files.rs b/crates/ui-egui/src/workflows/files.rs index b8bda7183a..6a7a0434b3 100644 --- a/crates/ui-egui/src/workflows/files.rs +++ b/crates/ui-egui/src/workflows/files.rs @@ -149,6 +149,8 @@ pub struct FileRequest { purpose: FilePurpose, target: Option, files: Vec<(String, Vec)>, + /// Dropped on the Combine files tab rather than picked: what Combine can't take is left out. + dropped: bool, } /// Files picked asynchronously (web), waiting to be used. @@ -310,6 +312,11 @@ fn read_named(path: &std::path::Path) -> Result<(String, Vec), (String, Stri } } +/// The name of a file dropped on the window ("dropped.pdf" when it has none). +pub(crate) fn dropped_name(f: &egui::DroppedFileHandle) -> String { + f.path().file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_else(|| "dropped.pdf".into()) +} + impl PdfCraftApp { /// Ask for files to add to the Combine files list (its Add files… button). pub fn combine_dialog(&mut self) { @@ -451,37 +458,53 @@ impl PdfCraftApp { } } - /// A file dropped on the Combine files tab: added to its list. + /// Files dropped on the Combine files tab join its list in the order dropped (see + /// [`Self::add_dropped_to_combine`]); a folder adds the files in it and its subfolders. #[cfg(not(target_arch = "wasm32"))] - pub(crate) fn drop_into_combine(&mut self, f: egui::DroppedFileHandle, _ctx: &egui::Context) { - // A folder: the PDFs in it and in the folders inside it. - if f.path().is_absolute() && f.path().is_dir() { - self.add_folder_to_combine(f.path(), true); - return; - } - if f.path().is_absolute() { - self.use_paths(FilePurpose::Combine, &[f.path().to_path_buf()]); - return; - } - let name = f.path().file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_else(|| "dropped.pdf".into()); - match f.bytes() { - Ok(bytes) => self.use_files(FilePurpose::Combine, vec![(name, bytes)]), - Err(e) => self.notify_fmt("Couldn't read {name}: {e}", &[("name", &name), ("e", &e.to_string())]), + pub(crate) fn drop_into_combine(&mut self, dropped: Vec, _ctx: &egui::Context) { + let mut files = Vec::new(); + for f in dropped { + let path = f.path(); + if path.is_absolute() && path.is_dir() { + // After the files dropped before it. + self.add_dropped_to_combine(std::mem::take(&mut files)); + self.add_folder_to_combine(path, true); + continue; + } + let name = dropped_name(&f); + // A file on disk is read from there, with when it was last modified; a drop that + // carries only bytes (no path) as it is. + let (read, modified) = if path.is_absolute() { + (std::fs::read(path).map_err(|e| e.to_string()), std::fs::metadata(path).and_then(|m| m.modified()).ok()) + } else { + (f.bytes(), None) + }; + match read { + Ok(bytes) => files.push(crate::combine_ui::Incoming { modified, ..crate::combine_ui::Incoming::new(name, bytes) }), + Err(e) => self.notify_fmt("Couldn't read {name}: {e}", &[("name", &name), ("e", &e)]), + } } + self.add_dropped_to_combine(files); } /// Browsers read dropped files asynchronously; they join the Combine files list next frame. #[cfg(target_arch = "wasm32")] - pub(crate) fn drop_into_combine(&mut self, f: egui::DroppedFileHandle, ctx: &egui::Context) { + pub(crate) fn drop_into_combine(&mut self, dropped: Vec, ctx: &egui::Context) { let requests = self.requests.clone(); - let request = self.file_request(FilePurpose::Combine, Vec::new()); + let request = FileRequest { dropped: true, ..self.file_request(FilePurpose::Combine, Vec::new()) }; let ctx = ctx.clone(); wasm_bindgen_futures::spawn_local(async move { - let name = f.path().file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_else(|| "dropped.pdf".into()); - if let Ok(bytes) = f.bytes_async().await + // One after another, so they join the list in the order dropped. + let mut files = Vec::new(); + for f in dropped { + if let Ok(bytes) = f.bytes_async().await { + files.push((dropped_name(&f), bytes)); + } + } + if !files.is_empty() && let Ok(mut q) = requests.lock() { - q.push(FileRequest { files: vec![(name, bytes)], ..request }); + q.push(FileRequest { files, ..request }); ctx.request_repaint(); } }); @@ -492,7 +515,7 @@ impl PdfCraftApp { /// `requests` once the files are read; it is used on a later frame. pub fn file_request(&self, purpose: FilePurpose, files: Vec<(String, Vec)>) -> FileRequest { let target = if purpose.edits_active_document() { self.pick_target(self.active_ids().map(|(_, id)| id)) } else { None }; - FileRequest { purpose, target, files } + FileRequest { purpose, target, files, dropped: false } } /// `doc` as it is now, for a pick that will edit it. @@ -516,12 +539,19 @@ impl PdfCraftApp { /// Handle files picked asynchronously. pub(crate) fn process_file_requests(&mut self) { let pending: Vec<_> = self.requests.lock().map(|mut q| std::mem::take(&mut *q)).unwrap_or_default(); - for FileRequest { purpose, target, files } in pending { + for FileRequest { purpose, target, files, dropped } in pending { if !self.still_pick_target(target) { continue; } // Last-resort guard (AGENTS.md §4), as for desktop picks and commands. - if let Err(m) = pdfcraft_engine::guard(|| self.use_files(purpose, files)) { + let used = pdfcraft_engine::guard(|| { + if dropped { + self.add_dropped_to_combine(files.into_iter().map(|(name, bytes)| crate::combine_ui::Incoming::new(name, bytes)).collect()); + } else { + self.use_files(purpose, files); + } + }); + if let Err(m) = used { self.notify_fmt("That didn't work: an internal error stopped it ({m}).", &[("m", m.as_str())]); } } diff --git a/crates/ui-egui/tests/it/ui.rs b/crates/ui-egui/tests/it/ui.rs index a7f5b4cf7f..a464595615 100644 --- a/crates/ui-egui/tests/it/ui.rs +++ b/crates/ui-egui/tests/it/ui.rs @@ -1166,6 +1166,86 @@ fn a_folder_dropped_on_the_combine_tab_adds_its_pdfs() { assert_eq!(names, ["two.pdf", "one.pdf"], "subfolders included, in path order"); } +/// The Combine files tab, showing, with one file in its list already. +fn combine_tab_with_one_file() -> Harness<'static, PdfCraftApp> { + let mut h = Harness::builder().with_size(egui::vec2(1200.0, 800.0)).build_eframe(|_cc| { + let mut app = PdfCraftApp::new(); + app.set_option("language", "en").unwrap(); + app + }); + h.run_steps(3); + h.state_mut().set_option("combine-view", "list").unwrap(); + h.state_mut().execute("page.combine"); + h.state_mut().use_files(pdfcraft_ui_egui::FilePurpose::Combine, vec![("first.pdf".into(), FIXTURE.to_vec())]); + h.run_steps(2); + h +} + +fn combine_names(h: &Harness<'_, PdfCraftApp>) -> Vec { + h.state().combine_draft.iter().map(|f| f.name.clone()).collect() +} + +#[test] +fn files_dropped_on_the_combine_tab_join_the_end_of_its_list_in_the_order_dropped() { + let mut h = combine_tab_with_one_file(); + let dir = std::env::temp_dir().join(format!("pdfcraft-combine-drop-order-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + // Files on disk come with their path (and no bytes); browsers hand over only a name and bytes. + let on_disk = |name: &str| -> egui::DroppedFileHandle { + std::fs::write(dir.join(name), FIXTURE).unwrap(); + std::sync::Arc::new(Dropped { path: dir.join(name), bytes: Vec::new() }) + }; + let in_memory = + |name: &str, bytes: &[u8]| -> egui::DroppedFileHandle { std::sync::Arc::new(Dropped { path: name.into(), bytes: bytes.to_vec() }) }; + let dropped = [on_disk("c.pdf"), in_memory("a.pdf", FIXTURE), in_memory("notes.txt", b"Some notes"), on_disk("b.pdf")]; + h.input_mut().dropped_files.extend(dropped); + h.run_steps(3); + let _ = std::fs::remove_dir_all(&dir); + assert!(h.state().views.is_empty(), "nothing opens"); + assert_eq!(combine_names(&h), ["first.pdf", "c.pdf", "a.pdf", "notes.txt", "b.pdf"]); + let draft = &h.state().combine_draft; + assert!(draft.iter().all(|f| f.problem.is_none()), "every file can be combined"); + assert!(draft[3].pages == 1, "the text file became a page"); + assert!(draft[1].modified.is_some() && draft[2].modified.is_none()); + // One drop, one step to undo. + h.state_mut().execute("edit.undo"); + h.run_steps(2); + assert_eq!(combine_names(&h), ["first.pdf"]); +} + +#[test] +fn a_file_combine_does_not_take_is_left_out_of_a_drop_and_named() { + let mut h = combine_tab_with_one_file(); + let path = std::env::temp_dir().join(format!("pdfcraft-combine-drop-{}.zip", std::process::id())); + std::fs::write(&path, b"PK\x03\x04").unwrap(); + let dropped: [egui::DroppedFileHandle; 4] = [ + std::sync::Arc::new(Dropped { path: "report.docx".into(), bytes: b"PK\x03\x04".to_vec() }), + std::sync::Arc::new(Dropped { path: "one.pdf".into(), bytes: FIXTURE.to_vec() }), + std::sync::Arc::new(Dropped { path: path.clone(), bytes: Vec::new() }), + // A PDF without the name of one is still a PDF. + std::sync::Arc::new(Dropped { path: "scan".into(), bytes: FIXTURE.to_vec() }), + ]; + h.input_mut().dropped_files.extend(dropped); + h.run_steps(3); + std::fs::remove_file(&path).ok(); + assert_eq!(combine_names(&h), ["first.pdf", "one.pdf", "scan"]); + let zip = path.file_name().unwrap().to_string_lossy().into_owned(); + h.get_by_label(&format!("Not added: report.docx, {zip}. Combine takes PDFs, images and text files.")); +} + +#[test] +fn a_pdf_dropped_on_a_document_opens_while_the_combine_tab_waits_behind_it() { + let mut h = combine_tab_with_one_file(); + h.state_mut().open_bytes("open.pdf", None, FIXTURE.to_vec()).unwrap(); + h.run_steps(2); + assert!(!h.state().combine_showing() && h.state().combine_tab.open); + let file: egui::DroppedFileHandle = std::sync::Arc::new(Dropped { path: "dropped.pdf".into(), bytes: FIXTURE.to_vec() }); + h.input_mut().dropped_files.push(file); + support::wait_until(&mut h, "dropped document", |app| app.views.len() == 2); + assert_eq!(combine_names(&h), ["first.pdf"], "the Combine files list is left as it was"); +} + #[test] fn files_and_quit_from_the_operating_system() { // #73: macOS hands Finder double-clicks, Open With and Dock drops over as Apple events. diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index bf46e16b41..67ee899f85 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -3034,8 +3034,8 @@ milestone = "M4" status = "shipped" commands = ["page.combine"] tools = ["doc_combine"] -evidence = ["crates/organize/src/tests.rs::combine_concatenates_with_a_bookmark_per_file", "crates/ui-egui/tests/it/editing.rs::combining_files_opens_a_new_unsaved_tab", "crates/ui-egui/tests/it/editing.rs::combine_files_opens_in_its_own_tab", "crates/ui-egui/tests/it/editing.rs::combine_lists_size_and_warns_before_combining", "crates/ui-egui/tests/it/editing.rs::a_password_protected_file_is_unlocked_with_its_password_and_combined", "crates/ui-egui/tests/it/editing.rs::a_folder_adds_its_pdfs_images_and_text_in_order_and_optionally_its_subfolders", "crates/ui-egui/tests/it/editing.rs::images_and_text_files_join_the_combine_list_as_pdfs", "crates/ui-egui/tests/it/editing.rs::files_that_cannot_be_read_are_listed_and_left_out_once_the_user_agrees", "crates/ui-egui/tests/it/ui.rs::pdfs_dropped_on_the_combine_tab_join_its_list", "crates/automation/tests/it/automation.rs::combine_extract_and_split", "crates/automation/tests/it/automation.rs::combine_opens_protected_files_with_their_passwords", "crates/automation/tests/it/automation.rs::combine_takes_images_and_text_files", "crates/engine/src/tests.rs::files_for_combine_are_made_into_pdfs_and_pdfs_pass_through", "crates/organize/src/tests.rs::combine_keeps_pdfx_when_every_source_agrees", "crates/organize/src/tests.rs::combine_claims_no_pdfx_when_the_sources_disagree"] -notes = "Combine files is its own tab: a table of the files (name, pages, size, modified, warnings) with column sorting, resizing and moving; files, folders (with subfolders) and open documents can be added, or dropped on the tab. Images (PNG, JPEG, TIFF, GIF, BMP, JPEG 2000) and .txt files become PDFs as they are added (#569; also in doc_combine). A file that can't be read stays in the list with why, and Combine asks before leaving it out; Office documents, HTML and other formats are not converted yet. Problems show before Combine (password, security settings that withhold page assembly, bad page ranges, duplicates); protected files are unlocked with their open or permissions password (also doc_combine passwords). The result is never encrypted, and is not yet offered for protection in the same step. A PDF/X result (output intent, GTS_PDFXVersion, Trapped, XMP pdfxid) only when every source declares the same version and output intent (#263). When they differ the result claims none, and nothing reports that yet; Acrobat behaviour not observed." +evidence = ["crates/organize/src/tests.rs::combine_concatenates_with_a_bookmark_per_file", "crates/ui-egui/tests/it/editing.rs::combining_files_opens_a_new_unsaved_tab", "crates/ui-egui/tests/it/editing.rs::combine_files_opens_in_its_own_tab", "crates/ui-egui/tests/it/editing.rs::combine_lists_size_and_warns_before_combining", "crates/ui-egui/tests/it/editing.rs::a_password_protected_file_is_unlocked_with_its_password_and_combined", "crates/ui-egui/tests/it/editing.rs::a_folder_adds_its_pdfs_images_and_text_in_order_and_optionally_its_subfolders", "crates/ui-egui/tests/it/editing.rs::images_and_text_files_join_the_combine_list_as_pdfs", "crates/ui-egui/tests/it/editing.rs::files_that_cannot_be_read_are_listed_and_left_out_once_the_user_agrees", "crates/ui-egui/tests/it/ui.rs::pdfs_dropped_on_the_combine_tab_join_its_list", "crates/ui-egui/tests/it/ui.rs::files_dropped_on_the_combine_tab_join_the_end_of_its_list_in_the_order_dropped", "crates/ui-egui/tests/it/ui.rs::a_file_combine_does_not_take_is_left_out_of_a_drop_and_named", "crates/ui-egui/tests/it/ui.rs::a_pdf_dropped_on_a_document_opens_while_the_combine_tab_waits_behind_it", "crates/automation/tests/it/automation.rs::combine_extract_and_split", "crates/automation/tests/it/automation.rs::combine_opens_protected_files_with_their_passwords", "crates/automation/tests/it/automation.rs::combine_takes_images_and_text_files", "crates/engine/src/tests.rs::files_for_combine_are_made_into_pdfs_and_pdfs_pass_through", "crates/organize/src/tests.rs::combine_keeps_pdfx_when_every_source_agrees", "crates/organize/src/tests.rs::combine_claims_no_pdfx_when_the_sources_disagree"] +notes = "Combine files is its own tab: a table of the files (name, pages, size, modified, warnings) with column sorting, resizing and moving; files, folders (with subfolders) and open documents can be added, or dropped on the tab (desktop and web): a drop joins the end of the list in the order dropped, as one undo step, not at the place under the pointer; files of a kind Combine does not take are left out and named. Dropped elsewhere, files open as before. Dropping adds no engine behaviour, so it has no tool of its own (doc_combine takes the files). Images (PNG, JPEG, TIFF, GIF, BMP, JPEG 2000) and .txt files become PDFs as they are added (#569; also in doc_combine). A file that can't be read stays in the list with why, and Combine asks before leaving it out; Office documents, HTML and other formats are not converted yet. Problems show before Combine (password, security settings that withhold page assembly, bad page ranges, duplicates); protected files are unlocked with their open or permissions password (also doc_combine passwords). The result is never encrypted, and is not yet offered for protection in the same step. A PDF/X result (output intent, GTS_PDFXVersion, Trapped, XMP pdfxid) only when every source declares the same version and output intent (#263). When they differ the result claims none, and nothing reports that yet; Acrobat behaviour not observed." [[feature]] id = "organize.combine-bookmarks" From b07a84f00316949cee4f2320d557e899d4ad4ee3 Mon Sep 17 00:00:00 2001 From: mtlcl410 <2368091+mtlcl410@users.noreply.github.com> Date: Mon, 9 Nov 2026 08:29:31 +0800 Subject: [PATCH 20/92] M5: a comment whose colour is written [r g b null] is drawn, as CMYK with the null read as 0 (view.render-annotation-appearances) (#6503) * M5: a comment whose colour is written [r g b null] is drawn, as CMYK with the null read as 0 (view.render-annotation-appearances) ISO 32000-2 Table 166 /C (and /IC, Tables 178, 180, 181; /MK /BC and /BG, Table 192) is an array of 0, 1, 3 or 4 numbers whose length picks the colour space. A four-entry array with a non-number in it, such as [1 0 0 null] (PDFBOX-2699), made the /C parser give up, so a shape without an appearance stream was not drawn at all. PDFium, MuPDF and Poppler all read such an array as CMYK with the non-number as 0. - annot: a four-entry /C or /IC reads non-numbers as 0; other lengths still need every entry to be a number. - annot: the properties' fill uses the same parser (it read [r g b null] as RGB before), and /C no longer drops a non-number to make three numbers. - forms: /MK /BC and /BG read a four-entry array the same way instead of dropping the non-number and drawing RGB. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Pq7HyDBXygTf7jcqdsjcfn * M5: comment properties report the stroke colour as drawn; a reference among four colour entries is not read as 0 (view.render-annotation-appearances) props() reads a non-FreeText /C through the appearance parser, so a [1 0 0 null] stroke reports CMYK cyan as drawn, and grey and CMYK strokes report their converted colour. A reference among the four entries of a /C array (ISO 32000-2 7.3.10) is left unreadable instead of standing in as 0, so /C [1 0 0 6 0 R] is never drawn cyan. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Pq7HyDBXygTf7jcqdsjcfn * M5: re-run CI (fill_sign Escape test depends on the clock's second, passes locally) Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Pq7HyDBXygTf7jcqdsjcfn * M5: the comment list reports a comment's colour as drawn (view.render-annotation-appearances) summaries() accepted only a three-entry /C, so a Square with /C [1 0 0 null], drawn cyan and reported cyan by props(), had no colour in the comment list; grey and CMYK colours were dropped the same way. Non-FreeText comments now read /C through drawn_color (the array itself followed when it is given by reference), keeping the three-number fallback and the FreeText /DA handling. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Pq7HyDBXygTf7jcqdsjcfn * M5: the render inspector lists a comment's colour as drawn (view.render-annotation-appearances) The inspector accepted only a three-entry /C, with a referenced entry read as 0, so a /C [1 0 0 null], a grey or a CMYK colour listed none, unlike the comment list and the drawing. Non-FreeText comments now read /C by the appearance builder's rule (one entry grey, four CMYK with a non-number as 0 and a reference unreadable, finite and clamped), with the three-number fallback reading referenced entries. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Pq7HyDBXygTf7jcqdsjcfn --------- Co-authored-by: mtlcl410 Co-authored-by: Claude Opus 5.5 --- crates/annot/src/appearance.rs | 19 +++++-- crates/annot/src/info.rs | 40 +++++++++++---- crates/annot/src/tests.rs | 65 ++++++++++++++++++++++++ crates/forms/src/appearance.rs | 10 +++- crates/forms/src/tests.rs | 32 ++++++++++++ crates/render/src/inspect/annotations.rs | 40 +++++++++++++-- crates/render/src/inspect/tests.rs | 34 +++++++++++++ parity/acrobat-features.toml | 4 +- 8 files changed, 222 insertions(+), 22 deletions(-) diff --git a/crates/annot/src/appearance.rs b/crates/annot/src/appearance.rs index dafc4b01b1..7996567f04 100644 --- a/crates/annot/src/appearance.rs +++ b/crates/annot/src/appearance.rs @@ -205,11 +205,24 @@ fn quads(context: &AppearanceContext<'_>) -> Option> { (!q.is_empty()).then_some(q) } -/// `/C`-style colour arrays: none (transparent), gray, RGB or CMYK. -fn color(d: &Dict, key: &[u8]) -> Option> { +/// `/C`-style colour arrays (ISO 32000-2 Table 166): none (transparent), gray, RGB or CMYK. +/// The entries should all be numbers; a four-entry array with a non-number in it, such as +/// `[1 0 0 null]`, is still read as CMYK with that entry 0, as PDFium, MuPDF and Poppler read it. +/// A reference is not such an entry: it stands for a value this builder can't see (§7.3.10), so +/// the colour is unreadable, as the other colour and number readers here treat one. +pub(crate) fn color(d: &Dict, key: &[u8]) -> Option> { let c = match d.get(key) { None => return Some(None), - Some(o) => o.as_array()?.iter().map(|o| o.as_f64()).collect::>>()?, + Some(o) => match o.as_array()? { + a if a.len() == 4 => a + .iter() + .map(|o| match o { + Object::Ref(_) => None, + o => Some(o.as_f64().unwrap_or(0.0)), + }) + .collect::>>()?, + a => a.iter().map(|o| o.as_f64()).collect::>>()?, + }, }; Some(match c.as_slice() { [] => None, diff --git a/crates/annot/src/info.rs b/crates/annot/src/info.rs index 8af76cb2ed..c4913e7ab8 100644 --- a/crates/annot/src/info.rs +++ b/crates/annot/src/info.rs @@ -105,7 +105,10 @@ pub fn summaries(doc: &Document) -> Vec { let r = nums(b"Rect"); let rect = if r.len() == 4 { [r[0].min(r[2]), r[1].min(r[3]), r[0].max(r[2]), r[1].max(r[3])] } else { [0.0; 4] }; let c = nums(b"C"); - let mut color = (c.len() == 3).then(|| [c[0], c[1], c[2]]); + let rgb = (c.len() == 3).then(|| [c[0], c[1], c[2]]); + // Other comments report the stroke colour as it is drawn (grey, CMYK, a null entry + // read as 0), like `props`; three numbers, some given by reference, are still read. + let mut color = if subtype == "FreeText" { rgb } else { summary_color(doc, d).or(rgb) }; if subtype == "FreeText" && let Some(da) = text_value(doc, d, b"DA") { @@ -244,8 +247,7 @@ pub fn props(doc: &Document, page: usize, index: usize) -> Option { let obj = doc.resolve(&entry); let d = obj.as_dict()?; let subtype = String::from_utf8_lossy(d.name(b"Subtype")?).into_owned(); - let c: Vec = d.get(b"C").and_then(|o| o.as_array().map(|a| a.iter().filter_map(|x| x.as_f64()).collect())).unwrap_or_default(); - let color = if subtype == "FreeText" { Some(appearance::parse_da(d).0) } else { (c.len() == 3).then(|| [c[0], c[1], c[2]]) }; + let color = if subtype == "FreeText" { Some(appearance::parse_da(d).0) } else { drawn_color(d, b"C") }; let width = matches!(subtype.as_str(), "Square" | "Circle" | "Line" | "Ink" | "Polygon" | "PolyLine" | "FreeText").then(|| { if subtype == "FreeText" { if !d.contains(b"BS") && !d.contains(b"Border") { 0.0 } else { appearance::border_width(d) } @@ -275,16 +277,32 @@ pub fn props(doc: &Document, page: usize, index: usize) -> Option { }) } -/// A shape’s `/IC` or FreeText `/C` as RGB. Grey and CMYK fills are converted. +/// A shape’s `/IC` or FreeText `/C` as RGB, read as it is drawn. Grey and CMYK fills are converted. fn fill_of(d: &Dict) -> Option { let key: &[u8] = if d.name(b"Subtype") == Some(b"FreeText") { b"C" } else { b"IC" }; - let c: Vec = d.get(key)?.as_array()?.iter().filter_map(|x| x.as_f64()).collect(); - let rgb = match c.as_slice() { - [g] => [*g; 3], - [r, g, b] => [*r, *g, *b], - [c, m, y, k] => [(1.0 - c) * (1.0 - k), (1.0 - m) * (1.0 - k), (1.0 - y) * (1.0 - k)], - _ => return None, - }; + drawn_color(d, key) +} + +/// `/C` as [`drawn_color`] reads it, the array itself followed when it is given by reference. +/// Only an array of at most four entries is copied out of the reference. +fn summary_color(doc: &Document, d: &Dict) -> Option<[f32; 3]> { + let rgb = match d.get(b"C") { + Some(o @ Object::Ref(_)) => { + let held = doc.resolve(o); + let entries = held.as_array().filter(|a| a.len() <= 4)?; + let mut direct = Dict::new(); + direct.set(b"C".to_vec(), Object::Array(entries.clone())); + drawn_color(&direct, b"C") + } + _ => drawn_color(d, b"C"), + }?; + Some(rgb.map(|x| x as f32)) +} + +/// The colour array at `key` as RGB, read as the appearance builder draws it: grey and CMYK are +/// converted, and a four-entry array with a non-number in it is CMYK with that entry 0. +fn drawn_color(d: &Dict, key: &[u8]) -> Option { + let rgb = appearance::color(d, key).flatten()?; finite(&rgb).then(|| rgb.map(|x| x.clamp(0.0, 1.0))) } diff --git a/crates/annot/src/tests.rs b/crates/annot/src/tests.rs index 0c9ac7ed5a..9e414078e9 100644 --- a/crates/annot/src/tests.rs +++ b/crates/annot/src/tests.rs @@ -2786,6 +2786,71 @@ fn indirect_typewriter_names_keep_outer_whitespace_in_summaries() { check(&reopen(&doc)); } +/// PDFBOX-2699: a `/C` written `[1 0 0 null]` (Table 166 asks for numbers) is still a four-entry +/// colour, CMYK with the null as 0 as PDFium, MuPDF and Poppler read it, so a shape without an +/// appearance is drawn instead of dropped. `/IC` is read the same way, and three numbers stay RGB. +/// The properties and the comment list report the colours as drawn. +#[test] +fn a_four_entry_colour_with_a_null_is_cmyk_with_zero() { + let doc = document_of(&[ + b"<< /Type /Catalog /Pages 2 0 R >>", + b"<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 100 40] >>", + b"<< /Type /Page /Parent 2 0 R /Annots [4 0 R 5 0 R] >>", + b"<< /Type /Annot /Subtype /Square /Rect [5 5 45 35] /C [1 0 0 null] /IC [0 0 1 null] >>", + b"<< /Type /Annot /Subtype /Square /Rect [55 5 95 35] /C [1 0 0] >>", + ]); + let display = with_missing_appearances(&doc).expect("both squares get a fallback appearance"); + let squares = list(&display, 0); + let cmyk = ap_content(&display, &squares[0]); + assert!(cmyk.contains("0 1 1 RG") && cmyk.contains("1 1 0 rg"), "CMYK (1,0,0,0) stroke and (0,0,1,0) fill: {cmyk}"); + let rgb = ap_content(&display, &squares[1]); + assert!(rgb.contains("1 0 0 RG"), "three numbers stay RGB: {rgb}"); + // The properties read the stroke and the fill as they are drawn. + let p = props(&doc, 0, 0).expect("the square has properties"); + assert_eq!((p.color, p.fill), (Some([0.0, 1.0, 1.0]), Some([1.0, 1.0, 0.0]))); + assert_eq!(props(&doc, 0, 1).and_then(|p| p.color), Some([1.0, 0.0, 0.0])); + // The comment list reports the same colours. + let colors: Vec> = summaries(&doc).iter().map(|s| s.color).collect(); + assert_eq!(colors, [Some([0.0, 1.0, 1.0]), Some([1.0, 0.0, 0.0])]); +} + +/// A `/C` given by reference is followed for the comment list, as three numbers were, and read +/// like a direct one: `[0 0 1 null]` is CMYK yellow, and a referenced grey level is grey. +#[test] +fn a_referenced_colour_is_listed_like_a_direct_one() { + let doc = document_of(&[ + b"<< /Type /Catalog /Pages 2 0 R >>", + b"<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 100 40] >>", + b"<< /Type /Page /Parent 2 0 R /Annots [4 0 R 5 0 R] >>", + b"<< /Type /Annot /Subtype /Square /Rect [5 5 45 35] /C 6 0 R >>", + b"<< /Type /Annot /Subtype /Circle /Rect [55 5 95 35] /C 7 0 R >>", + b"[0 0 1 null]", + b"[0.5]", + ]); + let colors: Vec> = summaries(&doc).iter().map(|s| s.color).collect(); + assert_eq!(colors, [Some([1.0, 1.0, 0.0]), Some([0.5, 0.5, 0.5])]); +} + +/// A reference among the four entries is not a non-number read as 0: `/C [1 0 0 6 0 R]` with +/// object 6 the number 1 is CMYK black (§7.3.10), so drawing it cyan would be wrong. The builder +/// doesn't follow it and draws no appearance, as for any colour it can't read. +#[test] +fn a_four_entry_colour_with_a_reference_is_not_read_as_zero() { + let doc = document_of(&[ + b"<< /Type /Catalog /Pages 2 0 R >>", + b"<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 100 40] >>", + b"<< /Type /Page /Parent 2 0 R /Annots [4 0 R 5 0 R] >>", + b"<< /Type /Annot /Subtype /Square /Rect [5 5 45 35] /C [1 0 0 6 0 R] >>", + b"<< /Type /Annot /Subtype /Square /Rect [55 5 95 35] /C [1 0 0] >>", + b"1", + ]); + let display = with_missing_appearances(&doc).expect("the RGB square gets a fallback appearance"); + let squares = list(&display, 0); + assert!(!squares[0].contains(b"AP"), "no cyan stand-in for CMYK black"); + assert_eq!(props(&doc, 0, 0).and_then(|p| p.color), None); + assert_eq!(summaries(&doc)[0].color, None, "nor in the comment list"); +} + /// The `y` of every `1 0 0 1 x y Tm` in `content`: where each piece of text sits. fn baselines(content: &str) -> Vec { content.split(" Tm").filter_map(|s| s.rsplit(' ').next()?.parse().ok()).collect() diff --git a/crates/forms/src/appearance.rs b/crates/forms/src/appearance.rs index 91cc352701..891a533eaa 100644 --- a/crates/forms/src/appearance.rs +++ b/crates/forms/src/appearance.rs @@ -399,9 +399,17 @@ fn helvetica() -> Object { Object::Dict(f) } +/// An `/MK /BC` or `/BG` colour (ISO 32000-2 Table 192) as a fill operator. A four-entry array +/// with a non-number in it, such as `[1 0 0 null]`, is CMYK with that entry 0, as annotation `/C` +/// colours are read. fn color_array(doc: &Document, mk: Option<&Dict>, key: &[u8]) -> Option { let a = doc.resolve(mk?.get(key)?); - let v: Vec = a.as_array()?.iter().filter_map(|x| doc.resolve(x).as_f64()).collect(); + let a = a.as_array()?; + let v: Vec = if a.len() == 4 { + a.iter().map(|x| doc.resolve(x).as_f64().unwrap_or(0.0)).collect() + } else { + a.iter().filter_map(|x| doc.resolve(x).as_f64()).collect() + }; match v.as_slice() { [g] => Some(format!("{} g", n(*g))), [r, g, b] => Some(format!("{} {} {} rg", n(*r), n(*g), n(*b))), diff --git a/crates/forms/src/tests.rs b/crates/forms/src/tests.rs index 353545f2b3..6cf207d0de 100644 --- a/crates/forms/src/tests.rs +++ b/crates/forms/src/tests.rs @@ -5706,3 +5706,35 @@ fn locked_button_export_edits_leave_the_document_unchanged() { assert_eq!(write_incremental(&doc, &SaveOptions::default()).unwrap(), before); } } + +/// A `/MK /BC` or `/BG` written `[1 0 0 null]` is CMYK with the null as 0, as annotation `/C` +/// colours are read (PDFBOX-2699), not RGB from the three numbers left; three numbers stay RGB. +#[test] +fn a_four_entry_mk_colour_with_a_null_is_cmyk_with_zero() { + let widget = |name: &str, mk: &str| { + format!("<< /Type /Annot /Subtype /Widget /FT /Btn /T ({name}) /Rect [50 700 70 720] /P 3 0 R /MK << {mk} >> /BS << /W 1 /S /S >> >>") + }; + let objs: Vec = vec![ + "<< /Type /Catalog /Pages 2 0 R /AcroForm 4 0 R >>".into(), + "<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 600 800] >>".into(), + "<< /Type /Page /Parent 2 0 R /Annots [5 0 R 6 0 R] >>".into(), + "<< /Fields [5 0 R 6 0 R] >>".into(), + widget("cmyk", "/BG [0 0 1 null] /BC [1 0 0 null]"), + widget("rgb", "/BG [0 0 1] /BC [1 0 0]"), + ]; + let mut doc = document(&objs); + let off = |doc: &mut Document, name: &str| { + redraw_field(doc, name).unwrap(); + let all = fields(doc); + let w = field(&all, name).widgets[0].obj; + let wd = doc.get(w).as_dict().cloned().unwrap(); + let n = doc.resolve(wd.get(b"AP").unwrap().as_dict().unwrap().get(b"N").unwrap()); + let r = n.as_dict().unwrap().reference(b"Off").expect("an Off appearance"); + let Object::Stream(s) = &*doc.get(r) else { panic!("Off is a stream") }; + String::from_utf8_lossy(&s.decoded().unwrap()).into_owned() + }; + let cmyk = off(&mut doc, "cmyk"); + assert!(cmyk.contains("0 0 1 0 k") && cmyk.contains("1 0 0 0 K"), "CMYK background and border: {cmyk}"); + let rgb = off(&mut doc, "rgb"); + assert!(rgb.contains("0 0 1 rg") && rgb.contains("1 0 0 RG"), "three numbers stay RGB: {rgb}"); +} diff --git a/crates/render/src/inspect/annotations.rs b/crates/render/src/inspect/annotations.rs index 7c4543b733..5ed635fdcb 100644 --- a/crates/render/src/inspect/annotations.rs +++ b/crates/render/src/inspect/annotations.rs @@ -22,6 +22,31 @@ pub(super) const MAX_ANNOTATION_BYTES: usize = 64 << 20; /// key does not fit is not listed. pub(super) const MAX_LINK_ATTACHMENT_TEXT_BYTES: usize = 16 << 20; +/// A colour array as RGB, read as pdfcraft-annot's appearance builder reads it (`appearance::color` +/// and `drawn_color`): one entry is grey, three RGB, four CMYK; among four entries a non-number is +/// 0 but a reference is unreadable, otherwise every entry must be a number. `None` for anything +/// else, an empty array or a value out of range; each component is clamped to 0..=1. +fn drawn_color(c: &[Object]) -> Option<[f32; 3]> { + let v: Vec = if c.len() == 4 { + c.iter() + .map(|o| match o { + Object::Reference(_) => None, + o => Some(o.as_float().unwrap_or(0.0)), + }) + .collect::>()? + } else { + c.iter().map(|o| o.as_float().ok()).collect::>()? + }; + let rgb = match v.as_slice() { + [g] => [*g; 3], + [r, g, b] => [*r, *g, *b], + [c, m, y, k] => [(1.0 - c) * (1.0 - k), (1.0 - m) * (1.0 - k), (1.0 - y) * (1.0 - k)], + _ => return None, + }; + // pdfcraft-annot's `finite`: finite and below 1e7 in magnitude. + rgb.iter().all(|x| x.is_finite() && x.abs() < 1e7).then(|| rgb.map(|x| x.clamp(0.0, 1.0))) +} + /// The bytes a listed annotation keeps. pub(super) fn retained(a: &Annotation) -> usize { let text = [&a.author, &a.contents, &a.modified, &a.name, &a.in_reply_to, &a.state, &a.intent]; @@ -119,13 +144,18 @@ impl<'a> Inspector<'a> { continue; } let rect = rect4(self.resolve(d.get(b"Rect").unwrap_or(&Object::Null))); - let mut color = match d.get(b"C").map(|o| self.resolve(o)) { - Ok(Object::Array(c)) if c.len() == 3 => { - let f = |i: usize| c[i].as_float().unwrap_or(0.0); - Some([f(0), f(1), f(2)]) - } + let c = match d.get(b"C").map(|o| self.resolve(o)) { + Ok(Object::Array(c)) => Some(c), _ => None, }; + // Three numbers, some given by reference, with a non-number read as 0. + let rgb = c.filter(|c| c.len() == 3).map(|c| { + let f = |i: usize| c.get(i).map_or(0.0, |o| self.resolve(o).as_float().unwrap_or(0.0)); + [f(0), f(1), f(2)] + }); + // Other comments list the stroke colour as pdfcraft-annot draws it and lists it + // (`summaries`): grey, CMYK, a non-number among four entries read as 0. + let mut color = if subtype == "FreeText" { rgb } else { c.and_then(|c| drawn_color(c)).or(rgb) }; // A text box's /C is its background; its text colour is in /DA. if subtype == "FreeText" && let Some(da) = self.text(d, b"DA") diff --git a/crates/render/src/inspect/tests.rs b/crates/render/src/inspect/tests.rs index 907401f8bb..012e40f79a 100644 --- a/crates/render/src/inspect/tests.rs +++ b/crates/render/src/inspect/tests.rs @@ -877,6 +877,40 @@ fn an_annotation_reached_through_a_reference_chain_is_listed_once() { } } +/// Comments list their `/C` as pdfcraft-annot draws and lists it: four entries with a null are +/// CMYK with the null as 0, one entry is grey, a reference among four entries is unreadable, +/// three numbers (some by reference) are RGB, and a text box keeps its three-number `/C`. +#[test] +fn comment_colours_are_listed_as_drawn() { + let pdf = "%PDF-1.7 +1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj + 2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 >> endobj + 3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 200 200] /Annots [4 0 R 5 0 R 6 0 R 7 0 R 8 0 R 9 0 R] >> endobj + 4 0 obj << /Type /Annot /Subtype /Square /Rect [0 0 10 10] /C [1 0 0 null] >> endobj + 5 0 obj << /Type /Annot /Subtype /Square /Rect [0 20 10 30] /C 10 0 R >> endobj + 6 0 obj << /Type /Annot /Subtype /Square /Rect [0 40 10 50] /C [1 0 0 11 0 R] >> endobj + 7 0 obj << /Type /Annot /Subtype /Square /Rect [0 60 10 70] /C [11 0 R 0 0] >> endobj + 8 0 obj << /Type /Annot /Subtype /Circle /Rect [0 80 10 90] /C [0 0 0 1] >> endobj + 9 0 obj << /Type /Annot /Subtype /FreeText /Rect [0 100 10 110] /C [0 1 0] >> endobj + 10 0 obj [0.5] endobj + 11 0 obj 1 endobj + trailer << /Root 1 0 R >> +%%EOF"; + let mut doc = Document::load_mem(pdf.as_bytes()).expect("loads"); + let mut saved = Vec::new(); + doc.save_to(&mut saved).expect("saves"); + let lazy = LazyStructure::new(Arc::new(saved), None).expect("opens"); + let page = PageInfo { width: 200.0, height: 200.0, crop: [0.0, 0.0, 200.0, 200.0], rotation: 0, label: String::new() }; + for reader in [&doc as &dyn Structure, &lazy] { + let mut info = DocInfo { pages: vec![page.clone()], ..Default::default() }; + Inspector::new(reader).fill(&mut info); + let mut listed: Vec<(f32, Option<[f32; 3]>)> = info.annotations.iter().map(|a| (a.rect[1], a.color)).collect(); + listed.sort_by(|a, b| a.0.total_cmp(&b.0)); + let colors: Vec> = listed.into_iter().map(|(_, c)| c).collect(); + assert_eq!(colors, [Some([0.0, 1.0, 1.0]), Some([0.5, 0.5, 0.5]), None, Some([1.0, 0.0, 0.0]), Some([0.0, 0.0, 0.0]), Some([0.0, 1.0, 0.0])]); + } +} + /// A reply finds its comment where the listing keeps it: on the first page of a page-shared /// `/Annots` array, whether its `/IRT` names the comment or an object holding a reference to it. #[test] diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index 67ee899f85..af026b3a85 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -818,8 +818,8 @@ milestone = "M2" status = "shipped" commands = [] tools = [] -evidence = ["crates/render/src/raster/tests.rs::widget_appearance_states", "crates/render/src/raster/tests.rs::highlights_without_an_appearance_are_drawn", "crates/engine/src/tests.rs::comments_without_appearances_are_drawn_but_not_saved", "crates/engine/src/tests.rs::cli_display_adds_missing_appearances_without_changing_the_source", "crates/engine/src/tests.rs::fields_left_to_the_viewer_are_displayed_from_their_values", "crates/forms/src/tests.rs::need_appearances_draws_every_text_and_choice_field_in_the_display_copy", "crates/forms/src/tests.rs::the_field_appearance_cache_sees_every_input", "crates/engine/src/tests.rs::buttons_and_combs_left_to_the_viewer_are_displayed", "crates/forms/src/tests.rs::check_boxes_without_an_appearance_show_their_caption_symbol", "crates/forms/src/tests.rs::push_buttons_without_an_appearance_show_their_caption_and_frame", "crates/forms/src/tests.rs::need_appearances_draws_check_boxes_and_radio_buttons_from_their_captions", "crates/engine/src/tests.rs::need_appearances_buttons_are_displayed_from_their_captions", "crates/render/src/raster/tests.rs::invisible_unknown_annotations_and_popups_paint_nothing", "crates/print/src/tests.rs::invisible_annotations_of_unknown_types_do_not_print", "crates/engine/src/tests.rs::stamps_without_appearances_draw_the_stamp_they_name", "crates/automation/tests/it/automation.rs::page_render_draws_stamps_without_an_appearance"] -notes = "comments without /AP (FreeText, Ink, notes, stamps, shapes, markup) are drawn from their dictionaries in the page view, page_render and page images, including the CLI render command, never saved; a stamp from another program draws the stamp its /Name names (the fourteen standard names of ISO 32000-2 Table 184, Draft when there is none) or, for any other name, a grey frame with both diagonals; text and choice fields are drawn from their values when the form sets /NeedAppearances (every one) or a widget has no /N (#3470), also display-only and cached per widget (a field edit on a 1000-field /NeedAppearances form takes ~13 ms against ~7 ms on a form that needs none) (a read-only document still gets the renderer's Highlight fallback only); check boxes and radio buttons with no appearance for their /AS are drawn display-only from /MK (background, border, and when on the /MK /CA ZapfDingbats symbol in the /DA colour, a check or a dot by default), and when the form sets /NeedAppearances so is every one that has an /MK, over the appearance the file carries (ISO 32000-2 Table 224; one without /MK keeps its own), and push buttons with none from /MK and /BS with their caption; Link borders without /AP and printing are not covered yet; an annotation of a non-standard type with the Invisible flag (ISO 32000-2 Table 167) is neither drawn nor printed, and a popup's own /AP is never painted on the page (its parent's text shows in the popup window, §12.5.6.14)" +evidence = ["crates/render/src/raster/tests.rs::widget_appearance_states", "crates/render/src/raster/tests.rs::highlights_without_an_appearance_are_drawn", "crates/engine/src/tests.rs::comments_without_appearances_are_drawn_but_not_saved", "crates/engine/src/tests.rs::cli_display_adds_missing_appearances_without_changing_the_source", "crates/engine/src/tests.rs::fields_left_to_the_viewer_are_displayed_from_their_values", "crates/forms/src/tests.rs::need_appearances_draws_every_text_and_choice_field_in_the_display_copy", "crates/forms/src/tests.rs::the_field_appearance_cache_sees_every_input", "crates/engine/src/tests.rs::buttons_and_combs_left_to_the_viewer_are_displayed", "crates/forms/src/tests.rs::check_boxes_without_an_appearance_show_their_caption_symbol", "crates/forms/src/tests.rs::push_buttons_without_an_appearance_show_their_caption_and_frame", "crates/forms/src/tests.rs::need_appearances_draws_check_boxes_and_radio_buttons_from_their_captions", "crates/engine/src/tests.rs::need_appearances_buttons_are_displayed_from_their_captions", "crates/render/src/raster/tests.rs::invisible_unknown_annotations_and_popups_paint_nothing", "crates/print/src/tests.rs::invisible_annotations_of_unknown_types_do_not_print", "crates/engine/src/tests.rs::stamps_without_appearances_draw_the_stamp_they_name", "crates/automation/tests/it/automation.rs::page_render_draws_stamps_without_an_appearance", "crates/annot/src/tests.rs::a_four_entry_colour_with_a_null_is_cmyk_with_zero", "crates/forms/src/tests.rs::a_four_entry_mk_colour_with_a_null_is_cmyk_with_zero"] +notes = "comments without /AP (FreeText, Ink, notes, stamps, shapes, markup) are drawn from their dictionaries in the page view, page_render and page images, including the CLI render command, never saved; a four-entry colour with a non-number in it (/C, /IC, /MK /BC and /BG written [1 0 0 null]) is read as CMYK with that entry 0; a stamp from another program draws the stamp its /Name names (the fourteen standard names of ISO 32000-2 Table 184, Draft when there is none) or, for any other name, a grey frame with both diagonals; text and choice fields are drawn from their values when the form sets /NeedAppearances (every one) or a widget has no /N (#3470), also display-only and cached per widget (a field edit on a 1000-field /NeedAppearances form takes ~13 ms against ~7 ms on a form that needs none) (a read-only document still gets the renderer's Highlight fallback only); check boxes and radio buttons with no appearance for their /AS are drawn display-only from /MK (background, border, and when on the /MK /CA ZapfDingbats symbol in the /DA colour, a check or a dot by default), and when the form sets /NeedAppearances so is every one that has an /MK, over the appearance the file carries (ISO 32000-2 Table 224; one without /MK keeps its own), and push buttons with none from /MK and /BS with their caption; Link borders without /AP and printing are not covered yet; an annotation of a non-standard type with the Invisible flag (ISO 32000-2 Table 167) is neither drawn nor printed, and a popup's own /AP is never painted on the page (its parent's text shows in the popup window, §12.5.6.14)" [[feature]] id = "view.render-error-isolation" From 9b231f241d58ee221015870c7533b2a611d5d9a5 Mon Sep 17 00:00:00 2001 From: mtlcl410 <2368091+mtlcl410@users.noreply.github.com> Date: Mon, 9 Nov 2026 08:29:44 +0800 Subject: [PATCH 21/92] perf(render): undo TIFF predictor 2 a byte row at a time at 8 and 16 bits (#6505) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit TIFF predictor 2 (ISO 32000-2 §7.4.4.4, Table 10) went through the bit-level BitChunks/BitWriter path for every bit depth, re-reading the previous output pixel through a new BitReader per pixel. Whole-byte samples (8 and 16 bits per component) are now undone on byte slices, each sample added to the same component of the pixel to its left, wrapping at its width; 1-, 2- and 4-bit samples keep the bit-level path. The output is byte-identical, including the dropped partial last row. A page of 45 full-page 8-bit grey images so predicted (PDFBOX-1350) renders in about 4 s instead of 23 s with the same pixels. Vendored hayro-syntax patch 42. Claude-Session: https://claude.ai/code/session_01Pq7HyDBXygTf7jcqdsjcfn Co-authored-by: mtlcl410 Co-authored-by: Claude Opus 5.5 --- crates/render/src/raster/tests.rs | 4 + .../src/raster/tests/tiff_predicted_images.rs | 94 +++++++++ parity/acrobat-features.toml | 4 +- vendor/README.md | 2 +- vendor/hayro-syntax/src/filter/lzw_flate.rs | 187 ++++++++++++++++++ 5 files changed, 288 insertions(+), 3 deletions(-) create mode 100644 crates/render/src/raster/tests/tiff_predicted_images.rs diff --git a/crates/render/src/raster/tests.rs b/crates/render/src/raster/tests.rs index be232d80eb..f6569e51fa 100644 --- a/crates/render/src/raster/tests.rs +++ b/crates/render/src/raster/tests.rs @@ -8983,6 +8983,10 @@ mod devicen_inks; // #5430: PNG-predicted Flate images are undone byte by byte (vendored hayro-syntax patch 35). mod png_predicted_images; +// TIFF-predicted Flate images at 8 and 16 bits are undone byte by byte (vendored hayro-syntax +// patch 42). +mod tiff_predicted_images; + // #3776: isolated DeviceCMYK groups blend in CMYK (vendored hayro-interpret patch 70, hayro // patch 27). mod isolated_cmyk_groups; diff --git a/crates/render/src/raster/tests/tiff_predicted_images.rs b/crates/render/src/raster/tests/tiff_predicted_images.rs new file mode 100644 index 0000000000..30a163daeb --- /dev/null +++ b/crates/render/src/raster/tests/tiff_predicted_images.rs @@ -0,0 +1,94 @@ +//! Flate images with TIFF predictor 2 (ISO 32000-2 §7.4.4.4, Table 10) at 8 and 16 bits per +//! component are undone a byte row at a time (vendored hayro-syntax patch 42), with the samples +//! the bit-level decoder gave. + +use super::rgb_at; +use crate::{PageRenderer, RenderConfig, RenderRequest, RequestKind}; +use flate2::{Compression, write::ZlibEncoder}; +use std::io::Write; +use std::sync::Arc; + +/// The page is 150 × 200 pt, drawn at 72 dpi; a 3 × 4 image fills it. +const WIDTH: usize = 150; +const HEIGHT: usize = 200; + +/// Renders a 3 × 4 image whose samples are `rows` (each row's bytes, before prediction), +/// differenced by TIFF predictor 2 with `pixel_len`-byte pixels of `sample_len`-byte samples. +fn render(rows: &[Vec], colour_space: &str, colors: usize, bpc: usize) -> Vec { + let (sample_len, pixel_len) = (bpc / 8, colors * bpc / 8); + let mut predicted = Vec::new(); + for row in rows { + let mut out = row.clone(); + for i in (pixel_len..row.len()).step_by(sample_len).rev() { + if sample_len == 2 { + let cur = u16::from_be_bytes([row[i], row[i + 1]]); + let left = u16::from_be_bytes([row[i - pixel_len], row[i - pixel_len + 1]]); + out[i..i + 2].copy_from_slice(&cur.wrapping_sub(left).to_be_bytes()); + } else { + out[i] = row[i].wrapping_sub(row[i - pixel_len]); + } + } + predicted.extend(out); + } + let mut encoder = ZlibEncoder::new(Vec::new(), Compression::default()); + encoder.write_all(&predicted).unwrap(); + let data = encoder.finish().unwrap(); + + let content = "q 150 0 0 200 0 0 cm /Im0 Do Q"; + let mut pdf = format!( + "%PDF-1.7\n1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj\n\ + 2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 >> endobj\n\ + 3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 150 200] /Resources << /XObject << /Im0 5 0 R >> >> /Contents 4 0 R >> endobj\n\ + 4 0 obj << /Length {} >> stream\n{content}\nendstream endobj\n\ + 5 0 obj << /Type /XObject /Subtype /Image /Width 3 /Height 4 /ColorSpace /{colour_space} /BitsPerComponent {bpc} \ + /Filter /FlateDecode /DecodeParms << /Predictor 2 /Colors {colors} /BitsPerComponent {bpc} /Columns 3 >> /Length {} >> stream\n", + content.len(), + data.len() + ) + .into_bytes(); + pdf.extend_from_slice(&data); + pdf.extend_from_slice(b"\nendstream endobj\ntrailer << /Root 1 0 R >>\n%%EOF"); + + let mut renderer = PageRenderer::new(Arc::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(), "{:?}", page.error); + assert_eq!((page.width, page.height), (WIDTH as u32, HEIGHT as u32)); + page.rgba.to_vec() +} + +#[test] +fn an_eight_bit_rgb_image_with_tiff_predicted_rows_draws_its_colours() { + // Each pixel differs from the one to its left, and some differences wrap past 255. + let pixels: [[[u8; 3]; 3]; 4] = [ + [[255, 0, 0], [0, 255, 0], [0, 0, 255]], + [[10, 200, 30], [250, 5, 128], [0, 0, 0]], + [[255, 255, 255], [1, 2, 3], [128, 64, 32]], + [[0, 128, 255], [255, 128, 0], [64, 64, 64]], + ]; + let rows: Vec> = pixels.iter().map(|row| row.iter().flatten().copied().collect()).collect(); + let rgba = render(&rows, "DeviceRGB", 3, 8); + for (row, colours) in pixels.iter().enumerate() { + for (column, rgb) in colours.iter().enumerate() { + let (x, y) = (column * 50 + 25, row * 50 + 25); + assert_eq!(rgb_at(&rgba, WIDTH, x, y), *rgb, "image row {row}, column {column}"); + } + } +} + +#[test] +fn a_sixteen_bit_grey_image_with_tiff_predicted_rows_draws_its_levels() { + // The high byte decides the level drawn; the differences carry across the byte boundary. + let levels: [[u16; 3]; 4] = [[0xffff, 0x00ff, 0x8000], [0x0100, 0xff00, 0x4080], [0, 0x7fff, 0xc000], [0x2000, 0x2000, 0xe0ff]]; + let rows: Vec> = levels.iter().map(|row| row.iter().flat_map(|v| v.to_be_bytes()).collect()).collect(); + let rgba = render(&rows, "DeviceGray", 1, 16); + for (row, values) in levels.iter().enumerate() { + for (column, value) in values.iter().enumerate() { + let (x, y) = (column * 50 + 25, row * 50 + 25); + let [r, g, b] = rgb_at(&rgba, WIDTH, x, y); + let expected = i32::from(value >> 8); + for got in [r, g, b] { + assert!((i32::from(got) - expected).abs() <= 1, "image row {row}, column {column}: {got} for {value:#06x}"); + } + } + } +} diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index af026b3a85..abc7e8ceeb 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -118,8 +118,8 @@ milestone = "M1" status = "shipped" commands = [] tools = [] -evidence = ["crates/filters/src/lib.rs::chain_three_filters_with_predictor", "crates/filters/src/lzw.rs::spec_example", "crates/filters/src/predictor.rs::png_hand_vectors", "crates/filters/src/lib.rs::raw_flate_fallback_preserves_output_limits", "crates/cos/src/object.rs::raw_flate_fallback_streams_preserve_the_caller_limit"] -notes = "Raw-deflate fallback preserves output limits in strict and tolerant decoding, including misleading zlib headers. Image codecs remain outside this general-purpose filter implementation." +evidence = ["crates/filters/src/lib.rs::chain_three_filters_with_predictor", "crates/filters/src/lzw.rs::spec_example", "crates/filters/src/predictor.rs::png_hand_vectors", "crates/filters/src/lib.rs::raw_flate_fallback_preserves_output_limits", "crates/cos/src/object.rs::raw_flate_fallback_streams_preserve_the_caller_limit", "crates/render/src/raster/tests/tiff_predicted_images.rs::an_eight_bit_rgb_image_with_tiff_predicted_rows_draws_its_colours", "crates/render/src/raster/tests/tiff_predicted_images.rs::a_sixteen_bit_grey_image_with_tiff_predicted_rows_draws_its_levels"] +notes = "Raw-deflate fallback preserves output limits in strict and tolerant decoding, including misleading zlib headers. The renderer undoes PNG predictors, and TIFF predictor 2 at 8 and 16 bits per component, a byte row at a time. Image codecs remain outside this general-purpose filter implementation." [[feature]] id = "core.image-filters" diff --git a/vendor/README.md b/vendor/README.md index 7ae867cb11..aa8b43ba4a 100644 --- a/vendor/README.md +++ b/vendor/README.md @@ -43,7 +43,7 @@ Other resource categories retain their existing parent-chain lookup. | hayro | 0.7.1 | Apache-2.0 OR MIT | (1) `RenderSettings::{x_offset, y_offset}` to render one tile of a large page; (2) images over `MAX_IMAGE_PIXELS` (2^28) are skipped (a fuzzed `/W 4294967295` hung in resampling); (3) `tiling_cell_scale` keeps a tiling pattern's cell pixmap within 3000 px a side whatever its `/XStep`/`/YStep` (a fuzzed step far beyond `/BBox` allocated 2 GB); (4) stroke widths clamped to 4 × (canvas width + height) device pixels (a fuzzed `/LW 9223372036854775807` allocated 10 GB in stroke expansion); (5) `usable_dash_pattern`: a stroke whose dash pattern could cut it into more than `MAX_DASHES_PER_STROKE` (250,000) pieces, counting kurbo's restart of the pattern at every subpath and `ClosePath`, is drawn solid (a fuzzed `/D [[11] 0]` on a line from x = 92234775807 allocated over 5 GB, `[0.000001] 0 d` on a 20 pt line over 10 GB); (6) a dash pattern with a negative or non-finite entry, or a period that isn't positive, is drawn solid (`[-1 -1] 0 d` looped for ever in kurbo); (7) `render_size` and `render_into`: render into a buffer the caller allocates (PdfCraft renders into pixel-aligned memory that the GUI takes over as texture data); `render` is `render_into` on a new pixmap; (8) checked image source/target/RGBA, alpha-premultiplication scratch and intermediate sizes before resampling, finite transforms and exact source/mask buffers; sources wider than u16 may shrink before backend limits, with a 2^20 source-axis cap and conservative 128 MiB filter-planning estimate; an over-limit or failed resize keeps the original image resolution and geometry when the source fits the backend, otherwise skips it, with a logged warning; staging, resampling output/scratch, RGBA conversion and padding buffers use fallible reserves; (9) `may_paint_canvas`: a fill or stroke whose control-point box, grown by how far its paint reaches past the path (half the stroke width times the larger of the miter limit and √2) plus 0.5 user units and 2 pixels, misses the canvas is not drawn, so a tile no longer strokes and fills every path of its page (item 5 of #623); `set_skip_offscreen_paths` turns this off for the regression test; (10) `/Interpolate` governs the pre-resize too (#624): a minified image that does not request interpolation is pre-resized with nearest-neighbour (since (21), only inside Type 3 glyphs), planned without the Catmull-Rom filter limits (`image_resampling_size` takes the flag); a Type 3 glyph image is sampled bicubically only when interpolated; a minified image with a matching soft mask is nearest-sampled straight into premultiplied RGBA; (11) `may_paint_rect`: an image whose stream-size box, mapped to the device, misses the canvas is skipped before decoding (decoded data can only shrink against that box), so a tile of an image-heavy page no longer decodes, resamples and rasterizes every image on it; (12) preview renders (`InterpreterSettings::preview`: backdrops, thumbnails, grid cells, signature-drag backgrounds) skip images under `PREVIEW_MIN_IMAGE_AREA` device pixels, while final renders always draw them; (13) reading colours (#897): `RenderSettings::color_replacement` takes a `ColorReplacement` (text colour, background, `only_black`, `line_art`) that remaps solid text and line-art paint while rendering, glyph outlines counting as text and stencil-mask ink as line art; images, image alpha masks, soft masks and pattern cells keep their colours; `render_pdf` gains a `color_replacement: Option` argument (pass `None` for true colours); the upstream-only `[dev-dependencies.log]` entry is removed from `Cargo.toml` (log is a normal dependency since the logging patch), so `cargo test -p hayro` runs this crate's unit tests; (14) a shrunk image that is interpolated (or, since (21), area-averaged) and whose axes lie along the device's (upright, mirrored or a quarter turn, as on a page under `/Rotate`) is snapped to whole device pixels: shrunk once to its rounded device rectangle (halves round up, so a tile snaps an edge where the whole page does) and drawn 1:1 from the rounded corner, instead of a ceil-sized raster resampled again at a fractional scale and offset (`snap_minified_image`; scans looked soft next to Acrobat, and until #5530 a turned one still did, up to 200 grey levels off the same page drawn upright). An image counts as axis-aligned when the terms the snap drops move its far edges by at most a hundredth of a device pixel (`SNAP_SKEW`), not by a tolerance on the coefficients, which let a slight skew of a very wide image through; and the snapped 1:1 transform is used only when the shrink produced the snapped size, so a failed resize keeps the full transform instead of drawing the source raster at its source size; (15) a soft mask of another size than its image is filtered when shrunk, whatever its `/Interpolate` (#1649): such a mask carries the shape on its own (Word saves text as a tiny black image with a glyph-bitmap mask), and nearest-neighbour dropped every stroke thinner than the shrink factor; (16) under a soft mask or a blend mode, axial and radial shadings are sampled instead of drawn as native gradients (#2128): vello_cpu 0.0.8 copies an opaque gradient over fully covered tiles without the mask or blend mode, so PowerPoint's faded gradient fills came out solid; (17) the renderer keeps annotation paints out of the reading-colours mapping between `begin_annotations` and `end_annotations` (#1548); (18) the page renders as one transparency group; under reading colours its background is painted inside it as the initial backdrop, so a Multiply highlight blends with the dark background instead of staying an opaque block, and otherwise the background stays outside, so a blend mode over an empty part of the page keeps its colour instead of blending with the white paper (#2970); (19) `set_paint` samples a mesh shading's texture over its triangles alone (`EncodedShadingPattern::extent`, padded rather than repeated) and skips a mesh with none on the canvas: a BricsCAD plan's 310 one-triangle `sh` each covered the whole tile, 2 s per 1024 px tile at any zoom (the watchdog gave the page up on a busy machine), now about 70 ms; (20) a tiling pattern whose step is too large for a cell pixmap, where what is painted (a fill, or a stroke with its width) can only show the first cell (PowerPoint's `/XStep 2147480100` for a picture fill drawn once), has its `/BBox` drawn at full scale with a transparent border and padded instead of tiled (`shows_one_cell`; a cell whose scale isn't finite and positive, from a `/BBox` too thin to divide by, keeps the old path: `usable_cell_scale`); shrinking the cell to fit the 3000 px limit of (3) left the picture under a pixel, so network diagrams lost their pictures (#3349); (21) an image that does not request interpolation and is drawn below its resolution, outside a Type 3 glyph, is shrunk by area averaging (`area_shrink`: each device pixel the coverage-weighted mean of the samples it covers, in 16.16 fixed point whose weights sum to exactly one, premultiplied when the image has a mask; a soft mask no larger than the image is first sampled onto the image's grid by `mask_on_image_grid` (nearest-neighbour, or bilinearly when the mask asks for `/Interpolate`, so its edge stays as smooth as an enlarged mask's), so its colour and mask are averaged together instead of the averaged colour of transparent samples showing under a mask layer at the mask's edges) to its snapped device rectangle, instead of nearest-neighbour: `/Interpolate` asks for smoothing only when an image is enlarged (ISO 32000-2 §8.9.5.3), and nearest-neighbour kept one sample in N per axis, so the text of a 300 dpi scan at 100% lost most of its strokes (sampling them again at a fractional offset dropped more). PicScale's `Box` and `Area` filters were not used: shrinking, both average a window four target pixels wide. On a 13-page 300 dpi scan every rendered sample at 96, 120 and 192 dpi is within 1 of the exact area average (it was up to 255 off, 17-23% of samples by more than 1). A snapped image whose shrink failed keeps the full transform, so it is not drawn at its source size. The shrink never enlarges: a side drawn at or above its resolution (an image shrunk across and stretched down, as a 204 × 98 dpi fax is) keeps its samples for the sampler. Each target row averages the whole source rows it covers (contiguous, cheap) and is then shrunk across with taps worked out once per image, which made the shrink about three times faster (a 300 dpi page at 96 dpi: 25 ms, nearest-neighbour 11 ms); working a row at a time (`area_shrink_rows`) keeps its work memory to a few rows. An image with a soft mask, a stencil's ink included, is premultiplied a source row at a time as it is averaged (`area_shrink_premultiplied`), never as a full-resolution RGBA copy: a 10718 × 13701 stencil shown at page size peaked at 1.16 GB with that copy and peaks at 1.03 GB without it, as with nearest-neighbour. A source with a side over `MAX_RESAMPLING_SOURCE_SIDE` (2^20) keeps (10)'s nearest-neighbour shrink, and `area_shrink` refuses it, so its accumulator row and tap table stay a few times one source row (a 2^27 × 2 image of zeros is a tiny file); (22) knockout groups: while one is innermost, fills, strokes, glyph outlines and shadings are drawn with `Compose::Copy` (weighted by coverage), so each element replaces what earlier elements of the group drew instead of compositing over it; an image or nested group, drawn into a layer of its own, first has what is under its shape cleared (`knockout_image` for an unmasked image's rectangle, only when `draw_image` will draw it, decoded at the size it is drawn; a knockout erase pass that draws a nested group's content with every paint clearing instead of painting, a shading fill's clear clipped to the shading's `/BBox` as its paint is) and then composites over it (#2971); (23) backport of hayro 0.8: shadings that `as_svg_gradient` can represent are painted as a `vello_cpu` linear or two-point radial gradient instead of a texture sampled pixel by pixel through the shading function (InDesign fades are luminosity soft masks of one full-page axial `sh`: 40 on a report page took 15 s at 144 dpi, now under 1 s); without upstream's half-pixel shift, which suits vello_cpu 0.3 but moves 0.0.8's gradients off pixel centres; (25) a tiling cell is drawn at the scale that makes one `/XStep` (`/YStep`) exactly the cell pixmap's whole number of pixels (`tiling_cell_pixels`, at least 1 and at most the cap), so the repeated pixmap puts cells at whole multiples of the step at any device scale; drawing at the unrounded scale repeated the cell up to half a pixel off at every step, and the error grew with distance from the pattern's origin (ISO 32000-2 8.7.3.1); (28) an image of page-scan size (`SMOOTHED_IMAGE_SIDE`: 512 samples or more on its shorter side) that does not request interpolation is drawn with `ImageQuality::Medium` (bilinear, as interpolated images are) when enlarged by more than 1% on either side (`SMOOTHED_IMAGE_SCALE`: a scan drawn at its resolution can come out a few ulps above 1, and blurred by half a pixel at a half-pixel offset), also when the other side is shrunk (the area shrink leaves that side at 1:1), outside a Type 3 glyph: scans rarely set `/Interpolate`, and drawn nearest-neighbour every sample of a zoomed scan became a block, so its text had staircase edges; icons, QR codes and pixel art keep their hard pixels. Bicubic (`High`) looked the same on a zoomed 300 dpi scan and drew four 1024 px tiles at 400% in 267 ms against 50 ms | (1) `raster::tests::tiles_match_full_render`; (2) `raster::tests::absurd_image_dimensions_are_skipped`; (3) `raster::tests::tiling_cells_stay_small_whatever_the_step`; (4) `raster::tests::absurd_predictor_columns_and_line_widths_render`; (5) `raster::tests::dash_patterns_that_cut_a_stroke_into_billions_of_pieces_render`; (6) `raster::tests::invalid_dash_patterns_terminate_and_are_drawn_solid`; (7) `raster::tests::pixels_are_a_plain_renders_bytes_in_a_buffer_a_gui_can_take_over`; (8) `raster::tests::image_resampling_rejects_anisotropic_target_growth`, `raster::tests::image_resampling_bounds_intermediate_before_planning`, `raster::tests::image_resampling_rejects_invalid_sources_and_scales`, `raster::tests::image_resampling_keeps_valid_sizes_and_exact_limits`, `raster::tests::resampling_fallback_preserves_original_geometry_and_pixels`, `raster::tests::image_resampling_wide_sources_shrink_before_backend_side_limits`, `raster::tests::image_resampling_wide_transparent_sources_still_shrink`, `raster::tests::image_resampling_padded_backend_dimensions_stay_checked`, `raster::tests::image_resampling_mismatched_alpha_mask_keeps_pixels_and_placement`; (9) `raster::tests::skipping_paths_off_the_canvas_changes_no_pixel`; (10) `raster::tests::non_interpolated_images_are_not_smoothed_when_minified`, `raster::tests::non_interpolated_gray_images_are_not_smoothed_when_minified`, `raster::tests::non_integer_minification_averages_the_columns_each_pixel_covers`, `raster::tests::non_integer_minification_averages_colour_and_mask_together`, `raster::tests::non_interpolated_alpha_images_keep_their_colours_and_mask_when_minified`, `raster::tests::non_interpolated_type3_glyph_images_are_not_smoothed_when_minified`, `raster::tests::image_resampling_nearest_accepts_wide_sources_the_filter_refuses`, `raster::tests::wide_non_interpolated_sources_draw_after_shrinking`; (11) `raster::tests::skipping_paths_off_the_canvas_changes_no_pixel`; (12) `raster::tests::preview_renders_skip_tiny_images_but_finals_draw_everything`; (13) `color_replacement_tests::{black_maps_to_text_colour, white_maps_to_background, midgrey_interpolates, alpha_copied_verbatim, only_black_threshold, line_art_false_skips_paths_but_not_text}`, `color_replacement_render_tests::{color_replacement_maps_text_and_line_art, color_replacement_maps_stencil_mask_ink}`; (14) `raster::tests::minified_image_edges_snap_to_whole_pixels`, `raster::tests::a_tile_snaps_half_pixel_image_edges_as_the_whole_page_does` (`cargo test -p hayro`), `raster::tests::a_turned_or_mirrored_minified_image_snaps_to_whole_pixels`, `rotated_image_snap::a_shrunk_image_on_a_turned_page_is_the_upright_render_turned` (`cargo test -p pdfcraft-render --test it`), `renderer::snap_tests::a_skew_that_moves_a_far_edge_is_not_snapped` (`cargo test -p hayro`); (15) `raster::tests::shrunk_soft_mask_larger_than_its_image_keeps_thin_strokes`; (16) `raster::tests::soft_mask_applies_to_a_shading_pattern_fill`; (17), (18) `raster::tests::reading_colors_keep_highlight_annotations_readable`; (19) `mesh_extent_tests::{a_mesh_paints_its_triangles_alone, a_mesh_keeps_the_graphics_state_alpha, a_mesh_off_the_canvas_paints_nothing}` (`cargo test -p hayro`); (20) `raster::tests::a_tiling_pattern_drawn_once_shows_its_cell`, `raster::tests::a_large_step_still_tiles_where_the_next_cell_shows`, `raster::tests::a_tiling_cell_too_thin_to_scale_keeps_the_old_path`; (21) `raster::tests::non_interpolated_minification_keeps_thin_strokes`, `raster::tests::non_integer_minification_averages_the_columns_each_pixel_covers`, `raster::tests::non_integer_minification_averages_colour_and_mask_together`, `raster::tests::non_interpolated_minification_does_not_bleed_transparent_colour`, `raster::tests::a_smaller_soft_mask_does_not_bleed_transparent_colour_when_minified`, `raster::tests::an_interpolated_smaller_soft_mask_stays_smooth_when_its_image_is_minified`, `raster::tests::a_shrunk_stencil_mask_keeps_thin_strokes`, `raster::tests::minified_image_edges_snap_to_whole_pixels`, `raster::tests::an_image_shrunk_one_way_and_stretched_the_other_is_still_averaged`, `raster::tests::wide_non_interpolated_sources_draw_after_shrinking`, `renderer::area_shrink_tests` (`cargo test -p hayro`); (22) `raster::tests::knockout_groups_replace_what_their_elements_cover`; (23) `raster::tests::axial_and_radial_shadings_draw_as_native_gradients`; (25) `tiling_cell_placement::{far_cells_of_a_fill_sit_at_whole_steps, far_cells_sit_at_whole_steps_under_a_turned_scaled_matrix, far_cells_of_a_stroke_sit_at_whole_steps, a_cell_pixmap_spans_exactly_one_step}` (`cargo test -p pdfcraft-render --test it`); (28) `raster::tests::large_non_interpolated_images_are_smoothed_when_enlarged`, `raster::tests::small_non_interpolated_images_keep_hard_pixels_when_enlarged`, `raster::tests::a_scan_shrunk_one_way_is_smoothed_the_way_it_is_stretched`, `raster::tests::a_scan_drawn_a_rounding_error_above_its_resolution_stays_sharp` | | hayro-jbig2 | 0.3.0 | Apache-2.0 OR MIT | (1) at most 2^28 pixels per bitmap and page bitmap, and 2^25 cells per halftone grid and grayscale image (each side could be 65535: 4.3 billion pixels decoded one by one, and a 4-byte value per cell); (2) at most 2^26 symbol pixels per symbol dictionary (a fuzzed dictionary decoded for minutes); (3) a symbol dictionary's height classes are capped at one per new symbol and its export runs at `2 × symbols + 1`, each plus `EXTRA_EMPTY_STEPS` (1024): an empty height class or a zero-length export run doesn't advance, and past the end of its data the arithmetic decoder can decode them for ever (one changed byte hung the renderer); (4) a segment header's long-form referred-to segment count is capped at `u16::MAX` and a larger count is refused before its retention or referred-to bytes are read: the 29-bit count went straight to `Vec::with_capacity`, a reservation of about 2 GiB whose failure aborts the process (from storytold/craft-libs#176); (5) what a page's segments hold for later ones (intermediate regions, pattern and symbol dictionaries with their headers and retained contexts, and Huffman tables) is at most 64 MiB, two square bitmaps of 16,384 a side (`MAX_RETAINED_WORDS`), and a dictionary's new and exported symbols are counted against it as they are made: each was bounded on its own, but a few dozen bytes an intermediate region held 33 MB each, and 8 MiB symbols exported again and again killed the process, so a few kilobytes could exhaust memory; (6) a segment may refer to at most 2^20 symbols, checked before any are collected: references may repeat, and 65,535 of them to one dictionary, in a 78 KB stream, asked for 17 GB; (7) a pattern dictionary keeps at most 16 MiB of shifted copies, which only speed up drawing, and none that wouldn't fit what the page has left (`MAX_SHIFTED_WORDS`); (8) a custom Huffman table has at most 2^16 lines, and a stream with its globals at most 2^20 segments and 2^22 references, checked as they are read: both grew before anything counted them, and PDFs of a kilobyte or so reached 5 GB and more behind nested Flate; (9) a page past the bounds of (1) is refused when the stream is opened: a caller sizes its output from the header before the page bitmap is made, and an Indexed image in a 743-byte PDF asked for 137 GB, which aborts; (10) a stream may ask for at most 2^30 units of decoding work, four of the largest pages, about 6 s to 17 s (`MAX_WORK`): decoding a bitmap costs a pixel each and 4 a row, copying one a word each and 1 a row, and each integer an item reads costs 38, what one takes at worst, so one with no pixels pays too, before it is decoded: each bitmap was bounded, but nothing bounded how many there were, and a 795-byte PDF took 265 s; (11) `Image::decode_within` spends from what a run of images may still do, starting with its page's pixels, and `charge_reading` charges reading a stream and its globals before anything is parsed, so an operation over a document can bound what `MAX_WORK` bounds for one image, and images that fail or decode nothing add up too: a file of thousands of images made to reach that limit took as many times as long; (12) adding to a location, a reference offset, a symbol's corner or a Huffman range line near the end of an `i32`, and negating a decoded integer of 2^31, overflowed: a panic wherever overflow is checked. The refinement context, a text region's corners, the integer decoder and the Huffman table wrap, as a release build did, and `Bitmap::combine` saturates, which comes to the same, as a location that overflows is past the end of any page. What is drawn doesn't change; (13) a bitmap filled with a default pixel of 1 (a page, or an intermediate text or halftone region) had 1s past its width in the last word of each row, and a refinement, which reads its reference a word at a time, read them as pixels where the standard has 0 (6.3.5.2): refining such a reference not a multiple of 32 wide decoded to other pixels than were coded, release builds too. `Bitmap::clear_padding` clears those bits when a bitmap is filled with 1s, as cutting symbols and patterns from a collective bitmap already did. Ghostscript reads the test's page and text streams as they decode now; (14) a region's location is unsigned (5.4.4), so one past `i32::MAX` is off the page, where the decoder took it for a signed one: a region there was drawn back onto the page from the left or the top (at 0xFFFFFFFC, four pixels left of it), and a refinement there, of the page or of a region at the same place (7.4.7.5), was refused as an overflow, and the page with it. `Bitmap::combine_region` keeps such a location past the page. A refinement of another region refines it where it is, as Table 35 has it (GRREFERENCEDX and GRREFERENCEDY 0), whatever the two locations say; a refinement of the page, see (16); (15) a refinement whose reference is offset right of it (GRREFERENCEDX above 0, which a refined text instance has from a wider bitmap, Table 12, and a refined symbol from an RDX above 0, Table 18) read the reference's first 32 columns as 0: `RefinementContextGatherer::maybe_reload_buffers` emptied its buffers left of the reference and didn't load them again at its first column. Such a refinement decoded to other pixels than were coded, or failed and lost the page. Left of the reference the buffers now keep the words of its first column each row starts with; with an offset of 0 or less nothing changes; (16) a refinement of the page refines "the current contents of the page buffer, restricted to the area of the page buffer specified by this segment's region segment information field" (7.4.7.4), at an offset of 0 (Table 35): `Bitmap::area` copies that area a word at a time, 0 off the page and past its width whatever the padding of its rows holds, and every refinement region reads its reference at offset 0. The page around the area was read too, so a refinement of the page decoded to other pixels than were coded, and one wider or taller than the page was refused. And `RefinementContextGatherer::start_row` loads the reference's words a column left of where a row starts in it, as the reload would, not from column 0: with the reference offset 32 or more left of the refinement (GRREFERENCEDX below −31, as a refined text instance has from a narrower bitmap or an RDX below −31, and a refined symbol from such an RDX), each row's first context read 0 for the reference column it starts at, and from 33 left for the one before it too, and the refinement decoded to other pixels or failed | (1) `raster::tests::jbig2_regions_of_billions_of_pixels_are_refused`, `raster::tests::jbig2_halftone_grids_past_their_bound_are_refused`; (2) the nightly `cargo xtask fuzz` finding, which now finishes in about a second; (3) `raster::tests::jbig2_symbol_dictionaries_that_never_advance_terminate`; (4) `segment::tests::test_referred_to_count_is_capped` (`cargo test --manifest-path vendor/hayro-jbig2/Cargo.toml`); (5) `raster::tests::jbig2_regions_held_past_a_pages_bound_are_refused`, `jbig2_symbols_without_pixels_count_towards_a_pages_bound`, `jbig2_retained_contexts_count_towards_a_pages_bound`, `jbig2_huffman_tables_count_towards_a_pages_bound`, `jbig2_new_symbols_count_towards_a_pages_bound`, `jbig2_new_symbols_are_held_to_what_a_page_has_left`, `jbig2_pattern_dictionaries_count_towards_a_pages_bound`, and in the crate (`cargo test --manifest-path vendor/hayro-jbig2/Cargo.toml`) `decode::symbol::tests::exported_symbols_are_counted_before_they_are_copied`, `tests::the_room_left_shrinks_as_segments_are_stored`; (6) `raster::tests::jbig2_segments_referring_to_too_many_symbols_are_refused`; (7) `raster::tests::jbig2_pattern_dictionaries_too_large_to_shift_still_draw`, `jbig2_shifted_patterns_never_refuse_an_image`; (8) `raster::tests::jbig2_huffman_tables_of_too_many_lines_are_refused`, `jbig2_streams_of_too_many_segments_are_refused`, `jbig2_streams_of_too_many_references_are_refused`, and in the crate `tests::the_segments_of_a_stream_and_its_globals_count_together`; (9) `raster::tests::jbig2_pages_past_the_bounds_are_refused_before_anything_is_sized_from_them`, `tests::a_page_past_the_bounds_is_refused_when_opened`; (10) `raster::tests::jbig2_decoding_work_is_counted_item_by_item`; (11) `jbig2::decode::tests::a_run_of_images_spends_its_allowance_down_to_the_step` (pdfcraft-filters); (12) `raster::tests::jbig2_placements_at_the_end_of_an_i32_draw_nothing`; (13) `jbig2::decode::tests::a_refinement_reads_nothing_past_its_references_width` (pdfcraft-filters), and in the crate `tests::a_bitmap_of_1s_has_nothing_past_its_width`; (14) `jbig2::decode::tests::a_region_located_past_i32_max_is_off_the_page` (pdfcraft-filters), and in the crate `tests::a_region_located_past_i32_max_is_off_the_page` and `tests::a_refinement_of_a_region_decodes_the_same_wherever_they_are`; (15) `jbig2::decode::tests::a_refinement_reads_a_reference_right_of_it_from_its_first_column` (pdfcraft-filters), and in the crate `tests::a_refinement_reads_a_reference_right_of_it_from_its_first_column`; (16) `jbig2::decode::tests::a_refinement_refines_the_page_in_its_area_and_any_reference_where_it_is` (pdfcraft-filters), and in the crate `tests::a_refinement_refines_the_page_in_its_area_and_any_reference_where_it_is` and `tests::a_page_area_is_the_page_inside_it_and_0_outside` | | lopdf | 0.45.0 | MIT | (1) PNG predictor rows longer than the data are refused before their buffers are allocated (a fuzzed `/Columns 4294967295` allocated two 4 GiB rows), and empty frames return without row buffers; (2) predictor row widths use saturating arithmetic or reject overflow; (3) sub-byte TIFF Predictor 2 scratch is bounded by the samples present in the input instead of the declared `/Colors`, aligned with [lopdf#589](https://github.com/J-F-Liu/lopdf/pull/589); (4) the object lookup keeps its cycle history through compressed objects, requires an ordinary in-file container, and caps the chain of indirect lookups at 16 (a chain of new objects, each a `/Length` compressed in a container whose own `/Length` names the next, or a stream whose `/Length` names the next stream, overflowed the stack: a dev build's 2 MiB thread within 800 hops); (5) object-stream members are parsed within their own extent (up to the next distinct offset), an entry whose offset repeats an earlier entry's is skipped (so a repeated object number can resolve to its earlier entry), and offsets are processed in index order; (6) `find_latest_trailer`: a rebuild takes a `/Type /XRef` stream dictionary as the trailer when the file has no `trailer` keyword (the latest in the file wins, as a later `trailer` would), so a concatenated or truncated file keeps its /Info and /ID instead of failing to load; a candidate whose /Root is the catalog beats one whose /Root is another dictionary, and an in-file /Root must match its generation (#2365) | (1) `inspect::tests::png_predictor_rows_longer_than_the_data_are_refused_before_allocating`, `inspect::tests::empty_png_predictor_frames_do_not_allocate_rows`; (2) `inspect::tests::tiff_subbyte_predictor_row_width_overflow_is_refused`; (3) `inspect::tests::tiff_subbyte_predictor_scratch_is_bounded_by_available_samples`, `inspect::tests::tiff_subbyte_predictors_keep_components_rows_and_padding`; (4) `inspect::tests::compressed_object_self_container_does_not_recurse`, `inspect::tests::compressed_object_mutual_containers_do_not_recurse`, `inspect::tests::object_stream_length_cycle_does_not_recurse`, `inspect::tests::compressed_object_container_chain_does_not_recurse`, `inspect::tests::compressed_length_chain_does_not_recurse`, `inspect::tests::plain_stream_length_chain_does_not_recurse`, `inspect::tests::compressed_objects_and_indirect_stream_length_still_load`, `inspect::tests::compressed_length_in_a_container_with_an_indirect_length_still_loads`; (5) `inspect::tests::object_stream_member_extents_bound_owned_strings`, `inspect::tests::object_stream_member_extents_preserve_index_order_and_whitespace`; (6) `inspect::tests::rebuilt_file_keeps_the_info_of_its_cross_reference_stream`, `inspect::tests::rebuilt_file_prefers_a_trailer_whose_root_is_the_catalog` | -| hayro-syntax | 0.7.2 | Apache-2.0 OR MIT | (1) Page-tree cycle guard in `resolve_pages` (visited set + depth cap): a `/Kids` loop among compressed objects overflowed the stack and aborted the process; (2) `ccitt_size_ok`: CCITT images wider than 2^20 columns or over 2^28 pixels are not decoded (a fuzzed `/Columns 4294967295` allocated 4 GiB); (3) Flate/LZW predictor row widths are saturating `usize`, a zero width is refused and a row longer than the data is not allocated (a fuzzed `/Columns 9223372036854775807` wrapped to a 2^61-byte allocation and aborted); (4) the inline-image end-of-data check (`EI`) doesn't nest inside its own look-ahead and examines at most 256 candidates and steps (50 images with "EI" in their data ran for over ten minutes); an EI followed by a delimiter (EI%..., EI[...) ends the image like one followed by white space; only a regular character after EI skips the candidate; (5) a stream whose `/Length` is wrong is recovered only when `stream` is the next token — scanning ahead to a later object's `stream` made an indirect appearance-state dictionary (Quartz checkboxes: `/AP /N` → `<< /Yes … /Off … >>`) render as a non-form, so the widget was skipped; (6) JPX rescaling uses an exact integer sample range for 1–32-bit components (32 shifted past u32, and floating-point maxima at 25–31 bits wrapped to zero), and refuses zero-sized rows; (7) `decode_limit`: Flate (through flate2 and the fallback inflater), LZW, RunLength and ASCII85 never write past `MAX_DECODED_STREAM` (256 MiB), or, for the last filter of an image of known size, past twice its pixel bytes (a predictor tag byte per byte), at most 1 GiB; `Stream::decoded_within` for budgeted callers; a page's `/Contents` array shares one `MAX_DECODED_STREAM` (a 2 MB page whose content stream or 8 × 8 image inflated to 2 GB took the renderer past 3 GB in under a second; naming the stream twenty times in `/Contents`, 5 GB); (8) high-precision numbers (jsPDF MediaBox coordinates with over 19 digits) parse without overflowing the u64 mantissa: fractional digits past it are dropped, integer digits past it scale the value (a real, kept finite); (9) large dictionary indexes are built on first use and shared across repeated reads within each PDF (#307); the validated-byte cache is bounded, reader contexts remain separate, repairs and inline/content dictionaries bypass sharing, and small dictionaries stay eager; (10) decoded object-stream slots publish once under contention, cold loading rejects recursive dependencies, and xref repair serializes without holding recursive read guards; this permits one parser per native render pool with private worker caches; (11) `sync.rs`: a poisoned lock is recovered instead of unwrapped (a worker panic no longer cascades into every reader of the same PDF), and xref repair takes a blocking write lock instead of asserting a non-blocking one; (12) `SegmentList` (`util.rs`) returns `None` for an out-of-range index instead of panicking; (13) a page's `/UserUnit` scales `render_dimensions` and `initial_transform`, so the page and everything on it are sized in points, as Acrobat shows them; it is read as Acrobat reads it (the page's own entry, not inherited; a number from 1 up, at most 75,000; anything else counts as 1), and a page without it keeps exactly the transform it had; (14) revision 2–4 passwords are tried as given and then in PDFDocEncoding (`legacy_passwords` in `crypto/mod.rs`, which resolves the upstream `TODO: Convert to PDFDocEncoding`): passwords arrive as UTF-8 text, so a password with characters outside ASCII (`Çok güzel`, `ılık€`) written in PDFDocEncoding, as ISO 32000-2 §7.6.4.3.2 asks and qpdf and Acrobat write it, was always wrong; (15) page content is read a piece at a time (`page::ContentReader`, `Page::content_reader`): plain streams and Flate streams without a predictor are decoded as they are read, with a budget of 64 times their compressed size and never less than `MAX_DECODED_STREAM` (a stream that expands past it is cut and reported); other streams are decoded whole, as before, and share `MAX_DECODED_STREAM`. The content tokenizer reads a window at a time (`UntypedIter::new_window`, `TypedIter::needs_more`, `TypedIter::committed`): an instruction that runs past the window is not decided until more data follows, which `Reader` records by flagging reads and `at_end` past the end of the data, including through the clones the parser makes. The Flate decoder keeps its own 32 KiB history, so it pauses at any output size. `ContentReader::new` reuses raw/decrypted stream bytes for budget accounting and piece creation, and `decoded_raw_within` applies the existing Whole-stream filters and limits to those bytes without decrypting twice; (16) revision 5–6 (AES-256) passwords are tried in their SASLprep form (RFC 4013, then UTF-8, at most 127 bytes, as ISO 32000-2 §7.6.4.3.3 Algorithm 2.A asks and pdfcraft-crypt writes them) and then, if that differs, as given (`aes256_passwords` in `crypto/mod.rs`, which resolves the upstream `TODO: Do the above.`; `stringprep`, already in the tree through pdfcraft-crypt, is an optional dependency enabled by `std`, and without `std` passwords are used as given): the renderer used the password as typed, so one that SASLprep changes (a decomposed `café`, a no-break space, the fullwidth `ab` a Japanese IME types in full-width mode) was wrong, although pdfcraft-cos accepted it: protecting a document with such an open password was rolled back ("the password is incorrect"), and a file with such a password did not open with it.; (17) CCITT `/Rows 0` means the height is not predetermined (ISO 32000-2 Table 11), not zero rows: scanned Group 4 pages written that way rendered blank; (18) The streaming Flate decoder (`lzw_flate::flate::fallback`) copies a back-reference in runs of up to its distance (`extend_from_within`), not byte by byte. The copy reads only bytes already in the output, and a copy whose source does not exist still writes nothing, so the bytes are those a byte-by-byte copy writes. Inflating the A1 sample's content stream (278.6 MB) takes about 14% less time (0.66 s to 0.56 s); the output is identical; (19) CCITT `/Rows` below the image's `/Height` decodes to the height: a plotter driver wrote `/Rows` equal to `/Columns`, so only the first rows were decoded and the rest drawn black (#3308) (20) `stream` followed by spaces or tabs and then the line break (`stream \r\n`, as Microsoft Reporting Services writes it) starts the data after the line break; the content stream was taken to start at the space and the page rendered blank.; (21) a JP2 colour specification naming an enumerated colour space the decoder refuses (CIEJab in a veraPDF fixture, YCbCr, PhotoYCC, YPbPr, bilevel…) no longer fails the image: it is decoded again from a copy whose `colr` names the space the decoder assumes without one, greyscale, sRGB or CMYK by component count, as MuPDF and Poppler draw it (`without_refused_enumerated_colour`; the page drew blank, #3306); (22) an object stream's index of `objnum offset` pairs is parsed once per stream and kept beside its decoded data (`ObjectStreamIndexes`, `XRef::with_object_stream_indexes`), and an offset past `usize` is malformed instead of wrapping: `XRef::get_with` parsed the whole index again for every object it fetched from the stream, so reading all of a stream of N objects took N² steps (a Firefox page whose 65,542 objects share one stream took 37 s to draw and 40 s to extract its text); (23) the inline-image end-of-data look-ahead no longer rejects a candidate `EI` because a byte above 0x7F lies between it and the next one: when the candidate is the end, those bytes are the content stream that follows the image, and its strings carry such bytes (WinAnsi `(Grüße) Tj`, the two-byte codes of any Type 0 font), so the operator check that follows decides alone, bounded as in (4) (a scanned page whose OCR text held an accented word and a later capital "EI" — EINGANG, BEI, KLEIN — drew and extracted nothing after the image: 40 of 275 pages across five real documents); (24) `xref::prev_offset`: a trailer `/Prev` written as a reference `N G R` is read as the offset that object holds when the section's own entries place it (iText's `indirectPrev.pdf`; each section's entries are read before its `Prev` is followed), and otherwise as offset N (tiff2pdf; JHOVE's PDF-HUL-157 files, #2366), as `pdfcraft-cos` and other readers take it, so the earlier sections' pages are seen instead of one; (25) direct arrays and dictionaries share a reader-local nesting cap of 128, counted while skipping and while parsing a dictionary (its lenient repair reads, rather than skips, an object found where a key belongs, so `<< << …` recursed through that read); deeper objects return `None` instead of overflowing the stack (including unused trailer values, trailer entries, content operands and marked-content properties); a chain of 65 direct stitching functions now stops at this cap, before the 64-function guard; (26) an xref stream subsection must be backed by its decoded data: all-zero `/W` widths (an entry reads no bytes) and a declared count longer than the data are refused before any entry is inserted, where a tiny stream with a large `/Size` or `/Index` count inserted that many entries (from storytold/craft-libs#239); a subsection running past object number `i32::MAX` is refused instead of overflowing or turning negative; (27) an xref stream's type field is read big-endian at its full `/W[0]` width, where only its first byte was read and wider fields shifted every later entry, and `/W[0]` over 8 bytes is refused like `/W[1]` (from storytold/craft-libs#241); (28) a content instruction keeps up to 256 operands (`MAX_OPERANDS`) instead of 10, and past that drops the oldest instead of ending the content stream: the eleventh operand of a twelve-ink DeviceN `scn` (ISO 32000-2 §8.6.6.5 allows any number of components) blanked the rest of the page (#4260); (29) a `/JBIG2Globals` stream that is itself JBIG2 isn't decoded: globals are raw segments, and a chain of 16,000 such streams overflowed the stack, which aborts the process; (30) a stream's own crypt filter: `/Crypt` in its chain read as the filter it names, `/Identity` without one, instead of the document's filter and then a filter that can't be decoded, which left such a stream unreadable in any spelling (ISO 32000-2 §7.4.10); (31) `set_page_jbig2_work`: the JBIG2 images decoded on a thread spend one allowance, set per page, 2^30 steps (`MAX_PAGE_JBIG2_WORK`), so a page of many images made to reach their own limit stops at one, and what they cost to read counts, whether they decode or not; unbounded until a page sets it; (32) a group of array items read together (a tuple: an image's `/Decode`, `/W`, `/W2`, and a function's `/Domain`, `/Range`, `/Encode` and `/Decode`) can start with a reference: it was taken for the whole group and lost, which ended the array, so an image with `/Decode [7 0 R 0]` didn't draw and CID widths fell back to `/DW`; (33) an object that holds only a reference stands for what that names, stored on its own or in an object stream, up to 32 references: read as a number it was the reference's own object number, so a `/Decode` or `/W` entry naming such an object took a wrong value, and as anything else it was lost. Past 32, as any cycle goes, it reads as a missing object. An object stream isn't reached that way, as its checks are of the object named; reading its index counts against the 64 nested loads and reads its dictionary alone, as a chain of them, each with a member of the next for its `/N`, `/First` or `/Length`, ran out of stack; and a comment inside a reference counts as white space; (34) a table rebuilt by scanning the file (`fallback_xref_map`) finds an object by its number when its header names another generation, including one no generation may be (`3 18446744073709551616 obj` in `poppler-85140-0.pdf`, #5580): the header is read leniently only in that scan (`read_object_header`), the object is also filed under generation 0 unless a real `N 0 obj` holds that key (a later header for the number moves the stand-in), `XRef::get_with` matches by object number only in a repaired table, and `XRef::objects` yields each location once; (35) PNG predictors (`lzw_flate::unfilter_png`) are undone a byte row at a time on slices, as the PNG filters are defined, instead of through the bit-level readers TIFF predictors need: a 993 × 936 RGB image took about 63 ms to unfilter whatever its row filters, and now takes about 3 ms (Up) to 16 ms (Paeth) in a release build (#5430); a row ending part-way through a pixel (three 4-bit samples, an odd width) keeps its last byte, where the bit-level readers dropped it and each later row started a byte early, shearing the picture | (1) `cos/tests/writer.rs::page_tree_loops_in_object_streams_do_not_crash_readers`; (2) `raster::tests::absurd_mask_and_fax_sizes_are_refused_before_decoding`; (3) `raster::tests::absurd_predictor_columns_and_line_widths_render`; (4) `raster::tests::inline_images_with_ei_in_their_data_parse_in_linear_time`, `content::tests::inline_image_ei_may_be_followed_by_a_delimiter`; (5) `raster::tests::indirect_appearance_state_dict_is_not_another_stream`; (6) `raster::tests::jpx_stream_bit_depth_boundaries_do_not_panic`, `raster::tests::jpx_32_bit_image_does_not_prevent_other_page_content_from_rendering`, `raster::tests::jpx_gray_rgb_and_alpha_keep_eight_bit_samples`, `raster::tests::jpx_streams_reject_out_of_range_bit_depths`; (7) `raster::tests::decompression_bombs_stop_at_the_stream_limit`, `raster::tests::page_contents_and_nested_forms_share_one_budget`; (8) `inspect::tests::jspdf_high_precision_mediabox_does_not_overflow_dimensions`, `object::number::tests::high_precision_float_does_not_overflow_mantissa` (hayro-syntax); (9) `object::dict::lazy_index_tests`, `raster::tests::large_resource_indexes_preserve_pixels_and_text`; (10) `data::tests`, `xref::tests::concurrent_cold_object_stream_reads_all_succeed`, `raster::tests::shared_parser_is_lazy_and_initializes_once_under_contention`; (11) `data::tests::panicking_initializer_wakes_waiters_and_prevents_retries`, `xref::tests::concurrent_cold_object_stream_reads_all_succeed`; (12) `util::tests::segment_list_rejects_out_of_range_indices`; (13) `raster::tests::user_unit_scales_the_page_and_everything_on_it`; (14) `crypto::pdfcraft_password_tests`, `pdfcraft-engine` `tests::legacy_passwords_with_turkish_characters_open`; (15) `content::tests::windows_keep_every_instruction_at_every_split`, `content::tests::windows_keep_every_instruction_in_pieces_of_any_size`, `content::tests::windows_match_the_slice_on_random_content`, `content::tests::windows_drop_blanks_and_keep_a_comment_that_may_go_on`, `page::content_reader_tests::content_past_the_cap_is_read_to_the_end`, `page::content_reader_tests::a_stream_that_expands_past_the_budget_is_cut_there`, `page::content_reader_tests::a_small_expansion_under_the_cap_is_not_cut`, `raster::tests::page_content_past_the_old_cap_renders`, `raster::tests::content_reader_returns_the_same_bytes_in_any_pieces`, `raster::tests::content_bombs_stop_at_the_budget_and_say_so`, `page::content_reader_tests::plain_and_whole_pieces_keep_their_bytes`, `page::content_reader_tests::repeated_content_reference_counts_compressed_bytes_once`, `page::content_reader_tests::whole_cap_still_counts_later_unique_streams`; (16) `crypto::pdfcraft_password_tests::aes256_passwords_are_tried_in_their_saslprep_form_then_as_given`, `crypto::pdfcraft_password_tests::aes256_passwords_open_as_typed_and_in_their_saslprep_form`, `pdfcraft-engine` `tests::aes256_open_passwords_that_saslprep_changes_protect_and_reopen_as_typed`, `tests::aes256_files_whose_passwords_saslprep_changes_open_as_typed`; (17) `raster::tests::group4_fax_with_rows_zero_decodes_to_the_image_height`; (18) `filter::lzw_flate::tests::back_reference_longer_than_its_distance_repeats_its_source`, `back_reference_cut_by_the_limit_resumes_in_the_next_call` and `back_reference_reads_bytes_written_by_an_earlier_call`; each fails when the copy returns the wrong remaining count; (19) `raster::tests::group4_fax_with_rows_below_its_height_decodes_to_the_image_height`; (20) `raster::tests::space_before_the_line_break_after_stream_still_renders`; (21) `raster::tests::a_jp2_image_in_a_refused_enumerated_colour_space_is_drawn`, `filter::jpx::tests::refused_enumerated_colour_spaces_become_the_component_counts_default`, `filter::jpx::tests::what_the_decoder_handles_or_cant_be_read_is_left_alone` (`cargo test --manifest-path vendor/hayro-syntax/Cargo.toml`); (22) `raster::tests::objects_in_one_large_object_stream_are_read_in_linear_time`; (23) `text::tests::text_after_an_inline_image_survives_a_non_ascii_byte_before_a_later_ei`, `content::tests::an_inline_image_ends_at_its_own_ei_when_later_text_holds_a_non_ascii_byte` (hayro-syntax); (24) `xref::tests::prev_as_reference_reaches_the_earlier_section`, `xref::tests::prev_as_reference_to_an_object_holding_the_offset_reads_that_offset`, `pdfcraft-cos` `document::tests::a_prev_written_as_a_reference_still_reaches_the_earlier_sections`, `document::tests::a_prev_reference_to_an_object_holding_the_offset_reads_that_offset`; (25) `raster::tests::deeply_nested_direct_objects_do_not_overflow_the_stack`, `raster::tests::dictionaries_nested_without_keys_in_content_do_not_overflow_the_stack`, `raster::tests::dictionaries_nested_without_keys_in_the_trailer_do_not_overflow_the_stack`, `raster::tests::dictionaries_nested_without_keys_as_marked_content_properties_do_not_overflow_the_stack`, `raster::tests::dictionaries_nested_without_keys_are_read_up_to_the_nesting_cap`, `raster::tests::nesting_at_the_cap_fits_a_512_kib_stack` (native only), `raster::tests::function_limits_depth_guard_refuses_a_chain_of_references`; (26) `xref::tests::xref_stream_entries_need_backing_bytes`; (27) `xref::tests::xref_stream_type_field_uses_the_full_w0_width`, `xref::tests::xref_stream_loader_rejects_oversized_w0` (`cargo test --manifest-path vendor/hayro-syntax/Cargo.toml`); (28) `content::ops::tests::scn_with_twelve_components_keeps_them_all`, `content::ops::tests::operands_past_the_cap_keep_the_latest_in_order` (hayro-syntax), `raster::tests::devicen_inks::a_twelve_ink_devicen_colour_paints`, `raster::tests::devicen_inks::a_four_ink_devicen_colour_paints`, `raster::tests::devicen_inks::an_operator_with_hundreds_of_operands_does_not_end_the_content`; (29) `raster::tests::jbig2_globals_that_are_jbig2_themselves_are_not_decoded`, `raster::tests::jbig2_globals_are_read`, `raster::tests::the_jbig2_images_of_a_page_share_one_allowance`, `raster::tests::an_image_that_fails_spends_the_pages_allowance_too`; (30) `raster::tests::a_stream_with_a_crypt_filter_of_its_own_is_drawn`; (32) `raster::tests::a_decode_array_with_references_in_it_is_read`, `raster::tests::a_functions_arrays_can_start_with_a_reference` (pdfcraft-render), `font::cid::width_tests::a_width_group_can_start_with_a_reference` (hayro-interpret); (33) `raster::tests::a_reference_to_a_reference_is_what_it_names`, `raster::tests::a_decode_array_with_references_in_it_is_read` (pdfcraft-render), `font::cid::width_tests::a_width_group_can_start_with_a_reference` (hayro-interpret), `xref::tests::an_object_stream_isnt_reached_through_a_reference`, `xref::tests::a_chain_of_object_streams_ends_at_the_limit_of_nested_loads`, `xref::tests::a_chain_of_object_streams_takes_no_more_work_for_two_ways_on`, `xref::tests::a_reference_held_in_either_storage_is_followed`, `xref::tests::a_page_a_reference_is_held_to_is_found_once` (hayro-syntax); (34) `raster::tests::a_rebuilt_table_finds_an_object_whose_header_names_another_generation`, `raster::tests::a_rebuilt_tables_generation_0_stand_in_is_not_a_second_object` (pdfcraft-render), `xref::tests::an_object_stream_and_its_first_member_are_both_enumerated` (hayro-syntax); (35) `filter::lzw_flate::pdfcraft_png_predictor_tests` (hayro-syntax: every filter type at 1–16 bits and 1–4 colours against the PNG specification's byte-by-byte definition, a row ending part-way through a pixel, Average and Paeth over 6-byte pixels), `raster::tests::png_predicted_images::a_four_bit_rgb_image_of_odd_width_with_up_predicted_rows_is_not_sheared` | +| hayro-syntax | 0.7.2 | Apache-2.0 OR MIT | (1) Page-tree cycle guard in `resolve_pages` (visited set + depth cap): a `/Kids` loop among compressed objects overflowed the stack and aborted the process; (2) `ccitt_size_ok`: CCITT images wider than 2^20 columns or over 2^28 pixels are not decoded (a fuzzed `/Columns 4294967295` allocated 4 GiB); (3) Flate/LZW predictor row widths are saturating `usize`, a zero width is refused and a row longer than the data is not allocated (a fuzzed `/Columns 9223372036854775807` wrapped to a 2^61-byte allocation and aborted); (4) the inline-image end-of-data check (`EI`) doesn't nest inside its own look-ahead and examines at most 256 candidates and steps (50 images with "EI" in their data ran for over ten minutes); an EI followed by a delimiter (EI%..., EI[...) ends the image like one followed by white space; only a regular character after EI skips the candidate; (5) a stream whose `/Length` is wrong is recovered only when `stream` is the next token — scanning ahead to a later object's `stream` made an indirect appearance-state dictionary (Quartz checkboxes: `/AP /N` → `<< /Yes … /Off … >>`) render as a non-form, so the widget was skipped; (6) JPX rescaling uses an exact integer sample range for 1–32-bit components (32 shifted past u32, and floating-point maxima at 25–31 bits wrapped to zero), and refuses zero-sized rows; (7) `decode_limit`: Flate (through flate2 and the fallback inflater), LZW, RunLength and ASCII85 never write past `MAX_DECODED_STREAM` (256 MiB), or, for the last filter of an image of known size, past twice its pixel bytes (a predictor tag byte per byte), at most 1 GiB; `Stream::decoded_within` for budgeted callers; a page's `/Contents` array shares one `MAX_DECODED_STREAM` (a 2 MB page whose content stream or 8 × 8 image inflated to 2 GB took the renderer past 3 GB in under a second; naming the stream twenty times in `/Contents`, 5 GB); (8) high-precision numbers (jsPDF MediaBox coordinates with over 19 digits) parse without overflowing the u64 mantissa: fractional digits past it are dropped, integer digits past it scale the value (a real, kept finite); (9) large dictionary indexes are built on first use and shared across repeated reads within each PDF (#307); the validated-byte cache is bounded, reader contexts remain separate, repairs and inline/content dictionaries bypass sharing, and small dictionaries stay eager; (10) decoded object-stream slots publish once under contention, cold loading rejects recursive dependencies, and xref repair serializes without holding recursive read guards; this permits one parser per native render pool with private worker caches; (11) `sync.rs`: a poisoned lock is recovered instead of unwrapped (a worker panic no longer cascades into every reader of the same PDF), and xref repair takes a blocking write lock instead of asserting a non-blocking one; (12) `SegmentList` (`util.rs`) returns `None` for an out-of-range index instead of panicking; (13) a page's `/UserUnit` scales `render_dimensions` and `initial_transform`, so the page and everything on it are sized in points, as Acrobat shows them; it is read as Acrobat reads it (the page's own entry, not inherited; a number from 1 up, at most 75,000; anything else counts as 1), and a page without it keeps exactly the transform it had; (14) revision 2–4 passwords are tried as given and then in PDFDocEncoding (`legacy_passwords` in `crypto/mod.rs`, which resolves the upstream `TODO: Convert to PDFDocEncoding`): passwords arrive as UTF-8 text, so a password with characters outside ASCII (`Çok güzel`, `ılık€`) written in PDFDocEncoding, as ISO 32000-2 §7.6.4.3.2 asks and qpdf and Acrobat write it, was always wrong; (15) page content is read a piece at a time (`page::ContentReader`, `Page::content_reader`): plain streams and Flate streams without a predictor are decoded as they are read, with a budget of 64 times their compressed size and never less than `MAX_DECODED_STREAM` (a stream that expands past it is cut and reported); other streams are decoded whole, as before, and share `MAX_DECODED_STREAM`. The content tokenizer reads a window at a time (`UntypedIter::new_window`, `TypedIter::needs_more`, `TypedIter::committed`): an instruction that runs past the window is not decided until more data follows, which `Reader` records by flagging reads and `at_end` past the end of the data, including through the clones the parser makes. The Flate decoder keeps its own 32 KiB history, so it pauses at any output size. `ContentReader::new` reuses raw/decrypted stream bytes for budget accounting and piece creation, and `decoded_raw_within` applies the existing Whole-stream filters and limits to those bytes without decrypting twice; (16) revision 5–6 (AES-256) passwords are tried in their SASLprep form (RFC 4013, then UTF-8, at most 127 bytes, as ISO 32000-2 §7.6.4.3.3 Algorithm 2.A asks and pdfcraft-crypt writes them) and then, if that differs, as given (`aes256_passwords` in `crypto/mod.rs`, which resolves the upstream `TODO: Do the above.`; `stringprep`, already in the tree through pdfcraft-crypt, is an optional dependency enabled by `std`, and without `std` passwords are used as given): the renderer used the password as typed, so one that SASLprep changes (a decomposed `café`, a no-break space, the fullwidth `ab` a Japanese IME types in full-width mode) was wrong, although pdfcraft-cos accepted it: protecting a document with such an open password was rolled back ("the password is incorrect"), and a file with such a password did not open with it.; (17) CCITT `/Rows 0` means the height is not predetermined (ISO 32000-2 Table 11), not zero rows: scanned Group 4 pages written that way rendered blank; (18) The streaming Flate decoder (`lzw_flate::flate::fallback`) copies a back-reference in runs of up to its distance (`extend_from_within`), not byte by byte. The copy reads only bytes already in the output, and a copy whose source does not exist still writes nothing, so the bytes are those a byte-by-byte copy writes. Inflating the A1 sample's content stream (278.6 MB) takes about 14% less time (0.66 s to 0.56 s); the output is identical; (19) CCITT `/Rows` below the image's `/Height` decodes to the height: a plotter driver wrote `/Rows` equal to `/Columns`, so only the first rows were decoded and the rest drawn black (#3308) (20) `stream` followed by spaces or tabs and then the line break (`stream \r\n`, as Microsoft Reporting Services writes it) starts the data after the line break; the content stream was taken to start at the space and the page rendered blank.; (21) a JP2 colour specification naming an enumerated colour space the decoder refuses (CIEJab in a veraPDF fixture, YCbCr, PhotoYCC, YPbPr, bilevel…) no longer fails the image: it is decoded again from a copy whose `colr` names the space the decoder assumes without one, greyscale, sRGB or CMYK by component count, as MuPDF and Poppler draw it (`without_refused_enumerated_colour`; the page drew blank, #3306); (22) an object stream's index of `objnum offset` pairs is parsed once per stream and kept beside its decoded data (`ObjectStreamIndexes`, `XRef::with_object_stream_indexes`), and an offset past `usize` is malformed instead of wrapping: `XRef::get_with` parsed the whole index again for every object it fetched from the stream, so reading all of a stream of N objects took N² steps (a Firefox page whose 65,542 objects share one stream took 37 s to draw and 40 s to extract its text); (23) the inline-image end-of-data look-ahead no longer rejects a candidate `EI` because a byte above 0x7F lies between it and the next one: when the candidate is the end, those bytes are the content stream that follows the image, and its strings carry such bytes (WinAnsi `(Grüße) Tj`, the two-byte codes of any Type 0 font), so the operator check that follows decides alone, bounded as in (4) (a scanned page whose OCR text held an accented word and a later capital "EI" — EINGANG, BEI, KLEIN — drew and extracted nothing after the image: 40 of 275 pages across five real documents); (24) `xref::prev_offset`: a trailer `/Prev` written as a reference `N G R` is read as the offset that object holds when the section's own entries place it (iText's `indirectPrev.pdf`; each section's entries are read before its `Prev` is followed), and otherwise as offset N (tiff2pdf; JHOVE's PDF-HUL-157 files, #2366), as `pdfcraft-cos` and other readers take it, so the earlier sections' pages are seen instead of one; (25) direct arrays and dictionaries share a reader-local nesting cap of 128, counted while skipping and while parsing a dictionary (its lenient repair reads, rather than skips, an object found where a key belongs, so `<< << …` recursed through that read); deeper objects return `None` instead of overflowing the stack (including unused trailer values, trailer entries, content operands and marked-content properties); a chain of 65 direct stitching functions now stops at this cap, before the 64-function guard; (26) an xref stream subsection must be backed by its decoded data: all-zero `/W` widths (an entry reads no bytes) and a declared count longer than the data are refused before any entry is inserted, where a tiny stream with a large `/Size` or `/Index` count inserted that many entries (from storytold/craft-libs#239); a subsection running past object number `i32::MAX` is refused instead of overflowing or turning negative; (27) an xref stream's type field is read big-endian at its full `/W[0]` width, where only its first byte was read and wider fields shifted every later entry, and `/W[0]` over 8 bytes is refused like `/W[1]` (from storytold/craft-libs#241); (28) a content instruction keeps up to 256 operands (`MAX_OPERANDS`) instead of 10, and past that drops the oldest instead of ending the content stream: the eleventh operand of a twelve-ink DeviceN `scn` (ISO 32000-2 §8.6.6.5 allows any number of components) blanked the rest of the page (#4260); (29) a `/JBIG2Globals` stream that is itself JBIG2 isn't decoded: globals are raw segments, and a chain of 16,000 such streams overflowed the stack, which aborts the process; (30) a stream's own crypt filter: `/Crypt` in its chain read as the filter it names, `/Identity` without one, instead of the document's filter and then a filter that can't be decoded, which left such a stream unreadable in any spelling (ISO 32000-2 §7.4.10); (31) `set_page_jbig2_work`: the JBIG2 images decoded on a thread spend one allowance, set per page, 2^30 steps (`MAX_PAGE_JBIG2_WORK`), so a page of many images made to reach their own limit stops at one, and what they cost to read counts, whether they decode or not; unbounded until a page sets it; (32) a group of array items read together (a tuple: an image's `/Decode`, `/W`, `/W2`, and a function's `/Domain`, `/Range`, `/Encode` and `/Decode`) can start with a reference: it was taken for the whole group and lost, which ended the array, so an image with `/Decode [7 0 R 0]` didn't draw and CID widths fell back to `/DW`; (33) an object that holds only a reference stands for what that names, stored on its own or in an object stream, up to 32 references: read as a number it was the reference's own object number, so a `/Decode` or `/W` entry naming such an object took a wrong value, and as anything else it was lost. Past 32, as any cycle goes, it reads as a missing object. An object stream isn't reached that way, as its checks are of the object named; reading its index counts against the 64 nested loads and reads its dictionary alone, as a chain of them, each with a member of the next for its `/N`, `/First` or `/Length`, ran out of stack; and a comment inside a reference counts as white space; (34) a table rebuilt by scanning the file (`fallback_xref_map`) finds an object by its number when its header names another generation, including one no generation may be (`3 18446744073709551616 obj` in `poppler-85140-0.pdf`, #5580): the header is read leniently only in that scan (`read_object_header`), the object is also filed under generation 0 unless a real `N 0 obj` holds that key (a later header for the number moves the stand-in), `XRef::get_with` matches by object number only in a repaired table, and `XRef::objects` yields each location once; (35) PNG predictors (`lzw_flate::unfilter_png`) are undone a byte row at a time on slices, as the PNG filters are defined, instead of through the bit-level readers TIFF predictors need: a 993 × 936 RGB image took about 63 ms to unfilter whatever its row filters, and now takes about 3 ms (Up) to 16 ms (Paeth) in a release build (#5430); a row ending part-way through a pixel (three 4-bit samples, an odd width) keeps its last byte, where the bit-level readers dropped it and each later row started a byte early, shearing the picture; (42) TIFF predictor 2 on 8- and 16-bit samples (`lzw_flate::undo_tiff_bytes`) is undone a byte row at a time on slices, each sample added to the same component of the pixel to its left, wrapping at the sample's width (16-bit samples big-endian), instead of through the bit-level readers 1-, 2- and 4-bit samples still need; the bytes are those the bit-level path wrote, and a partial last row is still dropped. A page of 45 full-page 2445 × 4724 8-bit grey images so predicted (PDFBOX-1350) took about 23 s to render at 96 dpi and now takes about 4 s, with the same pixels | (1) `cos/tests/writer.rs::page_tree_loops_in_object_streams_do_not_crash_readers`; (2) `raster::tests::absurd_mask_and_fax_sizes_are_refused_before_decoding`; (3) `raster::tests::absurd_predictor_columns_and_line_widths_render`; (4) `raster::tests::inline_images_with_ei_in_their_data_parse_in_linear_time`, `content::tests::inline_image_ei_may_be_followed_by_a_delimiter`; (5) `raster::tests::indirect_appearance_state_dict_is_not_another_stream`; (6) `raster::tests::jpx_stream_bit_depth_boundaries_do_not_panic`, `raster::tests::jpx_32_bit_image_does_not_prevent_other_page_content_from_rendering`, `raster::tests::jpx_gray_rgb_and_alpha_keep_eight_bit_samples`, `raster::tests::jpx_streams_reject_out_of_range_bit_depths`; (7) `raster::tests::decompression_bombs_stop_at_the_stream_limit`, `raster::tests::page_contents_and_nested_forms_share_one_budget`; (8) `inspect::tests::jspdf_high_precision_mediabox_does_not_overflow_dimensions`, `object::number::tests::high_precision_float_does_not_overflow_mantissa` (hayro-syntax); (9) `object::dict::lazy_index_tests`, `raster::tests::large_resource_indexes_preserve_pixels_and_text`; (10) `data::tests`, `xref::tests::concurrent_cold_object_stream_reads_all_succeed`, `raster::tests::shared_parser_is_lazy_and_initializes_once_under_contention`; (11) `data::tests::panicking_initializer_wakes_waiters_and_prevents_retries`, `xref::tests::concurrent_cold_object_stream_reads_all_succeed`; (12) `util::tests::segment_list_rejects_out_of_range_indices`; (13) `raster::tests::user_unit_scales_the_page_and_everything_on_it`; (14) `crypto::pdfcraft_password_tests`, `pdfcraft-engine` `tests::legacy_passwords_with_turkish_characters_open`; (15) `content::tests::windows_keep_every_instruction_at_every_split`, `content::tests::windows_keep_every_instruction_in_pieces_of_any_size`, `content::tests::windows_match_the_slice_on_random_content`, `content::tests::windows_drop_blanks_and_keep_a_comment_that_may_go_on`, `page::content_reader_tests::content_past_the_cap_is_read_to_the_end`, `page::content_reader_tests::a_stream_that_expands_past_the_budget_is_cut_there`, `page::content_reader_tests::a_small_expansion_under_the_cap_is_not_cut`, `raster::tests::page_content_past_the_old_cap_renders`, `raster::tests::content_reader_returns_the_same_bytes_in_any_pieces`, `raster::tests::content_bombs_stop_at_the_budget_and_say_so`, `page::content_reader_tests::plain_and_whole_pieces_keep_their_bytes`, `page::content_reader_tests::repeated_content_reference_counts_compressed_bytes_once`, `page::content_reader_tests::whole_cap_still_counts_later_unique_streams`; (16) `crypto::pdfcraft_password_tests::aes256_passwords_are_tried_in_their_saslprep_form_then_as_given`, `crypto::pdfcraft_password_tests::aes256_passwords_open_as_typed_and_in_their_saslprep_form`, `pdfcraft-engine` `tests::aes256_open_passwords_that_saslprep_changes_protect_and_reopen_as_typed`, `tests::aes256_files_whose_passwords_saslprep_changes_open_as_typed`; (17) `raster::tests::group4_fax_with_rows_zero_decodes_to_the_image_height`; (18) `filter::lzw_flate::tests::back_reference_longer_than_its_distance_repeats_its_source`, `back_reference_cut_by_the_limit_resumes_in_the_next_call` and `back_reference_reads_bytes_written_by_an_earlier_call`; each fails when the copy returns the wrong remaining count; (19) `raster::tests::group4_fax_with_rows_below_its_height_decodes_to_the_image_height`; (20) `raster::tests::space_before_the_line_break_after_stream_still_renders`; (21) `raster::tests::a_jp2_image_in_a_refused_enumerated_colour_space_is_drawn`, `filter::jpx::tests::refused_enumerated_colour_spaces_become_the_component_counts_default`, `filter::jpx::tests::what_the_decoder_handles_or_cant_be_read_is_left_alone` (`cargo test --manifest-path vendor/hayro-syntax/Cargo.toml`); (22) `raster::tests::objects_in_one_large_object_stream_are_read_in_linear_time`; (23) `text::tests::text_after_an_inline_image_survives_a_non_ascii_byte_before_a_later_ei`, `content::tests::an_inline_image_ends_at_its_own_ei_when_later_text_holds_a_non_ascii_byte` (hayro-syntax); (24) `xref::tests::prev_as_reference_reaches_the_earlier_section`, `xref::tests::prev_as_reference_to_an_object_holding_the_offset_reads_that_offset`, `pdfcraft-cos` `document::tests::a_prev_written_as_a_reference_still_reaches_the_earlier_sections`, `document::tests::a_prev_reference_to_an_object_holding_the_offset_reads_that_offset`; (25) `raster::tests::deeply_nested_direct_objects_do_not_overflow_the_stack`, `raster::tests::dictionaries_nested_without_keys_in_content_do_not_overflow_the_stack`, `raster::tests::dictionaries_nested_without_keys_in_the_trailer_do_not_overflow_the_stack`, `raster::tests::dictionaries_nested_without_keys_as_marked_content_properties_do_not_overflow_the_stack`, `raster::tests::dictionaries_nested_without_keys_are_read_up_to_the_nesting_cap`, `raster::tests::nesting_at_the_cap_fits_a_512_kib_stack` (native only), `raster::tests::function_limits_depth_guard_refuses_a_chain_of_references`; (26) `xref::tests::xref_stream_entries_need_backing_bytes`; (27) `xref::tests::xref_stream_type_field_uses_the_full_w0_width`, `xref::tests::xref_stream_loader_rejects_oversized_w0` (`cargo test --manifest-path vendor/hayro-syntax/Cargo.toml`); (28) `content::ops::tests::scn_with_twelve_components_keeps_them_all`, `content::ops::tests::operands_past_the_cap_keep_the_latest_in_order` (hayro-syntax), `raster::tests::devicen_inks::a_twelve_ink_devicen_colour_paints`, `raster::tests::devicen_inks::a_four_ink_devicen_colour_paints`, `raster::tests::devicen_inks::an_operator_with_hundreds_of_operands_does_not_end_the_content`; (29) `raster::tests::jbig2_globals_that_are_jbig2_themselves_are_not_decoded`, `raster::tests::jbig2_globals_are_read`, `raster::tests::the_jbig2_images_of_a_page_share_one_allowance`, `raster::tests::an_image_that_fails_spends_the_pages_allowance_too`; (30) `raster::tests::a_stream_with_a_crypt_filter_of_its_own_is_drawn`; (32) `raster::tests::a_decode_array_with_references_in_it_is_read`, `raster::tests::a_functions_arrays_can_start_with_a_reference` (pdfcraft-render), `font::cid::width_tests::a_width_group_can_start_with_a_reference` (hayro-interpret); (33) `raster::tests::a_reference_to_a_reference_is_what_it_names`, `raster::tests::a_decode_array_with_references_in_it_is_read` (pdfcraft-render), `font::cid::width_tests::a_width_group_can_start_with_a_reference` (hayro-interpret), `xref::tests::an_object_stream_isnt_reached_through_a_reference`, `xref::tests::a_chain_of_object_streams_ends_at_the_limit_of_nested_loads`, `xref::tests::a_chain_of_object_streams_takes_no_more_work_for_two_ways_on`, `xref::tests::a_reference_held_in_either_storage_is_followed`, `xref::tests::a_page_a_reference_is_held_to_is_found_once` (hayro-syntax); (34) `raster::tests::a_rebuilt_table_finds_an_object_whose_header_names_another_generation`, `raster::tests::a_rebuilt_tables_generation_0_stand_in_is_not_a_second_object` (pdfcraft-render), `xref::tests::an_object_stream_and_its_first_member_are_both_enumerated` (hayro-syntax); (35) `filter::lzw_flate::pdfcraft_png_predictor_tests` (hayro-syntax: every filter type at 1–16 bits and 1–4 colours against the PNG specification's byte-by-byte definition, a row ending part-way through a pixel, Average and Paeth over 6-byte pixels), `raster::tests::png_predicted_images::a_four_bit_rgb_image_of_odd_width_with_up_predicted_rows_is_not_sheared`; (42) `filter::lzw_flate::pdfcraft_tiff_predictor_tests` (hayro-syntax: random 8- and 16-bit rows at 1–7 colours and 1–50 columns against the bit-level path, partial and empty rows, and a row counter showing whole-byte samples skip the bit-level path), `raster::tests::tiff_predicted_images` | | winit | 0.30.13 | Apache-2.0 | (1) on Windows 11 (build 22000 and later), `WM_DPICHANGED` applies the rect Windows suggests. It used to nudge the window onto the monitor `MonitorFromWindow` names, which during a drag is still the monitor being left; the nudge sent another `WM_DPICHANGED`, so a window dragged to a monitor with another scale factor grew and stayed on the wrong monitor at the wrong scale (#324). Backport of winit master 488c036a ([winit#4341](https://github.com/rust-windowing/winit/pull/4341)), which no 0.30 release has; Windows 10 keeps the old adjustment, as on winit master. (2) on Wayland, a seat's text input is destroyed when the seat goes away, not whenever it loses a capability: losing any capability (a touch screen or tablet unplugged, a remote-desktop session ending) destroyed it, and only an added capability recreated it, so IME stopped working until restart. Port of [winit#4747](https://github.com/rust-windowing/winit/pull/4747); (3) on Windows, an IME composition reports its cursor from `GCS_CURSORPOS` only when `WM_IME_COMPOSITION` says it is set, and at the end of the composition otherwise: the Microsoft Korean IME never sets it, so the cursor read as 0 (egui then drew no caret while composing; it now draws it after the syllable). Port of [winit#4746](https://github.com/rust-windowing/winit/pull/4746); (4) on Windows, a hidden window is not maximized until it is shown, and a hidden window shown while maximized gets one `ShowWindow(SW_MAXIMIZE)`: `SW_MAXIMIZE` shows a hidden window, so PdfCraft, which eframe keeps hidden until its first frame is drawn, appeared unpainted (white) when it restored a maximized window, and could lose its focus. Port of [winit#4587](https://github.com/rust-windowing/winit/pull/4587) (the later, focus-safe form of [winit#4575](https://github.com/rust-windowing/winit/pull/4575)); (5) on X11, when allowing or disallowing IME replaces the focused window's input context (egui does it each time a text field gains or loses focus), the new context is focused: no focus event follows, so it stayed unfocused until the window lost and regained focus. Port of [winit#4727](https://github.com/rust-windowing/winit/pull/4727); (6) on macOS, `insertText` commits through IME when a composition was started (`pending_commit`, set by `setMarkedText`) rather than when marked text is left: input methods that end a composition with `unmarkText` before `insertText` lost the text, and Apple Korean dropped the key typed after a syllable (`한` then `5` or `;`); other `insertText` calls stay `KeyboardInput`; a commit outside a key press (a candidate clicked with the mouse, dictation) clears the marked text and leaves no key to swallow, as in [winit#4748](https://github.com/rust-windowing/winit/pull/4748) (without it the next key, such as Backspace, was lost). `validAttributesForMarkedText` lists the underline and marked-clause attributes, without which some input methods don't compose. Port of [winit#4650](https://github.com/rust-windowing/winit/pull/4650), the 0.30 backport of [winit#4651](https://github.com/rust-windowing/winit/pull/4651) (merged on winit master), with the underline attribute spelled as AppKit's `NSUnderlineStyleAttributeName` (`NSUnderline`); (7) on macOS, disallowing IME with a composition in progress (focus leaving a text field) tells the input method to discard its marked text (`discardMarkedText`): it kept composing it once IME was allowed again, so the abandoned syllable came back (`하`, then `가` typed in the next field, became `학`, then `하` + `가`; Japanese `か` then `さ` became `傘`). Port of [winit#4745](https://github.com/rust-windowing/winit/pull/4745); (8) on macOS, text inserted outside a key press without a composition (the emoji picker) is reported as `Ime::Commit`; it was dropped. Port of the emoji-picker part of [winit#4749](https://github.com/rust-windowing/winit/pull/4749) (0.30 backport of [winit#4748](https://github.com/rust-windowing/winit/pull/4748)); its `selectedRange` change for dictation is left out because it switches on press-and-hold for every held key (an accent menu instead of key repeat, and the chosen accent added after the repeated letters); (9) on macOS, a key whose text was committed through IME isn't also sent as `KeyboardInput` unless AppKit forwards it as a command (Enter): Apple Korean answers Space with no syllable in progress with `setMarkedText(" ")`, `insertText(" ")`, `setMarkedText("")`, and the last call reset winit's state, so the space arrived twice. Same goal as [winit#4478](https://github.com/rust-windowing/winit/pull/4478), whose changes don't reach this call order (macOS 14); to be proposed upstream there; (10) on macOS, when the first key in a fresh app reaches Apple Korean's `insertText` as a raw compatibility jamo instead of starting a composition, that key is interpreted once more, synchronously, before it is sent as `KeyboardInput` (g k s typed ㅎㅏㄴ instead of 한); only an unmodified, non-repeated first key whose one callback inserts that same jamo, with IME enabled and nothing composed, focus and input source unchanged, is retried (`ime_startup.rs`). Port of [winit#4744](https://github.com/rust-windowing/winit/pull/4744), with its policy tests.; (11) on X11 without `Xft.dpi`, the scale factor comes from the X screen's configured DPI (its size in pixels and millimetres) before the output's physical density from RandR: Xorg can deliberately run a dense panel at 96 DPI, and only winit applications then looked zoomed; `Xft.dpi` still wins, `WINIT_X11_SCALE_FACTOR` still overrides, and a screen reporting no size keeps the RandR fallback. PdfCraft patch (#1150, replacing the launcher pin proposed in #908). (12) on macOS, `WinitView` answers `mouseDownCanMoveWindow` with the window's `movable_by_window_background` attribute (NO unless asked): NSView answers YES for a view that isn't opaque, so with the transparent title bar and full-size content view PdfCraft uses, AppKit moved the window for any drag that began in the 28 pt title-bar band before the app saw it, and document tabs (which live in that band) could not be dragged to reorder them. The empty strip still moves the window through `ViewportCommand::StartDrag`. PdfCraft patch. The published crate's asset files (`docs/res/*.svg`, `examples/data/*.png`) are left out, so its `window` example doesn't build; (13) on Wayland, pixel-only wheel, wheel-tilt and continuous-source scroll events report `Moved` rather than starting an unterminated finger gesture, so egui follows modifier release instead of keeping Ctrl zoom latched (#991). Finger gestures and unknown sources retain their existing phase policy | (1) `pdfcraft` `tests::winit_carries_the_windows_11_monitor_scale_fix` keeps the patch in the build; the drag itself needs two monitors with different scale factors on Windows 11; (2) `pdfcraft` `tests/winit_wayland_text_input.rs`; the capability change itself needs a Wayland session and an input method; (3) `pdfcraft` `tests/winit_windows_ime_cursor.rs`; composing itself needs the Microsoft Korean IME; (4) `pdfcraft` `tests/winit_windows_hidden_maximized.rs`; the start-up itself needs Windows and a window that was closed maximized; (5) `pdfcraft` `tests/winit_x11_ime_focus.rs`; switching fields itself needs an X11 session and an input method (IBus, fcitx5); (6) `pdfcraft` `tests/winit_macos_ime_commit.rs`; composing itself needs macOS and an input method; (7) `pdfcraft` `tests/winit_macos_ime_discard.rs`; composing itself needs macOS and an input method; (8) `pdfcraft` `tests/winit_macos_emoji_picker.rs`; the picker itself needs macOS; (9) `pdfcraft` `tests/winit_macos_korean_space.rs`; composing itself needs macOS and Apple Korean; (10) `pdfcraft` `tests/winit_macos_korean_first_key.rs` and `ime_startup.rs`'s own tests (run them with `rustc --test`); typing itself needs macOS and Apple Korean; (11) `pdfcraft` `tests/winit_x11_dpi.rs` keeps the patch in the build, and `util::randr` tests the policy (run them in the vendored crate); the scale itself needs an X11 session; (12) `pdfcraft` `tests/winit_macos_title_bar_drag.rs` keeps the patch in the build; the drag itself needs macOS and a pointer; (13) `pdfcraft` `tests/winit_wayland_scroll.rs` compiles the production phase policy and exercises modifier release through egui; live COSMIC confirmation remains open | | egui-wgpu | 0.36.2 | MIT OR Apache-2.0 | (1) `winit::surface_fit`: a window's surface is configured within the device's `max_texture_dimension_2d`, both sides shrunk by one factor that egui then draws at (`pixels_per_point × render_scale`), so the whole window is drawn into the surface (on DX12 it is stretched over the window and lines up with the pointer; wgpu's GL backend copies it unscaled into a corner); the longer side becomes exactly the limit; the depth and MSAA textures follow, and a recreated surface starts again from the window's own size. egui-wgpu configured the window's size as is, and `Surface::configure` panics beyond the limit: a window restored at another monitor's scale (opening an Outlook attachment on a mixed-DPI laptop asked an 8192-pixel device for 8600 × 3423, #577) or stretched across monitors closed PdfCraft, before its window appeared when it happened at start-up ([emilk/egui#8361](https://github.com/emilk/egui/issues/8361)). The published crate leaves out egui's licence files; `LICENSE-APACHE` and `LICENSE-MIT` are from the 0.36.2 tag; (2) `winit::catch_errors`: a new window's surface is configured inside wgpu error scopes (validation, out of memory, internal), and a failure is returned as `WgpuError::ConfigureSurface` (for the root window at start-up eframe returns it as `Error::Wgpu`, which PdfCraft retries with OpenGL); nothing is kept for the window. wgpu's default handler panicked instead, and eframe configures the root surface before the app is created, so a driver that can't configure one closed PdfCraft at launch (a 2015 Intel GPU through wgpu's GL backend timed out waiting for the GPU to go idle, `GpuWaitTimeout`, #519). Unchanged, so still a panic: an error in a later reconfigure (resize, a lost surface), a device lost during the first configure (wgpu reports it only to a callback), and a window shown at zero size (first configured on resize) | (1) `pdfcraft` `tests::a_window_larger_than_the_gpu_limit_gets_a_surface_within_it`, `tests::surface_fit_never_empties_or_overflows_a_surface`, and `tests::vendor_provenance_egui_wgpu_surface_fit_identity`, which keeps the patch in the build and in the paths that size a surface; (2) `pdfcraft` `tests::a_gpu_error_while_a_window_is_set_up_is_returned_not_a_panic` (a real device: an error inside `catch_errors` is returned, not a panic) and `tests::vendor_provenance_egui_wgpu_catch_errors_identity`, which keeps it around `add_surface`'s configure | | vello_common | 0.0.8 | Apache-2.0 OR MIT | (1) `clip::intersect` (the clip step every clipped path passes through) starts its row walk at the first row both paths have strips in, not the earlier of the two first rows. The walk visited every row from the top of the clip for each path (on the A1 sample's full-page canvas, about half its strip rows per stroke), and rows where one path has no strips produce no output, so the strips are unchanged (vello_common 0.1 starts at the later row too). Its cost grew with the page, and a full-page render of the A1 sample was about 30% faster; the output is byte-identical | (1) `clip::tests::intersect_walks_only_rows_both_paths_have_strips` counts the rows the walk visits (`cargo test --manifest-path vendor/vello_common/Cargo.toml`) and fails without the patch; the render crate's pixel tests run through the patched crate | diff --git a/vendor/hayro-syntax/src/filter/lzw_flate.rs b/vendor/hayro-syntax/src/filter/lzw_flate.rs index 5a0027597b..2ebfa4b7f9 100644 --- a/vendor/hayro-syntax/src/filter/lzw_flate.rs +++ b/vendor/hayro-syntax/src/filter/lzw_flate.rs @@ -1017,6 +1017,17 @@ fn apply_predictor(data: Vec, params: &PredictorParams) -> Option> { return Some(unfilter_png(&data, row_len, bpp)); } + if i == 2 && matches!(params.bits_per_component, 8 | 16) && !bit_level_only() { + // PdfCraft patch: whole-byte samples are undone a row at a time on byte slices + // instead of through the bit-level readers, which packed samples need. + let sample_len = usize::from(params.bits_per_component / 8); + let pixel_len = usize::from(params.colors).saturating_mul(sample_len); + return Some(undo_tiff_bytes(data, row_len, pixel_len, sample_len)); + } + + #[cfg(all(test, feature = "std"))] + BIT_LEVEL_TIFF_ROWS.with(|rows| rows.set(rows.get().saturating_add(num_rows))); + let (bit_size, chunk_len) = (params.bits_per_component, params.colors as usize); let zero_row = vec![0; row_len]; let mut prev_row = BitChunks::new(&zero_row, bit_size, chunk_len)?; @@ -1054,6 +1065,69 @@ fn apply_predictor(data: Vec, params: &PredictorParams) -> Option> { } } +/// PdfCraft patch: undoes TIFF predictor 2 (ISO 32000-2 §7.4.4.4, Table 10; TIFF 6.0 §14) +/// for 8- and 16-bit samples, in rows of `row_len` bytes holding pixels of `pixel_len` bytes, +/// each sample `sample_len` bytes (1 or 2, big-endian): every sample is added to the same +/// component of the pixel to its left, wrapping at the sample's width. Bytes after the last +/// complete row are dropped, as the bit-level path drops them. +fn undo_tiff_bytes( + mut data: Vec, + row_len: usize, + pixel_len: usize, + sample_len: usize, +) -> Vec { + let num_rows = data.len() / row_len.max(1); + data.truncate(num_rows.saturating_mul(row_len)); + if pixel_len == 0 { + return data; + } + + for row in data.chunks_exact_mut(row_len) { + let mut pixels = row.chunks_exact_mut(pixel_len); + let Some(mut left) = pixels.next() else { + continue; + }; + for px in pixels { + if sample_len == 2 { + for (c, l) in px.chunks_exact_mut(2).zip(left.chunks_exact(2)) { + if let ([c0, c1], [l0, l1]) = (c, l) { + let sum = u16::from_be_bytes([*c0, *c1]) + .wrapping_add(u16::from_be_bytes([*l0, *l1])); + [*c0, *c1] = sum.to_be_bytes(); + } + } + } else { + for (c, l) in px.iter_mut().zip(left.iter()) { + *c = c.wrapping_add(*l); + } + } + left = px; + } + } + + data +} + +#[cfg(all(test, feature = "std"))] +std::thread_local! { + /// PdfCraft patch tests: rows the bit-level TIFF predictor path has undone on this thread. + static BIT_LEVEL_TIFF_ROWS: core::cell::Cell = const { core::cell::Cell::new(0) }; + /// PdfCraft patch tests: send whole-byte TIFF samples through the bit-level path too. + static BIT_LEVEL_ONLY: core::cell::Cell = const { core::cell::Cell::new(false) }; +} + +/// PdfCraft patch: whether whole-byte TIFF samples must take the bit-level path (only when a +/// test compares the two paths). +#[cfg(all(test, feature = "std"))] +fn bit_level_only() -> bool { + BIT_LEVEL_ONLY.with(core::cell::Cell::get) +} + +#[cfg(not(all(test, feature = "std")))] +fn bit_level_only() -> bool { + false +} + /// PdfCraft patch: undoes PNG row filters (ISO 32000-2 §7.4.4.4, PNG specification §9) in /// rows of `row_len` bytes, each after its filter-type byte, with `bpp` bytes per complete pixel /// (at least 1). An unknown filter type copies the row as it is. Bytes after the last complete @@ -1491,3 +1565,116 @@ mod pdfcraft_png_predictor_tests { ); } } + +/// PdfCraft patch tests: TIFF predictor 2 on 8- and 16-bit samples is undone on byte rows +/// (`undo_tiff_bytes`), with the same output as the bit-level path. +#[cfg(all(test, feature = "std"))] +mod pdfcraft_tiff_predictor_tests { + use super::{BIT_LEVEL_ONLY, BIT_LEVEL_TIFF_ROWS}; + use crate::filter::lzw_flate::{PredictorParams, apply_predictor}; + + fn tiff(colors: u8, bits_per_component: u8, columns: usize) -> PredictorParams { + PredictorParams { + predictor: 2, + colors, + bits_per_component, + columns, + early_change: false, + } + } + + /// Both paths' output, the byte path's first; `None` from either is kept. + fn both(data: &[u8], params: &PredictorParams) -> (Option>, Option>) { + let fast = apply_predictor(data.to_vec(), params); + BIT_LEVEL_ONLY.with(|only| only.set(true)); + let bits = apply_predictor(data.to_vec(), params); + BIT_LEVEL_ONLY.with(|only| only.set(false)); + (fast, bits) + } + + #[test] + fn whole_byte_samples_match_the_bit_level_path_on_random_rows() { + let mut seed: u32 = 0x9e37_79b9; + let mut next = move || { + seed ^= seed << 13; + seed ^= seed >> 17; + seed ^= seed << 5; + seed + }; + for case in 0..600 { + let colors = [1u8, 2, 3, 4, 5, 7][(next() % 6) as usize]; + let bits_per_component = [8u8, 16][(next() % 2) as usize]; + let columns = 1 + (next() % 50) as usize; + let row_len = columns * usize::from(colors) * usize::from(bits_per_component) / 8; + let rows = (next() % 7) as usize; + // A partial row at the end, or none. + let tail = (next() % 4) as usize % row_len; + let data: Vec = (0..rows * row_len + tail).map(|_| next() as u8).collect(); + let params = tiff(colors, bits_per_component, columns); + let (fast, bits) = both(&data, ¶ms); + assert_eq!( + fast, bits, + "case {case}: {colors} colours at {bits_per_component} bits, {columns} columns, {rows} rows and {tail} bytes" + ); + } + } + + #[test] + fn whole_byte_samples_add_to_the_same_component_of_the_pixel_to_their_left() { + // Two 8-bit colours, three columns, two rows; sums wrap at 256. + let data = [10, 20, 1, 2, 3, 4, /* row 2 */ 5, 5, 250, 10, 1, 1]; + assert_eq!( + apply_predictor(data.to_vec(), &tiff(2, 8, 3)).unwrap(), + [10, 20, 11, 22, 14, 26, 5, 5, 255, 15, 0, 16] + ); + // One 16-bit colour: a carry crosses the byte boundary and the sum wraps at 65,536. + let data = [ + 0x01, 0x00, 0xff, 0xff, 0x00, 0x02, /* row 2 */ 0x00, 0xff, 0x00, 0x01, 0, 0, + ]; + assert_eq!( + apply_predictor(data.to_vec(), &tiff(1, 16, 3)).unwrap(), + [ + 0x01, 0x00, 0x00, 0xff, 0x01, 0x01, 0x00, 0xff, 0x01, 0x00, 0x01, 0x00 + ] + ); + } + + #[test] + fn whole_byte_samples_do_not_take_the_bit_level_path() { + let start = BIT_LEVEL_TIFF_ROWS.with(core::cell::Cell::get); + for bits_per_component in [8, 16] { + let data = vec![7; 3 * 4 * usize::from(bits_per_component) / 8 * 5]; + apply_predictor(data, &tiff(3, bits_per_component, 4)).unwrap(); + } + assert_eq!(BIT_LEVEL_TIFF_ROWS.with(core::cell::Cell::get), start); + + // Packed samples still do. + apply_predictor(vec![0x5f; 4], &tiff(1, 4, 2)).unwrap(); + assert_eq!(BIT_LEVEL_TIFF_ROWS.with(core::cell::Cell::get), start + 4); + } + + #[test] + fn rows_without_a_complete_row_of_data_are_dropped_on_both_paths() { + for (data, params, want) in [ + // Shorter than one row: nothing. + (vec![1, 2, 3], tiff(1, 8, 4), Some(vec![])), + (vec![1, 2, 3], tiff(1, 16, 2), Some(vec![])), + // A zero-width row is refused. + (vec![1, 2, 3], tiff(1, 8, 0), None), + (vec![], tiff(1, 16, 0), None), + // No data at all. + (vec![], tiff(3, 8, 2), Some(vec![])), + // The trailing partial row is dropped. + ( + vec![1, 1, 1, 1, 2, 2], + tiff(1, 8, 4), + Some(vec![1, 2, 3, 4]), + ), + (vec![0, 1, 0, 1, 9], tiff(1, 16, 2), Some(vec![0, 1, 0, 2])), + ] { + let (fast, bits) = both(&data, ¶ms); + assert_eq!(fast, want, "{data:?}"); + assert_eq!(bits, want, "{data:?}"); + } + } +} From 42b21e5362c99be04b3fd48c23ba58290a815987 Mon Sep 17 00:00:00 2001 From: mtlcl410 <2368091+mtlcl410@users.noreply.github.com> Date: Mon, 9 Nov 2026 08:30:14 +0800 Subject: [PATCH 22/92] fix(render): a form XObject that draws itself is drawn once (hayro-interpret patch 89) (#6515) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * M2: a form XObject that draws itself is drawn once (hayro-interpret patch 89) A form whose content names itself, directly or through another form, a pattern or a glyph, drew itself again at every level until the page's nested-paint budget (50,000) ran out. PDFBOX-5782's poppler-748 page 2, whose form names a /Pages dictionary as /X0 and so finds itself in the page's resources, took 38 s. hayro-interpret now keeps the forms being drawn on the context and skips a form already on it, reporting ContentTruncated (ISO 32000-2 §8.10.1: a form is a self-contained description). The lenient fallback from a form's own resources to the page's stays: MuPDF, Poppler and PDFium all draw a name from the page's resources when the form's /Resources has no XObject subdictionary (MuPDF and Poppler also when it has an empty one). Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Pq7HyDBXygTf7jcqdsjcfn * fix(render): a form that reaches itself through a pattern or a glyph is drawn once (hayro-interpret patch 89) The forms being drawn are now kept per page and thread rather than per context, so the tiling patterns, Type 3 glyphs, soft masks and groups painted inside a form, which hayro interprets in contexts of their own, see that the form is being drawn and skip it inside themselves. A form drawn again after it has finished draws as before. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Pq7HyCHD1R3rywQBPCHXZA * chore: re-run CI after the Fill & Sign save comparison failed on the clock (#6066) `fill_sign_enter_adds_lines_outside_click_commits_and_escape_cancels` compares two saves whose /ModDate fell in different seconds; it passes locally on this commit. No change. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Pq7HyCHD1R3rywQBPCHXZA --------- Co-authored-by: mtlcl410 Co-authored-by: Claude Opus 5.5 --- crates/render/src/raster/tests.rs | 86 ++++++++++++++++++++++++++ parity/acrobat-features.toml | 2 +- vendor/README.md | 2 +- vendor/hayro-interpret/src/context.rs | 58 +++++++++++++++++ vendor/hayro-interpret/src/x_object.rs | 18 ++++++ 5 files changed, 164 insertions(+), 2 deletions(-) diff --git a/crates/render/src/raster/tests.rs b/crates/render/src/raster/tests.rs index f6569e51fa..92ec72afee 100644 --- a/crates/render/src/raster/tests.rs +++ b/crates/render/src/raster/tests.rs @@ -5796,6 +5796,92 @@ fn page_contents_and_nested_forms_share_one_budget() { assert_eq!(&page.rgba[((38 * 40 + 1) * 4)..][..4], &[255, 0, 0, 255], "the rest of the page draws"); } +/// A form XObject that draws itself (PDFBOX-5782, a fuzzed file whose form names a `/Pages` +/// dictionary as `/X0`, found the form again in the page's resources and spent 38 s drawing +/// itself until the page's nested-paint budget ran out) is drawn once: the drawing inside +/// itself is skipped and reported, as other readers draw it. Vendored patch: hayro-interpret +/// `Context::enter_form` (89). +#[test] +fn a_form_that_draws_itself_is_drawn_once() { + // Each form paints a 10 x 10 square and draws `/X0` 20 units to its right. + let draw = "0 0 1 rg 0 0 10 10 re f q 1 0 0 1 20 0 cm /X0 Do Q"; + // Two forms that draw each other: blue, then red, then blue again would follow. + let other = "1 0 0 rg 0 0 10 10 re f q 1 0 0 1 20 0 cm /X0 Do Q"; + let other = format!( + "6 0 obj << /Type /XObject /Subtype /Form /BBox [0 0 200 40] /Resources << /XObject << /X0 5 0 R >> >> /Length {} >> stream\n{other}\nendstream endobj\n", + other.len() + ); + let cases = [ + ("its own resources name it", "/Resources << /XObject << /X0 5 0 R >> >>", ""), + ("it names a /Pages dictionary and the page's resources name it", "/Resources << /XObject << /X0 2 0 R >> >>", ""), + ("it has no resources and the page's name it", "", ""), + ("two forms draw each other", "/Resources << /XObject << /X0 6 0 R >> >>", other.as_str()), + ]; + for (what, resources, extra) in cases { + let mut pdf = b"%PDF-1.7\n1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj\n2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 >> endobj\n3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 200 40] /Contents 4 0 R /Resources << /XObject << /X0 5 0 R >> >> >> endobj\n4 0 obj << /Length 7 >> stream\n/X0 Do\nendstream endobj\n".to_vec(); + pdf.extend_from_slice( + format!( + "5 0 obj << /Type /XObject /Subtype /Form /BBox [0 0 200 40] {resources} /Length {} >> stream\n{draw}\nendstream endobj\n", + draw.len() + ) + .as_bytes(), + ); + pdf.extend_from_slice(extra.as_bytes()); + pdf.extend_from_slice(b"trailer << /Root 1 0 R >>\n%%EOF\n"); + let mut r = PageRenderer::new(Arc::new(pdf), RenderConfig::default()); + let page = r.render(RenderRequest { page: 0, kind: RequestKind::Pixels, tile: None, scale: 1.0, tag: 0, ..Default::default() }); + assert!(page.error.is_none(), "{what}: {:?}", page.error); + assert_eq!(page.width, 200, "{what}"); + assert_eq!(rgb_at(&page.rgba, 200, 5, 35), [0, 0, 255], "{what}: the form draws"); + let second = if extra.is_empty() { [255, 255, 255] } else { [255, 0, 0] }; + assert_eq!(rgb_at(&page.rgba, 200, 25, 35), second, "{what}: the form one level in"); + assert_eq!(rgb_at(&page.rgba, 200, 45, 35), [255, 255, 255], "{what}: the form is not drawn inside itself"); + assert!(page.warnings.contains(&RenderWarning::ContentTruncated), "{what}: the skipped drawing is reported"); + } +} + +/// A form that reaches itself through a tiling pattern it fills with, or a Type 3 glyph it +/// shows, is not drawn inside itself either: the pattern cell or the glyph procedure is +/// interpreted in a context of its own, which still knows the form is being drawn. The form +/// paints a blue square, then fills a square 20 units to its right with the pattern, or shows +/// the glyph there, whose content draws the form. Vendored patch: hayro-interpret +/// `Context::enter_form` (89). +#[test] +fn a_form_that_reaches_itself_through_a_pattern_or_a_glyph_is_drawn_once() { + let cell = "q 1 0 0 1 20 0 cm /X0 Do Q"; + let pattern = format!( + "7 0 obj << /Type /Pattern /PatternType 1 /PaintType 1 /TilingType 1 /BBox [0 0 200 40] /XStep 200 /YStep 40 /Resources << /XObject << /X0 5 0 R >> >> /Length {} >> stream\n{cell}\nendstream endobj\n", + cell.len() + ); + let glyph = "0 0 d0 /X0 Do"; + let glyph = format!( + "7 0 obj << /Type /Font /Subtype /Type3 /FontBBox [0 0 200 40] /FontMatrix [1 0 0 1 0 0] /CharProcs << /a 8 0 R >> /Encoding << /Type /Encoding /Differences [97 /a] >> /FirstChar 97 /LastChar 97 /Widths [10] /Resources << /XObject << /X0 5 0 R >> >> >> endobj\n8 0 obj << /Length {} >> stream\n{glyph}\nendstream endobj\n", + glyph.len() + ); + let cases = [ + ("a tiling pattern", "/Resources << /Pattern << /P0 7 0 R >> >>", "0 0 1 rg 0 0 10 10 re f /Pattern cs /P0 scn 20 0 10 10 re f", pattern), + ("a Type 3 glyph", "/Resources << /Font << /T3 7 0 R >> >>", "0 0 1 rg 0 0 10 10 re f BT /T3 1 Tf 20 0 Td (a) Tj ET", glyph), + ]; + for (what, resources, draw, extra) in cases { + let mut pdf = b"%PDF-1.7\n1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj\n2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 >> endobj\n3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 200 40] /Contents 4 0 R /Resources << /XObject << /X0 5 0 R >> >> >> endobj\n4 0 obj << /Length 7 >> stream\n/X0 Do\nendstream endobj\n".to_vec(); + pdf.extend_from_slice( + format!( + "5 0 obj << /Type /XObject /Subtype /Form /BBox [0 0 200 40] {resources} /Length {} >> stream\n{draw}\nendstream endobj\n", + draw.len() + ) + .as_bytes(), + ); + pdf.extend_from_slice(extra.as_bytes()); + pdf.extend_from_slice(b"trailer << /Root 1 0 R >>\n%%EOF\n"); + let mut r = PageRenderer::new(Arc::new(pdf), RenderConfig::default()); + let page = r.render(RenderRequest { page: 0, kind: RequestKind::Pixels, tile: None, scale: 1.0, tag: 0, ..Default::default() }); + assert!(page.error.is_none(), "{what}: {:?}", page.error); + assert_eq!(rgb_at(&page.rgba, 200, 5, 35), [0, 0, 255], "{what}: the form draws"); + assert_eq!(rgb_at(&page.rgba, 200, 25, 35), [255, 255, 255], "{what}: the form is not drawn inside itself"); + assert!(page.warnings.contains(&RenderWarning::ContentTruncated), "{what}: the skipped drawing is reported"); + } +} + /// The page content budget counts what is held at once, not everything decoded: a form /// with 2 MiB of content painted three hundred times (600 MiB in all) draws every time. #[test] diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index abc7e8ceeb..d8918e5bd6 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -58,7 +58,7 @@ milestone = "M1" status = "shipped" commands = [] tools = [] -evidence = ["crates/ui-egui/tests/it/ui.rs::garbage_input_is_rejected_without_panicking", "crates/cos/src/parser.rs::garbage_never_panics", "crates/render/src/raster/tests.rs::missing_page_and_garbage_input_fail_gracefully"] +evidence = ["crates/ui-egui/tests/it/ui.rs::garbage_input_is_rejected_without_panicking", "crates/cos/src/parser.rs::garbage_never_panics", "crates/render/src/raster/tests.rs::missing_page_and_garbage_input_fail_gracefully", "crates/render/src/raster/tests.rs::a_form_that_draws_itself_is_drawn_once"] notes = "fuzzed nightly (M1.10)" [[feature]] diff --git a/vendor/README.md b/vendor/README.md index aa8b43ba4a..4733eba49c 100644 --- a/vendor/README.md +++ b/vendor/README.md @@ -39,7 +39,7 @@ Other resource categories retain their existing parent-chain lookup. | Crate | Version | Licence | Patches | Test | |---|---|---|---|---| -| hayro-interpret | 0.7.0 | Apache-2.0 OR MIT | (1) select `/AP /N` appearance states via `/AS` (checkboxes, radios, stateful icons) and honour the NoView flag; (2) Type3 Unicode fallback to `/Encoding` glyph names, then printable ASCII; (3) `Type3Glyph::advance_width`; (4) `InterpreterSettings::ocg_overrides` for viewer layer toggles; (5) soft masks whose group lacks `/CS` were dropped (Chrome gradient text); (6) paint nesting cap `MAX_PAINT_NESTING` for tiling patterns and Type 3 glyphs (self-referencing content overflowed the stack); (7) CID width ranges in `/W` and `/W2` clamped to `MAX_CID` (a `0 4294967295 w` range hung); (8) `InterpreterSettings::hide_comments` skips markup annotations but keeps widgets and links (View ▸ Hide all comments); (9) `image_size_ok`: images and masks over 2^28 pixels are not decoded (a fuzzed `/W 4294967295` stencil mask allocated 4 GiB); (10) `MAX_NESTED_PAINTS`: nested paints (forms, Type 3 glyphs and tiling patterns inside one another) share a per-page budget of 50,000 (a glyph showing eight glyphs of its own font, sixteen deep, hung); (11) depends on skrifa 0.47 instead of 0.42 (upstream read-fonts fixed a Type 1 tokenizer that recursed through runs of numbers: a fuzzed font hung or overflowed the stack), with the one API change (`Charset::glyph_id` returns `Option`); (12) annotation appearances whose `/BBox` or `/Matrix` collapses to a line or a point are skipped (mapping them onto `/Rect` divided by zero and handed the device infinite or NaN matrices); (13) a Highlight annotation without a usable normal appearance is drawn from its `/QuadPoints`, `/C` and `/CA` with Multiply blending, as pdfcraft-annot draws highlights (it was not drawn at all); (14) simple-font `/Widths` (`read_widths`): entries only for codes 0–255 (one entry per code below `/FirstChar` was allocated: `4294967295` asked for 34 GB), an entry that isn't a number leaves its code missing instead of ending the array, and a `/LastChar` below `/FirstChar` gives no widths instead of discarding the font for a standard one; (15) the appearance box is mapped onto `/Rect` corner to corner: the translation now accounts for the scale (an appearance whose box didn't start at the origin and differed in size from `/Rect` was drawn shifted); (16) a clip set by `W`/`W*` takes effect after whichever path-painting operator ends the path (`S`, `f`, `B`, … as well as `n`; ISO 32000-2 §8.5.4): it was applied only on `n`, so `re W* S` clipped nothing and the clip stayed pending for a later, unrelated `n`; (17) `InterpreterSettings::cancelled`: once set, interpretation stops before the next content operator (a render pool sets it for every worker when dropped, after an edit or on closing, and for one worker when the watchdog gives up on its render, so renders whose answers nobody can receive stop instead of running to the end); (18) `MAX_PAGE_CONTENT`: the content a page holds at once (its contents, and the forms, tiling patterns and Type 3 glyph procedures being painted) stays within 512 MiB (`decode_content`; once it runs out, `InterpreterWarning::ContentTruncated` is reported (once per page by pdfcraft-render) so a truncated page is not a clean render; each level gives its bytes back when it finishes): forms nest fifty deep and each level kept its content while the next ran, so a form painting itself around a stream inflating to 256 MiB would have held over 12 GB; and Type 0 sampled functions take at most `MAX_SAMPLE_ENTRIES` (2^21) table points and `MAX_SAMPLE_VALUES` (2^23) samples, read no further than `/Size` asks (256 MiB of 1-bit samples asked for over 8 GB); (19) mesh shadings (types 4–7) are sampled only inside the area being drawn (`ShadingPattern::encode_within`, which the vendored hayro renderer calls with that area plus a pixel), within 8 visits per pixel of it plus 4 per triangle (`TriangleSampler`), and patches are triangulated one at a time: points far off the page were sampled over their whole bounding box, up to 65536 × 65536 entries (a fuzzed 147-byte patch stream; a synthetic page passed 6 GB in six seconds), ten thousand overlapping triangles ran for minutes, and every patch became 722 triangles before any limit; meshes hold at most `MAX_MESH_PATCHES` (2^16) patches and `MAX_MESH_TRIANGLES` (2^20) triangles, a lattice needs two to 2^19 vertices per row (`/VerticesPerRow 0` pushed empty rows for ever; one huge row read the whole stream), and a page samples at most `MAX_PAGE_MESH_TRIANGLES` (2^25) mesh triangles in all (a mesh painted a thousand times was triangulated a thousand times); each cut logs a warning; (20) PDF function construction is limited to 64 levels and 10,000 total nodes with cycle detection; calculator parsing and evaluation share procedure depth/work budgets, integer division/bitshift/rotation/index are panic-free, and fixed-size stack errors propagate; (21) a Type 0 font drawn with a substitute that isn't CID-keyed (it isn't embedded, or its program doesn't load) draws `.notdef` for a CID of an Adobe character collection (Japan1, GB1, CNS1, Korea1) that it can't find by Unicode: hayro took the CID for a glyph index of the substitute, which numbers its glyphs its own way, so the character was drawn as an unrelated one (𠮷 as "Q" in BIZ UDPGothic); (22) a Type 0 font drawn with a substitute finds a character given with a variation selector (`/ToUnicode`, or Adobe-Japan1-UCS2, which maps the JIS X 0208 forms of the kanji JIS X 0213:2004 redrew that way: CID 1247 is 噂 U+E0100) through the substitute's variation sequences, else as the character alone: such a CID found no glyph and was drawn as an unrelated one; (23) backport of hayro 0.8's `gradient` module: `EncodedShadingPattern::as_svg_gradient` turns an axial or radial shading into stops of an sRGB gradient (within 0.01 per channel, a stop at every stitching-function bound via `Function::stitching_bounds`) when a native gradient draws it exactly (extended past the box drawn in; a radial one with no background colour); (24) `DecodedImageCache`: decoded image pixels shared across renders through the interpreter cache within a 64 MiB budget (an image-heavy page decoded its thousands of images again per tile and per scale), keyed by every decode input (`DecodedImageKey`: the stream's place and dictionary bytes, its object, mask/stencil/raster output, interpolation, decode target size, preview or final); images under a transfer function and inline images are not cached, an image over the budget is never kept, and a poisoned lock decodes uncached; (25) `Device::actual_text` hands the device a marked content sequence's `/ActualText` (inline or named in `/Properties`), so text extraction keeps the Thai, Indic and Arabic clusters Chrome marks this way (their shaped tone marks and vowels have no Unicode of their own); (26) non-embedded simple fonts drawn with a standard-font stand-in build the Latin Extended-A letters the stand-in lacks (`/Gbreve`, `/Idotaccent`, `/Scedilla`, `/aogonek`, `/ccaron`…) from the base letter and accent it has (`StandardKind::composite_glyph`, `ACCENTED_LETTERS`): PDFium's Foxit stand-ins hold only the Standard/WinAnsi/Mac Roman glyphs, so iText letters with Times New Roman unembedded showed blanks for Ğ, İ, Ş, ğ and ş; the comma-below letters (`/scommaaccent`, `/Tcommaaccent`, `/kcommaaccent`…, found by character, so `/uni0136` and the glyph list's `/Kcedilla` too) are the letter and the font's comma, smaller and under the baseline, since the stand-ins have no `commaaccent` (Romanian ș drew nothing and ț a cedilla in forms laid out from XFA; ģ, whose comma is turned and above, is not built); (27) an embedded TrueType font without a base encoding reads its codes as WinAnsi for text extraction (`EmbeddedKind::code_to_unicode_name`), matching pdftotext's output, instead of Standard: OCR text layers of scans write WinAnsi codes with no `/Encoding`, and Standard has no ü, ö or ß and reads é as Ø (glyph selection unchanged); (28) an image whose pixels hold at most 16 bits of samples (an 8-bit Separation or DeviceN image, a 1-, 2- or 4-bit gray or indexed one) is converted once per possible pixel value through a lookup table instead of once per pixel (`rgb_via_lookup`): a spot-colour tint transform is often a PostScript calculator program, run for every pixel, so a fillable character sheet's 3304 × 4105 DeviceN background took four seconds and 430 MB to draw a page; each entry is converted on its own (`convert_each`), so a tint transform giving the wrong number of components for a value the image never uses cannot shift the colours of the values it does; (29) `Device::begin_annotations`/`end_annotations` bracket the page's annotations, so a device can tell comment paints from document paints (reading colours recolour the document, never a comment, #1548); (30) the Annex D.2 alias codes read as the ink they draw: WinAnsiEncoding 0xA0 and 0xAD, and MacRomanEncoding 0xCA, carry the glyph names `space` and `hyphen` -- the names those tables also give 0x20 and 0x2D -- not `nbspace` and `sfthyphen`, whose glyph list values U+00A0 and U+00AD are invisible formatting characters. Extraction hid drawn ink behind them, so search and redaction matched neither (`XiaoBiaoSong.pdf` in the pdf.js corpus drew 104 soft hyphens; across the 989 files the soft hyphens we extract fall from 117 to 13 and the non-breaking spaces from 30 to 26, what remains coming from `/ToUnicode` maps, which say so explicitly). The base table only, in the text direction only: a `/Differences` name keeps its glyph list value and glyph lookup is untouched, both as pdftotext does; (31) a Type 0 sampled function keeps its samples in one flat array indexed by strides (`Type0::entry`) instead of a hash map keyed by each grid point: the map was built again, one hashed insert per sample, every time a shading using the function was painted (a 2,097,152-sample ramp painted 30 times took 38 s; `bug1721218` in the pdf.js corpus spent most of a 1.5 s page on it); the interpolation is unchanged, so results are bit for bit the same, a sample missing from a short stream still gives no value, and the maximum sample value is computed without overflowing at 32 bits per sample; (32) the decoded-image cache (24) keys a decode by the size the image is drawn at only when a JPX stream decodes it or its soft mask or mask (`decode_reads_target`): JPX is the only filter that decodes a reduced resolution for a size, so a JPEG or Flate scan was decoded again at every zoom step and kept once per zoom level; one decode now serves every size; `InterpreterCache::decoded_image_decodes` counts the cache's decodes; (33) Coons and tensor-product patches copy their vertices' colours less: each point of a patch's 20 × 20 triangle grid is interpolated once (`generate_patch_triangles`; each cell interpolated its four corners itself, 1,444 interpolations a patch for 400 points), a cell's second triangle takes the vertices its first copied, and the mesh sampler moves a patch's triangles into device space instead of copying each vertex (`TriangleSampler::sample_owned`); the same values, 10–16% faster on mesh-gradient pages; (34) `color::tint_transform_runs`: how many colours the calling thread has run through a Separation or DeviceN tint transform (counted once per batch in `convert_f32`), so a test counts patch (28)'s once-per-sample-value runs instead of timing them (its 10 s deadline failed on a busy FreeBSD VM in CI); (35) `EncodedShadingPattern::extent`: where a mesh shading (types 4–7) can paint at all, the pixels its triangles touched when it was encoded (`None` for the types that paint everywhere, and when the shading has a `/Background`), so the renderer samples a texture there alone and skips a mesh with nothing on the canvas; (36) a mesh shading's pixel whose corner no triangle holds strictly (on an edge two triangles share, or on the mesh's side) takes the triangle that covers its centre, edges included (`Triangle::covers`): testing only the corner left a white seam along every shared edge, and the colour is still taken at the corner; (38) the `/DefaultGray`, `/DefaultRGB` and `/DefaultCMYK` colour spaces of a resource dictionary (ISO 32000-2 §8.6.5.6) replace the device space content selects: with `g`/`G`, `rg`/`RG`, `k`/`K` and `cs`/`CS` (by name, or through a `/ColorSpace` resource that is a device space), and as the `/ColorSpace` of an image XObject, an inline image or a shading painted by `sh` or as a shading pattern (`DefaultColorSpaces`, looked up once per content window in the resources and those they inherit, so a form XObject without a default of its own draws in its page's, as Poppler and MuPDF draw it); a default that is a Lab, Indexed, Pattern or device space, has another number of components or is no colour space is ignored and device colour is drawn as before; device spaces inside other spaces (an Indexed base, a Separation or DeviceN alternate, a Pattern's underlying space), shading dictionaries, masks and transparency group spaces are unchanged, and the decoded-image cache (24) keys a decode by the default it was drawn in. hayro drew device colour as it was, so pages that calibrate it this way (common in PDF/A) came out in the wrong colours; (39) knockout groups (#2971): `Device::set_knockout` right after a `/K true` group is pushed; `Device::knockout_image` for an unmasked image before its group is pushed, when `Device::in_knockout_group`: the device clears its placement rectangle only if it will draw the image (on the canvas, not a preview's tiny image, and it decodes), decoded at its draw size so the draw reuses that decode; and, when `Device::knockout_erase_wanted`, a nested group's content drawn once between `begin_knockout_erase` and `end_knockout_erase` before the group itself, which then always gets a layer of its own; (40) an optional content membership dictionary's visibility expression (`/VE`: `/And`, `/Or`, `/Not`, nested up to 32 deep) decides its visibility ahead of `/OCGs` and `/P` (ISO 32000-2 §8.11.2.2); it was ignored, so content under it stayed visible with its layers hidden; a malformed expression, including one naming an object the document doesn't list in `/OCProperties /OCGs`, falls back to `/OCGs` and `/P` (#2986); (41) a transparency group that is neither isolated nor knockout, entered at full opacity with no soft mask and Normal blending, is drawn straight onto the page instead of into a layer of its own (§11.4.7: its elements composite with the backdrop; the layer made a `/BM /Multiply` highlight appearance an opaque block, #1968); (42) text in a Type 3 font is drawn filled whatever the text rendering mode, except mode 3 (invisible): only that mode has any effect on Type 3 fonts (ISO 32000-1 §9.3.6). Stroking ran the glyph procedure a second time, so `2 Tr` made bitmap fonts look bold, `1 Tr` painted an uncoloured glyph in the stroking colour, and `7 Tr` hid it (`7 Tr` since follows 64); (43) Lab colour spaces honour their own `/WhitePoint`: L\*a\*b\* goes to XYZ relative to that white, is Bradford-adapted to D65 and converted to sRGB (ISO 32000-2 §8.6.5.4) instead of through `LAB.icc` as if every white were D50 (a D65 blue came out 45 levels too red; the profile is removed); components normalised to [0, 1] (`manual_scale`, an `/Indexed` palette over Lab) map onto L\* 0..100 and the a\*/b\* `/Range`; (44) a Type 0 font whose `/ToUnicode` is the name `/Identity-H` or `/Identity-V` (not a CMap stream; WSCAD writes it) reads each code as its Unicode value, as Acrobat shows it (`Type0Font::identity_to_unicode`); the identity CMap the name resolved to maps codes to CIDs only, so such pages had no text to select or find; (46) an image XObject's group applies the fill opacity, so the fill alpha is reset to 1 before a stencil mask takes the fill colour as its paint, and a pattern paint drops the opacity it captured at `scn` (`Pattern::without_opacity`) (it was applied twice: a mask at `ca 0.5` was drawn at a quarter, #2975); (47) `path_as_rect`: a five-element path is a rectangle only when its four points are distinct, each step from one to the next runs along a single axis, and the last returns to the first. The length test admits one MoveTo and four LineTo as well as one MoveTo, three LineTo and a ClosePath, and asking only that every corner of the bounding box was visited says nothing about the order or the steps between, so a five-point polyline whose ends reached the corners was painted as that box -- a fourth side the path does not have -- and a clip of that shape was discarded; the coordinates are compared exactly, since the matrix is applied after this check and a `cm` of 10,000 turns `is_nearly_equal`'s 256th of a unit into tens of device units (#3327); (48) an `/ICCBased` colour space is cached by what its profile is read from (`IccProfileKey`): the stream object it names, looked up without reading the stream, and then the stream's dictionary and its own bytes (decrypted, not decoded), so a byte-for-byte copy of a profile in another object is read once, and a profile stream written directly in its array, which reports the object holding the array as every other stream written there does, is told apart by its bytes. hayro keyed it by the stream dictionary's bytes alone, so two different profiles with the same `/N` and `/Length` shared whichever was read first, and colours in the second were drawn through the first. `InterpreterCache::icc_profile_reads` counts the profiles read; (49) `OpenTypeFontBlob::new` also accepts a `post` table version 1.0, 2.0 or 3.0 written with its 16-bit halves swapped (`00 00 00 03`), as ScanSoft PDF Create! embeds every font; such fonts were refused and their text drawn with a substitute (Hebrew came out garbled or missing, #2946); `glyph_names` reads such a table from a copy with the version written correctly, so a 2.0 table's names serve `/Differences`. Any other unknown version is still refused (pdf.js 9462); (50) `CffFontBlob::glyph_index_by_name` looks a glyph name up in a name to glyph map built on first use (shared between clones) instead of searching the whole charset, with a string lookup per glyph, for every character code of a font; the first glyph with a name still wins; (51) at most 4096 graphics states saved at once, holding at most 64 MiB together (each `q` copies the whole state, dash array and clip list included, so short operators multiplied one large state); a `q` past either limit is counted but not copied, and its `Q` still removes the clips added since (from storytold/craft-libs#239); (52) a `/W2` run whose first CID is past `MAX_CID` stops there, as in `/W` (7), checked before the run's metrics are read: a first CID of 4294967295 inserted one entry and then overflowed, discarding the whole `/W2` table (from storytold/craft-libs#254); (55) a Separation or DeviceN tint transform that cannot be read keeps its colour space, and one that cannot be read or evaluated paints the alternate space's initial colour, as evaluation failures already did; that fallback, and a `cs`/`CS` colour space that cannot be read (drawn in DeviceGray as before), are now reported once per content stream as `InterpreterWarning::ColorFallback` (`RenderWarning::ColourFallback` in `pdfcraft-render`) instead of silently (#4260); (56) an annotation whose Invisible flag (bit 1) is set and whose `/Subtype` is none of the standard types of ISO 32000-2 Table 171 (`is_standard_annotation_type`; no `/Subtype` counts as unknown) is not drawn (Table 167), and a `/Popup` annotation's appearance is never painted on the page (§12.5.6.14: a popup has no appearance stream of its own; the viewer shows it as a window for its parent annotation). Both were drawn from their `/AP`; (57) a simple font drawn with a standard-font stand-in because the document names a font it doesn't embed (`StandardKind` with `fallback`) narrows a glyph to a narrower `/Widths` advance but no longer widens it to a wider one: the advance already places the next glyph (ISO 32000-2 §9.2.4; /Widths in Table 109), and widening drew a non-embedded `Arial,Bold` with 600-unit widths as a heavy fixed-pitch face whose `i` and `l` were solid blocks twice their width; an exact standard-14 font still matches its standard metrics both ways; (58) the fallback request for a Type 0 font that is not embedded (`FallbackFontQuery::with_descriptor`) takes its serif, italic and small-cap flags, `/FontWeight`, `/FontFamily` and `/FontStretch` from the descendant CIDFont's font descriptor (ISO 32000-2 §9.7.4.1, §9.7.6.1; §9.8): it read them from the Type 0 dictionary, which has none, so every such request asked for a regular sans-serif face and no resolver could tell a Song or Mincho font from a Hei or Gothic one, or see `/FontWeight 700`; (59) the NoRotate flag (`/F` bit 5) is honoured: the appearance, fitted to `/Rect`, is turned against the page's `/Rotate` about the rectangle's upper-left corner, which stays where it is, so it reads upright as the page is displayed (ISO 32000-2 §12.5.3). Sticky notes, which are saved NoRotate, were drawn turned with the page (#2583). Form fields and links stay in `/Rect`, where PdfCraft takes their click targets from it; (64) text in a Type 3 font follows ISO 32000-2 §9.3.6 in the clipping modes: under `7 Tr` it is not rendered (reported to the device as clipping text, `GlyphDrawMode::Clip`, so extraction keeps it), and in modes 4 to 7 a Type 3 glyph adds nothing to the clipping path (`clip_glyph`), so text shown only in a Type 3 font leaves no clip at `ET`. Since (42) `7 Tr` painted the glyphs in the fill colour (black by default), so a fill meant to follow showed them wherever it didn't cover them; 4 to 6 still render filled; (66) a Separation colour space whose colourant is the special name `All` paints every colourant at once (ISO 32000-2 §8.6.6.4): on screen and in an RGB print raster its tint t is the gray 1 − t, complemented because the device is additive, whatever its alternate space and tint transform say (hayro drew it through the tint transform, so a `/DeviceCMYK` transform giving cyan drew registration marks light cyan); `/None` still paints nothing, any other name still goes through its alternate, and a DeviceN space naming `All` keeps its alternate, since the clause gives the name that meaning in a Separation space; (63) a luminosity soft mask whose group colour space is `/DeviceCMYK` (by name, not an ICC-based or calibrated space) reads a `/DeviceCMYK` fill or stroke colour drawn in it, and its `/BC`, as their gray `1 - min(1, 0.3 c + 0.59 m + 0.11 y + k)` (ISO 32000-2 §10.4.2.3, §11.5.3: device colour converts to gray with no colour calibration; `Context::luminosity_in_device_cmyk`, `color::device_cmyk_to_gray`): it went through the CMYK display profile, where 100% K is a dark grey, so a 100% K mask let about an eighth of the masked paint through. CMYK images, shadings and tiling patterns inside such a mask, and CMYK on the page, still use the profile; (71) a lookup table in an indirect string reads: it was read as a stream first, which uses up a reference (ISO 32000-2 §8.6.6.3); (72) an uncoloured pattern's underlying space given as a name or a reference is used: it was skipped, so a CMYK cyan drew red (ISO 32000-2 §8.7.3.3); (73) `InterpreterSettings::jbig2_work`, set for each page in `interpret_page` and ended with it, and a JBIG2 image or an image with a JBIG2 mask past it left out with `ContentTruncated`; (74) colour spaces nest at most 32 levels, through every way one names another, so a chain of them ends instead of overflowing the stack; an ICC profile's `/Alternate` is read where it's met and isn't kept with the profile, or a page could build a chain past the limit on spaces already read; (75) a soft hyphen (U+00AD) that a CFF substitute for a CID font has no glyph for draws its hyphen, as Annex D.2 reads WinAnsi 0xAD in (30), instead of the CID taken as a glyph index (#5888) | (1) `raster::tests::widget_appearance_states`; (2, 3) `xtask text-oracle` on corpus/pdfjs issue918; (4) `raster::tests::layer_override_hides_content`; (5) `raster::tests::soft_mask_without_group_colour_space_is_applied`; (6) `raster::tests::self_referencing_tiling_pattern_terminates`, `raster::tests::self_referencing_type3_glyph_terminates`; (7) `raster::tests::huge_cid_width_ranges_terminate`; (8) `raster::tests::hiding_comments_keeps_fields`; (9) `raster::tests::absurd_mask_and_fax_sizes_are_refused_before_decoding`; (10) `raster::tests::type3_glyphs_that_fan_out_into_themselves_finish`; (11) `raster::tests::type1_font_programs_with_long_runs_of_numbers_load_quickly`; (12) `raster::tests::degenerate_annotation_appearances_are_skipped`; (13) `raster::tests::highlights_without_an_appearance_are_drawn`; (14) `raster::tests::malformed_simple_font_widths_keep_later_widths_and_the_font`, `raster::tests::huge_simple_font_first_char_terminates`; (15) `raster::tests::annotation_appearance_box_maps_onto_rect`; (16) `raster::tests::clipping_path_applies_after_any_painting_operator`; (17) `raster::tests::dropping_the_pool_cancels_its_renders`, `raster::tests::the_watchdog_stops_the_render_it_gives_up_on`; (18) `raster::tests::page_contents_and_nested_forms_share_one_budget`, `raster::tests::content_painted_again_and_again_keeps_drawing`, `raster::tests::sampled_functions_with_billions_of_samples_are_refused`; (19) `raster::tests::mesh_shadings_far_off_the_page_render`, `raster::tests::many_overlapping_mesh_triangles_finish`, `raster::tests::mesh_shadings_with_many_patches_or_no_rows_finish`; (20) `raster::tests::function_limits_calculator_arithmetic_and_depth`, `raster::tests::function_limits_public_construction_work_and_depth`, `raster::tests::function_limits_stitching_cycles_are_refused_and_shared_children_work`, `raster::tests::function_limits_invalid_calculator_keeps_the_page_renderable`; (21) `raster::tests::cids_a_substitute_lacks_are_not_drawn_as_other_glyphs`; (22) `raster::tests::variation_sequences_draw_their_kanji_in_a_substitute`; (23) `raster::tests::axial_and_radial_shadings_draw_as_native_gradients`; (24) `raster::tests::tiles_match_full_render`, `raster::tests::skipping_paths_off_the_canvas_changes_no_pixel` (both decode through the cache), `raster::tests::decoded_images_with_identical_dictionaries_keep_their_own_pixels`, `raster::tests::decoded_images_under_a_transfer_function_are_not_shared`; (25) `text::tests::actual_text_replaces_the_glyphs_it_marks_in_every_writing_direction`, `text::tests::actual_text_follows_the_pdf_rules_for_marked_content`; (26) `raster::tests::accented_letters_the_standard_stand_ins_lack_are_built_from_their_parts`, `raster::tests::comma_below_letters_in_standard_fonts_draw_a_comma_under_the_letter`; (27) `text::tests::truetype_without_an_encoding_reads_its_codes_as_win_ansi`; (28) `raster::tests::spot_colour_images_run_their_tint_transform_once_per_sample_value`, `raster::tests::images_converted_through_a_table_keep_every_samples_colour`, `raster::tests::a_malformed_tint_for_an_unused_sample_keeps_the_used_colours`; (29) `raster::tests::reading_colors_keep_highlight_annotations_readable`; (30) `text::tests::the_annex_d2_alias_codes_read_as_an_ordinary_hyphen_and_space`; (31) `raster::tests::shadings_with_large_sampled_functions_paint_quickly`, `raster::tests::sampled_functions_interpolate_their_grid_as_before`; (32) `raster::tests::decoded_images_serve_every_size_they_are_drawn_at`; (33) `raster::tests::patch_meshes_keep_their_corner_colours`; (34) `raster::tests::spot_colour_images_run_their_tint_transform_once_per_sample_value`; (35) `mesh_extent_tests::{a_mesh_paints_its_triangles_alone, a_mesh_keeps_the_graphics_state_alpha, a_mesh_off_the_canvas_paints_nothing}` (`cargo test -p hayro`); (36) `raster::tests::mesh_triangles_sharing_an_edge_leave_no_seam`; (38) `raster::tests::default_colour_spaces::device_rgb_operators_draw_in_default_rgb`, `raster::tests::default_colour_spaces::device_cmyk_operators_draw_in_default_cmyk`, `raster::tests::default_colour_spaces::device_gray_operators_draw_in_default_gray`, `raster::tests::default_colour_spaces::device_images_draw_in_the_default_colour_spaces`, `raster::tests::default_colour_spaces::device_shadings_draw_in_the_default_colour_spaces`, `raster::tests::default_colour_spaces::an_image_drawn_with_and_without_a_default_keeps_both_colours`, `raster::tests::default_colour_spaces::forms_inherit_the_default_colour_spaces_they_do_not_override`, `raster::tests::default_colour_spaces::unusable_defaults_leave_device_colour_alone`; (39) `raster::tests::knockout_groups_replace_what_their_elements_cover`; (40) `raster::tests::visibility_expressions_decide_what_a_membership_dictionary_shows`; (41) `raster::tests::a_multiply_highlight_inside_a_transparency_group_keeps_the_text`; (42) `raster::tests::rendering_modes_but_3_and_7_draw_type3_glyphs_as_filled`; (43) `raster::tests::lab_honours_the_space_white_point`, `raster::tests::indexed_palettes_over_lab_keep_their_colours`; (44) `text::tests::identity_to_unicode_names_give_text`; (46) `raster::tests::stencil_masks_are_drawn_at_the_fill_opacity_once`; (47) `raster::tests::a_five_point_polyline_is_not_painted_as_its_bounding_box`, `raster::tests::a_path_that_visits_every_corner_out_of_order_is_not_a_rectangle`, `raster::tests::a_rectangle_is_still_painted_as_a_rectangle`; (48) `raster::tests::icc_profile_cache::icc_profiles_with_identical_dictionaries_draw_their_own_colours`, `raster::tests::icc_profile_cache::icc_profiles_used_again_are_read_once`; (49) `raster::tests::a_font_whose_post_version_has_swapped_halves_is_still_used`; (50) `font::blob::tests::glyph_names_resolve_to_the_first_glyph_with_that_name`; (51) `context::saved_state_tests::repeated_saves_stay_bounded_and_balanced`; (52) `font::cid::tests::w2_run_past_the_largest_cid_keeps_the_rest_of_the_table` (hayro-interpret); (55) `raster::tests::devicen_inks::a_tint_transform_that_cannot_be_evaluated_draws_the_rest_of_the_page_and_warns`, `raster::tests::devicen_inks::a_tint_transform_that_cannot_be_read_falls_back_and_warns`; (56) `raster::tests::invisible_unknown_annotations_and_popups_paint_nothing`; (57) `substitute_widths::a_substitute_keeps_its_glyph_shapes_at_wider_advances`, `substitute_widths::a_substitute_still_narrows_glyphs_to_narrower_advances` (pdfcraft-render `tests/it`); (58) `cid_fallback_query::a_cid_font_fallback_request_carries_its_descendants_style`, `cid_fallback_query::a_cid_font_without_a_descriptor_asks_for_the_default_style` (pdfcraft-render integration tests); (59) `raster::tests::no_rotate_notes_stay_upright_at_their_corner_on_rotated_pages` (hayro-interpret); (64) `type3_text_clip::{clip_only_type3_text_is_not_rendered_and_leaves_no_clip, invisible_type3_text_is_not_rendered, other_modes_render_type3_glyphs_filled_and_never_clip, only_outline_glyphs_shape_a_clip_shared_with_type3_text, self_nesting_type3_text_in_a_clip_mode_stays_bounded}` (`cargo test -p pdfcraft-render --test it`), `raster::tests::rendering_modes_but_3_and_7_draw_type3_glyphs_as_filled`, `text::tests::clipping_mode_type3_text_is_kept_by_the_ocr_filter`; (66) `separation_all::{separation_all_fills_paint_the_complemented_tint_as_gray, separation_all_strokes_paint_the_complemented_tint_as_gray, separation_all_images_paint_each_sample_as_the_complemented_gray, separation_all_shadings_paint_the_complemented_gray, separation_none_paints_nothing, a_named_separation_still_paints_through_its_alternate, a_device_n_space_naming_all_still_paints_through_its_alternate}` (`cargo test -p pdfcraft-render --test it`); (63) `color::device_cmyk_gray_tests::{each_ink_darkens_by_its_iso_weight, ink_past_full_coverage_is_black_and_bad_components_read_as_zero}` (hayro-interpret), `it::luminosity_group_space::{black_ink_in_a_device_cmyk_group_masks_fully_and_no_ink_shows_fully, each_process_ink_in_a_device_cmyk_group_masks_by_its_weight, a_device_cmyk_backdrop_colour_masks_by_its_gray, gray_and_rgb_groups_mask_as_before, device_cmyk_on_the_page_keeps_its_display_colour}` (pdfcraft-render); (71) `raster::tests::an_indexed_lookup_table_is_read_in_every_legal_form` (pdfcraft-render); (72) `raster::tests::an_uncoloured_pattern_uses_its_underlying_space_in_every_form` (pdfcraft-render); (73) `raster::tests::the_jbig2_images_of_a_page_share_one_allowance`, `a_picture_whose_jbig2_mask_is_past_the_pages_allowance_is_not_drawn` and `a_pages_jbig2_allowance_ends_with_the_page` (pdfcraft-render); (74) `raster::tests::{a_chain_of_colour_spaces_ends, colour_spaces_are_read_to_the_limit_of_their_nesting, a_chain_built_on_spaces_already_read_ends, a_profile_read_at_the_limit_keeps_its_alternate_for_a_read_nearer_the_top}` (pdfcraft-render); (75) `raster::tests::a_soft_hyphen_a_substitute_lacks_draws_its_hyphen` (pdfcraft-render) | +| hayro-interpret | 0.7.0 | Apache-2.0 OR MIT | (1) select `/AP /N` appearance states via `/AS` (checkboxes, radios, stateful icons) and honour the NoView flag; (2) Type3 Unicode fallback to `/Encoding` glyph names, then printable ASCII; (3) `Type3Glyph::advance_width`; (4) `InterpreterSettings::ocg_overrides` for viewer layer toggles; (5) soft masks whose group lacks `/CS` were dropped (Chrome gradient text); (6) paint nesting cap `MAX_PAINT_NESTING` for tiling patterns and Type 3 glyphs (self-referencing content overflowed the stack); (7) CID width ranges in `/W` and `/W2` clamped to `MAX_CID` (a `0 4294967295 w` range hung); (8) `InterpreterSettings::hide_comments` skips markup annotations but keeps widgets and links (View ▸ Hide all comments); (9) `image_size_ok`: images and masks over 2^28 pixels are not decoded (a fuzzed `/W 4294967295` stencil mask allocated 4 GiB); (10) `MAX_NESTED_PAINTS`: nested paints (forms, Type 3 glyphs and tiling patterns inside one another) share a per-page budget of 50,000 (a glyph showing eight glyphs of its own font, sixteen deep, hung); (11) depends on skrifa 0.47 instead of 0.42 (upstream read-fonts fixed a Type 1 tokenizer that recursed through runs of numbers: a fuzzed font hung or overflowed the stack), with the one API change (`Charset::glyph_id` returns `Option`); (12) annotation appearances whose `/BBox` or `/Matrix` collapses to a line or a point are skipped (mapping them onto `/Rect` divided by zero and handed the device infinite or NaN matrices); (13) a Highlight annotation without a usable normal appearance is drawn from its `/QuadPoints`, `/C` and `/CA` with Multiply blending, as pdfcraft-annot draws highlights (it was not drawn at all); (14) simple-font `/Widths` (`read_widths`): entries only for codes 0–255 (one entry per code below `/FirstChar` was allocated: `4294967295` asked for 34 GB), an entry that isn't a number leaves its code missing instead of ending the array, and a `/LastChar` below `/FirstChar` gives no widths instead of discarding the font for a standard one; (15) the appearance box is mapped onto `/Rect` corner to corner: the translation now accounts for the scale (an appearance whose box didn't start at the origin and differed in size from `/Rect` was drawn shifted); (16) a clip set by `W`/`W*` takes effect after whichever path-painting operator ends the path (`S`, `f`, `B`, … as well as `n`; ISO 32000-2 §8.5.4): it was applied only on `n`, so `re W* S` clipped nothing and the clip stayed pending for a later, unrelated `n`; (17) `InterpreterSettings::cancelled`: once set, interpretation stops before the next content operator (a render pool sets it for every worker when dropped, after an edit or on closing, and for one worker when the watchdog gives up on its render, so renders whose answers nobody can receive stop instead of running to the end); (18) `MAX_PAGE_CONTENT`: the content a page holds at once (its contents, and the forms, tiling patterns and Type 3 glyph procedures being painted) stays within 512 MiB (`decode_content`; once it runs out, `InterpreterWarning::ContentTruncated` is reported (once per page by pdfcraft-render) so a truncated page is not a clean render; each level gives its bytes back when it finishes): forms nest fifty deep and each level kept its content while the next ran, so a form painting itself around a stream inflating to 256 MiB would have held over 12 GB; and Type 0 sampled functions take at most `MAX_SAMPLE_ENTRIES` (2^21) table points and `MAX_SAMPLE_VALUES` (2^23) samples, read no further than `/Size` asks (256 MiB of 1-bit samples asked for over 8 GB); (19) mesh shadings (types 4–7) are sampled only inside the area being drawn (`ShadingPattern::encode_within`, which the vendored hayro renderer calls with that area plus a pixel), within 8 visits per pixel of it plus 4 per triangle (`TriangleSampler`), and patches are triangulated one at a time: points far off the page were sampled over their whole bounding box, up to 65536 × 65536 entries (a fuzzed 147-byte patch stream; a synthetic page passed 6 GB in six seconds), ten thousand overlapping triangles ran for minutes, and every patch became 722 triangles before any limit; meshes hold at most `MAX_MESH_PATCHES` (2^16) patches and `MAX_MESH_TRIANGLES` (2^20) triangles, a lattice needs two to 2^19 vertices per row (`/VerticesPerRow 0` pushed empty rows for ever; one huge row read the whole stream), and a page samples at most `MAX_PAGE_MESH_TRIANGLES` (2^25) mesh triangles in all (a mesh painted a thousand times was triangulated a thousand times); each cut logs a warning; (20) PDF function construction is limited to 64 levels and 10,000 total nodes with cycle detection; calculator parsing and evaluation share procedure depth/work budgets, integer division/bitshift/rotation/index are panic-free, and fixed-size stack errors propagate; (21) a Type 0 font drawn with a substitute that isn't CID-keyed (it isn't embedded, or its program doesn't load) draws `.notdef` for a CID of an Adobe character collection (Japan1, GB1, CNS1, Korea1) that it can't find by Unicode: hayro took the CID for a glyph index of the substitute, which numbers its glyphs its own way, so the character was drawn as an unrelated one (𠮷 as "Q" in BIZ UDPGothic); (22) a Type 0 font drawn with a substitute finds a character given with a variation selector (`/ToUnicode`, or Adobe-Japan1-UCS2, which maps the JIS X 0208 forms of the kanji JIS X 0213:2004 redrew that way: CID 1247 is 噂 U+E0100) through the substitute's variation sequences, else as the character alone: such a CID found no glyph and was drawn as an unrelated one; (23) backport of hayro 0.8's `gradient` module: `EncodedShadingPattern::as_svg_gradient` turns an axial or radial shading into stops of an sRGB gradient (within 0.01 per channel, a stop at every stitching-function bound via `Function::stitching_bounds`) when a native gradient draws it exactly (extended past the box drawn in; a radial one with no background colour); (24) `DecodedImageCache`: decoded image pixels shared across renders through the interpreter cache within a 64 MiB budget (an image-heavy page decoded its thousands of images again per tile and per scale), keyed by every decode input (`DecodedImageKey`: the stream's place and dictionary bytes, its object, mask/stencil/raster output, interpolation, decode target size, preview or final); images under a transfer function and inline images are not cached, an image over the budget is never kept, and a poisoned lock decodes uncached; (25) `Device::actual_text` hands the device a marked content sequence's `/ActualText` (inline or named in `/Properties`), so text extraction keeps the Thai, Indic and Arabic clusters Chrome marks this way (their shaped tone marks and vowels have no Unicode of their own); (26) non-embedded simple fonts drawn with a standard-font stand-in build the Latin Extended-A letters the stand-in lacks (`/Gbreve`, `/Idotaccent`, `/Scedilla`, `/aogonek`, `/ccaron`…) from the base letter and accent it has (`StandardKind::composite_glyph`, `ACCENTED_LETTERS`): PDFium's Foxit stand-ins hold only the Standard/WinAnsi/Mac Roman glyphs, so iText letters with Times New Roman unembedded showed blanks for Ğ, İ, Ş, ğ and ş; the comma-below letters (`/scommaaccent`, `/Tcommaaccent`, `/kcommaaccent`…, found by character, so `/uni0136` and the glyph list's `/Kcedilla` too) are the letter and the font's comma, smaller and under the baseline, since the stand-ins have no `commaaccent` (Romanian ș drew nothing and ț a cedilla in forms laid out from XFA; ģ, whose comma is turned and above, is not built); (27) an embedded TrueType font without a base encoding reads its codes as WinAnsi for text extraction (`EmbeddedKind::code_to_unicode_name`), matching pdftotext's output, instead of Standard: OCR text layers of scans write WinAnsi codes with no `/Encoding`, and Standard has no ü, ö or ß and reads é as Ø (glyph selection unchanged); (28) an image whose pixels hold at most 16 bits of samples (an 8-bit Separation or DeviceN image, a 1-, 2- or 4-bit gray or indexed one) is converted once per possible pixel value through a lookup table instead of once per pixel (`rgb_via_lookup`): a spot-colour tint transform is often a PostScript calculator program, run for every pixel, so a fillable character sheet's 3304 × 4105 DeviceN background took four seconds and 430 MB to draw a page; each entry is converted on its own (`convert_each`), so a tint transform giving the wrong number of components for a value the image never uses cannot shift the colours of the values it does; (29) `Device::begin_annotations`/`end_annotations` bracket the page's annotations, so a device can tell comment paints from document paints (reading colours recolour the document, never a comment, #1548); (30) the Annex D.2 alias codes read as the ink they draw: WinAnsiEncoding 0xA0 and 0xAD, and MacRomanEncoding 0xCA, carry the glyph names `space` and `hyphen` -- the names those tables also give 0x20 and 0x2D -- not `nbspace` and `sfthyphen`, whose glyph list values U+00A0 and U+00AD are invisible formatting characters. Extraction hid drawn ink behind them, so search and redaction matched neither (`XiaoBiaoSong.pdf` in the pdf.js corpus drew 104 soft hyphens; across the 989 files the soft hyphens we extract fall from 117 to 13 and the non-breaking spaces from 30 to 26, what remains coming from `/ToUnicode` maps, which say so explicitly). The base table only, in the text direction only: a `/Differences` name keeps its glyph list value and glyph lookup is untouched, both as pdftotext does; (31) a Type 0 sampled function keeps its samples in one flat array indexed by strides (`Type0::entry`) instead of a hash map keyed by each grid point: the map was built again, one hashed insert per sample, every time a shading using the function was painted (a 2,097,152-sample ramp painted 30 times took 38 s; `bug1721218` in the pdf.js corpus spent most of a 1.5 s page on it); the interpolation is unchanged, so results are bit for bit the same, a sample missing from a short stream still gives no value, and the maximum sample value is computed without overflowing at 32 bits per sample; (32) the decoded-image cache (24) keys a decode by the size the image is drawn at only when a JPX stream decodes it or its soft mask or mask (`decode_reads_target`): JPX is the only filter that decodes a reduced resolution for a size, so a JPEG or Flate scan was decoded again at every zoom step and kept once per zoom level; one decode now serves every size; `InterpreterCache::decoded_image_decodes` counts the cache's decodes; (33) Coons and tensor-product patches copy their vertices' colours less: each point of a patch's 20 × 20 triangle grid is interpolated once (`generate_patch_triangles`; each cell interpolated its four corners itself, 1,444 interpolations a patch for 400 points), a cell's second triangle takes the vertices its first copied, and the mesh sampler moves a patch's triangles into device space instead of copying each vertex (`TriangleSampler::sample_owned`); the same values, 10–16% faster on mesh-gradient pages; (34) `color::tint_transform_runs`: how many colours the calling thread has run through a Separation or DeviceN tint transform (counted once per batch in `convert_f32`), so a test counts patch (28)'s once-per-sample-value runs instead of timing them (its 10 s deadline failed on a busy FreeBSD VM in CI); (35) `EncodedShadingPattern::extent`: where a mesh shading (types 4–7) can paint at all, the pixels its triangles touched when it was encoded (`None` for the types that paint everywhere, and when the shading has a `/Background`), so the renderer samples a texture there alone and skips a mesh with nothing on the canvas; (36) a mesh shading's pixel whose corner no triangle holds strictly (on an edge two triangles share, or on the mesh's side) takes the triangle that covers its centre, edges included (`Triangle::covers`): testing only the corner left a white seam along every shared edge, and the colour is still taken at the corner; (38) the `/DefaultGray`, `/DefaultRGB` and `/DefaultCMYK` colour spaces of a resource dictionary (ISO 32000-2 §8.6.5.6) replace the device space content selects: with `g`/`G`, `rg`/`RG`, `k`/`K` and `cs`/`CS` (by name, or through a `/ColorSpace` resource that is a device space), and as the `/ColorSpace` of an image XObject, an inline image or a shading painted by `sh` or as a shading pattern (`DefaultColorSpaces`, looked up once per content window in the resources and those they inherit, so a form XObject without a default of its own draws in its page's, as Poppler and MuPDF draw it); a default that is a Lab, Indexed, Pattern or device space, has another number of components or is no colour space is ignored and device colour is drawn as before; device spaces inside other spaces (an Indexed base, a Separation or DeviceN alternate, a Pattern's underlying space), shading dictionaries, masks and transparency group spaces are unchanged, and the decoded-image cache (24) keys a decode by the default it was drawn in. hayro drew device colour as it was, so pages that calibrate it this way (common in PDF/A) came out in the wrong colours; (39) knockout groups (#2971): `Device::set_knockout` right after a `/K true` group is pushed; `Device::knockout_image` for an unmasked image before its group is pushed, when `Device::in_knockout_group`: the device clears its placement rectangle only if it will draw the image (on the canvas, not a preview's tiny image, and it decodes), decoded at its draw size so the draw reuses that decode; and, when `Device::knockout_erase_wanted`, a nested group's content drawn once between `begin_knockout_erase` and `end_knockout_erase` before the group itself, which then always gets a layer of its own; (40) an optional content membership dictionary's visibility expression (`/VE`: `/And`, `/Or`, `/Not`, nested up to 32 deep) decides its visibility ahead of `/OCGs` and `/P` (ISO 32000-2 §8.11.2.2); it was ignored, so content under it stayed visible with its layers hidden; a malformed expression, including one naming an object the document doesn't list in `/OCProperties /OCGs`, falls back to `/OCGs` and `/P` (#2986); (41) a transparency group that is neither isolated nor knockout, entered at full opacity with no soft mask and Normal blending, is drawn straight onto the page instead of into a layer of its own (§11.4.7: its elements composite with the backdrop; the layer made a `/BM /Multiply` highlight appearance an opaque block, #1968); (42) text in a Type 3 font is drawn filled whatever the text rendering mode, except mode 3 (invisible): only that mode has any effect on Type 3 fonts (ISO 32000-1 §9.3.6). Stroking ran the glyph procedure a second time, so `2 Tr` made bitmap fonts look bold, `1 Tr` painted an uncoloured glyph in the stroking colour, and `7 Tr` hid it (`7 Tr` since follows 64); (43) Lab colour spaces honour their own `/WhitePoint`: L\*a\*b\* goes to XYZ relative to that white, is Bradford-adapted to D65 and converted to sRGB (ISO 32000-2 §8.6.5.4) instead of through `LAB.icc` as if every white were D50 (a D65 blue came out 45 levels too red; the profile is removed); components normalised to [0, 1] (`manual_scale`, an `/Indexed` palette over Lab) map onto L\* 0..100 and the a\*/b\* `/Range`; (44) a Type 0 font whose `/ToUnicode` is the name `/Identity-H` or `/Identity-V` (not a CMap stream; WSCAD writes it) reads each code as its Unicode value, as Acrobat shows it (`Type0Font::identity_to_unicode`); the identity CMap the name resolved to maps codes to CIDs only, so such pages had no text to select or find; (46) an image XObject's group applies the fill opacity, so the fill alpha is reset to 1 before a stencil mask takes the fill colour as its paint, and a pattern paint drops the opacity it captured at `scn` (`Pattern::without_opacity`) (it was applied twice: a mask at `ca 0.5` was drawn at a quarter, #2975); (47) `path_as_rect`: a five-element path is a rectangle only when its four points are distinct, each step from one to the next runs along a single axis, and the last returns to the first. The length test admits one MoveTo and four LineTo as well as one MoveTo, three LineTo and a ClosePath, and asking only that every corner of the bounding box was visited says nothing about the order or the steps between, so a five-point polyline whose ends reached the corners was painted as that box -- a fourth side the path does not have -- and a clip of that shape was discarded; the coordinates are compared exactly, since the matrix is applied after this check and a `cm` of 10,000 turns `is_nearly_equal`'s 256th of a unit into tens of device units (#3327); (48) an `/ICCBased` colour space is cached by what its profile is read from (`IccProfileKey`): the stream object it names, looked up without reading the stream, and then the stream's dictionary and its own bytes (decrypted, not decoded), so a byte-for-byte copy of a profile in another object is read once, and a profile stream written directly in its array, which reports the object holding the array as every other stream written there does, is told apart by its bytes. hayro keyed it by the stream dictionary's bytes alone, so two different profiles with the same `/N` and `/Length` shared whichever was read first, and colours in the second were drawn through the first. `InterpreterCache::icc_profile_reads` counts the profiles read; (49) `OpenTypeFontBlob::new` also accepts a `post` table version 1.0, 2.0 or 3.0 written with its 16-bit halves swapped (`00 00 00 03`), as ScanSoft PDF Create! embeds every font; such fonts were refused and their text drawn with a substitute (Hebrew came out garbled or missing, #2946); `glyph_names` reads such a table from a copy with the version written correctly, so a 2.0 table's names serve `/Differences`. Any other unknown version is still refused (pdf.js 9462); (50) `CffFontBlob::glyph_index_by_name` looks a glyph name up in a name to glyph map built on first use (shared between clones) instead of searching the whole charset, with a string lookup per glyph, for every character code of a font; the first glyph with a name still wins; (51) at most 4096 graphics states saved at once, holding at most 64 MiB together (each `q` copies the whole state, dash array and clip list included, so short operators multiplied one large state); a `q` past either limit is counted but not copied, and its `Q` still removes the clips added since (from storytold/craft-libs#239); (52) a `/W2` run whose first CID is past `MAX_CID` stops there, as in `/W` (7), checked before the run's metrics are read: a first CID of 4294967295 inserted one entry and then overflowed, discarding the whole `/W2` table (from storytold/craft-libs#254); (55) a Separation or DeviceN tint transform that cannot be read keeps its colour space, and one that cannot be read or evaluated paints the alternate space's initial colour, as evaluation failures already did; that fallback, and a `cs`/`CS` colour space that cannot be read (drawn in DeviceGray as before), are now reported once per content stream as `InterpreterWarning::ColorFallback` (`RenderWarning::ColourFallback` in `pdfcraft-render`) instead of silently (#4260); (56) an annotation whose Invisible flag (bit 1) is set and whose `/Subtype` is none of the standard types of ISO 32000-2 Table 171 (`is_standard_annotation_type`; no `/Subtype` counts as unknown) is not drawn (Table 167), and a `/Popup` annotation's appearance is never painted on the page (§12.5.6.14: a popup has no appearance stream of its own; the viewer shows it as a window for its parent annotation). Both were drawn from their `/AP`; (57) a simple font drawn with a standard-font stand-in because the document names a font it doesn't embed (`StandardKind` with `fallback`) narrows a glyph to a narrower `/Widths` advance but no longer widens it to a wider one: the advance already places the next glyph (ISO 32000-2 §9.2.4; /Widths in Table 109), and widening drew a non-embedded `Arial,Bold` with 600-unit widths as a heavy fixed-pitch face whose `i` and `l` were solid blocks twice their width; an exact standard-14 font still matches its standard metrics both ways; (58) the fallback request for a Type 0 font that is not embedded (`FallbackFontQuery::with_descriptor`) takes its serif, italic and small-cap flags, `/FontWeight`, `/FontFamily` and `/FontStretch` from the descendant CIDFont's font descriptor (ISO 32000-2 §9.7.4.1, §9.7.6.1; §9.8): it read them from the Type 0 dictionary, which has none, so every such request asked for a regular sans-serif face and no resolver could tell a Song or Mincho font from a Hei or Gothic one, or see `/FontWeight 700`; (59) the NoRotate flag (`/F` bit 5) is honoured: the appearance, fitted to `/Rect`, is turned against the page's `/Rotate` about the rectangle's upper-left corner, which stays where it is, so it reads upright as the page is displayed (ISO 32000-2 §12.5.3). Sticky notes, which are saved NoRotate, were drawn turned with the page (#2583). Form fields and links stay in `/Rect`, where PdfCraft takes their click targets from it; (64) text in a Type 3 font follows ISO 32000-2 §9.3.6 in the clipping modes: under `7 Tr` it is not rendered (reported to the device as clipping text, `GlyphDrawMode::Clip`, so extraction keeps it), and in modes 4 to 7 a Type 3 glyph adds nothing to the clipping path (`clip_glyph`), so text shown only in a Type 3 font leaves no clip at `ET`. Since (42) `7 Tr` painted the glyphs in the fill colour (black by default), so a fill meant to follow showed them wherever it didn't cover them; 4 to 6 still render filled; (66) a Separation colour space whose colourant is the special name `All` paints every colourant at once (ISO 32000-2 §8.6.6.4): on screen and in an RGB print raster its tint t is the gray 1 − t, complemented because the device is additive, whatever its alternate space and tint transform say (hayro drew it through the tint transform, so a `/DeviceCMYK` transform giving cyan drew registration marks light cyan); `/None` still paints nothing, any other name still goes through its alternate, and a DeviceN space naming `All` keeps its alternate, since the clause gives the name that meaning in a Separation space; (63) a luminosity soft mask whose group colour space is `/DeviceCMYK` (by name, not an ICC-based or calibrated space) reads a `/DeviceCMYK` fill or stroke colour drawn in it, and its `/BC`, as their gray `1 - min(1, 0.3 c + 0.59 m + 0.11 y + k)` (ISO 32000-2 §10.4.2.3, §11.5.3: device colour converts to gray with no colour calibration; `Context::luminosity_in_device_cmyk`, `color::device_cmyk_to_gray`): it went through the CMYK display profile, where 100% K is a dark grey, so a 100% K mask let about an eighth of the masked paint through. CMYK images, shadings and tiling patterns inside such a mask, and CMYK on the page, still use the profile; (71) a lookup table in an indirect string reads: it was read as a stream first, which uses up a reference (ISO 32000-2 §8.6.6.3); (72) an uncoloured pattern's underlying space given as a name or a reference is used: it was skipped, so a CMYK cyan drew red (ISO 32000-2 §8.7.3.3); (73) `InterpreterSettings::jbig2_work`, set for each page in `interpret_page` and ended with it, and a JBIG2 image or an image with a JBIG2 mask past it left out with `ContentTruncated`; (74) colour spaces nest at most 32 levels, through every way one names another, so a chain of them ends instead of overflowing the stack; an ICC profile's `/Alternate` is read where it's met and isn't kept with the profile, or a page could build a chain past the limit on spaces already read; (75) a soft hyphen (U+00AD) that a CFF substitute for a CID font has no glyph for draws its hyphen, as Annex D.2 reads WinAnsi 0xAD in (30), instead of the CID taken as a glyph index (#5888); (89) a form XObject already being drawn is not drawn inside itself (`Context::enter_form`, reported as `ContentTruncated`, §8.10.1): a name in its content that reached it again, through its own resources or the page resources a form with its own still falls back on, drew it until the page's nested-paint budget was spent (PDFBOX-5782, 38 s); the forms being drawn are kept per page and thread, so the tiling patterns, Type 3 glyphs, soft masks and groups painted inside a form, which are interpreted in contexts of their own, see it too | (1) `raster::tests::widget_appearance_states`; (2, 3) `xtask text-oracle` on corpus/pdfjs issue918; (4) `raster::tests::layer_override_hides_content`; (5) `raster::tests::soft_mask_without_group_colour_space_is_applied`; (6) `raster::tests::self_referencing_tiling_pattern_terminates`, `raster::tests::self_referencing_type3_glyph_terminates`; (7) `raster::tests::huge_cid_width_ranges_terminate`; (8) `raster::tests::hiding_comments_keeps_fields`; (9) `raster::tests::absurd_mask_and_fax_sizes_are_refused_before_decoding`; (10) `raster::tests::type3_glyphs_that_fan_out_into_themselves_finish`; (11) `raster::tests::type1_font_programs_with_long_runs_of_numbers_load_quickly`; (12) `raster::tests::degenerate_annotation_appearances_are_skipped`; (13) `raster::tests::highlights_without_an_appearance_are_drawn`; (14) `raster::tests::malformed_simple_font_widths_keep_later_widths_and_the_font`, `raster::tests::huge_simple_font_first_char_terminates`; (15) `raster::tests::annotation_appearance_box_maps_onto_rect`; (16) `raster::tests::clipping_path_applies_after_any_painting_operator`; (17) `raster::tests::dropping_the_pool_cancels_its_renders`, `raster::tests::the_watchdog_stops_the_render_it_gives_up_on`; (18) `raster::tests::page_contents_and_nested_forms_share_one_budget`, `raster::tests::content_painted_again_and_again_keeps_drawing`, `raster::tests::sampled_functions_with_billions_of_samples_are_refused`; (19) `raster::tests::mesh_shadings_far_off_the_page_render`, `raster::tests::many_overlapping_mesh_triangles_finish`, `raster::tests::mesh_shadings_with_many_patches_or_no_rows_finish`; (20) `raster::tests::function_limits_calculator_arithmetic_and_depth`, `raster::tests::function_limits_public_construction_work_and_depth`, `raster::tests::function_limits_stitching_cycles_are_refused_and_shared_children_work`, `raster::tests::function_limits_invalid_calculator_keeps_the_page_renderable`; (21) `raster::tests::cids_a_substitute_lacks_are_not_drawn_as_other_glyphs`; (22) `raster::tests::variation_sequences_draw_their_kanji_in_a_substitute`; (23) `raster::tests::axial_and_radial_shadings_draw_as_native_gradients`; (24) `raster::tests::tiles_match_full_render`, `raster::tests::skipping_paths_off_the_canvas_changes_no_pixel` (both decode through the cache), `raster::tests::decoded_images_with_identical_dictionaries_keep_their_own_pixels`, `raster::tests::decoded_images_under_a_transfer_function_are_not_shared`; (25) `text::tests::actual_text_replaces_the_glyphs_it_marks_in_every_writing_direction`, `text::tests::actual_text_follows_the_pdf_rules_for_marked_content`; (26) `raster::tests::accented_letters_the_standard_stand_ins_lack_are_built_from_their_parts`, `raster::tests::comma_below_letters_in_standard_fonts_draw_a_comma_under_the_letter`; (27) `text::tests::truetype_without_an_encoding_reads_its_codes_as_win_ansi`; (28) `raster::tests::spot_colour_images_run_their_tint_transform_once_per_sample_value`, `raster::tests::images_converted_through_a_table_keep_every_samples_colour`, `raster::tests::a_malformed_tint_for_an_unused_sample_keeps_the_used_colours`; (29) `raster::tests::reading_colors_keep_highlight_annotations_readable`; (30) `text::tests::the_annex_d2_alias_codes_read_as_an_ordinary_hyphen_and_space`; (31) `raster::tests::shadings_with_large_sampled_functions_paint_quickly`, `raster::tests::sampled_functions_interpolate_their_grid_as_before`; (32) `raster::tests::decoded_images_serve_every_size_they_are_drawn_at`; (33) `raster::tests::patch_meshes_keep_their_corner_colours`; (34) `raster::tests::spot_colour_images_run_their_tint_transform_once_per_sample_value`; (35) `mesh_extent_tests::{a_mesh_paints_its_triangles_alone, a_mesh_keeps_the_graphics_state_alpha, a_mesh_off_the_canvas_paints_nothing}` (`cargo test -p hayro`); (36) `raster::tests::mesh_triangles_sharing_an_edge_leave_no_seam`; (38) `raster::tests::default_colour_spaces::device_rgb_operators_draw_in_default_rgb`, `raster::tests::default_colour_spaces::device_cmyk_operators_draw_in_default_cmyk`, `raster::tests::default_colour_spaces::device_gray_operators_draw_in_default_gray`, `raster::tests::default_colour_spaces::device_images_draw_in_the_default_colour_spaces`, `raster::tests::default_colour_spaces::device_shadings_draw_in_the_default_colour_spaces`, `raster::tests::default_colour_spaces::an_image_drawn_with_and_without_a_default_keeps_both_colours`, `raster::tests::default_colour_spaces::forms_inherit_the_default_colour_spaces_they_do_not_override`, `raster::tests::default_colour_spaces::unusable_defaults_leave_device_colour_alone`; (39) `raster::tests::knockout_groups_replace_what_their_elements_cover`; (40) `raster::tests::visibility_expressions_decide_what_a_membership_dictionary_shows`; (41) `raster::tests::a_multiply_highlight_inside_a_transparency_group_keeps_the_text`; (42) `raster::tests::rendering_modes_but_3_and_7_draw_type3_glyphs_as_filled`; (43) `raster::tests::lab_honours_the_space_white_point`, `raster::tests::indexed_palettes_over_lab_keep_their_colours`; (44) `text::tests::identity_to_unicode_names_give_text`; (46) `raster::tests::stencil_masks_are_drawn_at_the_fill_opacity_once`; (47) `raster::tests::a_five_point_polyline_is_not_painted_as_its_bounding_box`, `raster::tests::a_path_that_visits_every_corner_out_of_order_is_not_a_rectangle`, `raster::tests::a_rectangle_is_still_painted_as_a_rectangle`; (48) `raster::tests::icc_profile_cache::icc_profiles_with_identical_dictionaries_draw_their_own_colours`, `raster::tests::icc_profile_cache::icc_profiles_used_again_are_read_once`; (49) `raster::tests::a_font_whose_post_version_has_swapped_halves_is_still_used`; (50) `font::blob::tests::glyph_names_resolve_to_the_first_glyph_with_that_name`; (51) `context::saved_state_tests::repeated_saves_stay_bounded_and_balanced`; (52) `font::cid::tests::w2_run_past_the_largest_cid_keeps_the_rest_of_the_table` (hayro-interpret); (55) `raster::tests::devicen_inks::a_tint_transform_that_cannot_be_evaluated_draws_the_rest_of_the_page_and_warns`, `raster::tests::devicen_inks::a_tint_transform_that_cannot_be_read_falls_back_and_warns`; (56) `raster::tests::invisible_unknown_annotations_and_popups_paint_nothing`; (57) `substitute_widths::a_substitute_keeps_its_glyph_shapes_at_wider_advances`, `substitute_widths::a_substitute_still_narrows_glyphs_to_narrower_advances` (pdfcraft-render `tests/it`); (58) `cid_fallback_query::a_cid_font_fallback_request_carries_its_descendants_style`, `cid_fallback_query::a_cid_font_without_a_descriptor_asks_for_the_default_style` (pdfcraft-render integration tests); (59) `raster::tests::no_rotate_notes_stay_upright_at_their_corner_on_rotated_pages` (hayro-interpret); (64) `type3_text_clip::{clip_only_type3_text_is_not_rendered_and_leaves_no_clip, invisible_type3_text_is_not_rendered, other_modes_render_type3_glyphs_filled_and_never_clip, only_outline_glyphs_shape_a_clip_shared_with_type3_text, self_nesting_type3_text_in_a_clip_mode_stays_bounded}` (`cargo test -p pdfcraft-render --test it`), `raster::tests::rendering_modes_but_3_and_7_draw_type3_glyphs_as_filled`, `text::tests::clipping_mode_type3_text_is_kept_by_the_ocr_filter`; (66) `separation_all::{separation_all_fills_paint_the_complemented_tint_as_gray, separation_all_strokes_paint_the_complemented_tint_as_gray, separation_all_images_paint_each_sample_as_the_complemented_gray, separation_all_shadings_paint_the_complemented_gray, separation_none_paints_nothing, a_named_separation_still_paints_through_its_alternate, a_device_n_space_naming_all_still_paints_through_its_alternate}` (`cargo test -p pdfcraft-render --test it`); (63) `color::device_cmyk_gray_tests::{each_ink_darkens_by_its_iso_weight, ink_past_full_coverage_is_black_and_bad_components_read_as_zero}` (hayro-interpret), `it::luminosity_group_space::{black_ink_in_a_device_cmyk_group_masks_fully_and_no_ink_shows_fully, each_process_ink_in_a_device_cmyk_group_masks_by_its_weight, a_device_cmyk_backdrop_colour_masks_by_its_gray, gray_and_rgb_groups_mask_as_before, device_cmyk_on_the_page_keeps_its_display_colour}` (pdfcraft-render); (71) `raster::tests::an_indexed_lookup_table_is_read_in_every_legal_form` (pdfcraft-render); (72) `raster::tests::an_uncoloured_pattern_uses_its_underlying_space_in_every_form` (pdfcraft-render); (73) `raster::tests::the_jbig2_images_of_a_page_share_one_allowance`, `a_picture_whose_jbig2_mask_is_past_the_pages_allowance_is_not_drawn` and `a_pages_jbig2_allowance_ends_with_the_page` (pdfcraft-render); (74) `raster::tests::{a_chain_of_colour_spaces_ends, colour_spaces_are_read_to_the_limit_of_their_nesting, a_chain_built_on_spaces_already_read_ends, a_profile_read_at_the_limit_keeps_its_alternate_for_a_read_nearer_the_top}` (pdfcraft-render); (75) `raster::tests::a_soft_hyphen_a_substitute_lacks_draws_its_hyphen` (pdfcraft-render); (89) `raster::tests::a_form_that_draws_itself_is_drawn_once`, `raster::tests::a_form_that_reaches_itself_through_a_pattern_or_a_glyph_is_drawn_once` (pdfcraft-render) | | hayro | 0.7.1 | Apache-2.0 OR MIT | (1) `RenderSettings::{x_offset, y_offset}` to render one tile of a large page; (2) images over `MAX_IMAGE_PIXELS` (2^28) are skipped (a fuzzed `/W 4294967295` hung in resampling); (3) `tiling_cell_scale` keeps a tiling pattern's cell pixmap within 3000 px a side whatever its `/XStep`/`/YStep` (a fuzzed step far beyond `/BBox` allocated 2 GB); (4) stroke widths clamped to 4 × (canvas width + height) device pixels (a fuzzed `/LW 9223372036854775807` allocated 10 GB in stroke expansion); (5) `usable_dash_pattern`: a stroke whose dash pattern could cut it into more than `MAX_DASHES_PER_STROKE` (250,000) pieces, counting kurbo's restart of the pattern at every subpath and `ClosePath`, is drawn solid (a fuzzed `/D [[11] 0]` on a line from x = 92234775807 allocated over 5 GB, `[0.000001] 0 d` on a 20 pt line over 10 GB); (6) a dash pattern with a negative or non-finite entry, or a period that isn't positive, is drawn solid (`[-1 -1] 0 d` looped for ever in kurbo); (7) `render_size` and `render_into`: render into a buffer the caller allocates (PdfCraft renders into pixel-aligned memory that the GUI takes over as texture data); `render` is `render_into` on a new pixmap; (8) checked image source/target/RGBA, alpha-premultiplication scratch and intermediate sizes before resampling, finite transforms and exact source/mask buffers; sources wider than u16 may shrink before backend limits, with a 2^20 source-axis cap and conservative 128 MiB filter-planning estimate; an over-limit or failed resize keeps the original image resolution and geometry when the source fits the backend, otherwise skips it, with a logged warning; staging, resampling output/scratch, RGBA conversion and padding buffers use fallible reserves; (9) `may_paint_canvas`: a fill or stroke whose control-point box, grown by how far its paint reaches past the path (half the stroke width times the larger of the miter limit and √2) plus 0.5 user units and 2 pixels, misses the canvas is not drawn, so a tile no longer strokes and fills every path of its page (item 5 of #623); `set_skip_offscreen_paths` turns this off for the regression test; (10) `/Interpolate` governs the pre-resize too (#624): a minified image that does not request interpolation is pre-resized with nearest-neighbour (since (21), only inside Type 3 glyphs), planned without the Catmull-Rom filter limits (`image_resampling_size` takes the flag); a Type 3 glyph image is sampled bicubically only when interpolated; a minified image with a matching soft mask is nearest-sampled straight into premultiplied RGBA; (11) `may_paint_rect`: an image whose stream-size box, mapped to the device, misses the canvas is skipped before decoding (decoded data can only shrink against that box), so a tile of an image-heavy page no longer decodes, resamples and rasterizes every image on it; (12) preview renders (`InterpreterSettings::preview`: backdrops, thumbnails, grid cells, signature-drag backgrounds) skip images under `PREVIEW_MIN_IMAGE_AREA` device pixels, while final renders always draw them; (13) reading colours (#897): `RenderSettings::color_replacement` takes a `ColorReplacement` (text colour, background, `only_black`, `line_art`) that remaps solid text and line-art paint while rendering, glyph outlines counting as text and stencil-mask ink as line art; images, image alpha masks, soft masks and pattern cells keep their colours; `render_pdf` gains a `color_replacement: Option` argument (pass `None` for true colours); the upstream-only `[dev-dependencies.log]` entry is removed from `Cargo.toml` (log is a normal dependency since the logging patch), so `cargo test -p hayro` runs this crate's unit tests; (14) a shrunk image that is interpolated (or, since (21), area-averaged) and whose axes lie along the device's (upright, mirrored or a quarter turn, as on a page under `/Rotate`) is snapped to whole device pixels: shrunk once to its rounded device rectangle (halves round up, so a tile snaps an edge where the whole page does) and drawn 1:1 from the rounded corner, instead of a ceil-sized raster resampled again at a fractional scale and offset (`snap_minified_image`; scans looked soft next to Acrobat, and until #5530 a turned one still did, up to 200 grey levels off the same page drawn upright). An image counts as axis-aligned when the terms the snap drops move its far edges by at most a hundredth of a device pixel (`SNAP_SKEW`), not by a tolerance on the coefficients, which let a slight skew of a very wide image through; and the snapped 1:1 transform is used only when the shrink produced the snapped size, so a failed resize keeps the full transform instead of drawing the source raster at its source size; (15) a soft mask of another size than its image is filtered when shrunk, whatever its `/Interpolate` (#1649): such a mask carries the shape on its own (Word saves text as a tiny black image with a glyph-bitmap mask), and nearest-neighbour dropped every stroke thinner than the shrink factor; (16) under a soft mask or a blend mode, axial and radial shadings are sampled instead of drawn as native gradients (#2128): vello_cpu 0.0.8 copies an opaque gradient over fully covered tiles without the mask or blend mode, so PowerPoint's faded gradient fills came out solid; (17) the renderer keeps annotation paints out of the reading-colours mapping between `begin_annotations` and `end_annotations` (#1548); (18) the page renders as one transparency group; under reading colours its background is painted inside it as the initial backdrop, so a Multiply highlight blends with the dark background instead of staying an opaque block, and otherwise the background stays outside, so a blend mode over an empty part of the page keeps its colour instead of blending with the white paper (#2970); (19) `set_paint` samples a mesh shading's texture over its triangles alone (`EncodedShadingPattern::extent`, padded rather than repeated) and skips a mesh with none on the canvas: a BricsCAD plan's 310 one-triangle `sh` each covered the whole tile, 2 s per 1024 px tile at any zoom (the watchdog gave the page up on a busy machine), now about 70 ms; (20) a tiling pattern whose step is too large for a cell pixmap, where what is painted (a fill, or a stroke with its width) can only show the first cell (PowerPoint's `/XStep 2147480100` for a picture fill drawn once), has its `/BBox` drawn at full scale with a transparent border and padded instead of tiled (`shows_one_cell`; a cell whose scale isn't finite and positive, from a `/BBox` too thin to divide by, keeps the old path: `usable_cell_scale`); shrinking the cell to fit the 3000 px limit of (3) left the picture under a pixel, so network diagrams lost their pictures (#3349); (21) an image that does not request interpolation and is drawn below its resolution, outside a Type 3 glyph, is shrunk by area averaging (`area_shrink`: each device pixel the coverage-weighted mean of the samples it covers, in 16.16 fixed point whose weights sum to exactly one, premultiplied when the image has a mask; a soft mask no larger than the image is first sampled onto the image's grid by `mask_on_image_grid` (nearest-neighbour, or bilinearly when the mask asks for `/Interpolate`, so its edge stays as smooth as an enlarged mask's), so its colour and mask are averaged together instead of the averaged colour of transparent samples showing under a mask layer at the mask's edges) to its snapped device rectangle, instead of nearest-neighbour: `/Interpolate` asks for smoothing only when an image is enlarged (ISO 32000-2 §8.9.5.3), and nearest-neighbour kept one sample in N per axis, so the text of a 300 dpi scan at 100% lost most of its strokes (sampling them again at a fractional offset dropped more). PicScale's `Box` and `Area` filters were not used: shrinking, both average a window four target pixels wide. On a 13-page 300 dpi scan every rendered sample at 96, 120 and 192 dpi is within 1 of the exact area average (it was up to 255 off, 17-23% of samples by more than 1). A snapped image whose shrink failed keeps the full transform, so it is not drawn at its source size. The shrink never enlarges: a side drawn at or above its resolution (an image shrunk across and stretched down, as a 204 × 98 dpi fax is) keeps its samples for the sampler. Each target row averages the whole source rows it covers (contiguous, cheap) and is then shrunk across with taps worked out once per image, which made the shrink about three times faster (a 300 dpi page at 96 dpi: 25 ms, nearest-neighbour 11 ms); working a row at a time (`area_shrink_rows`) keeps its work memory to a few rows. An image with a soft mask, a stencil's ink included, is premultiplied a source row at a time as it is averaged (`area_shrink_premultiplied`), never as a full-resolution RGBA copy: a 10718 × 13701 stencil shown at page size peaked at 1.16 GB with that copy and peaks at 1.03 GB without it, as with nearest-neighbour. A source with a side over `MAX_RESAMPLING_SOURCE_SIDE` (2^20) keeps (10)'s nearest-neighbour shrink, and `area_shrink` refuses it, so its accumulator row and tap table stay a few times one source row (a 2^27 × 2 image of zeros is a tiny file); (22) knockout groups: while one is innermost, fills, strokes, glyph outlines and shadings are drawn with `Compose::Copy` (weighted by coverage), so each element replaces what earlier elements of the group drew instead of compositing over it; an image or nested group, drawn into a layer of its own, first has what is under its shape cleared (`knockout_image` for an unmasked image's rectangle, only when `draw_image` will draw it, decoded at the size it is drawn; a knockout erase pass that draws a nested group's content with every paint clearing instead of painting, a shading fill's clear clipped to the shading's `/BBox` as its paint is) and then composites over it (#2971); (23) backport of hayro 0.8: shadings that `as_svg_gradient` can represent are painted as a `vello_cpu` linear or two-point radial gradient instead of a texture sampled pixel by pixel through the shading function (InDesign fades are luminosity soft masks of one full-page axial `sh`: 40 on a report page took 15 s at 144 dpi, now under 1 s); without upstream's half-pixel shift, which suits vello_cpu 0.3 but moves 0.0.8's gradients off pixel centres; (25) a tiling cell is drawn at the scale that makes one `/XStep` (`/YStep`) exactly the cell pixmap's whole number of pixels (`tiling_cell_pixels`, at least 1 and at most the cap), so the repeated pixmap puts cells at whole multiples of the step at any device scale; drawing at the unrounded scale repeated the cell up to half a pixel off at every step, and the error grew with distance from the pattern's origin (ISO 32000-2 8.7.3.1); (28) an image of page-scan size (`SMOOTHED_IMAGE_SIDE`: 512 samples or more on its shorter side) that does not request interpolation is drawn with `ImageQuality::Medium` (bilinear, as interpolated images are) when enlarged by more than 1% on either side (`SMOOTHED_IMAGE_SCALE`: a scan drawn at its resolution can come out a few ulps above 1, and blurred by half a pixel at a half-pixel offset), also when the other side is shrunk (the area shrink leaves that side at 1:1), outside a Type 3 glyph: scans rarely set `/Interpolate`, and drawn nearest-neighbour every sample of a zoomed scan became a block, so its text had staircase edges; icons, QR codes and pixel art keep their hard pixels. Bicubic (`High`) looked the same on a zoomed 300 dpi scan and drew four 1024 px tiles at 400% in 267 ms against 50 ms | (1) `raster::tests::tiles_match_full_render`; (2) `raster::tests::absurd_image_dimensions_are_skipped`; (3) `raster::tests::tiling_cells_stay_small_whatever_the_step`; (4) `raster::tests::absurd_predictor_columns_and_line_widths_render`; (5) `raster::tests::dash_patterns_that_cut_a_stroke_into_billions_of_pieces_render`; (6) `raster::tests::invalid_dash_patterns_terminate_and_are_drawn_solid`; (7) `raster::tests::pixels_are_a_plain_renders_bytes_in_a_buffer_a_gui_can_take_over`; (8) `raster::tests::image_resampling_rejects_anisotropic_target_growth`, `raster::tests::image_resampling_bounds_intermediate_before_planning`, `raster::tests::image_resampling_rejects_invalid_sources_and_scales`, `raster::tests::image_resampling_keeps_valid_sizes_and_exact_limits`, `raster::tests::resampling_fallback_preserves_original_geometry_and_pixels`, `raster::tests::image_resampling_wide_sources_shrink_before_backend_side_limits`, `raster::tests::image_resampling_wide_transparent_sources_still_shrink`, `raster::tests::image_resampling_padded_backend_dimensions_stay_checked`, `raster::tests::image_resampling_mismatched_alpha_mask_keeps_pixels_and_placement`; (9) `raster::tests::skipping_paths_off_the_canvas_changes_no_pixel`; (10) `raster::tests::non_interpolated_images_are_not_smoothed_when_minified`, `raster::tests::non_interpolated_gray_images_are_not_smoothed_when_minified`, `raster::tests::non_integer_minification_averages_the_columns_each_pixel_covers`, `raster::tests::non_integer_minification_averages_colour_and_mask_together`, `raster::tests::non_interpolated_alpha_images_keep_their_colours_and_mask_when_minified`, `raster::tests::non_interpolated_type3_glyph_images_are_not_smoothed_when_minified`, `raster::tests::image_resampling_nearest_accepts_wide_sources_the_filter_refuses`, `raster::tests::wide_non_interpolated_sources_draw_after_shrinking`; (11) `raster::tests::skipping_paths_off_the_canvas_changes_no_pixel`; (12) `raster::tests::preview_renders_skip_tiny_images_but_finals_draw_everything`; (13) `color_replacement_tests::{black_maps_to_text_colour, white_maps_to_background, midgrey_interpolates, alpha_copied_verbatim, only_black_threshold, line_art_false_skips_paths_but_not_text}`, `color_replacement_render_tests::{color_replacement_maps_text_and_line_art, color_replacement_maps_stencil_mask_ink}`; (14) `raster::tests::minified_image_edges_snap_to_whole_pixels`, `raster::tests::a_tile_snaps_half_pixel_image_edges_as_the_whole_page_does` (`cargo test -p hayro`), `raster::tests::a_turned_or_mirrored_minified_image_snaps_to_whole_pixels`, `rotated_image_snap::a_shrunk_image_on_a_turned_page_is_the_upright_render_turned` (`cargo test -p pdfcraft-render --test it`), `renderer::snap_tests::a_skew_that_moves_a_far_edge_is_not_snapped` (`cargo test -p hayro`); (15) `raster::tests::shrunk_soft_mask_larger_than_its_image_keeps_thin_strokes`; (16) `raster::tests::soft_mask_applies_to_a_shading_pattern_fill`; (17), (18) `raster::tests::reading_colors_keep_highlight_annotations_readable`; (19) `mesh_extent_tests::{a_mesh_paints_its_triangles_alone, a_mesh_keeps_the_graphics_state_alpha, a_mesh_off_the_canvas_paints_nothing}` (`cargo test -p hayro`); (20) `raster::tests::a_tiling_pattern_drawn_once_shows_its_cell`, `raster::tests::a_large_step_still_tiles_where_the_next_cell_shows`, `raster::tests::a_tiling_cell_too_thin_to_scale_keeps_the_old_path`; (21) `raster::tests::non_interpolated_minification_keeps_thin_strokes`, `raster::tests::non_integer_minification_averages_the_columns_each_pixel_covers`, `raster::tests::non_integer_minification_averages_colour_and_mask_together`, `raster::tests::non_interpolated_minification_does_not_bleed_transparent_colour`, `raster::tests::a_smaller_soft_mask_does_not_bleed_transparent_colour_when_minified`, `raster::tests::an_interpolated_smaller_soft_mask_stays_smooth_when_its_image_is_minified`, `raster::tests::a_shrunk_stencil_mask_keeps_thin_strokes`, `raster::tests::minified_image_edges_snap_to_whole_pixels`, `raster::tests::an_image_shrunk_one_way_and_stretched_the_other_is_still_averaged`, `raster::tests::wide_non_interpolated_sources_draw_after_shrinking`, `renderer::area_shrink_tests` (`cargo test -p hayro`); (22) `raster::tests::knockout_groups_replace_what_their_elements_cover`; (23) `raster::tests::axial_and_radial_shadings_draw_as_native_gradients`; (25) `tiling_cell_placement::{far_cells_of_a_fill_sit_at_whole_steps, far_cells_sit_at_whole_steps_under_a_turned_scaled_matrix, far_cells_of_a_stroke_sit_at_whole_steps, a_cell_pixmap_spans_exactly_one_step}` (`cargo test -p pdfcraft-render --test it`); (28) `raster::tests::large_non_interpolated_images_are_smoothed_when_enlarged`, `raster::tests::small_non_interpolated_images_keep_hard_pixels_when_enlarged`, `raster::tests::a_scan_shrunk_one_way_is_smoothed_the_way_it_is_stretched`, `raster::tests::a_scan_drawn_a_rounding_error_above_its_resolution_stays_sharp` | | hayro-jbig2 | 0.3.0 | Apache-2.0 OR MIT | (1) at most 2^28 pixels per bitmap and page bitmap, and 2^25 cells per halftone grid and grayscale image (each side could be 65535: 4.3 billion pixels decoded one by one, and a 4-byte value per cell); (2) at most 2^26 symbol pixels per symbol dictionary (a fuzzed dictionary decoded for minutes); (3) a symbol dictionary's height classes are capped at one per new symbol and its export runs at `2 × symbols + 1`, each plus `EXTRA_EMPTY_STEPS` (1024): an empty height class or a zero-length export run doesn't advance, and past the end of its data the arithmetic decoder can decode them for ever (one changed byte hung the renderer); (4) a segment header's long-form referred-to segment count is capped at `u16::MAX` and a larger count is refused before its retention or referred-to bytes are read: the 29-bit count went straight to `Vec::with_capacity`, a reservation of about 2 GiB whose failure aborts the process (from storytold/craft-libs#176); (5) what a page's segments hold for later ones (intermediate regions, pattern and symbol dictionaries with their headers and retained contexts, and Huffman tables) is at most 64 MiB, two square bitmaps of 16,384 a side (`MAX_RETAINED_WORDS`), and a dictionary's new and exported symbols are counted against it as they are made: each was bounded on its own, but a few dozen bytes an intermediate region held 33 MB each, and 8 MiB symbols exported again and again killed the process, so a few kilobytes could exhaust memory; (6) a segment may refer to at most 2^20 symbols, checked before any are collected: references may repeat, and 65,535 of them to one dictionary, in a 78 KB stream, asked for 17 GB; (7) a pattern dictionary keeps at most 16 MiB of shifted copies, which only speed up drawing, and none that wouldn't fit what the page has left (`MAX_SHIFTED_WORDS`); (8) a custom Huffman table has at most 2^16 lines, and a stream with its globals at most 2^20 segments and 2^22 references, checked as they are read: both grew before anything counted them, and PDFs of a kilobyte or so reached 5 GB and more behind nested Flate; (9) a page past the bounds of (1) is refused when the stream is opened: a caller sizes its output from the header before the page bitmap is made, and an Indexed image in a 743-byte PDF asked for 137 GB, which aborts; (10) a stream may ask for at most 2^30 units of decoding work, four of the largest pages, about 6 s to 17 s (`MAX_WORK`): decoding a bitmap costs a pixel each and 4 a row, copying one a word each and 1 a row, and each integer an item reads costs 38, what one takes at worst, so one with no pixels pays too, before it is decoded: each bitmap was bounded, but nothing bounded how many there were, and a 795-byte PDF took 265 s; (11) `Image::decode_within` spends from what a run of images may still do, starting with its page's pixels, and `charge_reading` charges reading a stream and its globals before anything is parsed, so an operation over a document can bound what `MAX_WORK` bounds for one image, and images that fail or decode nothing add up too: a file of thousands of images made to reach that limit took as many times as long; (12) adding to a location, a reference offset, a symbol's corner or a Huffman range line near the end of an `i32`, and negating a decoded integer of 2^31, overflowed: a panic wherever overflow is checked. The refinement context, a text region's corners, the integer decoder and the Huffman table wrap, as a release build did, and `Bitmap::combine` saturates, which comes to the same, as a location that overflows is past the end of any page. What is drawn doesn't change; (13) a bitmap filled with a default pixel of 1 (a page, or an intermediate text or halftone region) had 1s past its width in the last word of each row, and a refinement, which reads its reference a word at a time, read them as pixels where the standard has 0 (6.3.5.2): refining such a reference not a multiple of 32 wide decoded to other pixels than were coded, release builds too. `Bitmap::clear_padding` clears those bits when a bitmap is filled with 1s, as cutting symbols and patterns from a collective bitmap already did. Ghostscript reads the test's page and text streams as they decode now; (14) a region's location is unsigned (5.4.4), so one past `i32::MAX` is off the page, where the decoder took it for a signed one: a region there was drawn back onto the page from the left or the top (at 0xFFFFFFFC, four pixels left of it), and a refinement there, of the page or of a region at the same place (7.4.7.5), was refused as an overflow, and the page with it. `Bitmap::combine_region` keeps such a location past the page. A refinement of another region refines it where it is, as Table 35 has it (GRREFERENCEDX and GRREFERENCEDY 0), whatever the two locations say; a refinement of the page, see (16); (15) a refinement whose reference is offset right of it (GRREFERENCEDX above 0, which a refined text instance has from a wider bitmap, Table 12, and a refined symbol from an RDX above 0, Table 18) read the reference's first 32 columns as 0: `RefinementContextGatherer::maybe_reload_buffers` emptied its buffers left of the reference and didn't load them again at its first column. Such a refinement decoded to other pixels than were coded, or failed and lost the page. Left of the reference the buffers now keep the words of its first column each row starts with; with an offset of 0 or less nothing changes; (16) a refinement of the page refines "the current contents of the page buffer, restricted to the area of the page buffer specified by this segment's region segment information field" (7.4.7.4), at an offset of 0 (Table 35): `Bitmap::area` copies that area a word at a time, 0 off the page and past its width whatever the padding of its rows holds, and every refinement region reads its reference at offset 0. The page around the area was read too, so a refinement of the page decoded to other pixels than were coded, and one wider or taller than the page was refused. And `RefinementContextGatherer::start_row` loads the reference's words a column left of where a row starts in it, as the reload would, not from column 0: with the reference offset 32 or more left of the refinement (GRREFERENCEDX below −31, as a refined text instance has from a narrower bitmap or an RDX below −31, and a refined symbol from such an RDX), each row's first context read 0 for the reference column it starts at, and from 33 left for the one before it too, and the refinement decoded to other pixels or failed | (1) `raster::tests::jbig2_regions_of_billions_of_pixels_are_refused`, `raster::tests::jbig2_halftone_grids_past_their_bound_are_refused`; (2) the nightly `cargo xtask fuzz` finding, which now finishes in about a second; (3) `raster::tests::jbig2_symbol_dictionaries_that_never_advance_terminate`; (4) `segment::tests::test_referred_to_count_is_capped` (`cargo test --manifest-path vendor/hayro-jbig2/Cargo.toml`); (5) `raster::tests::jbig2_regions_held_past_a_pages_bound_are_refused`, `jbig2_symbols_without_pixels_count_towards_a_pages_bound`, `jbig2_retained_contexts_count_towards_a_pages_bound`, `jbig2_huffman_tables_count_towards_a_pages_bound`, `jbig2_new_symbols_count_towards_a_pages_bound`, `jbig2_new_symbols_are_held_to_what_a_page_has_left`, `jbig2_pattern_dictionaries_count_towards_a_pages_bound`, and in the crate (`cargo test --manifest-path vendor/hayro-jbig2/Cargo.toml`) `decode::symbol::tests::exported_symbols_are_counted_before_they_are_copied`, `tests::the_room_left_shrinks_as_segments_are_stored`; (6) `raster::tests::jbig2_segments_referring_to_too_many_symbols_are_refused`; (7) `raster::tests::jbig2_pattern_dictionaries_too_large_to_shift_still_draw`, `jbig2_shifted_patterns_never_refuse_an_image`; (8) `raster::tests::jbig2_huffman_tables_of_too_many_lines_are_refused`, `jbig2_streams_of_too_many_segments_are_refused`, `jbig2_streams_of_too_many_references_are_refused`, and in the crate `tests::the_segments_of_a_stream_and_its_globals_count_together`; (9) `raster::tests::jbig2_pages_past_the_bounds_are_refused_before_anything_is_sized_from_them`, `tests::a_page_past_the_bounds_is_refused_when_opened`; (10) `raster::tests::jbig2_decoding_work_is_counted_item_by_item`; (11) `jbig2::decode::tests::a_run_of_images_spends_its_allowance_down_to_the_step` (pdfcraft-filters); (12) `raster::tests::jbig2_placements_at_the_end_of_an_i32_draw_nothing`; (13) `jbig2::decode::tests::a_refinement_reads_nothing_past_its_references_width` (pdfcraft-filters), and in the crate `tests::a_bitmap_of_1s_has_nothing_past_its_width`; (14) `jbig2::decode::tests::a_region_located_past_i32_max_is_off_the_page` (pdfcraft-filters), and in the crate `tests::a_region_located_past_i32_max_is_off_the_page` and `tests::a_refinement_of_a_region_decodes_the_same_wherever_they_are`; (15) `jbig2::decode::tests::a_refinement_reads_a_reference_right_of_it_from_its_first_column` (pdfcraft-filters), and in the crate `tests::a_refinement_reads_a_reference_right_of_it_from_its_first_column`; (16) `jbig2::decode::tests::a_refinement_refines_the_page_in_its_area_and_any_reference_where_it_is` (pdfcraft-filters), and in the crate `tests::a_refinement_refines_the_page_in_its_area_and_any_reference_where_it_is` and `tests::a_page_area_is_the_page_inside_it_and_0_outside` | | lopdf | 0.45.0 | MIT | (1) PNG predictor rows longer than the data are refused before their buffers are allocated (a fuzzed `/Columns 4294967295` allocated two 4 GiB rows), and empty frames return without row buffers; (2) predictor row widths use saturating arithmetic or reject overflow; (3) sub-byte TIFF Predictor 2 scratch is bounded by the samples present in the input instead of the declared `/Colors`, aligned with [lopdf#589](https://github.com/J-F-Liu/lopdf/pull/589); (4) the object lookup keeps its cycle history through compressed objects, requires an ordinary in-file container, and caps the chain of indirect lookups at 16 (a chain of new objects, each a `/Length` compressed in a container whose own `/Length` names the next, or a stream whose `/Length` names the next stream, overflowed the stack: a dev build's 2 MiB thread within 800 hops); (5) object-stream members are parsed within their own extent (up to the next distinct offset), an entry whose offset repeats an earlier entry's is skipped (so a repeated object number can resolve to its earlier entry), and offsets are processed in index order; (6) `find_latest_trailer`: a rebuild takes a `/Type /XRef` stream dictionary as the trailer when the file has no `trailer` keyword (the latest in the file wins, as a later `trailer` would), so a concatenated or truncated file keeps its /Info and /ID instead of failing to load; a candidate whose /Root is the catalog beats one whose /Root is another dictionary, and an in-file /Root must match its generation (#2365) | (1) `inspect::tests::png_predictor_rows_longer_than_the_data_are_refused_before_allocating`, `inspect::tests::empty_png_predictor_frames_do_not_allocate_rows`; (2) `inspect::tests::tiff_subbyte_predictor_row_width_overflow_is_refused`; (3) `inspect::tests::tiff_subbyte_predictor_scratch_is_bounded_by_available_samples`, `inspect::tests::tiff_subbyte_predictors_keep_components_rows_and_padding`; (4) `inspect::tests::compressed_object_self_container_does_not_recurse`, `inspect::tests::compressed_object_mutual_containers_do_not_recurse`, `inspect::tests::object_stream_length_cycle_does_not_recurse`, `inspect::tests::compressed_object_container_chain_does_not_recurse`, `inspect::tests::compressed_length_chain_does_not_recurse`, `inspect::tests::plain_stream_length_chain_does_not_recurse`, `inspect::tests::compressed_objects_and_indirect_stream_length_still_load`, `inspect::tests::compressed_length_in_a_container_with_an_indirect_length_still_loads`; (5) `inspect::tests::object_stream_member_extents_bound_owned_strings`, `inspect::tests::object_stream_member_extents_preserve_index_order_and_whitespace`; (6) `inspect::tests::rebuilt_file_keeps_the_info_of_its_cross_reference_stream`, `inspect::tests::rebuilt_file_prefers_a_trailer_whose_root_is_the_catalog` | diff --git a/vendor/hayro-interpret/src/context.rs b/vendor/hayro-interpret/src/context.rs index de3907b1ff..a281cd04b9 100644 --- a/vendor/hayro-interpret/src/context.rs +++ b/vendor/hayro-interpret/src/context.rs @@ -37,6 +37,12 @@ std::thread_local! { // PdfCraft patch (18): whether this page's content budget ran out, reported as // `InterpreterWarning::ContentTruncated`. static CONTENT_TRUNCATED: core::cell::Cell = const { core::cell::Cell::new(false) }; + // PdfCraft patch (89): the form XObjects being drawn on this page, outermost first, across + // the contexts of the tiling patterns, Type 3 glyphs, soft masks and groups painted while + // they draw. A form already on it draws itself and is skipped. Its length is bounded by the + // nesting limits: every form on it is nested in the ones before it. + static FORMS_DRAWING: RefCell> = + const { RefCell::new(Vec::new()) }; } /// PdfCraft patch: decoded content-stream bytes one page may hold at once: its contents, and @@ -218,6 +224,8 @@ impl<'a> Context<'a> { NESTED_PAINTS.with(|n| n.set(0)); CONTENT_LEFT.with(|c| c.set(MAX_PAGE_CONTENT)); CONTENT_TRUNCATED.with(|truncated| truncated.set(false)); + // PdfCraft patch (89): no form of another page is being drawn. + FORMS_DRAWING.with(|forms| forms.borrow_mut().clear()); // PdfCraft patch (73): and fresh JBIG2 decoding, which every image of it shares. hayro_syntax::set_page_jbig2_work(settings.jbig2_work); // PdfCraft patch (55): a fallback left over from another page is not this page's. @@ -267,6 +275,56 @@ impl<'a> Context<'a> { } } + /// PdfCraft patch (89): begins drawing the form `id`; `false` when that form is already + /// being drawn, so drawing it again would never end (ISO 32000-2 §8.10.1: a form is a + /// self-contained description; one that contains itself describes nothing finite). The + /// skip is reported as `InterpreterWarning::ContentTruncated`. A form without an object + /// number can't be told apart and is only bounded by the nesting limits. + /// + /// The forms being drawn are those of this page's context and of every context painting + /// inside it (a tiling pattern, a Type 3 glyph, a soft mask or a group), so a form that + /// reaches itself through one of those is skipped too, while a form drawn again after it + /// has finished draws as usual. + pub(crate) fn enter_form( + &mut self, + id: Option, + ) -> bool { + let Some(id) = id else { + return true; + }; + // The stack is never borrowed across a call, so the borrow succeeds; if it didn't, + // the form draws, bounded by the nesting limits as before this patch. + let entered = FORMS_DRAWING.with(|forms| { + let Ok(mut forms) = forms.try_borrow_mut() else { + return None; + }; + if forms.contains(&id) { + return Some(false); + } + forms.push(id); + Some(true) + }); + if entered == Some(false) { + warn!("form XObject draws itself; the inner drawing is skipped"); + CONTENT_TRUNCATED.with(|truncated| truncated.set(true)); + return false; + } + true + } + + /// PdfCraft patch (89): ends drawing a form begun with `enter_form`. + pub(crate) fn leave_form(&mut self, id: Option) { + if let Some(id) = id { + FORMS_DRAWING.with(|forms| { + if let Ok(mut forms) = forms.try_borrow_mut() + && forms.last() == Some(&id) + { + forms.pop(); + } + }); + } + } + pub(crate) fn save_state(&mut self) { let Some(cur) = self.states.last() else { warn!("attempted to save state without existing state"); diff --git a/vendor/hayro-interpret/src/x_object.rs b/vendor/hayro-interpret/src/x_object.rs index 6fcb34a5d7..aa05c4f455 100644 --- a/vendor/hayro-interpret/src/x_object.rs +++ b/vendor/hayro-interpret/src/x_object.rs @@ -121,6 +121,24 @@ pub(crate) fn draw_form_xobject<'a, 'b>( x_object: &'b FormXObject<'a>, context: &mut Context<'a>, device: &mut impl Device<'a>, +) { + // PdfCraft patch (89): a form already being drawn is not drawn inside itself. A name its + // content uses can reach it again (through its own resources, or the page's, which a form + // with resources of its own still falls back on), and every such level used to run until + // the page's nested-paint budget was spent. + let id = x_object.dict.obj_id(); + if !context.enter_form(id) { + return; + } + draw_form_xobject_once(resources, x_object, context, device); + context.leave_form(id); +} + +fn draw_form_xobject_once<'a, 'b>( + resources: &Resources<'a>, + x_object: &'b FormXObject<'a>, + context: &mut Context<'a>, + device: &mut impl Device<'a>, ) { if !context.ocg_state.is_visible() { return; From 3ae9093d6198fe329ef6352ac674468213cb2ff8 Mon Sep 17 00:00:00 2001 From: mtlcl410 <2368091+mtlcl410@users.noreply.github.com> Date: Mon, 9 Nov 2026 08:30:37 +0800 Subject: [PATCH 23/92] M2: `info` lists the repairs made while opening, as Document Properties does (misc.cli-inspect) (#6518) * M2: `info` lists the repairs made while opening, as Document Properties does (misc.cli-inspect) `pdfcraft-cli info` now prints a `repairs` array: the notes about damage repaired while opening that the app shows in Document Properties and doc_info reports. It opens the document as the app does, once (the session the dynamic XFA summary already used), so notes the app adds after reading the objects, such as a page tree rebuilt from the pages found by scanning, are listed too. A file the app cannot open for editing falls back to what reading its objects repaired; a sound file prints an empty array. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Pq7HyDBXygTf7jcqdsjcfn * M2: `info` runs none of the document's scripts while inspecting it (misc.cli-inspect) The session `info` opens to read the app's repair notes and the dynamic XFA layout now has JavaScript turned off, so an XFA form's initialize and calculate scripts, which the app runs on opening, do not run during inspection of an untrusted file. Before this branch the session was opened for dynamic XFA forms only, and their scripts ran there too. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Pq7HyDBXygTf7jcqdsjcfn * M2: `info` reads the standards and repairs from the session it opens, and a laid-out form keeps its repair notes (misc.cli-inspect) `info` now takes the declared standards (`Document::standards`, as the Standards panel reads them) and the repair notes from the session it opened, and reads the file once more only when the app cannot open it. When opening lays out a dynamic XFA form, or writes any other revision, the notes about damage repaired while reading the file now stay with the re-read document, so Document Properties and `info` both list them. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Pq7HyCHD1R3rywQBPCHXZA --------- Co-authored-by: mtlcl410 Co-authored-by: Claude Opus 5.5 --- apps/pdfcraft-cli/src/commands.rs | 45 ++++++++----- apps/pdfcraft-cli/tests/it/info_repairs.rs | 75 ++++++++++++++++++++++ apps/pdfcraft-cli/tests/it/main.rs | 1 + apps/pdfcraft-cli/tests/it/xfa.rs | 43 ++++++++++++- crates/engine/src/open.rs | 13 ++-- parity/acrobat-features.toml | 4 +- 6 files changed, 156 insertions(+), 25 deletions(-) create mode 100644 apps/pdfcraft-cli/tests/it/info_repairs.rs diff --git a/apps/pdfcraft-cli/src/commands.rs b/apps/pdfcraft-cli/src/commands.rs index 3118c901b4..c64f9e1c0f 100644 --- a/apps/pdfcraft-cli/src/commands.rs +++ b/apps/pdfcraft-cli/src/commands.rs @@ -8,6 +8,9 @@ pub(crate) fn read(path: &str) -> Result>, String> { std::fs::read(path).map(Arc::new).map_err(|e| format!("{path}: {e}")) } +/// `info FILE`: a JSON summary of the document as the app opens it (pages, fields, metadata, the +/// standards it declares, and the notes about damage repaired while opening it), with none of +/// its scripts run. pub(crate) fn info(args: &[String]) -> Result<(), CliError> { validate_options("info", args, INFO_OPTIONS)?; let path = *positional(args, INFO_OPTIONS).first().ok_or("info: missing file")?; @@ -18,24 +21,33 @@ pub(crate) fn info(args: &[String]) -> Result<(), CliError> { let mut info = inspect(bytes.clone(), render_password.as_deref()).map_err(|e| e.to_string())?; let pdf_pages = info.pages.len(); let acroform_fields = info.fields.len(); + // The document as the app opens it: the notes about damage repaired while opening, which the + // app shows in Document Properties, and for a dynamic XFA form the laid-out pages and fields. + // Inspection runs none of the document's scripts: an XFA form's initialize and calculate + // scripts, which the app runs on opening, are left alone, as with JavaScript turned off. + let mut session = pdfcraft_engine::Session::new(); + session.set_javascript(false); + let opened = session.open("info.pdf", None, bytes.clone(), password).ok().and_then(|id| session.get(id)); // Dynamic XFA forms: say what laying the template out gives (what the app shows). - let xfa_layout = (info.xfa == Some(pdfcraft_render::Xfa::Dynamic)) - .then(|| { - let mut s = pdfcraft_engine::Session::new(); - let id = s.open("info.pdf", None, bytes.clone(), password).ok()?; - let d = s.get(id)?; - d.xfa.as_ref().map(|x| { - info = d.info.clone(); - // The in-memory layout is an appended revision, not the file being inspected. - info.file_size = bytes.len(); - serde_json::json!({ "pages": x.pages, "fields": x.fields, "warnings": x.warnings }) - }) + let xfa_layout = opened.filter(|_| info.xfa == Some(pdfcraft_render::Xfa::Dynamic)).and_then(|d| { + d.xfa.as_ref().map(|x| { + info = d.info.clone(); + // The in-memory layout is an appended revision, not the file being inspected. + info.file_size = bytes.len(); + serde_json::json!({ "pages": x.pages, "fields": x.fields, "warnings": x.warnings }) }) - .flatten(); - // What the document declares as a standard, read the way the Standards panel reads it, so - // `info` and the app agree. A file that declares nothing leaves these null and empty. - let declared = - pdfcraft_cos::Document::open_with_password(bytes.clone(), password).map(|doc| pdfcraft_preflight::declared(&doc)).unwrap_or_default(); + }); + // What the document declares as a standard, as the Standards panel reads it, and the notes + // Document Properties shows, which include what the app repaired after reading the objects (a + // page tree rebuilt from the pages found by scanning), both from the session's own reading. A + // file the app cannot open is read once more for what the object reader declares and repaired. + // A file that declares nothing leaves the standards null and empty. + let (declared, repairs) = match opened { + Some(d) => (d.standards(), d.repair_log()), + None => pdfcraft_cos::Document::open_with_password(bytes.clone(), password) + .map(|cos| (pdfcraft_preflight::declared(&cos), cos.repair_log().to_vec())) + .unwrap_or_default(), + }; let json = serde_json::json!({ "file": path, "pdf_version": info.pdf_version, @@ -56,6 +68,7 @@ pub(crate) fn info(args: &[String]) -> Result<(), CliError> { "custom_properties": info.custom.len(), "page_labels": info.pages.iter().take(8).map(|p| p.label.clone()).collect::>(), "warnings": info.warnings, + "repairs": repairs, }); stdout_line(format_args!("{}", serde_json::to_string_pretty(&json).unwrap_or_default()))?; Ok(()) diff --git a/apps/pdfcraft-cli/tests/it/info_repairs.rs b/apps/pdfcraft-cli/tests/it/info_repairs.rs new file mode 100644 index 0000000000..72e8bed1a3 --- /dev/null +++ b/apps/pdfcraft-cli/tests/it/info_repairs.rs @@ -0,0 +1,75 @@ +//! `info` lists the notes about damage repaired while opening, as the app shows them in Document +//! Properties and as `doc_info` reports them in `repairs`: a rebuilt cross-reference index, and a +//! page tree rebuilt from the pages found by scanning (a note the app adds after reading). + +use std::process::Command; + +const BIN: &str = env!("CARGO_BIN_EXE_pdfcraft-cli"); + +/// A PDF with `pages` pages, each drawing a rectangle (a page found by scanning has content), and +/// `catalog` as object 1; `startxref` points at its cross-reference table, or at `bad_startxref`. +fn fixture(catalog: &str, pages: usize, bad_startxref: Option) -> Vec { + let content = "0 0 0 rg 10 10 40 20 re f"; + let kids: String = (0..pages).map(|i| format!("{} 0 R ", 4 + i)).collect(); + let mut objs = vec![ + catalog.to_string(), + format!("<< /Type /Pages /Kids [{kids}] /Count {pages} >>"), + format!("<< /Length {} >>\nstream\n{content}\nendstream", content.len()), + ]; + objs.extend((0..pages).map(|_| "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 120 60] /Contents 3 0 R >>".to_string())); + let mut out = b"%PDF-1.7\n".to_vec(); + let mut offsets = Vec::new(); + for (i, o) in objs.iter().enumerate() { + offsets.push(out.len()); + out.extend_from_slice(format!("{} 0 obj\n{o}\nendobj\n", i + 1).as_bytes()); + } + let xref = out.len(); + out.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objs.len() + 1).as_bytes()); + for off in &offsets { + out.extend_from_slice(format!("{off:010} 00000 n \n").as_bytes()); + } + let start = bad_startxref.unwrap_or(xref); + out.extend_from_slice(format!("trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{start}\n%%EOF\n", objs.len() + 1).as_bytes()); + out +} + +const CATALOG: &str = "<< /Type /Catalog /Pages 2 0 R >>"; + +fn info_of(bytes: &[u8], name: &str) -> serde_json::Value { + let dir = std::env::temp_dir().join(format!("pdfcraft-info-repairs-{}-{name}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let path = dir.join("f.pdf"); + std::fs::write(&path, bytes).unwrap(); + let out = Command::new(BIN).args(["info", path.to_str().unwrap()]).output().unwrap(); + assert!(out.status.success(), "{}", String::from_utf8_lossy(&out.stderr)); + let json = serde_json::from_slice(&out.stdout).expect("info prints JSON"); + let _ = std::fs::remove_dir_all(&dir); + json +} + +fn repairs(json: &serde_json::Value) -> Vec { + json["repairs"].as_array().unwrap_or_else(|| panic!("no repairs array: {json}")).iter().map(|l| l.as_str().unwrap().to_string()).collect() +} + +#[test] +fn info_lists_a_rebuilt_cross_reference_index() { + // `startxref` points into the middle of an object, so the index is rebuilt from the headers. + let json = info_of(&fixture(CATALOG, 1, Some(20)), "xref"); + assert_eq!(json["pages"], serde_json::json!(1), "{json}"); + let lines = repairs(&json); + assert!(lines.iter().any(|l| l.contains("rebuilt cross-reference index")), "{lines:?}"); +} + +#[test] +fn info_lists_a_page_tree_the_app_rebuilt_by_scanning() { + // The catalog names a page tree object the file does not have (#1656). + let json = info_of(&fixture("<< /Type /Catalog /Pages 9 0 R >>", 2, None), "pagetree"); + let lines = repairs(&json); + assert!(lines.iter().any(|l| l.contains("page tree is missing")), "{lines:?}"); +} + +#[test] +fn info_lists_no_repairs_for_a_sound_file() { + let json = info_of(&fixture(CATALOG, 1, None), "sound"); + assert_eq!(json["repairs"], serde_json::json!([]), "{json}"); +} diff --git a/apps/pdfcraft-cli/tests/it/main.rs b/apps/pdfcraft-cli/tests/it/main.rs index 30a6e25882..7b7292fe45 100644 --- a/apps/pdfcraft-cli/tests/it/main.rs +++ b/apps/pdfcraft-cli/tests/it/main.rs @@ -8,6 +8,7 @@ mod check; mod edit; mod extract; mod help; +mod info_repairs; mod info_standards; mod mcp_compact; mod options; diff --git a/apps/pdfcraft-cli/tests/it/xfa.rs b/apps/pdfcraft-cli/tests/it/xfa.rs index 1b5ff7bc9d..4d5df67535 100644 --- a/apps/pdfcraft-cli/tests/it/xfa.rs +++ b/apps/pdfcraft-cli/tests/it/xfa.rs @@ -7,6 +7,11 @@ use std::sync::Arc; const BIN: &str = env!("CARGO_BIN_EXE_pdfcraft-cli"); fn fixture(test: &str) -> PathBuf { + fixture_with(test, "") +} + +/// The two-page form, with `event` inside its `name` field. +fn fixture_with(test: &str, event: &str) -> PathBuf { let xdp = r#" "#; +"# + .replace("{event}", event); let objects = [ "<< /Type /Catalog /Pages 2 0 R /NeedsRendering true /AcroForm << /XFA 5 0 R /Fields [] >> >>".to_string(), "<< /Type /Pages /Kids [3 0 R] /Count 1 >>".into(), @@ -67,6 +73,21 @@ fn info_reports_the_laid_out_pages_and_fields() { assert_eq!(std::fs::read(path).unwrap(), original, "inspection must not write the layout to the input"); } +/// `info` inspects an untrusted file: a form's initialize script, which the app runs on opening +/// and which here would change the field's value, does not run. +#[test] +fn info_runs_none_of_the_forms_scripts() { + let script = r#""#; + let path = fixture_with("info-script", script); + let output = Command::new(BIN).arg("info").arg(&path).output().unwrap(); + success(&output); + let info: serde_json::Value = serde_json::from_slice(&output.stdout).unwrap(); + assert_eq!(info["xfa_layout"]["pages"], 2, "the form is still laid out: {info}"); + let warnings = info["warnings"].as_array().unwrap(); + assert!(!warnings.iter().any(|w| w.as_str().is_some_and(|w| w.contains("scripts changed it on opening"))), "{warnings:?}"); + assert_eq!(info["repairs"], serde_json::json!([]), "{info}"); +} + #[test] fn text_reads_the_layout_including_a_page_absent_from_the_placeholder() { let path = fixture("text"); @@ -134,3 +155,21 @@ fn a_laid_out_form_keeps_the_original_copy_permissions() { assert_eq!(info["file_size"], original.len()); assert_eq!(std::fs::read(path).unwrap(), original); } + +/// A damaged dynamic XFA form: `info` lists the cross-reference index rebuilt while opening it, +/// as Document Properties does after the form is laid out as one more revision. +#[test] +fn info_lists_the_repairs_of_a_laid_out_form() { + let path = fixture("info-damaged"); + let bytes = std::fs::read(&path).unwrap(); + let at = bytes.windows(10).rposition(|w| w == b"startxref\n").unwrap() + 10; + let mut damaged = bytes[..at].to_vec(); + damaged.extend_from_slice(b"20\n%%EOF\n"); + std::fs::write(&path, &damaged).unwrap(); + let output = Command::new(BIN).arg("info").arg(&path).output().unwrap(); + success(&output); + let info: serde_json::Value = serde_json::from_slice(&output.stdout).unwrap(); + assert_eq!(info["xfa_layout"]["pages"], 2, "the form is still laid out: {info}"); + let repairs = info["repairs"].as_array().unwrap(); + assert!(repairs.iter().any(|l| l.as_str().is_some_and(|l| l.contains("rebuilt cross-reference index"))), "{repairs:?}"); +} diff --git a/crates/engine/src/open.rs b/crates/engine/src/open.rs index 80a5ad30c7..facf058695 100644 --- a/crates/engine/src/open.rs +++ b/crates/engine/src/open.rs @@ -125,11 +125,17 @@ impl Session { // pages and fields so the rest of the engine works on it. The original bytes stay: the // laid-out form is one appended revision. let opts = SaveOptions { mod_date: context.now.map(pdfcraft_cos::pdf_date), ..SaveOptions::default() }; - // Write `work` as one more revision and reopen it: what the document then is. + // Write `work` as one more revision and reopen it: what the document then is. The notes + // about damage repaired while reading the file stay with it (Document Properties), once. let rebase = |work: &pdfcraft_cos::Document| -> Result<(Arc>, DocInfo, pdfcraft_cos::Document), String> { let new_bytes = write_incremental(work, &opts).map(Arc::new).map_err(|e| e.to_string())?; let new_info = inspect(new_bytes.clone(), render_password.as_deref()).map_err(|e| e.to_string())?; - let new_cos = pdfcraft_cos::Document::open_with_password(new_bytes.clone(), password).map_err(|e| e.to_string())?; + let mut new_cos = pdfcraft_cos::Document::open_with_password(new_bytes.clone(), password).map_err(|e| e.to_string())?; + for line in work.repair_log() { + if !new_cos.repair_log().contains(line) { + new_cos.note_repair(line.clone()); + } + } Ok((new_bytes, new_info, new_cos)) }; let (bytes, info, cos, xfa) = match (info.xfa, cos) { @@ -254,9 +260,6 @@ impl Session { }; match rebuilt { Some((b, i, mut c)) => { - for line in cos.repair_log() { - c.note_repair(line.clone()); - } c.note_repair(format!("the page tree is missing; its {} pages were found by scanning the file", i.pages.len())); (b, i, Ok(Ok(c))) } diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index d8918e5bd6..9b341fcd8b 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -9637,8 +9637,8 @@ milestone = "M2" status = "shipped" commands = [] tools = ["doc_info", "page_render", "text_extract"] -evidence = ["crates/automation/tests/it/automation.rs::open_inspect_render_and_find"] -notes = "" +evidence = ["crates/automation/tests/it/automation.rs::open_inspect_render_and_find", "apps/pdfcraft-cli/tests/it/info_repairs.rs::info_lists_a_page_tree_the_app_rebuilt_by_scanning"] +notes = "`info` lists the repair notes the app shows in Document Properties (`repairs`)." [[feature]] id = "misc.cli-edit" From c02341f6c0e0afe5b9c458794fcf236e16c34f90 Mon Sep 17 00:00:00 2001 From: mtlcl410 <2368091+mtlcl410@users.noreply.github.com> Date: Mon, 9 Nov 2026 08:31:00 +0800 Subject: [PATCH 24/92] M2: a wide stroke of a path with `y` curves is drawn, not dropped (vello_common patch 4) (#6526) A cubic whose second control point is its end point (as the `y` operator builds, ISO 32000-2 8.5.2.1 Table 58) has a zero derivative at its end. kurbo 0.13.1's offset curve looks for cusps with a root search on the curvature, which grows without bound towards that end; at some widths the search stops on the end point and normalising the zero tangent there gives NaN. vello_common's fill drops any outline with NaN, so the whole stroke vanished (a two-curve `y` path drew nothing at 16, 32 and 63 pt; a PDFBox tracker file lost its 32 pt "XX" outline). The NaN fallback that #4913 added to `flatten::stroke` now also moves such a control point the stroke tolerance towards the previous control point before stroking again (the curve moves by at most 4/9 of the tolerance). Outlines that come back finite take the old path unchanged. The vendor ledger row also gets back #4913's entry, which a merge had dropped. Claude-Session: https://claude.ai/code/session_01Pq7HyCHD1R3rywQBPCHXZA Co-authored-by: mtlcl410 Co-authored-by: Claude Opus 5.5 --- crates/render/tests/it/main.rs | 1 + .../tests/it/stroke_zero_end_tangent.rs | 61 ++++++++++++ vendor/README.md | 2 +- vendor/vello_common/src/flatten.rs | 98 ++++++++++++++++++- 4 files changed, 158 insertions(+), 4 deletions(-) create mode 100644 crates/render/tests/it/stroke_zero_end_tangent.rs diff --git a/crates/render/tests/it/main.rs b/crates/render/tests/it/main.rs index 7e8307d908..591fc79b3e 100644 --- a/crates/render/tests/it/main.rs +++ b/crates/render/tests/it/main.rs @@ -13,6 +13,7 @@ mod search; mod separation_all; mod stray_q_in_a_form; mod stroke_degenerate_cubic; +mod stroke_zero_end_tangent; mod substitute_widths; mod tiling_cell_placement; mod type3_text_clip; diff --git a/crates/render/tests/it/stroke_zero_end_tangent.rs b/crates/render/tests/it/stroke_zero_end_tangent.rs new file mode 100644 index 0000000000..50a22f5c7b --- /dev/null +++ b/crates/render/tests/it/stroke_zero_end_tangent.rs @@ -0,0 +1,61 @@ +//! A contributor-original synthetic page: a wide stroke of a path built with `y` (the second +//! control point on the end point, ISO 32000-2 §8.5.2.1 Table 58) was not drawn at all at some widths, +//! because the stroker put NaN into its outline and the rasterizer dropped the whole path. A file +//! in the PDFBox issue tracker lost the 32 pt outline of its "XX" this way. +use pdfcraft_render::{PageRenderer, RenderConfig, RenderRequest}; +use std::sync::Arc; + +/// A one-page 200 × 200 pt PDF drawing `content`, with a cross-reference table. +fn pdf(content: &str) -> Vec { + let objects = [ + "<< /Type /Catalog /Pages 2 0 R >>".to_string(), + "<< /Type /Pages /Kids [3 0 R] /Count 1 >>".to_string(), + "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 200 200] /Contents 4 0 R >>".to_string(), + format!("<< /Length {} >>\nstream\n{content}\nendstream", content.len()), + ]; + let mut bytes = b"%PDF-1.7\n".to_vec(); + let mut offsets = Vec::new(); + for (i, object) in objects.iter().enumerate() { + offsets.push(bytes.len()); + bytes.extend_from_slice(format!("{} 0 obj\n{object}\nendobj\n", i + 1).as_bytes()); + } + let xref = bytes.len(); + bytes.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objects.len() + 1).as_bytes()); + for offset in offsets { + bytes.extend_from_slice(format!("{offset:010} 00000 n \n").as_bytes()); + } + bytes.extend_from_slice(format!("trailer << /Size {} /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n", objects.len() + 1).as_bytes()); + bytes +} + +/// The red channel of the pixel at (`x`, `y`) pt from the top left of `content` rendered at 1×. +fn red_at(content: &str, x: usize, y: usize) -> u8 { + let mut renderer = PageRenderer::new(Arc::new(pdf(content)), RenderConfig::default()); + let out = renderer.render(RenderRequest { page: 0, scale: 1.0, ..Default::default() }); + assert!(out.error.is_none(), "{:?}", out.error); + let at = (y * out.width as usize + x) * 4; + *out.rgba.get(at).expect("the pixel lies on the page") +} + +#[test] +fn a_wide_stroke_of_a_path_with_y_curves_is_drawn() { + // The path's exact coordinates are the trigger; `cm` moves the point where its two curves + // meet, (18.9, 236.3), to about (98.9, 99.7) from the top left, inside every stroke width + // below whatever the caps. The `v` path is the same two curves run backwards, a guard that + // draws with or without the fix. + let y = "21.4166 234.5928 m 19.9009 236.0303 18.9478 236.3428 y 19.2603 238.4209 20.8697 238.4209 y S"; + let v = "20.8697 238.4209 m 19.2603 238.4209 18.9478 236.3428 v 19.9009 236.0303 21.4166 234.5928 v S"; + let mut dropped = Vec::new(); + for path in [y, v] { + for width in [4, 8, 16, 24, 32, 48, 63, 64] { + for (join, cap) in [(0, 0), (1, 1), (2, 2)] { + let content = format!("0 1 0 RG {width} w {join} j {cap} J 1 0 0 1 80 -136 cm {path}"); + let red = red_at(&content, 98, 99); + if red > 64 { + dropped.push(content); + } + } + } + } + assert!(dropped.is_empty(), "strokes not drawn: {dropped:#?}"); +} diff --git a/vendor/README.md b/vendor/README.md index 4733eba49c..b398891efa 100644 --- a/vendor/README.md +++ b/vendor/README.md @@ -46,7 +46,7 @@ Other resource categories retain their existing parent-chain lookup. | hayro-syntax | 0.7.2 | Apache-2.0 OR MIT | (1) Page-tree cycle guard in `resolve_pages` (visited set + depth cap): a `/Kids` loop among compressed objects overflowed the stack and aborted the process; (2) `ccitt_size_ok`: CCITT images wider than 2^20 columns or over 2^28 pixels are not decoded (a fuzzed `/Columns 4294967295` allocated 4 GiB); (3) Flate/LZW predictor row widths are saturating `usize`, a zero width is refused and a row longer than the data is not allocated (a fuzzed `/Columns 9223372036854775807` wrapped to a 2^61-byte allocation and aborted); (4) the inline-image end-of-data check (`EI`) doesn't nest inside its own look-ahead and examines at most 256 candidates and steps (50 images with "EI" in their data ran for over ten minutes); an EI followed by a delimiter (EI%..., EI[...) ends the image like one followed by white space; only a regular character after EI skips the candidate; (5) a stream whose `/Length` is wrong is recovered only when `stream` is the next token — scanning ahead to a later object's `stream` made an indirect appearance-state dictionary (Quartz checkboxes: `/AP /N` → `<< /Yes … /Off … >>`) render as a non-form, so the widget was skipped; (6) JPX rescaling uses an exact integer sample range for 1–32-bit components (32 shifted past u32, and floating-point maxima at 25–31 bits wrapped to zero), and refuses zero-sized rows; (7) `decode_limit`: Flate (through flate2 and the fallback inflater), LZW, RunLength and ASCII85 never write past `MAX_DECODED_STREAM` (256 MiB), or, for the last filter of an image of known size, past twice its pixel bytes (a predictor tag byte per byte), at most 1 GiB; `Stream::decoded_within` for budgeted callers; a page's `/Contents` array shares one `MAX_DECODED_STREAM` (a 2 MB page whose content stream or 8 × 8 image inflated to 2 GB took the renderer past 3 GB in under a second; naming the stream twenty times in `/Contents`, 5 GB); (8) high-precision numbers (jsPDF MediaBox coordinates with over 19 digits) parse without overflowing the u64 mantissa: fractional digits past it are dropped, integer digits past it scale the value (a real, kept finite); (9) large dictionary indexes are built on first use and shared across repeated reads within each PDF (#307); the validated-byte cache is bounded, reader contexts remain separate, repairs and inline/content dictionaries bypass sharing, and small dictionaries stay eager; (10) decoded object-stream slots publish once under contention, cold loading rejects recursive dependencies, and xref repair serializes without holding recursive read guards; this permits one parser per native render pool with private worker caches; (11) `sync.rs`: a poisoned lock is recovered instead of unwrapped (a worker panic no longer cascades into every reader of the same PDF), and xref repair takes a blocking write lock instead of asserting a non-blocking one; (12) `SegmentList` (`util.rs`) returns `None` for an out-of-range index instead of panicking; (13) a page's `/UserUnit` scales `render_dimensions` and `initial_transform`, so the page and everything on it are sized in points, as Acrobat shows them; it is read as Acrobat reads it (the page's own entry, not inherited; a number from 1 up, at most 75,000; anything else counts as 1), and a page without it keeps exactly the transform it had; (14) revision 2–4 passwords are tried as given and then in PDFDocEncoding (`legacy_passwords` in `crypto/mod.rs`, which resolves the upstream `TODO: Convert to PDFDocEncoding`): passwords arrive as UTF-8 text, so a password with characters outside ASCII (`Çok güzel`, `ılık€`) written in PDFDocEncoding, as ISO 32000-2 §7.6.4.3.2 asks and qpdf and Acrobat write it, was always wrong; (15) page content is read a piece at a time (`page::ContentReader`, `Page::content_reader`): plain streams and Flate streams without a predictor are decoded as they are read, with a budget of 64 times their compressed size and never less than `MAX_DECODED_STREAM` (a stream that expands past it is cut and reported); other streams are decoded whole, as before, and share `MAX_DECODED_STREAM`. The content tokenizer reads a window at a time (`UntypedIter::new_window`, `TypedIter::needs_more`, `TypedIter::committed`): an instruction that runs past the window is not decided until more data follows, which `Reader` records by flagging reads and `at_end` past the end of the data, including through the clones the parser makes. The Flate decoder keeps its own 32 KiB history, so it pauses at any output size. `ContentReader::new` reuses raw/decrypted stream bytes for budget accounting and piece creation, and `decoded_raw_within` applies the existing Whole-stream filters and limits to those bytes without decrypting twice; (16) revision 5–6 (AES-256) passwords are tried in their SASLprep form (RFC 4013, then UTF-8, at most 127 bytes, as ISO 32000-2 §7.6.4.3.3 Algorithm 2.A asks and pdfcraft-crypt writes them) and then, if that differs, as given (`aes256_passwords` in `crypto/mod.rs`, which resolves the upstream `TODO: Do the above.`; `stringprep`, already in the tree through pdfcraft-crypt, is an optional dependency enabled by `std`, and without `std` passwords are used as given): the renderer used the password as typed, so one that SASLprep changes (a decomposed `café`, a no-break space, the fullwidth `ab` a Japanese IME types in full-width mode) was wrong, although pdfcraft-cos accepted it: protecting a document with such an open password was rolled back ("the password is incorrect"), and a file with such a password did not open with it.; (17) CCITT `/Rows 0` means the height is not predetermined (ISO 32000-2 Table 11), not zero rows: scanned Group 4 pages written that way rendered blank; (18) The streaming Flate decoder (`lzw_flate::flate::fallback`) copies a back-reference in runs of up to its distance (`extend_from_within`), not byte by byte. The copy reads only bytes already in the output, and a copy whose source does not exist still writes nothing, so the bytes are those a byte-by-byte copy writes. Inflating the A1 sample's content stream (278.6 MB) takes about 14% less time (0.66 s to 0.56 s); the output is identical; (19) CCITT `/Rows` below the image's `/Height` decodes to the height: a plotter driver wrote `/Rows` equal to `/Columns`, so only the first rows were decoded and the rest drawn black (#3308) (20) `stream` followed by spaces or tabs and then the line break (`stream \r\n`, as Microsoft Reporting Services writes it) starts the data after the line break; the content stream was taken to start at the space and the page rendered blank.; (21) a JP2 colour specification naming an enumerated colour space the decoder refuses (CIEJab in a veraPDF fixture, YCbCr, PhotoYCC, YPbPr, bilevel…) no longer fails the image: it is decoded again from a copy whose `colr` names the space the decoder assumes without one, greyscale, sRGB or CMYK by component count, as MuPDF and Poppler draw it (`without_refused_enumerated_colour`; the page drew blank, #3306); (22) an object stream's index of `objnum offset` pairs is parsed once per stream and kept beside its decoded data (`ObjectStreamIndexes`, `XRef::with_object_stream_indexes`), and an offset past `usize` is malformed instead of wrapping: `XRef::get_with` parsed the whole index again for every object it fetched from the stream, so reading all of a stream of N objects took N² steps (a Firefox page whose 65,542 objects share one stream took 37 s to draw and 40 s to extract its text); (23) the inline-image end-of-data look-ahead no longer rejects a candidate `EI` because a byte above 0x7F lies between it and the next one: when the candidate is the end, those bytes are the content stream that follows the image, and its strings carry such bytes (WinAnsi `(Grüße) Tj`, the two-byte codes of any Type 0 font), so the operator check that follows decides alone, bounded as in (4) (a scanned page whose OCR text held an accented word and a later capital "EI" — EINGANG, BEI, KLEIN — drew and extracted nothing after the image: 40 of 275 pages across five real documents); (24) `xref::prev_offset`: a trailer `/Prev` written as a reference `N G R` is read as the offset that object holds when the section's own entries place it (iText's `indirectPrev.pdf`; each section's entries are read before its `Prev` is followed), and otherwise as offset N (tiff2pdf; JHOVE's PDF-HUL-157 files, #2366), as `pdfcraft-cos` and other readers take it, so the earlier sections' pages are seen instead of one; (25) direct arrays and dictionaries share a reader-local nesting cap of 128, counted while skipping and while parsing a dictionary (its lenient repair reads, rather than skips, an object found where a key belongs, so `<< << …` recursed through that read); deeper objects return `None` instead of overflowing the stack (including unused trailer values, trailer entries, content operands and marked-content properties); a chain of 65 direct stitching functions now stops at this cap, before the 64-function guard; (26) an xref stream subsection must be backed by its decoded data: all-zero `/W` widths (an entry reads no bytes) and a declared count longer than the data are refused before any entry is inserted, where a tiny stream with a large `/Size` or `/Index` count inserted that many entries (from storytold/craft-libs#239); a subsection running past object number `i32::MAX` is refused instead of overflowing or turning negative; (27) an xref stream's type field is read big-endian at its full `/W[0]` width, where only its first byte was read and wider fields shifted every later entry, and `/W[0]` over 8 bytes is refused like `/W[1]` (from storytold/craft-libs#241); (28) a content instruction keeps up to 256 operands (`MAX_OPERANDS`) instead of 10, and past that drops the oldest instead of ending the content stream: the eleventh operand of a twelve-ink DeviceN `scn` (ISO 32000-2 §8.6.6.5 allows any number of components) blanked the rest of the page (#4260); (29) a `/JBIG2Globals` stream that is itself JBIG2 isn't decoded: globals are raw segments, and a chain of 16,000 such streams overflowed the stack, which aborts the process; (30) a stream's own crypt filter: `/Crypt` in its chain read as the filter it names, `/Identity` without one, instead of the document's filter and then a filter that can't be decoded, which left such a stream unreadable in any spelling (ISO 32000-2 §7.4.10); (31) `set_page_jbig2_work`: the JBIG2 images decoded on a thread spend one allowance, set per page, 2^30 steps (`MAX_PAGE_JBIG2_WORK`), so a page of many images made to reach their own limit stops at one, and what they cost to read counts, whether they decode or not; unbounded until a page sets it; (32) a group of array items read together (a tuple: an image's `/Decode`, `/W`, `/W2`, and a function's `/Domain`, `/Range`, `/Encode` and `/Decode`) can start with a reference: it was taken for the whole group and lost, which ended the array, so an image with `/Decode [7 0 R 0]` didn't draw and CID widths fell back to `/DW`; (33) an object that holds only a reference stands for what that names, stored on its own or in an object stream, up to 32 references: read as a number it was the reference's own object number, so a `/Decode` or `/W` entry naming such an object took a wrong value, and as anything else it was lost. Past 32, as any cycle goes, it reads as a missing object. An object stream isn't reached that way, as its checks are of the object named; reading its index counts against the 64 nested loads and reads its dictionary alone, as a chain of them, each with a member of the next for its `/N`, `/First` or `/Length`, ran out of stack; and a comment inside a reference counts as white space; (34) a table rebuilt by scanning the file (`fallback_xref_map`) finds an object by its number when its header names another generation, including one no generation may be (`3 18446744073709551616 obj` in `poppler-85140-0.pdf`, #5580): the header is read leniently only in that scan (`read_object_header`), the object is also filed under generation 0 unless a real `N 0 obj` holds that key (a later header for the number moves the stand-in), `XRef::get_with` matches by object number only in a repaired table, and `XRef::objects` yields each location once; (35) PNG predictors (`lzw_flate::unfilter_png`) are undone a byte row at a time on slices, as the PNG filters are defined, instead of through the bit-level readers TIFF predictors need: a 993 × 936 RGB image took about 63 ms to unfilter whatever its row filters, and now takes about 3 ms (Up) to 16 ms (Paeth) in a release build (#5430); a row ending part-way through a pixel (three 4-bit samples, an odd width) keeps its last byte, where the bit-level readers dropped it and each later row started a byte early, shearing the picture; (42) TIFF predictor 2 on 8- and 16-bit samples (`lzw_flate::undo_tiff_bytes`) is undone a byte row at a time on slices, each sample added to the same component of the pixel to its left, wrapping at the sample's width (16-bit samples big-endian), instead of through the bit-level readers 1-, 2- and 4-bit samples still need; the bytes are those the bit-level path wrote, and a partial last row is still dropped. A page of 45 full-page 2445 × 4724 8-bit grey images so predicted (PDFBOX-1350) took about 23 s to render at 96 dpi and now takes about 4 s, with the same pixels | (1) `cos/tests/writer.rs::page_tree_loops_in_object_streams_do_not_crash_readers`; (2) `raster::tests::absurd_mask_and_fax_sizes_are_refused_before_decoding`; (3) `raster::tests::absurd_predictor_columns_and_line_widths_render`; (4) `raster::tests::inline_images_with_ei_in_their_data_parse_in_linear_time`, `content::tests::inline_image_ei_may_be_followed_by_a_delimiter`; (5) `raster::tests::indirect_appearance_state_dict_is_not_another_stream`; (6) `raster::tests::jpx_stream_bit_depth_boundaries_do_not_panic`, `raster::tests::jpx_32_bit_image_does_not_prevent_other_page_content_from_rendering`, `raster::tests::jpx_gray_rgb_and_alpha_keep_eight_bit_samples`, `raster::tests::jpx_streams_reject_out_of_range_bit_depths`; (7) `raster::tests::decompression_bombs_stop_at_the_stream_limit`, `raster::tests::page_contents_and_nested_forms_share_one_budget`; (8) `inspect::tests::jspdf_high_precision_mediabox_does_not_overflow_dimensions`, `object::number::tests::high_precision_float_does_not_overflow_mantissa` (hayro-syntax); (9) `object::dict::lazy_index_tests`, `raster::tests::large_resource_indexes_preserve_pixels_and_text`; (10) `data::tests`, `xref::tests::concurrent_cold_object_stream_reads_all_succeed`, `raster::tests::shared_parser_is_lazy_and_initializes_once_under_contention`; (11) `data::tests::panicking_initializer_wakes_waiters_and_prevents_retries`, `xref::tests::concurrent_cold_object_stream_reads_all_succeed`; (12) `util::tests::segment_list_rejects_out_of_range_indices`; (13) `raster::tests::user_unit_scales_the_page_and_everything_on_it`; (14) `crypto::pdfcraft_password_tests`, `pdfcraft-engine` `tests::legacy_passwords_with_turkish_characters_open`; (15) `content::tests::windows_keep_every_instruction_at_every_split`, `content::tests::windows_keep_every_instruction_in_pieces_of_any_size`, `content::tests::windows_match_the_slice_on_random_content`, `content::tests::windows_drop_blanks_and_keep_a_comment_that_may_go_on`, `page::content_reader_tests::content_past_the_cap_is_read_to_the_end`, `page::content_reader_tests::a_stream_that_expands_past_the_budget_is_cut_there`, `page::content_reader_tests::a_small_expansion_under_the_cap_is_not_cut`, `raster::tests::page_content_past_the_old_cap_renders`, `raster::tests::content_reader_returns_the_same_bytes_in_any_pieces`, `raster::tests::content_bombs_stop_at_the_budget_and_say_so`, `page::content_reader_tests::plain_and_whole_pieces_keep_their_bytes`, `page::content_reader_tests::repeated_content_reference_counts_compressed_bytes_once`, `page::content_reader_tests::whole_cap_still_counts_later_unique_streams`; (16) `crypto::pdfcraft_password_tests::aes256_passwords_are_tried_in_their_saslprep_form_then_as_given`, `crypto::pdfcraft_password_tests::aes256_passwords_open_as_typed_and_in_their_saslprep_form`, `pdfcraft-engine` `tests::aes256_open_passwords_that_saslprep_changes_protect_and_reopen_as_typed`, `tests::aes256_files_whose_passwords_saslprep_changes_open_as_typed`; (17) `raster::tests::group4_fax_with_rows_zero_decodes_to_the_image_height`; (18) `filter::lzw_flate::tests::back_reference_longer_than_its_distance_repeats_its_source`, `back_reference_cut_by_the_limit_resumes_in_the_next_call` and `back_reference_reads_bytes_written_by_an_earlier_call`; each fails when the copy returns the wrong remaining count; (19) `raster::tests::group4_fax_with_rows_below_its_height_decodes_to_the_image_height`; (20) `raster::tests::space_before_the_line_break_after_stream_still_renders`; (21) `raster::tests::a_jp2_image_in_a_refused_enumerated_colour_space_is_drawn`, `filter::jpx::tests::refused_enumerated_colour_spaces_become_the_component_counts_default`, `filter::jpx::tests::what_the_decoder_handles_or_cant_be_read_is_left_alone` (`cargo test --manifest-path vendor/hayro-syntax/Cargo.toml`); (22) `raster::tests::objects_in_one_large_object_stream_are_read_in_linear_time`; (23) `text::tests::text_after_an_inline_image_survives_a_non_ascii_byte_before_a_later_ei`, `content::tests::an_inline_image_ends_at_its_own_ei_when_later_text_holds_a_non_ascii_byte` (hayro-syntax); (24) `xref::tests::prev_as_reference_reaches_the_earlier_section`, `xref::tests::prev_as_reference_to_an_object_holding_the_offset_reads_that_offset`, `pdfcraft-cos` `document::tests::a_prev_written_as_a_reference_still_reaches_the_earlier_sections`, `document::tests::a_prev_reference_to_an_object_holding_the_offset_reads_that_offset`; (25) `raster::tests::deeply_nested_direct_objects_do_not_overflow_the_stack`, `raster::tests::dictionaries_nested_without_keys_in_content_do_not_overflow_the_stack`, `raster::tests::dictionaries_nested_without_keys_in_the_trailer_do_not_overflow_the_stack`, `raster::tests::dictionaries_nested_without_keys_as_marked_content_properties_do_not_overflow_the_stack`, `raster::tests::dictionaries_nested_without_keys_are_read_up_to_the_nesting_cap`, `raster::tests::nesting_at_the_cap_fits_a_512_kib_stack` (native only), `raster::tests::function_limits_depth_guard_refuses_a_chain_of_references`; (26) `xref::tests::xref_stream_entries_need_backing_bytes`; (27) `xref::tests::xref_stream_type_field_uses_the_full_w0_width`, `xref::tests::xref_stream_loader_rejects_oversized_w0` (`cargo test --manifest-path vendor/hayro-syntax/Cargo.toml`); (28) `content::ops::tests::scn_with_twelve_components_keeps_them_all`, `content::ops::tests::operands_past_the_cap_keep_the_latest_in_order` (hayro-syntax), `raster::tests::devicen_inks::a_twelve_ink_devicen_colour_paints`, `raster::tests::devicen_inks::a_four_ink_devicen_colour_paints`, `raster::tests::devicen_inks::an_operator_with_hundreds_of_operands_does_not_end_the_content`; (29) `raster::tests::jbig2_globals_that_are_jbig2_themselves_are_not_decoded`, `raster::tests::jbig2_globals_are_read`, `raster::tests::the_jbig2_images_of_a_page_share_one_allowance`, `raster::tests::an_image_that_fails_spends_the_pages_allowance_too`; (30) `raster::tests::a_stream_with_a_crypt_filter_of_its_own_is_drawn`; (32) `raster::tests::a_decode_array_with_references_in_it_is_read`, `raster::tests::a_functions_arrays_can_start_with_a_reference` (pdfcraft-render), `font::cid::width_tests::a_width_group_can_start_with_a_reference` (hayro-interpret); (33) `raster::tests::a_reference_to_a_reference_is_what_it_names`, `raster::tests::a_decode_array_with_references_in_it_is_read` (pdfcraft-render), `font::cid::width_tests::a_width_group_can_start_with_a_reference` (hayro-interpret), `xref::tests::an_object_stream_isnt_reached_through_a_reference`, `xref::tests::a_chain_of_object_streams_ends_at_the_limit_of_nested_loads`, `xref::tests::a_chain_of_object_streams_takes_no_more_work_for_two_ways_on`, `xref::tests::a_reference_held_in_either_storage_is_followed`, `xref::tests::a_page_a_reference_is_held_to_is_found_once` (hayro-syntax); (34) `raster::tests::a_rebuilt_table_finds_an_object_whose_header_names_another_generation`, `raster::tests::a_rebuilt_tables_generation_0_stand_in_is_not_a_second_object` (pdfcraft-render), `xref::tests::an_object_stream_and_its_first_member_are_both_enumerated` (hayro-syntax); (35) `filter::lzw_flate::pdfcraft_png_predictor_tests` (hayro-syntax: every filter type at 1–16 bits and 1–4 colours against the PNG specification's byte-by-byte definition, a row ending part-way through a pixel, Average and Paeth over 6-byte pixels), `raster::tests::png_predicted_images::a_four_bit_rgb_image_of_odd_width_with_up_predicted_rows_is_not_sheared`; (42) `filter::lzw_flate::pdfcraft_tiff_predictor_tests` (hayro-syntax: random 8- and 16-bit rows at 1–7 colours and 1–50 columns against the bit-level path, partial and empty rows, and a row counter showing whole-byte samples skip the bit-level path), `raster::tests::tiff_predicted_images` | | winit | 0.30.13 | Apache-2.0 | (1) on Windows 11 (build 22000 and later), `WM_DPICHANGED` applies the rect Windows suggests. It used to nudge the window onto the monitor `MonitorFromWindow` names, which during a drag is still the monitor being left; the nudge sent another `WM_DPICHANGED`, so a window dragged to a monitor with another scale factor grew and stayed on the wrong monitor at the wrong scale (#324). Backport of winit master 488c036a ([winit#4341](https://github.com/rust-windowing/winit/pull/4341)), which no 0.30 release has; Windows 10 keeps the old adjustment, as on winit master. (2) on Wayland, a seat's text input is destroyed when the seat goes away, not whenever it loses a capability: losing any capability (a touch screen or tablet unplugged, a remote-desktop session ending) destroyed it, and only an added capability recreated it, so IME stopped working until restart. Port of [winit#4747](https://github.com/rust-windowing/winit/pull/4747); (3) on Windows, an IME composition reports its cursor from `GCS_CURSORPOS` only when `WM_IME_COMPOSITION` says it is set, and at the end of the composition otherwise: the Microsoft Korean IME never sets it, so the cursor read as 0 (egui then drew no caret while composing; it now draws it after the syllable). Port of [winit#4746](https://github.com/rust-windowing/winit/pull/4746); (4) on Windows, a hidden window is not maximized until it is shown, and a hidden window shown while maximized gets one `ShowWindow(SW_MAXIMIZE)`: `SW_MAXIMIZE` shows a hidden window, so PdfCraft, which eframe keeps hidden until its first frame is drawn, appeared unpainted (white) when it restored a maximized window, and could lose its focus. Port of [winit#4587](https://github.com/rust-windowing/winit/pull/4587) (the later, focus-safe form of [winit#4575](https://github.com/rust-windowing/winit/pull/4575)); (5) on X11, when allowing or disallowing IME replaces the focused window's input context (egui does it each time a text field gains or loses focus), the new context is focused: no focus event follows, so it stayed unfocused until the window lost and regained focus. Port of [winit#4727](https://github.com/rust-windowing/winit/pull/4727); (6) on macOS, `insertText` commits through IME when a composition was started (`pending_commit`, set by `setMarkedText`) rather than when marked text is left: input methods that end a composition with `unmarkText` before `insertText` lost the text, and Apple Korean dropped the key typed after a syllable (`한` then `5` or `;`); other `insertText` calls stay `KeyboardInput`; a commit outside a key press (a candidate clicked with the mouse, dictation) clears the marked text and leaves no key to swallow, as in [winit#4748](https://github.com/rust-windowing/winit/pull/4748) (without it the next key, such as Backspace, was lost). `validAttributesForMarkedText` lists the underline and marked-clause attributes, without which some input methods don't compose. Port of [winit#4650](https://github.com/rust-windowing/winit/pull/4650), the 0.30 backport of [winit#4651](https://github.com/rust-windowing/winit/pull/4651) (merged on winit master), with the underline attribute spelled as AppKit's `NSUnderlineStyleAttributeName` (`NSUnderline`); (7) on macOS, disallowing IME with a composition in progress (focus leaving a text field) tells the input method to discard its marked text (`discardMarkedText`): it kept composing it once IME was allowed again, so the abandoned syllable came back (`하`, then `가` typed in the next field, became `학`, then `하` + `가`; Japanese `か` then `さ` became `傘`). Port of [winit#4745](https://github.com/rust-windowing/winit/pull/4745); (8) on macOS, text inserted outside a key press without a composition (the emoji picker) is reported as `Ime::Commit`; it was dropped. Port of the emoji-picker part of [winit#4749](https://github.com/rust-windowing/winit/pull/4749) (0.30 backport of [winit#4748](https://github.com/rust-windowing/winit/pull/4748)); its `selectedRange` change for dictation is left out because it switches on press-and-hold for every held key (an accent menu instead of key repeat, and the chosen accent added after the repeated letters); (9) on macOS, a key whose text was committed through IME isn't also sent as `KeyboardInput` unless AppKit forwards it as a command (Enter): Apple Korean answers Space with no syllable in progress with `setMarkedText(" ")`, `insertText(" ")`, `setMarkedText("")`, and the last call reset winit's state, so the space arrived twice. Same goal as [winit#4478](https://github.com/rust-windowing/winit/pull/4478), whose changes don't reach this call order (macOS 14); to be proposed upstream there; (10) on macOS, when the first key in a fresh app reaches Apple Korean's `insertText` as a raw compatibility jamo instead of starting a composition, that key is interpreted once more, synchronously, before it is sent as `KeyboardInput` (g k s typed ㅎㅏㄴ instead of 한); only an unmodified, non-repeated first key whose one callback inserts that same jamo, with IME enabled and nothing composed, focus and input source unchanged, is retried (`ime_startup.rs`). Port of [winit#4744](https://github.com/rust-windowing/winit/pull/4744), with its policy tests.; (11) on X11 without `Xft.dpi`, the scale factor comes from the X screen's configured DPI (its size in pixels and millimetres) before the output's physical density from RandR: Xorg can deliberately run a dense panel at 96 DPI, and only winit applications then looked zoomed; `Xft.dpi` still wins, `WINIT_X11_SCALE_FACTOR` still overrides, and a screen reporting no size keeps the RandR fallback. PdfCraft patch (#1150, replacing the launcher pin proposed in #908). (12) on macOS, `WinitView` answers `mouseDownCanMoveWindow` with the window's `movable_by_window_background` attribute (NO unless asked): NSView answers YES for a view that isn't opaque, so with the transparent title bar and full-size content view PdfCraft uses, AppKit moved the window for any drag that began in the 28 pt title-bar band before the app saw it, and document tabs (which live in that band) could not be dragged to reorder them. The empty strip still moves the window through `ViewportCommand::StartDrag`. PdfCraft patch. The published crate's asset files (`docs/res/*.svg`, `examples/data/*.png`) are left out, so its `window` example doesn't build; (13) on Wayland, pixel-only wheel, wheel-tilt and continuous-source scroll events report `Moved` rather than starting an unterminated finger gesture, so egui follows modifier release instead of keeping Ctrl zoom latched (#991). Finger gestures and unknown sources retain their existing phase policy | (1) `pdfcraft` `tests::winit_carries_the_windows_11_monitor_scale_fix` keeps the patch in the build; the drag itself needs two monitors with different scale factors on Windows 11; (2) `pdfcraft` `tests/winit_wayland_text_input.rs`; the capability change itself needs a Wayland session and an input method; (3) `pdfcraft` `tests/winit_windows_ime_cursor.rs`; composing itself needs the Microsoft Korean IME; (4) `pdfcraft` `tests/winit_windows_hidden_maximized.rs`; the start-up itself needs Windows and a window that was closed maximized; (5) `pdfcraft` `tests/winit_x11_ime_focus.rs`; switching fields itself needs an X11 session and an input method (IBus, fcitx5); (6) `pdfcraft` `tests/winit_macos_ime_commit.rs`; composing itself needs macOS and an input method; (7) `pdfcraft` `tests/winit_macos_ime_discard.rs`; composing itself needs macOS and an input method; (8) `pdfcraft` `tests/winit_macos_emoji_picker.rs`; the picker itself needs macOS; (9) `pdfcraft` `tests/winit_macos_korean_space.rs`; composing itself needs macOS and Apple Korean; (10) `pdfcraft` `tests/winit_macos_korean_first_key.rs` and `ime_startup.rs`'s own tests (run them with `rustc --test`); typing itself needs macOS and Apple Korean; (11) `pdfcraft` `tests/winit_x11_dpi.rs` keeps the patch in the build, and `util::randr` tests the policy (run them in the vendored crate); the scale itself needs an X11 session; (12) `pdfcraft` `tests/winit_macos_title_bar_drag.rs` keeps the patch in the build; the drag itself needs macOS and a pointer; (13) `pdfcraft` `tests/winit_wayland_scroll.rs` compiles the production phase policy and exercises modifier release through egui; live COSMIC confirmation remains open | | egui-wgpu | 0.36.2 | MIT OR Apache-2.0 | (1) `winit::surface_fit`: a window's surface is configured within the device's `max_texture_dimension_2d`, both sides shrunk by one factor that egui then draws at (`pixels_per_point × render_scale`), so the whole window is drawn into the surface (on DX12 it is stretched over the window and lines up with the pointer; wgpu's GL backend copies it unscaled into a corner); the longer side becomes exactly the limit; the depth and MSAA textures follow, and a recreated surface starts again from the window's own size. egui-wgpu configured the window's size as is, and `Surface::configure` panics beyond the limit: a window restored at another monitor's scale (opening an Outlook attachment on a mixed-DPI laptop asked an 8192-pixel device for 8600 × 3423, #577) or stretched across monitors closed PdfCraft, before its window appeared when it happened at start-up ([emilk/egui#8361](https://github.com/emilk/egui/issues/8361)). The published crate leaves out egui's licence files; `LICENSE-APACHE` and `LICENSE-MIT` are from the 0.36.2 tag; (2) `winit::catch_errors`: a new window's surface is configured inside wgpu error scopes (validation, out of memory, internal), and a failure is returned as `WgpuError::ConfigureSurface` (for the root window at start-up eframe returns it as `Error::Wgpu`, which PdfCraft retries with OpenGL); nothing is kept for the window. wgpu's default handler panicked instead, and eframe configures the root surface before the app is created, so a driver that can't configure one closed PdfCraft at launch (a 2015 Intel GPU through wgpu's GL backend timed out waiting for the GPU to go idle, `GpuWaitTimeout`, #519). Unchanged, so still a panic: an error in a later reconfigure (resize, a lost surface), a device lost during the first configure (wgpu reports it only to a callback), and a window shown at zero size (first configured on resize) | (1) `pdfcraft` `tests::a_window_larger_than_the_gpu_limit_gets_a_surface_within_it`, `tests::surface_fit_never_empties_or_overflows_a_surface`, and `tests::vendor_provenance_egui_wgpu_surface_fit_identity`, which keeps the patch in the build and in the paths that size a surface; (2) `pdfcraft` `tests::a_gpu_error_while_a_window_is_set_up_is_returned_not_a_panic` (a real device: an error inside `catch_errors` is returned, not a panic) and `tests::vendor_provenance_egui_wgpu_catch_errors_identity`, which keeps it around `add_surface`'s configure | -| vello_common | 0.0.8 | Apache-2.0 OR MIT | (1) `clip::intersect` (the clip step every clipped path passes through) starts its row walk at the first row both paths have strips in, not the earlier of the two first rows. The walk visited every row from the top of the clip for each path (on the A1 sample's full-page canvas, about half its strip rows per stroke), and rows where one path has no strips produce no output, so the strips are unchanged (vello_common 0.1 starts at the later row too). Its cost grew with the page, and a full-page render of the A1 sample was about 30% faster; the output is byte-identical | (1) `clip::tests::intersect_walks_only_rows_both_paths_have_strips` counts the rows the walk visits (`cargo test --manifest-path vendor/vello_common/Cargo.toml`) and fails without the patch; the render crate's pixel tests run through the patched crate | +| vello_common | 0.0.8 | Apache-2.0 OR MIT | (1) `clip::intersect` (the clip step every clipped path passes through) starts its row walk at the first row both paths have strips in, not the earlier of the two first rows. The walk visited every row from the top of the clip for each path (on the A1 sample's full-page canvas, about half its strip rows per stroke), and rows where one path has no strips produce no output, so the strips are unchanged (vello_common 0.1 starts at the later row too). Its cost grew with the page, and a full-page render of the A1 sample was about 30% faster; the output is byte-identical; (4) `flatten::stroke`: when kurbo 0.13.1's stroker returns an outline containing NaN, which `fill` would drop whole, the path is stroked again from a copy (`robust_for_stroking`, one element out per element in) in which each curve whose control points lie within the stroke tolerance of its chord is a line (the #4907 fix, a nearly straight cubic given a NaN join, kurbo issues #617 and #519; its row here was lost in a merge and is restored with this entry) and each cubic whose second control point is its end point, as `y` builds (ISO 32000-2 §8.5.2.1 Table 58), has that control point moved the tolerance towards its first (`open_zero_end_handle`; the curve moves by at most 4/9 of the tolerance): kurbo's cusp search on such a curve's offset can stop on the end point, where the derivative is zero, and normalising it gives NaN, so at 16, 32 and 63 pt a two-curve `y` path drew nothing and a file in the PDFBox tracker lost its 32 pt "XX" outline. Only an exact zero triggers it (none of 192,000 random strokes with a handle 1e-9 long, nor of 256,000 `v` strokes, came back with NaN; 1,073 of 192,000 random `y`/`v` strokes at 1–64 pt did before and none after). A finite outline takes the old path unchanged | (1) `clip::tests::intersect_walks_only_rows_both_paths_have_strips` counts the rows the walk visits (`cargo test --manifest-path vendor/vello_common/Cargo.toml`) and fails without the patch; the render crate's pixel tests run through the patched crate; (4) `flatten::tests::a_tiny_degenerate_cubic_does_not_drop_the_stroke` and the render crate's `stroke_degenerate_cubic` fail without the near-line replacement, `flatten::tests::a_wide_stroke_of_a_curve_with_a_zero_end_tangent_is_drawn` (widths 1–64, every join and cap, `y` and the mirrored `v`) and the render crate's `stroke_zero_end_tangent` fail without the moved control point; `a_finite_outline_is_stroked_as_before` and `a_path_with_nan_is_still_dropped` pass either way | | hayro-cmap | 0.1.0 | Apache-2.0 OR MIT | (1) malformed parts of a CMap (bad bfchar/bfrange entries, unresolvable `usecmap`, unparseable tail) are skipped instead of discarding the whole map, so search and redaction find text the edit layer can read (#3177); (2) a binary CMap's `usecmap` counts against the 16-level nesting limit as a text one does, where it passed its own depth and a self-referencing binary CMap recursed until the stack overflowed (from storytold/craft-libs#254) | (1) `hayro-cmap::tests::bad_bfchar_entry_is_skipped`, `unresolvable_usecmap_is_skipped`; (2) `tests::self_referencing_binary_usecmap_stops_at_the_nesting_limit` | | hayro-postscript | 0.1.0 | Apache-2.0 OR MIT | (1) the token scanner's `object::read` skips `<<` and `>>` in a loop; it called itself once per delimiter, so a CMap stream holding a long run of `<` or `>` overflowed the stack and aborted the process (from storytold/craft-libs#263) | (1) `hayro-postscript::object::tests::long_open_dict_run_does_not_overflow`, `long_close_dict_run_does_not_overflow`, `dict_delimiters_are_skipped`, `nested_dict_delimiters_are_skipped` | diff --git a/vendor/vello_common/src/flatten.rs b/vendor/vello_common/src/flatten.rs index afacf7ecbc..25697c7032 100644 --- a/vendor/vello_common/src/flatten.rs +++ b/vendor/vello_common/src/flatten.rs @@ -240,8 +240,11 @@ pub fn stroke( // PdfCraft patch: kurbo's stroker can put NaN into the outline of a curve that is nearly a // line (all control points within a rounding error of the chord), and `fill` then drops the // whole outline. Stroke it again with such curves as lines. A finite outline is unchanged. + // PdfCraft patch (4): it can also put NaN into the offset of a cubic whose second control + // point is its end point (as `y` builds), so that control point is moved off it in the same + // pass. if !stroke_ctx.output().is_finite() { - let path = near_line_curves_as_lines(path, tolerance); + let path = robust_for_stroking(path, tolerance); expand_stroke(&path, style, tolerance, stroke_ctx); } fill( @@ -256,8 +259,10 @@ pub fn stroke( // PdfCraft patch. /// `path` with each quadratic or cubic whose control points all lie within -/// `tolerance` of its chord replaced by a line to its end point. One element out per element in. -fn near_line_curves_as_lines(path: impl IntoIterator, tolerance: f64) -> BezPath { +/// `tolerance` of its chord replaced by a line to its end point, and (patch (4)) each other +/// cubic whose second control point is its end point given a short end handle instead +/// ([`open_zero_end_handle`]). One element out per element in. +fn robust_for_stroking(path: impl IntoIterator, tolerance: f64) -> BezPath { let mut out = BezPath::new(); let mut start = kurbo::Point::ZERO; let mut last = start; @@ -270,6 +275,9 @@ fn near_line_curves_as_lines(path: impl IntoIterator, tolerance: { PathEl::LineTo(p3) } + PathEl::CurveTo(p1, p2, p3) => { + PathEl::CurveTo(p1, open_zero_end_handle(last, p1, p2, p3, tolerance), p3) + } el => el, }; match el { @@ -285,6 +293,33 @@ fn near_line_curves_as_lines(path: impl IntoIterator, tolerance: out } +// PdfCraft patch (4). +/// The second control point of the cubic `p0 p1 p2 p3`, moved `tolerance` towards `p1` (or `p0` +/// when `p1` is that close too) when it sits on the end point `p3`, as a `y` curve's does. +/// +/// There the curve's derivative vanishes at its end. kurbo finds the cusps of a wide stroke's +/// offset curve with a root search on the curvature, which grows without bound towards such an +/// end; at some widths the search returns the end point itself, and the unit tangent kurbo takes +/// there is `0 / 0`. Only an exact zero does this: a handle even 1e-9 long strokes finitely, as +/// does a first control point on the start point (`v`). The moved control point moves the curve +/// by at most `4 / 9 * tolerance`. +fn open_zero_end_handle( + p0: kurbo::Point, + p1: kurbo::Point, + p2: kurbo::Point, + p3: kurbo::Point, + tolerance: f64, +) -> kurbo::Point { + if p2 != p3 { + return p2; + } + [p1, p0] + .into_iter() + .map(|q| q - p3) + .find(|v| v.hypot() > tolerance) + .map_or(p2, |v| p3 + v * (tolerance / v.hypot())) +} + /// Whether `p` is within `tolerance` of the segment from `a` to `b`. False for non-finite input. fn near_segment(p: kurbo::Point, a: kurbo::Point, b: kurbo::Point, tolerance: f64) -> bool { let ab = b - a; @@ -390,6 +425,63 @@ mod tests { assert!(lines.is_empty()); } + /// The outline of `path` stroked with `style`, drawn translated by (80, -136) so that the + /// test paths below land inside the 200 × 200 cull box, and whether kurbo's outline was finite. + fn stroke_style_lines(path: &BezPath, style: &Stroke) -> (Vec, bool) { + let mut lines = Vec::new(); + let mut ctx = StrokeCtx::default(); + stroke( + Level::baseline(), + path, + style, + Affine::translate((80.0, -136.0)), + &mut lines, + &mut FlattenCtx::default(), + &mut ctx, + RectU16::new(0, 0, 200, 200), + ); + let finite = ctx.output().is_finite(); + (lines, finite) + } + + // A file in the PDFBox issue tracker strokes "XX" outlines at 32 pt whose subpaths include + // `y` curves (the second control point on the end point, so the end tangent vanishes), and at + // 16, 32 and 63 pt kurbo's offset of one of them came back with NaN, so the whole outline was + // dropped. These exact coordinates are the trigger. `v` (the first control point on the start + // point) is the mirrored case, the same two curves run backwards; kurbo strokes it finitely, + // and it is checked as a guard. + #[test] + fn a_wide_stroke_of_a_curve_with_a_zero_end_tangent_is_drawn() { + use crate::kurbo::{Cap, Join}; + let a = Point::new(21.4166, 234.5928); + let b = Point::new(18.9478, 236.3428); + let e = Point::new(20.8697, 238.4209); + let mut y = BezPath::new(); + y.move_to(a); + y.curve_to(Point::new(19.9009, 236.0303), b, b); + y.curve_to(Point::new(19.2603, 238.4209), e, e); + let mut v = BezPath::new(); + v.move_to(e); + v.curve_to(e, Point::new(19.2603, 238.4209), b); + v.curve_to(b, Point::new(19.9009, 236.0303), a); + + let mut failures = Vec::new(); + for (name, path) in [("y", &y), ("v", &v)] { + for width in 1..=64 { + for join in [Join::Miter, Join::Round, Join::Bevel] { + for cap in [Cap::Butt, Cap::Round, Cap::Square] { + let style = Stroke::new(f64::from(width)).with_join(join).with_caps(cap); + let (lines, finite) = stroke_style_lines(path, &style); + if lines.is_empty() || !finite { + failures.push((name, width, join, cap)); + } + } + } + } + } + assert!(failures.is_empty(), "strokes dropped: {failures:?}"); + } + // A regression guard: it passes with or without the patch. #[test] fn a_finite_outline_is_stroked_as_before() { From 90def111c90ef93cc77f52b1444534aba4ac533e Mon Sep 17 00:00:00 2001 From: jonathanwxh-cell Date: Mon, 9 Nov 2026 08:31:41 +0800 Subject: [PATCH 25/92] fix(engine): map URL text line rects with view_rect_to_user and filter non-finite coordinates (#6536) * fix(engine): map URL text line rects with view_rect_to_user and filter non-finite coordinates * test(engine): assert concrete rotated user rects and overflow guards (#6469) --- crates/engine/src/session/view.rs | 121 +++++++++++++++++++++++++++--- 1 file changed, 110 insertions(+), 11 deletions(-) diff --git a/crates/engine/src/session/view.rs b/crates/engine/src/session/view.rs index 5ef135d6c8..711779903a 100644 --- a/crates/engine/src/session/view.rs +++ b/crates/engine/src/session/view.rs @@ -120,17 +120,7 @@ impl Session { let rects: Vec<[f64; 4]> = text .line_rects(glyphs) .into_iter() - .map(|v| { - let q = doc.info.pages[page].view_rect_to_quad(v); - let xs = [q[0], q[2], q[4], q[6]]; - let ys = [q[1], q[3], q[5], q[7]]; - [ - xs.iter().copied().fold(f64::MAX, f64::min), - ys.iter().copied().fold(f64::MAX, f64::min), - xs.iter().copied().fold(f64::MIN, f64::max), - ys.iter().copied().fold(f64::MIN, f64::max), - ] - }) + .filter_map(|v| line_user_rect(&doc.info.pages[page], v)) .filter(|r| { !doc.links().iter().any(|l| l.page == page && l.rect[0] < r[2] && r[0] < l.rect[2] && l.rect[1] < r[3] && r[1] < l.rect[3]) }) @@ -313,3 +303,112 @@ impl Session { &self.docs } } + +/// Convert a view-space text line rectangle into a normalized, finite PDF user-space rectangle. +fn line_user_rect(page: &pdfcraft_render::PageInfo, v: [f32; 4]) -> Option<[f64; 4]> { + if !v.iter().all(|x| x.is_finite()) { + return None; + } + let r = page.view_rect_to_user(v).map(f64::from); + r.iter().all(|x| x.is_finite()).then_some(r) +} + +#[cfg(test)] +mod tests { + use super::line_user_rect; + use crate::{Edit, Session}; + use pdfcraft_render::PageInfo; + use std::sync::Arc; + + /// Build a sample `PageInfo` with a non-origin crop box `[10.0, 20.0, 210.0, 320.0]` (`200 × 300`). + fn sample_page(rotation: u16) -> PageInfo { + let (width, height) = if rotation % 180 == 90 { (300.0, 200.0) } else { (200.0, 300.0) }; + PageInfo { width, height, label: String::new(), crop: [10.0, 20.0, 210.0, 320.0], rotation } + } + + #[test] + fn line_user_rect_rejects_non_finite_coordinates() { + let page = sample_page(0); + assert_eq!(line_user_rect(&page, [f32::NAN; 4]), None); + assert_eq!(line_user_rect(&page, [f32::NAN, 10.0, 50.0, 20.0]), None); + assert_eq!(line_user_rect(&page, [10.0, 20.0, f32::INFINITY, 30.0]), None); + assert_eq!(line_user_rect(&page, [10.0, f32::NEG_INFINITY, 50.0, 30.0]), None); + + for bad_crop_box in [[f32::NAN, 0.0, 200.0, 300.0], [0.0, f32::INFINITY, 200.0, 300.0], [0.0, 0.0, f32::NEG_INFINITY, 300.0]] { + let mut bad_crop = sample_page(0); + bad_crop.crop = bad_crop_box; + assert_eq!(line_user_rect(&bad_crop, [10.0, 20.0, 50.0, 40.0]), None); + } + + // Finite inputs whose crop span overflows f32 during view-to-user mapping must also be rejected. + let overflow_page = PageInfo { width: 1.0, height: 1.0, label: String::new(), crop: [-f32::MAX, 0.0, f32::MAX, 300.0], rotation: 0 }; + assert_eq!(line_user_rect(&overflow_page, [1.0, 10.0, 2.0, 20.0]), None); + } + + #[test] + fn line_user_rect_matches_view_rect_to_user_across_rotations() { + let v = [12.0, 24.0, 96.0, 48.0]; + let cases = [ + (0, [22.0, 272.0, 106.0, 296.0]), + (90, [34.0, 32.0, 58.0, 116.0]), + (180, [114.0, 44.0, 198.0, 68.0]), + (270, [162.0, 224.0, 186.0, 308.0]), + ]; + for (rotation, expected) in cases { + let page = sample_page(rotation); + let got = line_user_rect(&page, v).expect("finite view rect maps to user space"); + for (g, e) in got.into_iter().zip(expected) { + assert!((g - e).abs() < 1e-4, "rotation {rotation}: got {got:?}, expected {expected:?}"); + } + } + } + + #[test] + fn find_urls_returns_finite_user_rects_on_rotated_page() { + let mut objs: Vec> = vec![ + b"<< /Type /Catalog /Pages 2 0 R >>".to_vec(), + b"<< /Type /Pages /Kids [4 0 R] /Count 1 /MediaBox [0 0 200 300] >>".to_vec(), + b"<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>".to_vec(), + b"<< /Type /Page /Parent 2 0 R /Contents 5 0 R /Resources << /Font << /F1 3 0 R >> >> >>".to_vec(), + ]; + let body = "BT /F1 12 Tf 20 150 Td (Visit https://example.com today) Tj ET"; + objs.push(format!("<< /Length {} >>\nstream\n{body}\nendstream", body.len()).into_bytes()); + let mut pdf = b"%PDF-1.7\n".to_vec(); + let mut offsets = Vec::new(); + for (i, o) in objs.iter().enumerate() { + offsets.push(pdf.len()); + pdf.extend_from_slice(format!("{} 0 obj\n", i + 1).as_bytes()); + pdf.extend_from_slice(o); + pdf.extend_from_slice(b"\nendobj\n"); + } + let xref = pdf.len(); + pdf.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objs.len() + 1).as_bytes()); + for o in offsets { + pdf.extend_from_slice(format!("{o:010} 00000 n \n").as_bytes()); + } + pdf.extend_from_slice(format!("trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n", objs.len() + 1).as_bytes()); + + let mut session = Session::new(); + let id = session.open("urls.pdf", None, Arc::new(pdf), None).expect("opens"); + let expected_rect = [46.004, 147.6, 153.38, 159.6]; + let base_urls = session.find_urls(id); + assert_eq!(base_urls.len(), 1); + let base_rect = base_urls[0].1[0]; + for (got, exp) in base_rect.into_iter().zip(expected_rect) { + assert!((got - exp).abs() < 1e-2, "0 deg: got {base_rect:?}, expected {expected_rect:?}"); + } + + for step in [90, 180, 270] { + session.apply(id, Edit::RotatePages { pages: vec![0], degrees: 90 }).expect("rotates"); + let urls = session.find_urls(id); + assert_eq!(urls.len(), 1); + assert_eq!(urls[0].0, 0); + assert_eq!(urls[0].2, "https://example.com"); + assert_eq!(urls[0].1.len(), 1); + let r = urls[0].1[0]; + for (got, exp) in r.into_iter().zip(expected_rect) { + assert!((got - exp).abs() < 1e-2, "rotation {step} deg: got {r:?}, expected {expected_rect:?}"); + } + } + } +} From ca21968e6474d4a573cbb52ef4246694f907cfd5 Mon Sep 17 00:00:00 2001 From: jonathanwxh-cell Date: Mon, 9 Nov 2026 08:31:54 +0800 Subject: [PATCH 26/92] fix(js): decode ', numeric, and HTML named character references in FormCalc Decode (#6538) * fix(js): decode ', numeric, and HTML named character references in FormCalc Decode * test(js): verify all HTML named entities and 8-byte name boundary (#6464) * docs(js): remove duplicate decode_entities summary and document LATIN1_ENTITIES order (#6464) --- crates/js/src/formcalc.rs | 38 ++++++++ crates/js/src/formcalc/builtins.rs | 5 +- crates/js/src/formcalc/words.rs | 137 +++++++++++++++++++++++++++++ 3 files changed, 178 insertions(+), 2 deletions(-) diff --git a/crates/js/src/formcalc.rs b/crates/js/src/formcalc.rs index cfad619938..06ff21f438 100644 --- a/crates/js/src/formcalc.rs +++ b/crates/js/src/formcalc.rs @@ -245,4 +245,42 @@ mod tests { assert_eq!(num_text(-1e20), "-100000000000000000000"); assert_eq!(num_text(f64::MAX).len(), 309); } + + #[test] + fn decode_xml_and_html_entities() { + let eval = |script: &str| { + let root = XfaNode::default(); + let out = run_formcalc(script, &XfaEvent::default(), &XfaDoc::default(), &root, Limits::default()); + assert!(out.error.is_none(), "{:?}", out.error); + out.result.unwrap_or_default() + }; + + // `'` in both xml and html + assert_eq!(eval(r#"Decode("It's <ok> & "fine"", "xml")"#), "It's & \"fine\""); + assert_eq!(eval(r#"Decode("It's", "html")"#), "It's"); + + // Decimal and hex numeric character references (case-insensitive #x/#X and hex digits, leading zeros) + assert_eq!(eval(r#"Decode("ABCDe€😀", "xml")"#), "ABCDe€😀"); + assert_eq!(eval(r#"Decode("ABCé", "html")"#), "ABCé"); + + // Invalid / surrogate / out-of-range / zero / malformed numeric references stay literal without panicking + assert_eq!( + eval(r#"Decode("� � � � � &#; &#x; A G; &;", "xml")"#), + "� � � � � &#; &#x; A G; &;" + ); + + // Single-pass decoding: `&lt;` and `&lt;` must decode to `<`, never `<` + assert_eq!(eval(r#"Decode("&lt; &#65; &lt; &apos;", "xml")"#), "< A < '"); + assert_eq!(eval(r#"Decode("&nbsp; &eacute;", "html")"#), "  é"); + + // HTML named entities decode in "html" mode but remain literal in "xml" mode + assert_eq!(eval(r#"Decode("café ©€—αΩ♥", "html")"#), "café\u{00A0}©€—αΩ♥"); + assert_eq!(eval(r#"Decode("café ©€", "xml")"#), "café ©€"); + // Case sensitivity of HTML named entities (`É` vs `é`, `″` vs `′`, `‡` vs `†`) + assert_eq!(eval(r#"Decode("Éé″′‡†&bogus;", "html")"#), "Éé″′‡†&bogus;"); + // Non-ASCII characters after `&` or `&#` and long runs of `&` without `;` + assert_eq!(eval(r#"Decode("&é; &#é; &#xé; &é", "html")"#), "&é; &#é; &#xé; &é"); + let amps = "&".repeat(5_000); + assert_eq!(eval(&format!(r#"Decode("{amps}'", "xml")"#)), format!("{amps}'")); + } } diff --git a/crates/js/src/formcalc/builtins.rs b/crates/js/src/formcalc/builtins.rs index c3b7c7f1f2..e9e3bf90be 100644 --- a/crates/js/src/formcalc/builtins.rs +++ b/crates/js/src/formcalc/builtins.rs @@ -2,7 +2,7 @@ use super::ast::Expr; use super::dates::{MAX_DAYS, civil_from_days, days_from_civil, format_date, parse_date}; use super::parser::parse; use super::value::{Val, fixed, is_numeric_text, parse_num}; -use super::words::word_num; +use super::words::{decode_entities, word_num}; use super::{Interp, MAX_STRING, R, STRING_TOO_LONG, XfaKind, is_limit}; impl Interp<'_> { @@ -200,7 +200,8 @@ impl Interp<'_> { let kind = if args.len() > 1 { self.arg_str(args, 1)?.to_ascii_lowercase() } else { "url".into() }; Val::Str(match (lower.as_str(), kind.as_str()) { ("encode", "html" | "xml") => s.replace('&', "&").replace('<', "<").replace('>', ">").replace('"', """), - ("decode", "html" | "xml") => s.replace("<", "<").replace(">", ">").replace(""", "\"").replace("&", "&"), + ("decode", "xml") => decode_entities(&s, false), + ("decode", "html") => decode_entities(&s, true), ("encode", _) => s .bytes() .map(|b| if b.is_ascii_alphanumeric() || b"-_.~".contains(&b) { (b as char).to_string() } else { format!("%{b:02X}") }) diff --git a/crates/js/src/formcalc/words.rs b/crates/js/src/formcalc/words.rs index d0d119abb5..a6a5141920 100644 --- a/crates/js/src/formcalc/words.rs +++ b/crates/js/src/formcalc/words.rs @@ -73,3 +73,140 @@ pub(super) fn word_num(n: f64, cents: bool) -> String { } out } + +/// Decode XML (`html == false`) or HTML (`html == true`) character and entity references in a +/// single pass so `&lt;` becomes `<` rather than `<`, and invalid or unrecognized +/// references remain literal without panicking. +/// +/// When `html` is `false`, only the five predefined XML entities (`<`, `>`, `&`, +/// `"`, `'`) and numeric character references (`&#...;`, `&#x...;`, `&#X...;`) are +/// decoded. When `html` is `true`, W3C HTML 4 / XHTML 1.0 named entities are decoded as well. +pub(super) fn decode_entities(s: &str, html: bool) -> String { + let mut out = String::with_capacity(s.len()); + let mut rest = s; + while let Some(at) = rest.find('&') { + out.push_str(rest.get(..at).unwrap_or_default()); + rest = rest.get(at..).unwrap_or_default(); + match entity_at(rest, html) { + Some((c, len)) => { + out.push(c); + rest = rest.get(len..).unwrap_or_default(); + } + None => { + out.push('&'); + rest = rest.get(1..).unwrap_or_default(); + } + } + } + out.push_str(rest); + out +} + +/// Parse a character reference starting at the leading `&` of `s`, returning the decoded `char` +/// and the byte length of the reference (including `&` and `;`). +fn entity_at(s: &str, html: bool) -> Option<(char, usize)> { + if let Some(body) = s.strip_prefix("&#") { + let (radix, digits, prefix_len) = match body.strip_prefix(['x', 'X']) { + Some(hex) => (16u32, hex, 3usize), + None => (10u32, body, 2usize), + }; + let mut value = 0u32; + let mut any_digit = false; + for (i, c) in digits.char_indices() { + match c.to_digit(radix) { + Some(d) => { + any_digit = true; + value = value.checked_mul(radix)?.checked_add(d)?; + if value > 0x10_FFFF { + return None; + } + } + None if c == ';' && any_digit && value != 0 => { + return Some((char::from_u32(value)?, prefix_len + i + 1)); + } + None => return None, + } + } + return None; + } + let body = s.strip_prefix('&')?; + let semi = body.bytes().take(9).position(|b| b == b';')?; + let name = body.get(..semi).filter(|n| !n.is_empty() && n.bytes().all(|b| b.is_ascii_alphanumeric()))?; + let ch = match name { + "lt" => '<', + "gt" => '>', + "amp" => '&', + "quot" => '"', + "apos" => '\'', + _ if html => html_named_entity(name)?, + _ => return None, + }; + Some((ch, 1 + semi + 1)) +} + +/// Look up a W3C HTML 4 / XHTML 1.0 named entity (excluding the five XML entities). +fn html_named_entity(name: &str) -> Option { + if let Some(idx) = LATIN1_ENTITIES.iter().position(|&n| n == name) { + return char::from_u32(0xA0 + idx as u32); + } + HTML_ENTITIES.iter().find(|&&(n, _)| n == name).map(|&(_, c)| c) +} + +/// ISO-8859-1 named entities in code-point order `U+00A0..=U+00FF`. Entries must remain ordered +/// by successive code points starting at `0xA0` and must not be reordered alphabetically, since +/// [`html_named_entity`] maps each entry's index to `0xA0 + idx as u32`. +#[rustfmt::skip] +const LATIN1_ENTITIES: [&str; 96] = [ + "nbsp", "iexcl", "cent", "pound", "curren", "yen", "brvbar", "sect", "uml", "copy", "ordf", "laquo", "not", "shy", "reg", "macr", + "deg", "plusmn", "sup2", "sup3", "acute", "micro", "para", "middot", "cedil", "sup1", "ordm", "raquo", "frac14", "frac12", "frac34", "iquest", + "Agrave", "Aacute", "Acirc", "Atilde", "Auml", "Aring", "AElig", "Ccedil", "Egrave", "Eacute", "Ecirc", "Euml", "Igrave", "Iacute", "Icirc", "Iuml", + "ETH", "Ntilde", "Ograve", "Oacute", "Ocirc", "Otilde", "Ouml", "times", "Oslash", "Ugrave", "Uacute", "Ucirc", "Uuml", "Yacute", "THORN", "szlig", + "agrave", "aacute", "acirc", "atilde", "auml", "aring", "aelig", "ccedil", "egrave", "eacute", "ecirc", "euml", "igrave", "iacute", "icirc", "iuml", + "eth", "ntilde", "ograve", "oacute", "ocirc", "otilde", "ouml", "divide", "oslash", "ugrave", "uacute", "ucirc", "uuml", "yacute", "thorn", "yuml", +]; + +#[rustfmt::skip] +const HTML_ENTITIES: &[(&str, char)] = &[ + ("OElig", '\u{0152}'), ("oelig", '\u{0153}'), ("Scaron", '\u{0160}'), ("scaron", '\u{0161}'), ("Yuml", '\u{0178}'), ("fnof", '\u{0192}'), ("circ", '\u{02C6}'), ("tilde", '\u{02DC}'), + ("Alpha", '\u{0391}'), ("Beta", '\u{0392}'), ("Gamma", '\u{0393}'), ("Delta", '\u{0394}'), ("Epsilon", '\u{0395}'), ("Zeta", '\u{0396}'), ("Eta", '\u{0397}'), ("Theta", '\u{0398}'), + ("Iota", '\u{0399}'), ("Kappa", '\u{039A}'), ("Lambda", '\u{039B}'), ("Mu", '\u{039C}'), ("Nu", '\u{039D}'), ("Xi", '\u{039E}'), ("Omicron", '\u{039F}'), ("Pi", '\u{03A0}'), + ("Rho", '\u{03A1}'), ("Sigma", '\u{03A3}'), ("Tau", '\u{03A4}'), ("Upsilon", '\u{03A5}'), ("Phi", '\u{03A6}'), ("Chi", '\u{03A7}'), ("Psi", '\u{03A8}'), ("Omega", '\u{03A9}'), + ("alpha", '\u{03B1}'), ("beta", '\u{03B2}'), ("gamma", '\u{03B3}'), ("delta", '\u{03B4}'), ("epsilon", '\u{03B5}'), ("zeta", '\u{03B6}'), ("eta", '\u{03B7}'), ("theta", '\u{03B8}'), + ("iota", '\u{03B9}'), ("kappa", '\u{03BA}'), ("lambda", '\u{03BB}'), ("mu", '\u{03BC}'), ("nu", '\u{03BD}'), ("xi", '\u{03BE}'), ("omicron", '\u{03BF}'), ("pi", '\u{03C0}'), + ("rho", '\u{03C1}'), ("sigmaf", '\u{03C2}'), ("sigma", '\u{03C3}'), ("tau", '\u{03C4}'), ("upsilon", '\u{03C5}'), ("phi", '\u{03C6}'), ("chi", '\u{03C7}'), ("psi", '\u{03C8}'), + ("omega", '\u{03C9}'), ("thetasym", '\u{03D1}'), ("upsih", '\u{03D2}'), ("piv", '\u{03D6}'), ("ensp", '\u{2002}'), ("emsp", '\u{2003}'), ("thinsp", '\u{2009}'), ("zwnj", '\u{200C}'), + ("zwj", '\u{200D}'), ("lrm", '\u{200E}'), ("rlm", '\u{200F}'), ("ndash", '\u{2013}'), ("mdash", '\u{2014}'), ("lsquo", '\u{2018}'), ("rsquo", '\u{2019}'), ("sbquo", '\u{201A}'), + ("ldquo", '\u{201C}'), ("rdquo", '\u{201D}'), ("bdquo", '\u{201E}'), ("dagger", '\u{2020}'), ("Dagger", '\u{2021}'), ("bull", '\u{2022}'), ("hellip", '\u{2026}'), ("permil", '\u{2030}'), + ("prime", '\u{2032}'), ("Prime", '\u{2033}'), ("lsaquo", '\u{2039}'), ("rsaquo", '\u{203A}'), ("oline", '\u{203E}'), ("frasl", '\u{2044}'), ("euro", '\u{20AC}'), ("image", '\u{2111}'), + ("weierp", '\u{2118}'), ("real", '\u{211C}'), ("trade", '\u{2122}'), ("alefsym", '\u{2135}'), ("larr", '\u{2190}'), ("uarr", '\u{2191}'), ("rarr", '\u{2192}'), ("darr", '\u{2193}'), + ("harr", '\u{2194}'), ("crarr", '\u{21B5}'), ("lArr", '\u{21D0}'), ("uArr", '\u{21D1}'), ("rArr", '\u{21D2}'), ("dArr", '\u{21D3}'), ("hArr", '\u{21D4}'), ("forall", '\u{2200}'), + ("part", '\u{2202}'), ("exist", '\u{2203}'), ("empty", '\u{2205}'), ("nabla", '\u{2207}'), ("isin", '\u{2208}'), ("notin", '\u{2209}'), ("ni", '\u{220B}'), ("prod", '\u{220F}'), + ("sum", '\u{2211}'), ("minus", '\u{2212}'), ("lowast", '\u{2217}'), ("radic", '\u{221A}'), ("prop", '\u{221D}'), ("infin", '\u{221E}'), ("ang", '\u{2220}'), ("and", '\u{2227}'), + ("or", '\u{2228}'), ("cap", '\u{2229}'), ("cup", '\u{222A}'), ("int", '\u{222B}'), ("there4", '\u{2234}'), ("sim", '\u{223C}'), ("cong", '\u{2245}'), ("asymp", '\u{2248}'), + ("ne", '\u{2260}'), ("equiv", '\u{2261}'), ("le", '\u{2264}'), ("ge", '\u{2265}'), ("sub", '\u{2282}'), ("sup", '\u{2283}'), ("nsub", '\u{2284}'), ("sube", '\u{2286}'), + ("supe", '\u{2287}'), ("oplus", '\u{2295}'), ("otimes", '\u{2297}'), ("perp", '\u{22A5}'), ("sdot", '\u{22C5}'), ("lceil", '\u{2308}'), ("rceil", '\u{2309}'), ("lfloor", '\u{230A}'), + ("rfloor", '\u{230B}'), ("lang", '\u{2329}'), ("rang", '\u{232A}'), ("loz", '\u{25CA}'), ("spades", '\u{2660}'), ("clubs", '\u{2663}'), ("hearts", '\u{2665}'), ("diams", '\u{2666}'), +]; + +#[cfg(test)] +mod tests { + use super::{HTML_ENTITIES, LATIN1_ENTITIES, decode_entities}; + + #[test] + fn every_listed_html_entity_decodes_including_eight_byte_boundary() { + for (i, &name) in LATIN1_ENTITIES.iter().enumerate() { + let expected = char::from_u32(0xA0 + i as u32).unwrap().to_string(); + let input = format!("&{name};"); + assert_eq!(decode_entities(&input, true), expected, "entity {name}"); + assert_eq!(decode_entities(&input, false), input, "xml mode leaves {name} literal"); + } + for &(name, ch) in HTML_ENTITIES { + assert!(name.len() <= 8, "entity name {name} exceeds 8-byte parser limit"); + let input = format!("&{name};"); + assert_eq!(decode_entities(&input, true), ch.to_string(), "entity {name}"); + assert_eq!(decode_entities(&input, false), input, "xml mode leaves {name} literal"); + } + assert_eq!(decode_entities("ϑ", true), "\u{03D1}"); + assert_eq!(decode_entities("&thetasymx;", true), "&thetasymx;"); + } +} From 1f48670fcef2e5b9f5294650cb7dc51cdf5a3cff Mon Sep 17 00:00:00 2001 From: jonathanwxh-cell Date: Mon, 9 Nov 2026 08:32:08 +0800 Subject: [PATCH 27/92] refactor(cos): consolidate duplicate civil_from_days onto pdfcraft_cos::civil_from_days (#6540) Fixes #6466 --- apps/pdfcraft/src/logging.rs | 23 ++++-------------- crates/cos/src/lib.rs | 2 +- crates/cos/src/writer.rs | 20 ++++++++++------ crates/engine/src/lib.rs | 2 +- crates/sign/src/der.rs | 14 ++--------- .../ui-egui/src/workflows/combine_ui/parts.rs | 23 ++++-------------- xtask/src/demo_pdf/pdf.rs | 24 ++----------------- 7 files changed, 29 insertions(+), 79 deletions(-) diff --git a/apps/pdfcraft/src/logging.rs b/apps/pdfcraft/src/logging.rs index bef27de169..4c0f39e85c 100644 --- a/apps/pdfcraft/src/logging.rs +++ b/apps/pdfcraft/src/logging.rs @@ -102,25 +102,12 @@ pub fn timestamp(t: SystemTime) -> String { format!("{y:04}-{m:02}-{d:02}T{:02}:{:02}:{:02}.{:03}Z", rem / 3600, rem % 3600 / 60, rem % 60, since.subsec_millis()) } -/// Days since 1970-01-01 → proleptic Gregorian (year, month, day). -/// -/// `timestamp` clamps a pre-1970 clock to the epoch, so `days` is an unsigned count that can run -/// past `NaiveDate`'s ±262143-year range. The Gregorian calendar repeats every 400 years -/// (146_097 days), so chrono converts the day within one cycle and whole cycles go back into the -/// year (an `i128`, then saturated into `i64`), keeping chrono as the only calendar logic and -/// reproducing the old `civil_from_days` result exactly without hand-rolled day math. 1970-01-01 -/// is `num_days_from_ce` 719_163. +/// Days since 1970-01-01 → proleptic Gregorian (year, month, day): `pdfcraft_engine::civil_from_days`, +/// which keeps the date past chrono's range by whole 400-year cycles. `days` is a `u64` second +/// count over 86_400, at most about 2.1e14, so it always fits an `i64`. fn civil_from_days(days: u64) -> (i64, u32, u32) { - use chrono::Datelike; - let (cycles, rest) = (days / 146_097, days % 146_097); - // `rest + 719_163` is 719_163..865_259, always a valid CE day. - match i32::try_from(rest + 719_163).ok().and_then(chrono::NaiveDate::from_num_days_from_ce_opt) { - Some(d) => { - let y = i128::from(d.year()) + i128::from(cycles) * 400; - (i64::try_from(y).unwrap_or(i64::MAX), d.month(), d.day()) - } - None => (1970, 1, 1), // unreachable - } + // The fallback is unreachable: `days` is at most `u64::MAX / 86_400`, far below `i64::MAX`. + pdfcraft_engine::civil_from_days(i64::try_from(days).unwrap_or(i64::MAX)) } fn numbered(dir: &Path, n: usize) -> PathBuf { diff --git a/crates/cos/src/lib.rs b/crates/cos/src/lib.rs index 32a6f6d8b3..0d0fa33f61 100644 --- a/crates/cos/src/lib.rs +++ b/crates/cos/src/lib.rs @@ -30,7 +30,7 @@ pub use parser::{Lexer, is_whitespace, parse_indirect}; pub use pdfcraft_crypt::{ Algorithm, Auth, Method as CryptMethod, NewEncryption, Permissions, SecurityHandler, effective_password, pdfdoc_byte, pdfdoc_char, }; -pub use writer::{SaveOptions, pdf_date, reachable_objects, serialize, write_full, write_incremental}; +pub use writer::{SaveOptions, civil_from_days, pdf_date, reachable_objects, serialize, write_full, write_incremental}; #[derive(Debug, thiserror::Error, Clone, PartialEq)] pub enum CosError { diff --git a/crates/cos/src/writer.rs b/crates/cos/src/writer.rs index 75d49dbd76..3037669aea 100644 --- a/crates/cos/src/writer.rs +++ b/crates/cos/src/writer.rs @@ -919,14 +919,14 @@ pub fn pdf_date(unix_secs: i64) -> String { format!("D:{y:04}{m:02}{d:02}{:02}{:02}{:02}Z", secs / 3600, secs / 60 % 60, secs % 60) } -/// Days since 1970-01-01 → proleptic Gregorian (year, month, day). +/// Days since 1970-01-01 → proleptic Gregorian (year, month, day). Total for any `i64` count: +/// dates past `NaiveDate`'s ±262143-year range keep their proleptic Gregorian year instead of +/// falling back to the epoch. /// -/// A `from_timestamp` epoch fallback would change dates past `NaiveDate`'s ±262143-year range, -/// which the old hand-rolled converter formatted directly. The Gregorian calendar repeats every -/// 400 years (146_097 days), so chrono converts the day within one cycle and whole cycles go back -/// into the year (an `i128` so the extremes can't overflow), keeping chrono as the only calendar -/// logic and reproducing the old result exactly. 1970-01-01 is `num_days_from_ce` 719_163. -fn civil_from_days(days: i64) -> (i64, u32, u32) { +/// The Gregorian calendar repeats every 400 years (146_097 days), so chrono converts the day +/// within one cycle and whole cycles go back into the year (an `i128` so the extremes can't +/// overflow), keeping chrono as the only calendar logic. 1970-01-01 is `num_days_from_ce` 719_163. +pub fn civil_from_days(days: i64) -> (i64, u32, u32) { use chrono::Datelike; let (cycles, rest) = (days.div_euclid(146_097), days.rem_euclid(146_097)); // `rest + 719_163` is 719_163..865_259, always a valid CE day. @@ -1110,6 +1110,12 @@ mod tests { // its sign, exactly as before). assert_eq!(pdf_date(10_000_000_000_000), "D:3188570520174640Z"); assert_eq!(pdf_date(-10_000_000_000_000), "D:-3149180813061320Z"); + assert_eq!(crate::civil_from_days(0), (1970, 1, 1)); + assert_eq!(crate::civil_from_days(-1), (1969, 12, 31)); + assert_eq!(crate::civil_from_days(11_016), (2000, 2, 29)); + assert_eq!(crate::civil_from_days(19_782), (2024, 2, 29)); + assert_eq!(crate::civil_from_days(i64::MAX), (25_252_734_927_768_524, 7, 27)); + assert_eq!(crate::civil_from_days(i64::MIN), (-25_252_734_927_764_585, 6, 7)); } #[test] diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index baa06b02fd..e6e21cb1eb 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -86,7 +86,7 @@ pub use pdfcraft_organize::Standard; pub use pdfcraft_organize::check_custom_key; pub use pdfcraft_organize::view::{InitialView, Layout as InitialLayout, Magnification, Navigation}; -pub use pdfcraft_cos::Algorithm; +pub use pdfcraft_cos::{Algorithm, civil_from_days}; pub use pdfcraft_create::{CONVERTIBLE, CreateError, ImageResolution, SourceKind, decode_text, source_kind}; pub use pdfcraft_edit::added::{ CHAR_SPACING_LIMIT as ADDED_CHAR_SPACING_LIMIT, paste_place as added_paste_place, paste_rect as added_paste_rect, text_rect as added_text_rect, diff --git a/crates/sign/src/der.rs b/crates/sign/src/der.rs index 0cac252c33..f9f2f3e6d9 100644 --- a/crates/sign/src/der.rs +++ b/crates/sign/src/der.rs @@ -349,19 +349,9 @@ impl Time { /// year is cast exactly as the old code did (`as u32`, wrapping) — no well-formed signing time /// is anywhere near that range, and this preserves byte-for-byte the old output everywhere. pub fn from_unix(t: i64) -> Time { - use chrono::Datelike; let (days, secs) = (t.div_euclid(86_400), t.rem_euclid(86_400)); - let (cycles, rest) = (days.div_euclid(146_097), days.rem_euclid(146_097)); - // `rest + 719_163` is 719_163..865_259, always a valid CE day. - let (year, month, day) = match i32::try_from(rest + 719_163).ok().and_then(chrono::NaiveDate::from_num_days_from_ce_opt) { - Some(d) => { - let y = i128::from(d.year()) + i128::from(cycles) * 400; - // Match the old `as u32` cast: saturate i128→i64 first (the old year was i64), then wrap into u32. - (i64::try_from(y).unwrap_or(if y < 0 { i64::MIN } else { i64::MAX }) as u32, d.month(), d.day()) - } - None => (1970, 1, 1), // unreachable - }; - Time { year, month, day, hour: (secs / 3600) as u32, minute: (secs % 3600 / 60) as u32, second: (secs % 60) as u32 } + let (year, month, day) = pdfcraft_cos::civil_from_days(days); + Time { year: year as u32, month, day, hour: (secs / 3600) as u32, minute: (secs % 3600 / 60) as u32, second: (secs % 60) as u32 } } pub fn add_seconds(&self, s: i64) -> Time { diff --git a/crates/ui-egui/src/workflows/combine_ui/parts.rs b/crates/ui-egui/src/workflows/combine_ui/parts.rs index a64d0eeb34..4527ec061d 100644 --- a/crates/ui-egui/src/workflows/combine_ui/parts.rs +++ b/crates/ui-egui/src/workflows/combine_ui/parts.rs @@ -274,23 +274,10 @@ pub(super) fn utc_stamp(time: SystemTime) -> String { format!("{y:04}-{m:02}-{d:02} {:02}:{:02} UTC", rem / 3600, rem % 3600 / 60) } -/// Days since 1970-01-01 → proleptic Gregorian (year, month, day). -/// -/// A pre-1970 time already clamps to the epoch (`unwrap_or(0)`), so `days` is an unsigned count -/// that can run past `NaiveDate`'s ±262143-year range, where the old hand-rolled converter still -/// produced a date. The Gregorian calendar repeats every 400 years (146_097 days), so chrono -/// converts the day within one cycle and whole cycles go back into the year (an `i128`, then -/// saturated into `i64`), keeping chrono as the only calendar logic and reproducing the old -/// result exactly. 1970-01-01 is `num_days_from_ce` 719_163. +/// Days since 1970-01-01 → proleptic Gregorian (year, month, day): `pdfcraft_cos::civil_from_days`, +/// which keeps the date past chrono's range by whole 400-year cycles. `days` is a `u64` second +/// count over 86_400, at most about 2.1e14, so it always fits an `i64`. fn civil_from_days(days: u64) -> (i64, u32, u32) { - use chrono::Datelike; - let (cycles, rest) = (days / 146_097, days % 146_097); - // `rest + 719_163` is 719_163..865_259, always a valid CE day. - match i32::try_from(rest + 719_163).ok().and_then(chrono::NaiveDate::from_num_days_from_ce_opt) { - Some(d) => { - let y = i128::from(d.year()) + i128::from(cycles) * 400; - (i64::try_from(y).unwrap_or(i64::MAX), d.month(), d.day()) - } - None => (1970, 1, 1), // unreachable - } + // The fallback is unreachable: `days` is at most `u64::MAX / 86_400`, far below `i64::MAX`. + pdfcraft_cos::civil_from_days(i64::try_from(days).unwrap_or(i64::MAX)) } diff --git a/xtask/src/demo_pdf/pdf.rs b/xtask/src/demo_pdf/pdf.rs index b51ea1a643..dcdc156683 100644 --- a/xtask/src/demo_pdf/pdf.rs +++ b/xtask/src/demo_pdf/pdf.rs @@ -77,29 +77,9 @@ impl Stamp { pub fn from_unix(secs: i64) -> Self { let (days, rem) = (secs.div_euclid(86_400), secs.rem_euclid(86_400)); let (h, mi, s) = (rem / 3600, rem % 3600 / 60, rem % 60); - let (y, m, d) = Self::civil_from_days(days); + let (y, m, d) = pdfcraft_cos::civil_from_days(days); // `{y:04}` rather than `%Y`, which writes a `+` before years past 9999. - Stamp { unix: secs, pdf: format!("D:{y:04}{m:02}{d:02}{h:02}{mi:02}{s:02}Z"), iso: format!("{y:04}-{m:02}-{d:02}T{h:02}:{mi:02}:{s:02}Z") } - } - - /// Days since 1970-01-01 → proleptic Gregorian (year, month, day). - /// - /// Past `NaiveDate`'s ±262143-year range the old hand-rolled converter still produced a date, - /// which both stamps formatted directly; a `from_timestamp` epoch fallback would change it. - /// The Gregorian calendar repeats every 400 years (146_097 days), so chrono converts the day - /// within one cycle and whole cycles go back into the year (an `i128` so the extremes can't - /// overflow), keeping chrono as the only calendar logic. 1970-01-01 is `num_days_from_ce` 719_163. - fn civil_from_days(days: i64) -> (i64, u32, u32) { - use chrono::Datelike; - let (cycles, rest) = (days.div_euclid(146_097), days.rem_euclid(146_097)); - // `rest + 719_163` is 719_163..865_259, always a valid CE day. - match i32::try_from(rest + 719_163).ok().and_then(chrono::NaiveDate::from_num_days_from_ce_opt) { - Some(d) => { - let y = i128::from(d.year()) + i128::from(cycles) * 400; - (i64::try_from(y).unwrap_or(if y < 0 { i64::MIN } else { i64::MAX }), d.month(), d.day()) - } - None => (1970, 1, 1), // unreachable - } + Stamp { unix: secs, pdf: pdfcraft_cos::pdf_date(secs), iso: format!("{y:04}-{m:02}-{d:02}T{h:02}:{mi:02}:{s:02}Z") } } /// The same moment shifted by `minutes` (used to order threaded replies). From ee2398f7107cbdf06a7ba5425efd69d0fc00e7a1 Mon Sep 17 00:00:00 2001 From: mtlcl410 <2368091+mtlcl410@users.noreply.github.com> Date: Mon, 9 Nov 2026 08:37:56 +0800 Subject: [PATCH 28/92] M11: PDF/A verify checks that predefined XMP properties have their value types (optimize.pdfa-validate) (#6220) --- crates/preflight/src/lib.rs | 102 +++- crates/preflight/src/tests.rs | 353 +++++++++++++- crates/preflight/src/xmp.rs | 13 +- crates/preflight/src/xmp_types.rs | 780 ++++++++++++++++++++++++++++++ parity/acrobat-features.toml | 4 +- 5 files changed, 1228 insertions(+), 24 deletions(-) create mode 100644 crates/preflight/src/xmp_types.rs diff --git a/crates/preflight/src/lib.rs b/crates/preflight/src/lib.rs index 5fdec67fac..1cbe9a6ed3 100644 --- a/crates/preflight/src/lib.rs +++ b/crates/preflight/src/lib.rs @@ -23,6 +23,7 @@ pub mod icc; mod xmp; +mod xmp_types; use pdfcraft_cos::{Dict, Document, ObjRef, Object, PdfString, Stream}; @@ -63,18 +64,25 @@ impl Level { /// another language says it from [`pattern`](Issue::pattern) and [`value`](Issue::value) instead: /// the pattern is wording of ours, listed in [`WORDINGS`], and the value — when there is one — is /// the document's own name for something, a PDF keyword that reads the same in every language. +/// A wording that names more than one thing has a named hole, such as `{object}`, for each, so a +/// language can put them in its own order; [`values`](Issue::values) fills those. /// [`Issue::said`] does that. #[derive(Clone, Debug, PartialEq)] pub struct Issue { /// The ISO 19005-2 clause. pub clause: &'static str, pub message: String, - /// The problem as it is worded here, with `{}` where the document's own name goes. + /// The problem as it is worded here, with `{}` or a named hole where the document's own name goes. pub pattern: &'static str, /// What goes in the pattern's `{}`: an annotation subtype, an action, a font, a page number. pub value: Option, + /// What goes in each of the pattern's named holes, by the hole's name (`"{object}"`, braces + /// included). + pub values: Vec<(&'static str, String)>, /// 0-based page, when the problem is on a page. pub page: Option, + /// The indirect object the problem is in, when that is what locates it: a metadata stream. + pub object: Option, /// Whether [`convert`] can fix it. pub fixable: bool, } @@ -86,14 +94,43 @@ impl Issue { /// font called `{}` cannot turn into anything else. With [`in_english`] this is /// [`message`](Issue::message). pub fn said(&self, words: Words) -> String { - let said = words(self.pattern); - match &self.value { - Some(v) => said.replacen("{}", v, 1), - None => said, - } + fill(&words(self.pattern), self.value.as_deref(), &self.values) } } +/// `wording` with its holes filled: the first `{}` with `value`, and each named hole, such as +/// `{object}`, with its entry in `values`. A hole with nothing for it stays as it is written. +/// +/// The values go in during one pass over the wording, so a value with braces in it is never read +/// as a hole. +fn fill(wording: &str, value: Option<&str>, values: &[(&str, String)]) -> String { + let mut out = String::with_capacity(wording.len().saturating_add(16)); + let mut value = value; + let mut rest = wording; + while let Some(open) = rest.find('{') { + let (before, from_brace) = rest.split_at(open); + out.push_str(before); + // A brace with nothing closing it is just text. `}` is one byte, so the end of the hole is a + // char boundary; `get` rather than slicing all the same. + let Some(hole) = from_brace.find('}').and_then(|close| from_brace.get(..=close)) else { + out.push_str(from_brace); + return out; + }; + if hole == "{}" + && let Some(v) = value.take() + { + out.push_str(v); + } else if let Some((_, v)) = values.iter().find(|(name, _)| *name == hole) { + out.push_str(v); + } else { + out.push_str(hole); + } + rest = from_brace.get(hole.len()..).unwrap_or_default(); + } + out.push_str(rest); + out +} + /// How an interface says one of our wordings in its own language: given the English in [`WORDINGS`], /// the same wording in that language. [`in_english`] keeps the English. pub type Words<'a> = &'a dyn Fn(&str) -> String; @@ -103,7 +140,8 @@ pub fn in_english(wording: &str) -> String { wording.to_owned() } -/// Every problem [`verify`] can report, in English, with `{}` where the document's own name goes. +/// Every problem [`verify`] can report, in English, with `{}` or a named hole, such as `{object}`, +/// where the document's own name goes. /// /// An interface that shows them in another language translates these, which is the whole reason they /// are gathered here; `wordings_are_complete` fails if a rule says something that is not on the @@ -113,6 +151,8 @@ pub const WORDINGS: &[&str] = &[ "The document has no XMP metadata", "The metadata doesn't identify the file as {}", "The title in the metadata and the document information differ", + "The XMP property {property} in metadata stream {object} should have the value type {type}", + "The XMP property {property} in metadata stream {object}, written {written}, should have the value type {type}", // The document as a whole. "The document is encrypted", "Device colour is used, but there is no PDF/A output intent", @@ -427,15 +467,24 @@ thread_local! { /// Check `doc` against `level`. pub fn verify(doc: &Document, level: Level) -> Vec { - let mut out = Vec::new(); + // Shared by `issue` and `located_issue`, each of which takes the list out, adds one issue and + // puts it back. + let out = std::cell::Cell::new(Vec::new()); + let push = |i: Issue| { + let mut list = out.take(); + list.push(i); + out.set(list); + }; // Each problem is worded here and named by the document there: the wording goes on the list in // `WORDINGS` for an interface to translate, and the name goes in its `{}` as it stands. let mut issue = |clause: &'static str, pattern: &'static str, value: Option, page: Option, fixable: bool| { - let message = match &value { - Some(v) => pattern.replacen("{}", v, 1), - None => pattern.to_owned(), - }; - out.push(Issue { clause, message, pattern, value, page, fixable }); + let message = fill(pattern, value.as_deref(), &[]); + push(Issue { clause, message, pattern, value, values: Vec::new(), page, object: None, fixable }); + }; + // A problem in one indirect object, worded with named holes. + let located_issue = |clause: &'static str, pattern: &'static str, values: Vec<(&'static str, String)>, object: ObjRef, fixable: bool| { + let message = fill(pattern, None, &values); + push(Issue { clause, message, pattern, value: None, values, page: None, object: Some(object), fixable }); }; let cat = catalog(doc); // 6.1.3 / 6.6: identification and metadata. @@ -460,6 +509,32 @@ pub fn verify(doc: &Document, level: Level) -> Vec { } // 6.2.2: output intent when device colour is used. let objs = objects(doc); + // 6.6.2.3.1: the value types of predefined XMP properties, in every metadata stream. + let mut mistyped = Vec::new(); + xmp_types::for_each_packet(&objs, |object, packet| { + mistyped.extend(xmp_types::mistyped(packet).into_iter().map(|((name, written), ty)| (object, name, written, ty))); + }); + for (object, name, written, ty) in mistyped { + let mut values = vec![("{property}", name.clone()), ("{object}", object.to_string()), ("{type}", ty)]; + if written == name { + located_issue( + "6.6.2.3.1", + "The XMP property {property} in metadata stream {object} should have the value type {type}", + values, + object, + false, + ); + } else { + values.push(("{written}", written)); + located_issue( + "6.6.2.3.1", + "The XMP property {property} in metadata stream {object}, written {written}, should have the value type {type}", + values, + object, + false, + ); + } + } let used = uses_device_colour(doc, &objs); match pdfa_intent_profile(doc) { None if used.any() => { @@ -516,6 +591,7 @@ pub fn verify(doc: &Document, level: Level) -> Vec { } check_objects(doc, level, &objs, &page_of, &mut issue); // One message per kind of problem is enough for repeated ones (as preflight summaries do). + let mut out = out.into_inner(); let mut seen = std::collections::HashSet::new(); out.retain(|i| seen.insert((i.clause, i.message.clone(), i.page))); out diff --git a/crates/preflight/src/tests.rs b/crates/preflight/src/tests.rs index 52e6903e60..e691adae84 100644 --- a/crates/preflight/src/tests.rs +++ b/crates/preflight/src/tests.rs @@ -78,12 +78,14 @@ fn wordings_are_complete() { let source = include_str!("lib.rs"); let mut said = Vec::new(); let mut rest = source; - while let Some(at) = rest.find("issue(\"") { - rest = &rest[at + 7..]; + // `issue(` and `located_issue(`, written on one line or, when rustfmt breaks it, on several. + while let Some(at) = rest.find("issue(") { + rest = &rest[at + 6..]; + let Some(clause) = rest.trim_start().strip_prefix('"') else { continue }; // Step over the clause, then take the wording that follows it. - let Some(end) = rest.find('"') else { break }; - rest = &rest[end + 1..]; - let Some(text) = rest.strip_prefix(", \"") else { continue }; + let Some(end) = clause.find('"') else { break }; + rest = &clause[end + 1..]; + let Some(text) = rest.strip_prefix(',').map(str::trim_start).and_then(|r| r.strip_prefix('"')) else { continue }; let Some(end) = text.find('"') else { break }; said.push(&text[..end]); rest = text; @@ -488,3 +490,344 @@ fn an_output_intent_profile_header_is_read_within_a_size_cap() { assert_eq!(pdfa_intent_profile(&doc), Some(n), "a {size}-byte profile"); } } + +const XMP_NS: &str = concat!( + r#"xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#" xmlns:dc="http://purl.org/dc/elements/1.1/" "#, + r#"xmlns:xmp="http://ns.adobe.com/xap/1.0/" xmlns:xmpMM="http://ns.adobe.com/xap/1.0/mm/" "#, + r#"xmlns:stRef="http://ns.adobe.com/xap/1.0/sType/ResourceRef#" xmlns:xmpDM="http://ns.adobe.com/xmp/1.0/DynamicMedia/" "#, + r#"xmlns:crs="http://ns.adobe.com/camera-raw-settings/1.0/" xmlns:xmpTPg="http://ns.adobe.com/xap/1.0/t/pg/" "#, + r#"xmlns:stDim="http://ns.adobe.com/xap/1.0/sType/Dimensions#" xmlns:pdf="http://ns.adobe.com/pdf/1.3/""# +); + +/// The mistyped predefined properties of a packet whose one description holds `body` (its +/// attributes, then `>` and its elements). +fn mistyped(body: &str) -> Vec { + let xmp = format!( + r#""# + ); + let parsed = xmp::parse(&xmp).expect("parses"); + xmp_types::mistyped(&parsed).into_keys().map(|(name, _)| name).collect() +} + +/// [`mistyped`] for the property `p` written as an element holding `v`. +fn element(p: &str, v: &str) -> Vec { + mistyped(&format!("><{p}>{v}")) +} + +/// PDF/A-2 6.6.2.3.1, simple values: their lexical forms (ISO 16684-1 8.2.1), as elements and attributes. +#[test] +fn xmp_simple_values_have_their_lexical_forms() { + // Simple types, as elements and as attributes. + assert!(element("crs:Contrast", "-25").is_empty()); + assert_eq!(element("crs:Contrast", "25.0"), ["crs:Contrast"]); + assert_eq!(element("crs:Contrast", "4/1"), ["crs:Contrast"]); + assert!(element("crs:Exposure", "+.5").is_empty() && element("crs:Exposure", "5").is_empty()); + assert_eq!(element("crs:Exposure", "5."), ["crs:Exposure"]); + assert_eq!(element("crs:Exposure", "1e3"), ["crs:Exposure"]); + assert!(element("pdf:Trapped", "True").is_empty()); + assert_eq!(element("pdf:Trapped", "true"), ["pdf:Trapped"]); + assert_eq!(mistyped(r#"crs:AutoContrast="Yes">"#), ["crs:AutoContrast"]); + assert!(mistyped(r#"crs:AutoContrast="False">"#).is_empty()); + for good in + ["2016", "2016-02", "2016-02-01", "2016-02-01T13:19Z", "2016-02-01T13:19:21+01:00", "2016-02-01T13:19:21.5-08:00", "2016-02-01T13:19:21"] + { + assert!(element("xmp:CreateDate", good).is_empty(), "{good}"); + } + for bad in [ + "16-02-01", + "2016-13-01", + "2016-02-32", + "2016-02T13:19", + "2016-02-01T25:00", + "2016-02-01T13:19:21+1:00", + "Feb 1 2016", + "2016-02-01T13:19Z01:00", + ] { + assert_eq!(element("xmp:CreateDate", bad), ["xmp:CreateDate"], "{bad}"); + } + assert!(element("xmpDM:videoPixelAspectRatio", "1001/30000").is_empty()); + assert_eq!(element("xmpDM:videoPixelAspectRatio", "1.5"), ["xmpDM:videoPixelAspectRatio"]); + // The whole text of a value counts, however a comment splits it. + assert_eq!(element("crs:Contrast", "5.0"), ["crs:Contrast"]); + assert!(element("pdf:Trapped", "True").is_empty()); + // rdf:resource makes a URI, which only a text type takes. + assert_eq!(mistyped(r#">"#), ["crs:Contrast"]); + // A GUID is a simple value but not a URI (ISO 16684-1 8.2.2.3). + assert!(element("xmpMM:DocumentID", "uuid:1234").is_empty()); + assert_eq!(mistyped(r#">"#), ["xmpMM:DocumentID"]); + assert_eq!( + mistyped(r#">"#), + ["xmpMM:DerivedFrom"] + ); +} + +/// PDF/A-2 6.6.2.3.1, arrays: the kind, every item, and the languages of a language alternative. +#[test] +fn xmp_arrays_have_their_kind_and_item_types() { + // Text can't be an array or a structure; a simple value can be given as rdf:resource. + assert_eq!(element("crs:RawFileName", "a"), ["crs:RawFileName"]); + assert_eq!(mistyped(r#">1"#), ["crs:RawFileName"]); + assert!(mistyped(r#">"#).is_empty()); + // Arrays: the kind and every item. + assert!(element("dc:creator", "AB").is_empty()); + assert_eq!(element("dc:creator", "A"), ["dc:creator"]); + assert_eq!(element("dc:creator", "A"), ["dc:creator"]); + assert_eq!(element("dc:date", "2016-02-01yesterday"), ["dc:date"]); + assert_eq!(element("dc:date", "yesterday"), ["dc:date"]); + // Only white space around an array's items. + assert_eq!(element("dc:creator", "strayA"), ["dc:creator"]); + assert_eq!(element("dc:creator", "strayA"), ["dc:creator"]); + // rdf:parseType or rdf:resource on the property makes its value something other than the array. + assert_eq!(mistyped(r#">A"#), ["dc:creator"]); + assert_eq!(mistyped(r#">A"#), ["dc:creator"]); + assert!(element("dc:creator", "\n \n A\n \n").is_empty()); + // An rdf:resource element is empty. + assert_eq!(mistyped(r#">x"#), ["dc:source"]); + // Language alternatives: an rdf:Alt whose items all carry xml:lang. + assert!(element("dc:title", r#"T"#).is_empty()); + assert_eq!(element("dc:title", "T"), ["dc:title"]); + assert_eq!(element("dc:title", "T"), ["dc:title"]); +} + +/// PDF/A-2 6.6.2.3.1, structures in their three RDF/XML forms, nested, and qualified values. +#[test] +fn xmp_structures_have_their_field_types() { + // Structures in their three forms, with each known field checked. + assert!(mistyped(r#">d2016"#).is_empty()); + assert!(mistyped(r#">"#).is_empty()); + assert!(mistyped(r#">"#).is_empty()); + assert_eq!(element("xmpMM:DerivedFrom", "uuid:1234"), ["xmpMM:DerivedFrom"]); + assert_eq!(mistyped(r#">letter"#), ["xmpMM:DerivedFrom"]); + assert_eq!( + mistyped(r#">letter"#), + ["xmpMM:DerivedFrom"] + ); + assert_eq!(mistyped(r#">letter"#), ["xmpMM:DerivedFrom"]); + // Predefined properties inside a Pantry item keep their types. + let pantry = |v: &str| { + mistyped(&format!( + r#">{v}"# + )) + }; + assert!(pantry("5").is_empty()); + assert_eq!(pantry("high"), ["xmpMM:Pantry"]); + assert_eq!( + mistyped(r#">soon"#), + ["xmpMM:DerivedFrom"] + ); + assert_eq!(mistyped(r#">"#), ["xmpMM:DerivedFrom"]); + assert_eq!(mistyped(r#">8.5 in"#), ["xmpTPg:MaxPageSize"]); + // Nested: an array of structures holding a structure. + let marker = |start: &str| format!(r#">{start}"#); + assert!( + mistyped(&marker( + r#"501/25"# + )) + .is_empty() + ); + assert!(mistyped(&marker("50f")).is_empty()); + assert_eq!(mistyped(&marker(r#"5.5"#)), ["xmpDM:markers"]); + // A qualified value is checked by its rdf:value. + assert!(mistyped(r#">5q"#).is_empty()); + assert_eq!(mistyped(r#">five"#), ["crs:Contrast"]); + // rdf:value as an attribute of the description. + assert!(mistyped(r#">"#).is_empty()); + assert_eq!(mistyped(r#">"#), ["crs:Contrast"]); + // rdf:value as an attribute of an empty property element. + assert!(mistyped(r#">"#).is_empty()); + assert_eq!(mistyped(r#">"#), ["crs:Contrast"]); + // Qualifiers that are predefined properties keep their types. + assert_eq!( + mistyped(r#">5high"#), + ["crs:Contrast"] + ); + assert_eq!(mistyped(r#">"#), ["crs:Contrast"]); + assert!(mistyped(r#">"#).is_empty()); + // Nesting past any type's depth doesn't have the type. + let nested = |levels: usize| { + let open = r#""#.repeat(levels); + let close = "".repeat(levels); + mistyped(&format!(r#">{open}5{close}"#)) + }; + assert!(nested(3).is_empty()); + assert_eq!(nested(40), ["crs:Contrast"]); +} + +/// PDF/A-2 6.6.2.3.1 through verify: one issue per mistyped property, not fixable. +#[test] +fn xmp_value_types_are_reported_by_verify() { + let metadata = |body: &str| { + let xmp = format!(r#""#); + format!("<< /Type /Metadata /Subtype /XML /Length {} >>\nstream\n{xmp}\nendstream", xmp.len()) + }; + let issues = |doc: &Document| -> Vec { verify(doc, Level::A2b).into_iter().filter(|i| i.clause == "6.6.2.3.1").collect() }; + // Every mistyped property, once per metadata stream, by stream and then by name; not fixable. + let catalog = metadata(r#"crs:Contrast="high" crs:Exposure="bright""#); + let page = metadata(r#"crs:Contrast="higher""#); + let doc = build( + &[ + "<< /Type /Catalog /Pages 2 0 R /Metadata 3 0 R >>", + "<< /Type /Pages /Kids [4 0 R] /Count 1 /MediaBox [0 0 200 200] >>", + &catalog, + "<< /Type /Page /Parent 2 0 R /Metadata 5 0 R >>", + &page, + ], + "", + ); + let found = issues(&doc); + let messages: Vec<&str> = found.iter().map(|i| i.message.as_str()).collect(); + assert_eq!( + messages, + [ + "The XMP property crs:Contrast in metadata stream 3 0 R should have the value type Integer", + "The XMP property crs:Exposure in metadata stream 3 0 R should have the value type Real", + "The XMP property crs:Contrast in metadata stream 5 0 R should have the value type Integer" + ] + ); + let objects: Vec> = found.iter().map(|i| i.object).collect(); + assert_eq!(objects, [Some(ObjRef::new(3, 0)), Some(ObjRef::new(3, 0)), Some(ObjRef::new(5, 0))]); + assert!(found.iter().all(|i| !i.fixable && i.page.is_none())); + // Said in another language, the property and the stream go where that language puts them, and + // what the document wrote goes in as it stands. + let reordered = |w: &str| w.replace("The XMP property {property} in metadata stream {object}", "In {object}, {property}"); + assert_eq!(found[0].said(&in_english), found[0].message); + assert_eq!(found[0].said(&reordered), "In 3 0 R, crs:Contrast should have the value type Integer"); + // A UTF-16 packet, either byte order, is read. + for big in [true, false] { + let xmp = format!( + r#""# + ); + let mut data = if big { vec![0xFE, 0xFF] } else { vec![0xFF, 0xFE] }; + data.extend(xmp.encode_utf16().flat_map(|u| if big { u.to_be_bytes() } else { u.to_le_bytes() })); + let hex: String = data.iter().map(|b| format!("{b:02X}")).collect(); + let stream = format!("<< /Type /Metadata /Subtype /XML /Filter /ASCIIHexDecode /Length {} >>\nstream\n{hex}>\nendstream", hex.len() + 1); + let doc = build(&["<< /Type /Catalog /Pages 2 0 R /Metadata 3 0 R >>", "<< /Type /Pages /Kids [] /Count 0 >>", &stream], ""); + assert_eq!(issues(&doc).len(), 1, "big-endian: {big}"); + } + // A metadata stream of a page counts as well as the catalog's. + let good = metadata(r#"crs:Contrast="5""#); + let doc = build( + &[ + "<< /Type /Catalog /Pages 2 0 R /Metadata 3 0 R >>", + "<< /Type /Pages /Kids [4 0 R] /Count 1 /MediaBox [0 0 200 200] >>", + &good, + "<< /Type /Page /Parent 2 0 R /Metadata 5 0 R >>", + &page, + ], + "", + ); + let found = issues(&doc); + assert_eq!(found.iter().map(|i| i.object).collect::>(), [Some(ObjRef::new(5, 0))]); +} + +/// Holes are filled in one pass: the first `{}` from `value`, named holes from `values`, and what +/// has nothing for it, a value's own braces and a brace left open stay as they are. +#[test] +fn wordings_fill_their_holes_once() { + let values = [("{a}", "{b}".to_owned()), ("{b}", "B".to_owned())]; + assert_eq!(fill("x {a} {b} {c} {} {}", Some("{a}"), &values), "x {b} B {c} {a} {}"); + assert_eq!(fill("{}", None, &[]), "{}"); + assert_eq!(fill("open { and é {a", Some("v"), &values), "open { and é {a"); + assert_eq!(fill("é{}ü", Some("v"), &[]), "évü"); +} + +/// PDF/A-2 6.6.2.3.1: a mistyped property is reported with the type the XMP Specification gives it, +/// as the specification writes it. +#[test] +fn xmp_mistyped_properties_name_their_type() { + let types = |body: &str| -> Vec<(String, String)> { + let xmp = format!( + r#""# + ); + xmp_types::mistyped(&xmp::parse(&xmp).expect("parses")).into_iter().map(|((name, _), ty)| (name, ty)).collect() + }; + let t = |name: &str, ty: &str| (name.to_owned(), ty.to_owned()); + assert_eq!( + types(r#"crs:Contrast="high" crs:Exposure="bright" xmp:CreateDate="never">"#), + [t("crs:Contrast", "Integer"), t("crs:Exposure", "Real"), t("xmp:CreateDate", "Date")] + ); + assert_eq!( + types(">CyanT"), + [t("dc:title", "Lang Alt"), t("xmpTPg:PlateNames", "seq Text")] + ); + assert_eq!( + types(">A4h"), + [t("xmpMM:History", "seq ResourceEvent"), t("xmpTPg:MaxPageSize", "Dimensions")] + ); + assert_eq!(types(">p"), [t("xmpMM:Pantry", "bag struct")]); +} + +/// PDF/A-2 6.6.2.3.1: a property is known by its namespace URI, whatever prefix the packet binds +/// to it, and is named by the namespace's preferred prefix and as the packet writes it. A property +/// a predefined namespace doesn't define, and a namespace that isn't predefined, are left to the +/// clauses about them. +#[test] +fn xmp_properties_are_known_by_namespace_uri_and_named_as_written() { + let crs = "http://ns.adobe.com/camera-raw-settings/1.0/"; + let found = |body: &str| -> Vec<(String, String)> { + let xmp = format!( + r#""# + ); + xmp_types::mistyped(&xmp::parse(&xmp).expect("parses")).into_keys().collect() + }; + let n = |name: &str, written: &str| (name.to_owned(), written.to_owned()); + // Another prefix for the namespace, as an attribute and as an element, and no prefix at all. + assert_eq!(found(&format!(r#"xmlns:c="{crs}" c:Contrast="high">"#)), [n("crs:Contrast", "c:Contrast")]); + assert_eq!(found(&format!(r#">bright"#)), [n("crs:Exposure", "c:Exposure")]); + assert_eq!(found(&format!(r#">bright"#)), [n("crs:Exposure", "Exposure")]); + assert!(found(&format!(r#"xmlns:c="{crs}" c:Contrast="5">"#)).is_empty()); + // The preferred prefix is written as it is. + assert_eq!(found(r#"crs:Contrast="high">"#), [n("crs:Contrast", "crs:Contrast")]); + // A long prefix is repeated only so far. + let long = "p".repeat(100); + let said = found(&format!(r#"xmlns:{long}="{crs}" {long}:Contrast="high">"#)); + assert_eq!(said.len(), 1); + assert_eq!(said[0].1, format!("{}…:Contrast", "p".repeat(64))); + // The preferred prefix bound to another namespace is not that namespace. + assert!(found(r#">high"#).is_empty()); + assert!(found(r#"xmlns:dc="http://example.com/not-dc/" dc:title="T">"#).is_empty()); + // A property a predefined namespace doesn't define, with any value. + assert!(found(r#"crs:NotAProperty="high" dc:notAProperty="T">"#).is_empty()); + assert!(found(">x").is_empty()); + // A namespace that isn't predefined, with a predefined property's local name. + assert!(found(r#"xmlns:ex="http://example.com/ns/" ex:Contrast="high">"#).is_empty()); + assert!(found(r#">T"#).is_empty()); + + // Through verify, the written name is said when it differs. + let xmp = format!( + r#""# + ); + let stream = format!("<< /Type /Metadata /Subtype /XML /Length {} >>\nstream\n{xmp}\nendstream", xmp.len()); + let doc = build(&["<< /Type /Catalog /Pages 2 0 R /Metadata 3 0 R >>", "<< /Type /Pages /Kids [] /Count 0 >>", &stream], ""); + let messages: Vec = verify(&doc, Level::A2b).into_iter().filter(|i| i.clause == "6.6.2.3.1").map(|i| i.message).collect(); + assert_eq!( + messages, + [ + "The XMP property crs:Contrast in metadata stream 3 0 R, written c:Contrast, should have the value type Integer", + "The XMP property crs:Exposure in metadata stream 3 0 R should have the value type Real", + ] + ); +} + +/// PDF/A-2 6.6.2.3.1, the RDF/XML value forms XMP allows (ISO 16684-1 7.9.2.5): one `rdf:value` +/// in a qualified value, no parse type but `Resource`, and an `rdf:resource` element left empty. +#[test] +fn xmp_value_forms_are_those_xmp_allows() { + // A qualified value has exactly one rdf:value. + assert_eq!( + mistyped(r#">5five"#), + ["crs:Contrast"] + ); + assert_eq!( + mistyped(r#">5"#), + ["crs:Contrast"] + ); + assert!(mistyped(r#">5"#).is_empty()); + // Only parseType="Resource" has a place in XMP; an rdf:resource element holds no element. + assert_eq!(mistyped(r#">5"#), ["crs:Contrast"]); + assert_eq!(mistyped(r#">"#), ["xmpMM:DerivedFrom"]); + assert_eq!( + mistyped(r#">"#), + ["xmpMM:DerivedFrom"] + ); +} diff --git a/crates/preflight/src/xmp.rs b/crates/preflight/src/xmp.rs index 7a54d70a1b..b2ea91c2f6 100644 --- a/crates/preflight/src/xmp.rs +++ b/crates/preflight/src/xmp.rs @@ -29,13 +29,18 @@ pub(crate) fn identifier(xmp: &str, namespace: &str, local: &str) -> Option Result, ()> { +/// `xmp` parsed within the depth, attribute and node limits; `None` when it can't be. +pub(crate) fn parse(xmp: &str) -> Option> { if !xml_limits_ok(xmp) { - return Err(()); + return None; } let options = roxmltree::ParsingOptions { allow_dtd: false, nodes_limit: 100_000, ..Default::default() }; - let doc = roxmltree::Document::parse_with_options(xmp, options).map_err(|_| ())?; + roxmltree::Document::parse_with_options(xmp, options).ok() +} + +/// [`identifier`] through an XML parser; `Err` when the packet can't be parsed within the limits. +fn parsed_identifier(xmp: &str, namespace: &str, local: &str) -> Result, ()> { + let doc = parse(xmp).ok_or(())?; for node in doc.descendants().filter(roxmltree::Node::is_element) { if let Some(value) = node.attribute((namespace, local)) { return Ok(Some(value.trim().to_owned())); diff --git a/crates/preflight/src/xmp_types.rs b/crates/preflight/src/xmp_types.rs new file mode 100644 index 0000000000..3563a671db --- /dev/null +++ b/crates/preflight/src/xmp_types.rs @@ -0,0 +1,780 @@ +//! PDF/A-2 6.6.2.3.1: a property of a predefined XMP schema has the value type the XMP +//! Specification gives it. +//! +//! The types come from ISO 16684-1:2011 (XMP Part 1) clause 8 for the Dublin Core, XMP Basic and +//! XMP Rights Management namespaces, and from XMP Specification Part 2 (Additional Properties) for +//! the Media Management, Basic Job Ticket, Paged-Text, Dynamic Media, Adobe PDF, Photoshop and +//! Camera Raw namespaces, with the structures they use. A value is read as RDF/XML serializes it +//! (ISO 16684-1 7.9): a simple value as text or an attribute, an array as `rdf:Seq`, `rdf:Bag` or +//! `rdf:Alt`, a structure as `rdf:parseType="Resource"`, an `rdf:Description` or attributes, and a +//! qualified value through its `rdf:value` (element or attribute). Every metadata stream of the +//! document is read, not only the catalog's, within one decode allowance. +//! +//! The simple types are checked by their lexical form (ISO 16684-1 8.2.1): Boolean is `True` or +//! `False`; Integer an optionally signed run of digits; Real a decimal; Date one of the six forms +//! of 8.2.1.2; Rational two integers around a `/`, as Part 2's `xmpDM:scale` example writes it. +//! The other simple types are text. A field a structure doesn't define, and a property a +//! namespace doesn't define, are left to the clauses about them. + +use std::collections::BTreeMap; + +use pdfcraft_cos::{DecodeBudget, ObjRef, Object}; +use roxmltree::Node; + +/// How deep arrays, structures and qualifiers are followed. No type nests nearly this deep, so a +/// value nested deeper doesn't have its type. +const MAX_DEPTH: usize = 32; +/// The decoded bytes all metadata streams of a document may take together. +const MAX_METADATA_BYTES: usize = 64 << 20; + +const RDF: &str = "http://www.w3.org/1999/02/22-rdf-syntax-ns#"; +const XML: &str = "http://www.w3.org/XML/1998/namespace"; + +/// A value type. +#[derive(Debug, Clone, Copy)] +enum Ty { + /// Any simple value: Text and the types derived from it (AgentName, Choice of Text, Locale, + /// MIMEType, ProperName, RenditionClass, URI, URL, XPath, and the like). + Text, + /// A GUID: any simple value, but not a URI (ISO 16684-1 8.2.2.3), so not an `rdf:resource`. + Guid, + Integer, + Real, + Boolean, + Date, + Rational, + /// An alternative array of text items, each with an `xml:lang` (ISO 16684-1 8.2.2.4). + LangAlt, + Seq(&'static Ty), + Bag(&'static Ty), + Alt(&'static Ty), + /// A structure: the name the specification gives its type, and its fields. + Struct(&'static str, &'static [Field]), + /// A structure whose fields aren't given (`xmpMM:Pantry`). + AnyStruct, + /// Any of these: where editions of the specification differ. + OneOf(&'static [Ty]), +} + +/// A structure field: namespace, local name, type. +type Field = (&'static str, &'static str, Ty); + +/// A predefined namespace: URI, preferred prefix, properties. +struct Schema { + uri: &'static str, + prefix: &'static str, + properties: &'static [(&'static str, Ty)], +} + +use Ty::{Boolean, Date, Integer, LangAlt, Rational, Real, Text}; + +const ST_DIM: &str = "http://ns.adobe.com/xap/1.0/sType/Dimensions#"; +// Part 2 1.2.2.3 prints the Font field namespace as "http:ns.adobe.com/xap/1.0/sType/Font#"; the +// other sType namespaces show the intended form. +const ST_FNT: &str = "http://ns.adobe.com/xap/1.0/sType/Font#"; +const XMP_G: &str = "http://ns.adobe.com/xap/1.0/g/"; +const XMP_G_IMG: &str = "http://ns.adobe.com/xap/1.0/g/img/"; +const ST_EVT: &str = "http://ns.adobe.com/xap/1.0/sType/ResourceEvent#"; +const ST_REF: &str = "http://ns.adobe.com/xap/1.0/sType/ResourceRef#"; +const ST_VER: &str = "http://ns.adobe.com/xap/1.0/sType/Version#"; +const ST_JOB: &str = "http://ns.adobe.com/xap/1.0/sType/Job#"; +const XMP_DM: &str = "http://ns.adobe.com/xmp/1.0/DynamicMedia/"; +const PHOTOSHOP: &str = "http://ns.adobe.com/photoshop/1.0/"; + +/// Part 2 Table 3. +const COLORANT: &[Field] = &[ + (XMP_G, "A", Integer), + (XMP_G, "B", Integer), + (XMP_G, "L", Real), + (XMP_G, "black", Real), + (XMP_G, "cyan", Real), + (XMP_G, "magenta", Real), + (XMP_G, "yellow", Real), + (XMP_G, "blue", Integer), + (XMP_G, "green", Integer), + (XMP_G, "red", Integer), + (XMP_G, "mode", Text), + (XMP_G, "swatchName", Text), + (XMP_G, "type", Text), +]; +/// Part 2 Table 4. +const DIMENSIONS: &[Field] = &[(ST_DIM, "h", Real), (ST_DIM, "w", Real), (ST_DIM, "unit", Text)]; +/// Part 2 Table 5. +const FONT: &[Field] = &[ + (ST_FNT, "childFontFiles", Ty::Seq(&Text)), + (ST_FNT, "composite", Boolean), + (ST_FNT, "fontFace", Text), + (ST_FNT, "fontFamily", Text), + (ST_FNT, "fontFileName", Text), + (ST_FNT, "fontName", Text), + (ST_FNT, "fontType", Text), + (ST_FNT, "versionString", Text), +]; +/// Part 2 Table 6. +const THUMBNAIL: &[Field] = &[(XMP_G_IMG, "format", Text), (XMP_G_IMG, "height", Integer), (XMP_G_IMG, "width", Integer), (XMP_G_IMG, "image", Text)]; +/// Part 2 Table 8. +const RESOURCE_EVENT: &[Field] = &[ + (ST_EVT, "action", Text), + (ST_EVT, "changed", Text), + (ST_EVT, "instanceID", Ty::Guid), + (ST_EVT, "parameters", Text), + (ST_EVT, "softwareAgent", Text), + (ST_EVT, "when", Date), +]; +/// ISO 16684-1 Table 3 and Part 2 Table 9. +const RESOURCE_REF: &[Field] = &[ + (ST_REF, "alternatePaths", Ty::Seq(&Text)), + (ST_REF, "documentID", Ty::Guid), + (ST_REF, "filePath", Text), + (ST_REF, "fromPart", Text), + (ST_REF, "instanceID", Ty::Guid), + (ST_REF, "lastModifyDate", Date), + (ST_REF, "manager", Text), + (ST_REF, "managerVariant", Text), + (ST_REF, "manageTo", Text), + (ST_REF, "manageUI", Text), + (ST_REF, "maskMarkers", Text), + (ST_REF, "partMapping", Text), + (ST_REF, "renditionClass", Text), + (ST_REF, "renditionParams", Text), + (ST_REF, "toPart", Text), + (ST_REF, "versionID", Text), +]; +/// Part 2 Table 10. +const VERSION: &[Field] = &[ + (ST_VER, "comments", Text), + (ST_VER, "event", Ty::Struct("ResourceEvent", RESOURCE_EVENT)), + (ST_VER, "modifier", Text), + (ST_VER, "modifyDate", Date), + (ST_VER, "version", Text), +]; +/// Part 2 Table 11. +const JOB: &[Field] = &[(ST_JOB, "id", Text), (ST_JOB, "name", Text), (ST_JOB, "url", Text)]; +/// Part 2 Table 12. +const BEAT_SPLICE_STRETCH: &[Field] = + &[(XMP_DM, "riseInDecibel", Real), (XMP_DM, "riseInTimeDuration", Ty::Struct("Time", TIME)), (XMP_DM, "useFileBeatsMarker", Boolean)]; +/// Part 2 Table 13. +const CUE_POINT_PARAM: &[Field] = &[(XMP_DM, "key", Text), (XMP_DM, "value", Text)]; +/// A marker's start and duration: FrameCount (text) in Part 2 Table 16, a Time structure in the +/// September 2005 edition that PDF/A-2 cites. Either is taken. +const FRAME_COUNT_OR_TIME: Ty = Ty::OneOf(&[Text, Ty::Struct("Time", TIME)]); +/// Part 2 Table 16. +const MARKER: &[Field] = &[ + (XMP_DM, "comment", Text), + (XMP_DM, "cuePointParams", Ty::Seq(&Ty::Struct("CuePointParam", CUE_POINT_PARAM))), + (XMP_DM, "cuePointType", Text), + (XMP_DM, "duration", FRAME_COUNT_OR_TIME), + (XMP_DM, "location", Text), + (XMP_DM, "name", Text), + (XMP_DM, "probability", Real), + (XMP_DM, "speaker", Text), + (XMP_DM, "startTime", FRAME_COUNT_OR_TIME), + (XMP_DM, "target", Text), + (XMP_DM, "type", Text), +]; +/// Part 2 Table 17. +const MEDIA: &[Field] = &[ + (XMP_DM, "duration", Ty::Struct("Time", TIME)), + (XMP_DM, "managed", Boolean), + (XMP_DM, "path", Text), + (XMP_DM, "startTime", Ty::Struct("Time", TIME)), + (XMP_DM, "track", Text), + (XMP_DM, "webStatement", Text), +]; +/// Part 2 Table 18. +const PROJECT_LINK: &[Field] = &[(XMP_DM, "path", Text), (XMP_DM, "type", Text)]; +/// Part 2 Table 19. +const RESAMPLE_STRETCH: &[Field] = &[(XMP_DM, "quality", Text)]; +/// Part 2 Table 20. +const TIME: &[Field] = &[(XMP_DM, "scale", Rational), (XMP_DM, "value", Integer)]; +/// Part 2 Table 21. +const TIMECODE: &[Field] = &[(XMP_DM, "timeFormat", Text), (XMP_DM, "timeValue", Text)]; +/// Part 2 Table 22. +const TIME_SCALE_STRETCH: &[Field] = &[(XMP_DM, "frameOverlappingPercentage", Real), (XMP_DM, "frameSize", Real), (XMP_DM, "quality", Text)]; +/// Part 2 Table 23. +const TRACK: &[Field] = &[ + (XMP_DM, "frameRate", Text), + (XMP_DM, "markers", Ty::Seq(&Ty::Struct("Marker", MARKER))), + (XMP_DM, "trackName", Text), + (XMP_DM, "trackType", Text), +]; +/// Part 2 Table 31. +const ANCESTOR: &[Field] = &[(PHOTOSHOP, "AncestorID", Text)]; +/// Part 2 Table 32. +const LAYER: &[Field] = &[(PHOTOSHOP, "LayerName", Text), (PHOTOSHOP, "LayerText", Text)]; + +const SCHEMAS: &[Schema] = &[ + // ISO 16684-1 Table 4. + Schema { + uri: "http://purl.org/dc/elements/1.1/", + prefix: "dc", + properties: &[ + ("contributor", Ty::Bag(&Text)), + ("coverage", Text), + ("creator", Ty::Seq(&Text)), + ("date", Ty::Seq(&Date)), + ("description", LangAlt), + ("format", Text), + ("identifier", Text), + ("language", Ty::Bag(&Text)), + ("publisher", Ty::Bag(&Text)), + ("relation", Ty::Bag(&Text)), + ("rights", LangAlt), + ("source", Text), + ("subject", Ty::Bag(&Text)), + ("title", LangAlt), + ("type", Ty::Bag(&Text)), + ], + }, + // ISO 16684-1 Table 5, with Part 2 Table 24. + Schema { + uri: "http://ns.adobe.com/xap/1.0/", + prefix: "xmp", + properties: &[ + ("Advisory", Ty::Bag(&Text)), + ("BaseURL", Text), + ("CreateDate", Date), + ("CreatorTool", Text), + ("Identifier", Ty::Bag(&Text)), + ("Label", Text), + ("MetadataDate", Date), + ("ModifyDate", Date), + ("Nickname", Text), + ("Rating", Real), + ("Thumbnails", Ty::Alt(&Ty::Struct("Thumbnail", THUMBNAIL))), + ], + }, + // ISO 16684-1 Table 6. + Schema { + uri: "http://ns.adobe.com/xap/1.0/rights/", + prefix: "xmpRights", + properties: &[("Certificate", Text), ("Marked", Boolean), ("Owner", Ty::Bag(&Text)), ("UsageTerms", LangAlt), ("WebStatement", Text)], + }, + // ISO 16684-1 Table 7, with Part 2 Tables 25 and 26. + Schema { + uri: "http://ns.adobe.com/xap/1.0/mm/", + prefix: "xmpMM", + properties: &[ + ("DerivedFrom", Ty::Struct("ResourceRef", RESOURCE_REF)), + ("DocumentID", Ty::Guid), + ("History", Ty::Seq(&Ty::Struct("ResourceEvent", RESOURCE_EVENT))), + ("Ingredients", Ty::Bag(&Ty::Struct("ResourceRef", RESOURCE_REF))), + ("InstanceID", Ty::Guid), + ("ManagedFrom", Ty::Struct("ResourceRef", RESOURCE_REF)), + ("Manager", Text), + ("ManageTo", Text), + ("ManageUI", Text), + ("ManagerVariant", Text), + ("OriginalDocumentID", Ty::Guid), + ("Pantry", Ty::Bag(&Ty::AnyStruct)), + ("RenditionClass", Text), + ("RenditionParams", Text), + ("VersionID", Text), + ("Versions", Ty::Seq(&Ty::Struct("Version", VERSION))), + ("LastURL", Text), + ("RenditionOf", Ty::Struct("ResourceRef", RESOURCE_REF)), + ("SaveID", Integer), + ], + }, + // Part 2 Table 27. + Schema { uri: "http://ns.adobe.com/xap/1.0/bj/", prefix: "xmpBJ", properties: &[("JobRef", Ty::Bag(&Ty::Struct("Job", JOB)))] }, + // Part 2 Table 28. + Schema { + uri: "http://ns.adobe.com/xap/1.0/t/pg/", + prefix: "xmpTPg", + properties: &[ + ("Colorants", Ty::Seq(&Ty::Struct("Colorant", COLORANT))), + ("Fonts", Ty::Bag(&Ty::Struct("Font", FONT))), + ("MaxPageSize", Ty::Struct("Dimensions", DIMENSIONS)), + ("NPages", Integer), + ("PlateNames", Ty::Seq(&Text)), + ], + }, + // Part 2 Table 29. + Schema { + uri: XMP_DM, + prefix: "xmpDM", + properties: &[ + ("absPeakAudioFilePath", Text), + ("album", Text), + ("altTapeName", Text), + ("altTimecode", Ty::Struct("Timecode", TIMECODE)), + ("artist", Text), + ("audioModDate", Date), + ("audioChannelType", Text), + ("audioCompressor", Text), + ("audioSampleRate", Integer), + ("audioSampleType", Text), + ("beatSpliceParams", Ty::Struct("BeatSpliceStretch", BEAT_SPLICE_STRETCH)), + ("cameraAngle", Text), + ("cameraLabel", Text), + ("cameraModel", Text), + ("cameraMove", Text), + ("client", Text), + ("comment", Text), + ("composer", Text), + ("contributedMedia", Ty::Bag(&Ty::Struct("Media", MEDIA))), + ("copyright", Text), + ("director", Text), + ("directorPhotography", Text), + ("duration", Ty::Struct("Time", TIME)), + ("engineer", Text), + ("fileDataRate", Rational), + ("genre", Text), + ("good", Boolean), + ("instrument", Text), + ("introTime", Ty::Struct("Time", TIME)), + ("key", Text), + ("logComment", Text), + ("loop", Boolean), + ("numberOfBeats", Real), + ("markers", Ty::Seq(&Ty::Struct("Marker", MARKER))), + ("metadataModDate", Date), + ("outCue", Ty::Struct("Time", TIME)), + ("projectName", Text), + ("projectRef", Ty::Struct("ProjectLink", PROJECT_LINK)), + ("pullDown", Text), + ("relativePeakAudioFilePath", Text), + ("relativeTimestamp", Ty::Struct("Time", TIME)), + ("releaseDate", Date), + ("resampleParams", Ty::Struct("ResampleStretch", RESAMPLE_STRETCH)), + ("scaleType", Text), + ("scene", Text), + ("shotDate", Date), + ("shotDay", Text), + ("shotLocation", Text), + ("shotName", Text), + ("shotNumber", Text), + ("shotSize", Text), + ("speakerPlacement", Text), + ("startTimecode", Ty::Struct("Timecode", TIMECODE)), + ("stretchMode", Text), + ("takeNumber", Integer), + ("tapeName", Text), + ("tempo", Real), + ("timeScaleParams", Ty::Struct("TimeScaleStretch", TIME_SCALE_STRETCH)), + ("timeSignature", Text), + ("trackNumber", Integer), + ("Tracks", Ty::Bag(&Ty::Struct("Track", TRACK))), + ("videoAlphaMode", Text), + ("videoAlphaPremultipleColor", Ty::Struct("Colorant", COLORANT)), + ("videoAlphaUnityIsTransparent", Boolean), + ("videoColorSpace", Text), + ("videoCompressor", Text), + ("videoFieldOrder", Text), + ("videoFrameRate", Text), + ("videoFrameSize", Ty::Struct("Dimensions", DIMENSIONS)), + ("videoModDate", Date), + ("videoPixelDepth", Text), + ("videoPixelAspectRatio", Rational), + ("partOfCompilation", Boolean), + ("lyrics", Text), + ("discNumber", Text), + ], + }, + // Part 2 Table 30. + Schema { + uri: "http://ns.adobe.com/pdf/1.3/", + prefix: "pdf", + properties: &[("Keywords", Text), ("PDFVersion", Text), ("Producer", Text), ("Trapped", Boolean)], + }, + // Part 2 Table 33. + Schema { + uri: PHOTOSHOP, + prefix: "photoshop", + properties: &[ + ("AuthorsPosition", Text), + ("CaptionWriter", Text), + ("Category", Text), + ("City", Text), + ("ColorMode", Integer), + ("Country", Text), + ("Credit", Text), + ("DateCreated", Date), + ("DocumentAncestors", Ty::Bag(&Ty::Struct("Ancestor", ANCESTOR))), + ("Headline", Text), + ("History", Text), + ("ICCProfile", Text), + ("Instructions", Text), + ("Source", Text), + ("State", Text), + ("SupplementalCategories", Ty::Bag(&Text)), + ("TextLayers", Ty::Seq(&Ty::Struct("Layer", LAYER))), + ("TransmissionReference", Text), + ("Urgency", Integer), + ], + }, + // Part 2 Table 34. + Schema { + uri: "http://ns.adobe.com/camera-raw-settings/1.0/", + prefix: "crs", + properties: &[ + ("AutoBrightness", Boolean), + ("AutoContrast", Boolean), + ("AutoExposure", Boolean), + ("AutoShadows", Boolean), + ("BlueHue", Integer), + ("BlueSaturation", Integer), + ("Brightness", Integer), + ("CameraProfile", Text), + ("ChromaticAberrationB", Integer), + ("ChromaticAberrationR", Integer), + ("ColorNoiseReduction", Integer), + ("Contrast", Integer), + ("CropTop", Real), + ("CropLeft", Real), + ("CropBottom", Real), + ("CropRight", Real), + ("CropAngle", Real), + ("CropWidth", Real), + ("CropHeight", Real), + ("CropUnits", Integer), + ("Exposure", Real), + ("GreenHue", Integer), + ("GreenSaturation", Integer), + ("HasCrop", Boolean), + ("HasSettings", Boolean), + ("LuminanceSmoothing", Integer), + ("RawFileName", Text), + ("RedHue", Integer), + ("RedSaturation", Integer), + ("Saturation", Integer), + ("Shadows", Integer), + ("ShadowTint", Integer), + ("Sharpness", Integer), + ("Temperature", Integer), + ("Tint", Integer), + ("ToneCurve", Ty::Seq(&Text)), + ("ToneCurveName", Text), + ("Version", Text), + ("VignetteAmount", Integer), + ("VignetteMidpoint", Integer), + ("WhiteBalance", Text), + ], + }, +]; + +/// Calls `f` with every metadata stream among the indirect objects `objs` (ISO 32000-1 14.3.2) that +/// parses as XML, and the stream's object, each decoded once within one allowance. +pub(crate) fn for_each_packet(objs: &[(ObjRef, std::sync::Arc)], mut f: impl FnMut(ObjRef, &roxmltree::Document<'_>)) { + let mut budget = DecodeBudget::new(MAX_METADATA_BYTES, 0); + for (r, o) in objs { + let Object::Stream(s) = &**o else { continue }; + if s.dict.name(b"Type") != Some(b"Metadata") { + continue; + } + if budget.remaining() == 0 { + break; + } + let Ok(bytes) = budget.decode_within(s, crate::xmp::MAX_XMP_BYTES) else { continue }; + let text = unicode(&bytes); + if let Some(parsed) = crate::xmp::parse(&text) { + f(*r, &parsed); + } + } +} + +/// A packet's text in the Unicode encoding its byte order mark gives: UTF-16 big- or +/// little-endian, or UTF-8 (ISO 16684-1 7.3). +fn unicode(bytes: &[u8]) -> String { + let utf16 = |data: &[u8], big: bool| -> String { + let (units, _) = data.as_chunks::<2>(); + let units = units.iter().map(|&[a, b]| if big { u16::from_be_bytes([a, b]) } else { u16::from_le_bytes([a, b]) }); + char::decode_utf16(units).map(|c| c.unwrap_or(char::REPLACEMENT_CHARACTER)).collect() + }; + match bytes { + [0xFE, 0xFF, rest @ ..] => utf16(rest, true), + [0xFF, 0xFE, rest @ ..] => utf16(rest, false), + [0xEF, 0xBB, 0xBF, rest @ ..] => String::from_utf8_lossy(rest).into_owned(), + _ => String::from_utf8_lossy(bytes).into_owned(), + } +} + +/// The properties of predefined schemas in the packet `doc` whose values don't have their type, in +/// name order: each by its name with the namespace's preferred prefix and by its name as the packet +/// writes it (see [`written`]), with the type it should have as [`named`] gives it. +pub(crate) fn mistyped(doc: &roxmltree::Document<'_>) -> BTreeMap<(String, String), String> { + let mut out = BTreeMap::new(); + let text = doc.input_text(); + let descriptions = doc.descendants().filter(|n| is(n, RDF, "Description") && n.parent_element().is_some_and(|p| is(&p, RDF, "RDF"))); + for d in descriptions { + for a in d.attributes() { + if let Some((schema, ty)) = a.namespace().and_then(|ns| property(ns, a.name())) + && !simple(ty, a.value()) + { + let qname = text.get(a.range_qname()).unwrap_or_default(); + out.insert((format!("{}:{}", schema.prefix, a.name()), written(qname, a.name())), named(ty)); + } + } + for p in d.children().filter(Node::is_element) { + let name = p.tag_name(); + if let Some((schema, ty)) = name.namespace().and_then(|ns| property(ns, name.name())) + && !fits(ty, &p, 0) + { + let qname = text.get(p.range()).unwrap_or_default().strip_prefix('<').unwrap_or_default(); + out.insert((format!("{}:{}", schema.prefix, name.name()), written(qname, name.name())), named(ty)); + } + } + } + out +} + +/// How long a prefix the packet writes is repeated. +const MAX_PREFIX_CHARS: usize = 64; + +/// The property `local` as the packet writes it, from the text its qualified name begins +/// (`qname`): `prefix:local`, or `local` alone in the default namespace. A prefix past +/// [`MAX_PREFIX_CHARS`] is cut, with `…`. +fn written(qname: &str, local: &str) -> String { + let qname = qname.split(|c: char| c.is_whitespace() || matches!(c, '>' | '/' | '=')).next().unwrap_or_default(); + let Some((prefix, _)) = qname.split_once(':') else { return local.to_owned() }; + let mut out: String = prefix.chars().take(MAX_PREFIX_CHARS).collect(); + if out.len() < prefix.len() { + out.push('…'); + } + format!("{out}:{local}") +} + +/// `ty` as the XMP Specification writes value types: `Integer`, `Lang Alt`, `seq Rational`, a +/// structure by its type's name, such as `Dimensions`. +fn named(ty: Ty) -> String { + let array = |kind: &str, item: &Ty| format!("{kind} {}", named(*item)); + match ty { + Text => "Text".into(), + Ty::Guid => "GUID".into(), + Integer => "Integer".into(), + Real => "Real".into(), + Boolean => "Boolean".into(), + Date => "Date".into(), + Rational => "Rational".into(), + LangAlt => "Lang Alt".into(), + Ty::Seq(item) => array("seq", item), + Ty::Bag(item) => array("bag", item), + Ty::Alt(item) => array("alt", item), + Ty::Struct(name, _) => name.into(), + Ty::AnyStruct => "struct".into(), + Ty::OneOf(types) => types.iter().map(|t| named(*t)).collect::>().join(" or "), + } +} + +fn property(ns: &str, name: &str) -> Option<(&'static Schema, Ty)> { + let schema = SCHEMAS.iter().find(|s| s.uri == ns)?; + schema.properties.iter().find(|(n, _)| *n == name).map(|(_, t)| (schema, *t)) +} + +fn is(n: &Node<'_, '_>, ns: &str, name: &str) -> bool { + n.is_element() && n.tag_name().namespace() == Some(ns) && n.tag_name().name() == name +} + +/// The attributes of `n` that are fields or qualifiers, not RDF or XML syntax. +fn field_attributes<'a, 'i>(n: &Node<'a, 'i>) -> impl Iterator> { + n.attributes().filter(|a| !matches!(a.namespace(), Some(RDF) | Some(XML))) +} + +/// Whether the value of the property or array item element `e` has type `ty`. +fn fits(ty: Ty, e: &Node<'_, '_>, depth: usize) -> bool { + if depth > MAX_DEPTH { + return false; + } + let deeper = depth + 1; + let children: Vec> = e.children().filter(Node::is_element).collect(); + // A qualified value: `rdf:value` holds the value itself (ISO 16684-1 7.9.2.5). + let resource = e.attribute((RDF, "parseType")) == Some("Resource"); + // `parseType="Resource"` is the only parse type XMP has (ISO 16684-1 7.9.2.5); an + // `rdf:resource` element is empty. + if e.attribute((RDF, "parseType")).is_some_and(|p| p != "Resource") || e.attribute((RDF, "resource")).is_some() && !children.is_empty() { + return false; + } + let description = match children.as_slice() { + [d] if is(d, RDF, "Description") => Some(*d), + _ => None, + }; + // `rdf:value` as an element of a `parseType="Resource"` node or `rdf:Description`, or as an + // attribute of the description or of an empty property element. Its qualifiers that are + // predefined properties keep their types too. + let node = if resource { Some(*e) } else { description }; + if let Some(n) = node { + // A qualified value has one `rdf:value`: an element, or an attribute in its place. + match n.children().filter(|c| is(c, RDF, "value")).collect::>().as_slice() { + [] => {} + [v] => return n.attribute((RDF, "value")).is_none() && blank(e) && blank(&n) && fits(ty, v, deeper) && predefined_ok(&n, deeper), + _ => return false, + } + } + let attribute_node = description.or_else(|| (children.is_empty() && !resource).then_some(*e)); + if let Some(n) = attribute_node + && let Some(v) = n.attribute((RDF, "value")) + { + return blank(e) && blank(&n) && simple(ty, v) && predefined_ok(&n, deeper); + } + match ty { + Ty::Text | Ty::Guid | Ty::Integer | Ty::Real | Ty::Boolean | Ty::Date | Ty::Rational => { + // `rdf:resource` makes the value a URI, which only a text type takes. + // The element is then empty. + if e.attribute((RDF, "resource")).is_some() { + return matches!(ty, Ty::Text) && children.is_empty() && blank(e); + } + children.is_empty() && !resource && field_attributes(e).next().is_none() && simple(ty, &text(e)) + } + Ty::LangAlt => match array(e, &children, "Alt") { + Some(items) => items.iter().all(|i| i.attribute((XML, "lang")).is_some() && fits(Ty::Text, i, deeper)), + None => false, + }, + Ty::Seq(item) | Ty::Bag(item) | Ty::Alt(item) => { + let kind = match ty { + Ty::Seq(_) => "Seq", + Ty::Bag(_) => "Bag", + _ => "Alt", + }; + array(e, &children, kind).is_some_and(|items| items.iter().all(|i| fits(*item, i, deeper))) + } + Ty::Struct(_, fields) => { + let Some(holder) = structure(e, &children, resource, description) else { return false }; + let attrs_ok = field_attributes(&holder).all(|a| match a.namespace().and_then(|ns| field(fields, ns, a.name())) { + Some(t) => simple(t, a.value()), + None => true, + }); + let elements_ok = holder.children().filter(Node::is_element).all(|f| { + let n = f.tag_name(); + match n.namespace().and_then(|ns| field(fields, ns, n.name())) { + Some(t) => fits(t, &f, deeper), + None => true, + } + }); + attrs_ok && elements_ok + } + // A structure of any fields, such as a Pantry item: predefined properties in it still have + // their types. + Ty::AnyStruct => structure(e, &children, resource, description).is_some_and(|holder| predefined_ok(&holder, deeper)), + Ty::OneOf(types) => types.iter().any(|t| fits(*t, e, depth)), + } +} + +/// Whether the fields of `holder`, as attributes and elements, that are predefined properties have +/// their types. +fn predefined_ok(holder: &Node<'_, '_>, depth: usize) -> bool { + let attrs_ok = field_attributes(holder).all(|a| a.namespace().and_then(|ns| property(ns, a.name())).is_none_or(|(_, t)| simple(t, a.value()))); + let elements_ok = holder.children().filter(Node::is_element).all(|f| { + let n = f.tag_name(); + n.namespace().and_then(|ns| property(ns, n.name())).is_none_or(|(_, t)| fits(t, &f, depth)) + }); + attrs_ok && elements_ok +} + +fn field(fields: &[Field], ns: &str, name: &str) -> Option { + fields.iter().find(|(n, l, _)| *n == ns && *l == name).map(|(_, _, t)| *t) +} + +/// The items of `e`'s value when it is an `rdf:` array. +fn array<'a, 'i>(e: &Node<'a, 'i>, children: &[Node<'a, 'i>], kind: &str) -> Option>> { + match children { + // Items are `rdf:li` (ISO 16684-1 7.9.2.4); any other element, or character data around + // them, breaks the array, and so does `rdf:parseType` or `rdf:resource` on the property, + // which makes its value something else. + [a] if is(a, RDF, kind) && field_attributes(e).next().is_none() && blank(e) && blank(a) && !other_value_form(e) => { + let items: Vec> = a.children().filter(Node::is_element).collect(); + items.iter().all(|i| is(i, RDF, "li")).then_some(items) + } + _ => None, + } +} + +/// The node holding the fields of `e`'s value when it is a structure. +fn structure<'a, 'i>(e: &Node<'a, 'i>, children: &[Node<'a, 'i>], resource: bool, description: Option>) -> Option> { + if resource { + // A structure's fields are elements; character data has no place in it. + return text(e).trim().is_empty().then_some(*e); + } + if let Some(d) = description { + return (blank(e) && blank(&d)).then_some(d); + } + // Fields written as attributes of the property element, with no content. + let empty = text(e).trim().is_empty(); + (children.is_empty() && empty && e.attribute((RDF, "resource")).is_none() && field_attributes(e).next().is_some()).then_some(*e) +} + +/// Whether `e` says its value is a resource or a literal of another kind, not the element it holds. +fn other_value_form(e: &Node<'_, '_>) -> bool { + e.attribute((RDF, "parseType")).is_some() || e.attribute((RDF, "resource")).is_some() +} + +/// Whether `e` holds no character data but white space. +fn blank(e: &Node<'_, '_>) -> bool { + text(e).trim().is_empty() +} + +/// All the character data directly in `e`, however comments or other nodes split it. +fn text(e: &Node<'_, '_>) -> String { + e.children().filter(Node::is_text).filter_map(|t| t.text()).collect() +} + +/// Whether the simple value `v` has the simple type `ty`. +fn simple(ty: Ty, v: &str) -> bool { + match ty { + Ty::Integer => integer(v), + Ty::Real => real(v), + Ty::Boolean => matches!(v, "True" | "False"), + Ty::Date => date(v), + Ty::Rational => v.split_once('/').is_some_and(|(n, d)| integer(n) && integer(d)), + Ty::Text | Ty::Guid => true, + Ty::OneOf(types) => types.iter().any(|t| simple(*t, v)), + _ => false, + } +} + +fn digits(s: &str) -> bool { + !s.is_empty() && s.bytes().all(|b| b.is_ascii_digit()) +} + +/// ISO 16684-1 8.2.1.3. +fn integer(v: &str) -> bool { + digits(v.strip_prefix(['+', '-']).unwrap_or(v)) +} + +/// ISO 16684-1 8.2.1.4: an integer part, a fraction part, or both. +fn real(v: &str) -> bool { + let v = v.strip_prefix(['+', '-']).unwrap_or(v); + match v.split_once('.') { + Some((i, f)) => (i.is_empty() || digits(i)) && digits(f), + None => digits(v), + } +} + +/// ISO 16684-1 8.2.1.2: YYYY, YYYY-MM, YYYY-MM-DD, or a date with hh:mm, hh:mm:ss or hh:mm:ss.s +/// and an optional time zone (Z, +hh:mm, -hh:mm). +fn date(v: &str) -> bool { + let num = |s: &str, len: usize, max: u32| s.len() == len && digits(s) && s.parse::().is_ok_and(|n| n <= max); + let in_range = |s: &str, min: u32, max: u32| num(s, 2, max) && s.parse::().is_ok_and(|n| n >= min); + let (day, time) = match v.split_once('T') { + Some((d, t)) => (d, Some(t)), + None => (v, None), + }; + let parts: Vec<&str> = day.split('-').collect(); + let day_ok = match parts.as_slice() { + [y] => num(y, 4, 9999), + [y, m] => num(y, 4, 9999) && in_range(m, 1, 12), + [y, m, d] => num(y, 4, 9999) && in_range(m, 1, 12) && in_range(d, 1, 31), + _ => false, + }; + let Some(time) = time else { return day_ok }; + if parts.len() != 3 || !day_ok { + return false; + } + let (clock, zone) = match time.find(['Z', '+', '-']) { + Some(i) => (time.get(..i).unwrap_or_default(), time.get(i..).unwrap_or_default()), + None => (time, ""), + }; + let zone_ok = match zone { + "" | "Z" => true, + z => z.strip_prefix(['+', '-']).and_then(|z| z.split_once(':')).is_some_and(|(h, m)| num(h, 2, 23) && num(m, 2, 59)), + }; + let clock_ok = match clock.split(':').collect::>().as_slice() { + [h, m] => num(h, 2, 23) && num(m, 2, 59), + [h, m, s] => { + let (whole, fraction) = s.split_once('.').map_or((*s, None), |(w, f)| (w, Some(f))); + num(h, 2, 23) && num(m, 2, 59) && num(whole, 2, 59) && fraction.is_none_or(digits) + } + _ => false, + }; + zone_ok && clock_ok +} diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index 9b341fcd8b..b2e651ec57 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -8017,8 +8017,8 @@ milestone = "M11" status = "partial" commands = ["standards.pdfa"] tools = ["pdfa_verify"] -evidence = ["crates/preflight/src/tests.rs::verify_reports_what_breaks_pdfa", "crates/preflight/src/tests.rs::standards_identification_uses_namespace_not_prefix", "crates/preflight/src/tests.rs::convert_fixes_all_but_fonts", "crates/preflight/src/tests.rs::verify_reports_an_output_intent_that_does_not_match_device_colour", "crates/automation/tests/it/automation.rs::pdfa_through_tools", "crates/ui-egui/tests/it/compare.rs::pdfa_dialog_verifies_and_converts"] -notes = "PDF/A-2b/3b structural rules: identification, encryption, output intent (present, and RGB/CMYK to match DeviceRGB/DeviceCMYK use), fonts embedded, annotations, actions, images, embedded files. Not yet: content-stream operator checks, transparency details, PDF/A-1 and -4, a veraPDF comparison." +evidence = ["crates/preflight/src/tests.rs::verify_reports_what_breaks_pdfa", "crates/preflight/src/tests.rs::standards_identification_uses_namespace_not_prefix", "crates/preflight/src/tests.rs::convert_fixes_all_but_fonts", "crates/preflight/src/tests.rs::verify_reports_an_output_intent_that_does_not_match_device_colour", "crates/preflight/src/tests.rs::xmp_simple_values_have_their_lexical_forms", "crates/preflight/src/tests.rs::xmp_arrays_have_their_kind_and_item_types", "crates/preflight/src/tests.rs::xmp_structures_have_their_field_types", "crates/preflight/src/tests.rs::xmp_value_types_are_reported_by_verify", "crates/preflight/src/tests.rs::xmp_mistyped_properties_name_their_type", "crates/preflight/src/tests.rs::xmp_properties_are_known_by_namespace_uri_and_named_as_written", "crates/automation/tests/it/automation.rs::pdfa_through_tools", "crates/ui-egui/tests/it/compare.rs::pdfa_dialog_verifies_and_converts"] +notes = "PDF/A-2b/3b structural rules: identification, the value types of the predefined XMP schemas (6.6.2.3.1: dc, xmp, xmpRights, xmpMM, xmpBJ, xmpTPg, xmpDM, pdf, photoshop, crs and their structures; the tiff, exif and aux namespaces, whose tables XMP Part 2 defers to CIPA DC-010, are not yet checked), encryption, output intent (present, and RGB/CMYK to match DeviceRGB/DeviceCMYK use), fonts embedded, annotations, actions, images, embedded files. Not yet: content-stream operator checks, transparency details, PDF/A-1 and -4, a veraPDF comparison." [[feature]] id = "optimize.pdfa-convert" From 6062cd6a4481b15762fbadd01011a00f7d739a75 Mon Sep 17 00:00:00 2001 From: mtlcl410 <2368091+mtlcl410@users.noreply.github.com> Date: Mon, 9 Nov 2026 08:38:25 +0800 Subject: [PATCH 29/92] M5: Replace Text keeps its replacement on the group's primary, the caret (comment.replace-text) (#5689) --- crates/annot/src/modify.rs | 95 ++++++++++-- crates/annot/src/tests.rs | 77 +++++++++- crates/automation/src/comments.rs | 6 +- crates/automation/tests/it/automation.rs | 23 ++- crates/engine/src/edit/run.rs | 4 +- crates/ui-egui/src/panels/comments_panel.rs | 2 +- crates/ui-egui/src/tools/comments.rs | 2 +- crates/ui-egui/src/tools/comments/composer.rs | 5 +- crates/ui-egui/src/tools/comments/geometry.rs | 7 + crates/ui-egui/src/tools/comments/input.rs | 16 +- crates/ui-egui/src/tools/comments/state.rs | 18 ++- crates/ui-egui/tests/it/comments.rs | 48 +++++- crates/xfdf/src/tests.rs | 139 +++++++++++++++++- crates/xfdf/src/xfdf.rs | 28 ++-- parity/acrobat-features.toml | 2 +- 15 files changed, 414 insertions(+), 58 deletions(-) diff --git a/crates/annot/src/modify.rs b/crates/annot/src/modify.rs index 60e97f6a72..6fbb0f35c3 100644 --- a/crates/annot/src/modify.rs +++ b/crates/annot/src/modify.rs @@ -94,7 +94,7 @@ pub fn deletable(doc: &Document, page: usize, index: usize) -> Result<(), AnnotE if let Some(r) = target.as_ref() { unlocked(doc, r)?; } - let doomed = with_replies_and_popups(doc, &list, seed(target)); + let doomed = with_replies_and_popups(doc, &list, seed(doc, target)); // A locked reply can't be deleted on its own, so it doesn't go with the comment or reply it // answers either. Pop-ups follow their comment. for (_, e) in list.iter().enumerate().filter(|(i, e)| *i != index && goes_with(e, &doomed)) { @@ -115,15 +115,28 @@ pub fn lift_annotation(doc: &mut Document, page: usize, index: usize) -> Result< let p = page_ref(doc, page)?; let list = annots(doc, p); let target = list.get(index).ok_or(AnnotError::NoSuchAnnotation { page, index })?; - let doomed = with_replies_and_popups(doc, &list, seed(target)); + let doomed = with_replies_and_popups(doc, &list, seed(doc, target)); let kept: Vec = list.iter().enumerate().filter(|(i, e)| *i != index && !goes_with(e, &doomed)).map(|(_, e)| e.clone()).collect(); Ok(set_annots(doc, p, kept)?) } /// What deleting the `/Annots` entry `target` starts from: its reference, or for an inline -/// comment (which has none to collect) the pop-up it names, which still goes. -fn seed(target: &Object) -> Option { - target.as_ref().or_else(|| target.as_dict().and_then(|d| d.reference(b"Popup"))) +/// comment (which has none to collect) the pop-up it names, which still goes. The strikeout of a +/// Replace Text proposal also takes the caret it is grouped under, so the two go together +/// whichever is deleted. +fn seed(doc: &Document, target: &Object) -> Vec { + let own = target.as_ref().or_else(|| target.as_dict().and_then(|d| d.reference(b"Popup"))); + own.into_iter().chain(replaced_by(doc, target)).collect() +} + +/// The caret a Replace Text strikeout is grouped under (`/IT /StrikeOutTextEdit`, `/RT /Group`, +/// `/IRT` to a caret): the proposal's primary, which holds the replacement. +fn replaced_by(doc: &Document, strike: &Object) -> Option { + let obj = doc.resolve(strike); + let d = obj.as_dict()?; + let edit = d.name(b"Subtype") == Some(b"StrikeOut") && d.name(b"IT") == Some(b"StrikeOutTextEdit") && d.name(b"RT") == Some(b"Group"); + let caret = d.reference(b"IRT").filter(|_| edit)?; + (annot_dict(doc, caret).name(b"Subtype") == Some(b"Caret")).then_some(caret) } /// Whether the `/Annots` entry `entry` goes with `going` (from [`with_replies_and_popups`]): it is @@ -713,9 +726,61 @@ fn ink_rect_for_width(d: &Dict, old_w: f64, w: f64) -> Option<[f64; 4]> { /// Change a comment's colour, opacity (of the whole comment), line width and/or line endings, and /// redraw it. +/// +/// A Replace Text proposal is one look: colour and opacity are the group's (`/C` is read from +/// the primary, ISO 32000-2 §12.5.6.2), so restyling either the caret or its strikeout restyles +/// and redraws the other too, in the same edit. The strikeout takes no line width (a text markup +/// annotation has no border) and neither part has line endings. All or nothing: if one member +/// can't be restyled, none is. pub fn set_style(doc: &mut Document, page: usize, index: usize, change: StyleChange, meta: &Meta) -> Result<(), AnnotError> { - let StyleChange { color, opacity, width, endings } = change; let (_, r) = annot_ref(doc, page, index)?; + let others = replace_text_partners(doc, page, r)?; + // Check every partner before changing anything. + for &o in &others { + unlocked(doc, o)?; + } + let before: Vec<(ObjRef, Dict)> = others.iter().map(|&o| (o, annot_dict(doc, o))).collect(); + let mut done: Vec<(ObjRef, Dict)> = vec![(r, annot_dict(doc, r))]; + style_one(doc, r, change.clone(), meta)?; + for (o, d) in before { + let caret = d.name(b"Subtype") == Some(b"Caret"); + let part = StyleChange { color: change.color, opacity: change.opacity, width: change.width.filter(|_| caret), endings: None }; + let unchanged = part.color.is_none() && part.opacity.is_none() && part.width.is_none(); + if unchanged { + continue; + } + if let Err(e) = style_one(doc, o, part, meta) { + // Put the members already restyled back, the target included. + for (o, d) in done.into_iter().rev() { + doc.set(o, Object::Dict(d)); + } + return Err(e); + } + done.push((o, d)); + } + Ok(()) +} + +/// The other members of the Replace Text proposal `r` belongs to, on `page`: for its caret, the +/// strikeouts grouped under it; for a strikeout, its caret and any other strikeout under it. +/// Empty for anything else. +fn replace_text_partners(doc: &Document, page: usize, r: ObjRef) -> Result, AnnotError> { + let own = Object::Ref(r); + let caret = match replaced_by(doc, &own) { + Some(c) => c, + None if annot_dict(doc, r).name(b"Subtype") == Some(b"Caret") => r, + None => return Ok(Vec::new()), + }; + let list = annots(doc, page_ref(doc, page)?); + let strikes = list.iter().filter(|e| replaced_by(doc, e) == Some(caret)).filter_map(Object::as_ref); + // `/Annots` may list one annotation twice: restyle each once. + let mut seen: HashSet = HashSet::from([r]); + Ok(std::iter::once(caret).chain(strikes).filter(|&o| seen.insert(o)).collect()) +} + +/// [`set_style`] on one annotation. +fn style_one(doc: &mut Document, r: ObjRef, change: StyleChange, meta: &Meta) -> Result<(), AnnotError> { + let StyleChange { color, opacity, width, endings } = change; unlocked(doc, r)?; let d = annot_dict(doc, r); let subtype = String::from_utf8_lossy(d.name(b"Subtype").unwrap_or_default()).into_owned(); @@ -994,8 +1059,13 @@ fn pair_points(d: &Dict, key: &[u8]) -> Option> { } /// Replace Text (Acrobat's proposal): strike out `quads` and add a caret at the end of the -/// struck text holding `replacement`, grouped with the strikeout (`/IRT` + `/RT /Group`) so they -/// move, list and delete as one. Returns the strikeout's index. +/// struck text holding `replacement`, the two grouped so they move, list and delete as one. +/// Returns the strikeout's index. +/// +/// The caret is the group's primary (`/IT /Replace`) and the strikeout its subordinate (`/IRT` to +/// the caret, `/RT /Group`, `/IT /StrikeOutTextEdit`): a group's text is read from its primary, +/// and the subordinates' `Contents` are ignored (ISO 32000-2 §12.5.6.2), so the replacement has +/// to be on the primary. /// /// The two annotations need their own `/NM`: XFDF names a reply by the `/NM` of what it replies /// to, so one id for both writes `inreplyto` equal to the caret's own `name`, and a reader that @@ -1016,7 +1086,6 @@ pub fn add_text_replacement(doc: &mut Document, replacement: TextReplacement<'_> meta, )?; let (_, sr) = annot_ref(doc, page, s)?; - doc.update_dict(sr, |d| d.set(b"IT".to_vec(), Object::name("StrikeOutTextEdit")))?; // The caret sits on the baseline at the end of the last line, its point at the text. let xs = [last[0], last[2], last[4], last[6]]; let ys = [last[1], last[3], last[5], last[7]]; @@ -1031,10 +1100,14 @@ pub fn add_text_replacement(doc: &mut Document, replacement: TextReplacement<'_> )?; let (_, cr) = annot_ref(doc, page, c)?; doc.update_dict(cr, |d| { - d.set(b"IRT".to_vec(), Object::Ref(sr)); - d.set(b"RT".to_vec(), Object::name("Group")); + d.set(b"IT".to_vec(), Object::name("Replace")); d.set(b"Subj".to_vec(), PdfString::text("Replace Text")); })?; + doc.update_dict(sr, |d| { + d.set(b"IT".to_vec(), Object::name("StrikeOutTextEdit")); + d.set(b"IRT".to_vec(), Object::Ref(cr)); + d.set(b"RT".to_vec(), Object::name("Group")); + })?; Ok(s) } diff --git a/crates/annot/src/tests.rs b/crates/annot/src/tests.rs index 9e414078e9..7561938676 100644 --- a/crates/annot/src/tests.rs +++ b/crates/annot/src/tests.rs @@ -1264,22 +1264,36 @@ fn replace_text_groups_a_strikeout_and_a_caret() { let strike = &a[i]; assert_eq!((strike.name(b"Subtype"), strike.name(b"IT")), (Some(&b"StrikeOut"[..]), Some(&b"StrikeOutTextEdit"[..]))); let caret = a.iter().find(|d| d.name(b"Subtype") == Some(b"Caret")).unwrap(); - assert_eq!((text(caret, b"Contents").as_str(), caret.name(b"RT")), ("new words", Some(&b"Group"[..]))); - assert!(caret.reference(b"IRT").is_some()); + // The caret is the group's primary: a group's text is read from its primary and the + // subordinates' Contents are ignored (ISO 32000-2 §12.5.6.2), so the replacement is on it. + assert_eq!((text(caret, b"Contents").as_str(), caret.name(b"IT")), ("new words", Some(&b"Replace"[..]))); + assert!(caret.get(b"IRT").is_none() && caret.get(b"RT").is_none(), "a primary has no /IRT: {caret:?}"); + assert_eq!(strike.name(b"RT"), Some(&b"Group"[..])); + assert!(text(strike, b"Contents").is_empty()); // #2666: the pair needs its own `/NM` each. XFDF names a reply by the `/NM` of what it // replies to, so sharing one id writes `inreplyto` equal to the caret's own `name` and the // pair does not survive an export and import. assert_eq!(text(caret, b"NM").as_str(), "r-caret"); assert_eq!(text(strike, b"NM").as_str(), "r"); assert_ne!(text(caret, b"NM"), text(strike, b"NM"), "the caret must not carry the strikeout's name"); - let irt = caret.reference(b"IRT").unwrap(); - assert_eq!(text(doc.get(irt).as_dict().unwrap(), b"NM").as_str(), "r", "the caret replies to the strikeout"); + let irt = strike.reference(b"IRT").unwrap(); + assert_eq!(text(doc.get(irt).as_dict().unwrap(), b"NM").as_str(), "r-caret", "the strikeout is grouped under the caret"); let r = rect(caret); assert!(r[0] < 200.0 && r[2] > 200.0 && r[1] < 706.0, "at the end of the line, below its baseline: {r:?}"); - // Deleting the strikeout takes the caret with it (the group). + // Deleting either one takes the other with it (the proposal is one comment). + let marks = |doc: &Document| list(doc, 0).iter().filter(|d| matches!(d.name(b"Subtype"), Some(b"Caret" | b"StrikeOut"))).count(); + let mut by_strike = doc.clone(); + delete_annotation(&mut by_strike, 0, i).unwrap(); + assert_eq!(marks(&by_strike), 0, "deleting the strikeout deletes the caret"); let mut doc = doc; - delete_annotation(&mut doc, 0, i).unwrap(); - assert!(!list(&doc, 0).iter().any(|d| d.name(b"Subtype") == Some(b"Caret"))); + let c = list(&doc, 0).iter().position(|d| d.name(b"Subtype") == Some(b"Caret")).unwrap(); + // A locked caret keeps the strikeout too: the pair can't be broken by deleting the other. + let mut locked = doc.clone(); + set_locked(&mut locked, 0, c, true).unwrap(); + assert!(deletable(&locked, 0, i).is_err() && delete_annotation(&mut locked, 0, i).is_err(), "a locked caret refuses its strikeout's deletion"); + assert_eq!(marks(&locked), 2); + delete_annotation(&mut doc, 0, c).unwrap(); + assert_eq!(marks(&doc), 0, "deleting the caret deletes the strikeout"); assert!( add_text_replacement( &mut doc, @@ -1298,6 +1312,55 @@ fn replace_text_groups_a_strikeout_and_a_caret() { ); } +/// Review on #5689 (CodeRabbit): clicking a Replace Text strikeout selects its caret, the +/// group's primary, so a restyle from Comment Properties lands on the caret. Colour and opacity +/// are the proposal's, so the strikeout is restyled and redrawn in the same edit, whichever +/// member is restyled; it takes no border width. A locked member refuses the whole change. +#[test] +fn restyling_a_replace_text_pair_restyles_both_members() { + let mut doc = fixture(); + let quads = [[100.0, 720.0, 200.0, 720.0, 100.0, 706.0, 200.0, 706.0]]; + let red = Style { color: [0.89, 0.13, 0.13], opacity: 1.0, width: 1.0, fill: None, ..Style::default() }; + let tr = TextReplacement { + page: 0, + quads: &quads, + replacement: "new words", + author: "Ada", + strike: &red, + caret: &red, + strike_meta: &meta("r"), + caret_meta: &meta("r-caret"), + }; + let s = add_text_replacement(&mut doc, tr).unwrap(); + let c = list(&doc, 0).iter().position(|d| d.name(b"Subtype") == Some(b"Caret")).unwrap(); + let color = |doc: &Document, i: usize| { + list(doc, 0)[i].get(b"C").and_then(|o| o.as_array().cloned()).unwrap().iter().filter_map(Object::as_f64).collect::>() + }; + let green = [0.0, 0.5, 0.0]; + + // Restyled through the caret (what a click on either part selects). + let mut by_caret = doc.clone(); + set_style(&mut by_caret, 0, c, StyleChange::new(Some(green), Some(0.5), Some(3.0), None), &meta("")).unwrap(); + let by_caret = reopen(&by_caret); + assert_eq!(color(&by_caret, c), green.to_vec()); + assert_eq!(color(&by_caret, s), green.to_vec(), "the strikeout takes the proposal's colour"); + let strike = &list(&by_caret, 0)[s]; + assert_eq!(strike.get(b"CA").and_then(Object::as_f64), Some(0.5), "and its opacity"); + assert!(strike.get(b"BS").is_none(), "a strikeout has no border to widen: {strike:?}"); + assert!(ap_content(&by_caret, strike).contains("0 0.5 0 RG"), "its appearance is redrawn: {}", ap_content(&by_caret, strike)); + + // Restyled through the strikeout: the caret follows. + let mut by_strike = doc.clone(); + set_style(&mut by_strike, 0, s, StyleChange::new(Some(green), None, None, None), &meta("")).unwrap(); + assert_eq!(color(&by_strike, c), green.to_vec(), "the caret takes the proposal's colour"); + + // A locked strikeout refuses the change, and nothing moves. + let mut locked = doc.clone(); + set_locked(&mut locked, 0, s, true).unwrap(); + assert!(set_style(&mut locked, 0, c, StyleChange::new(Some(green), None, None, None), &meta("")).is_err()); + assert_eq!(color(&locked, c), red.color.to_vec(), "the caret keeps its colour"); +} + #[test] fn files_attach_as_comments() { let mut doc = fixture(); diff --git a/crates/automation/src/comments.rs b/crates/automation/src/comments.rs index 78f4cefbe0..390e3b4d1a 100644 --- a/crates/automation/src/comments.rs +++ b/crates/automation/src/comments.rs @@ -490,8 +490,12 @@ impl Automation { if contents.trim().is_empty() { return Err(ToolError::InvalidArgs("replace needs `contents`: the replacement text".into())); } + // `/C` is a group attribute, taken from the primary (ISO 32000-2 §12.5.6.2), so the + // caret carries the colour too. Only the colour: opacity and the rest are the + // caret's own. + let mut caret = Style::default_for(&Shape::Caret { rect: [0.0; 4] }); + caret.color = style.color; let strike = style; - let caret = Style::default_for(&Shape::Caret { rect: [0.0; 4] }); let mut out = self.apply(a, Edit::ReplaceText { page, quads, text: contents, author, strike, caret })?; out["comment"] = json!({ "page": page + 1, "type": "replace" }); return Ok(out); diff --git a/crates/automation/tests/it/automation.rs b/crates/automation/tests/it/automation.rs index 0849f8fa65..da634490e4 100644 --- a/crates/automation/tests/it/automation.rs +++ b/crates/automation/tests/it/automation.rs @@ -6783,11 +6783,26 @@ fn drawing_comments_through_tools() { a.call("comment_add", &json!({ "doc": doc, "page": 1, "type": "polygon", "points": [[1, 1]] })), Err(ToolError::Failed(_) | ToolError::InvalidArgs(_)) )); - // Replace Text: a strikeout over the found text and a grouped caret. - ok(&mut a, "comment_add", json!({ "doc": doc, "page": 2, "type": "replace", "find": "page 2", "contents": "second page" })); + // Replace Text: a strikeout over the found text, grouped under a caret holding the + // replacement (the group's primary, ISO 32000-2 §12.5.6.2). + ok(&mut a, "comment_add", json!({ "doc": doc, "page": 2, "type": "replace", "find": "page 2", "contents": "second page", "color": "green" })); let list = ok(&mut a, "comment_list", json!({ "doc": doc, "page": 2 })); - let strike = list["comments"].as_array().unwrap().iter().find(|c| c["type"] == "StrikeOut").unwrap().clone(); - assert_eq!(strike["replies"][0]["contents"], "second page", "the caret threads under the strikeout"); + let caret = list["comments"].as_array().unwrap().iter().find(|c| c["type"] == "Caret").unwrap().clone(); + assert_eq!(caret["contents"], "second page", "the replacement is on the caret"); + assert_eq!(caret["replies"][0]["type"], "StrikeOut", "the strikeout is grouped under the caret"); + // Review on #5689 (CodeRabbit): the requested colour is the group's, so the primary has it. + assert_eq!(caret["replies"][0]["color"], caret["color"], "one colour for the pair: {caret}"); + assert_eq!(caret["color"], json!("#2E9E5C"), "the requested green, not the caret's default blue: {caret}"); + // Restyling the caret restyles the proposal, its strikeout too, as one undo step. + let pair = |a: &mut Automation| { + let list = ok(a, "comment_list", json!({ "doc": doc, "page": 2 })); + let c = list["comments"].as_array().unwrap().iter().find(|c| c["type"] == "Caret").unwrap().clone(); + (c["color"].clone(), c["replies"][0]["color"].clone()) + }; + ok(&mut a, "comment_edit", json!({ "doc": doc, "page": 2, "index": caret["index"], "color": "purple" })); + assert_eq!(pair(&mut a), (json!("#8A3FD1"), json!("#8A3FD1")), "the strikeout follows its caret"); + ok(&mut a, "edit_undo", json!({ "doc": doc })); + assert_eq!(pair(&mut a), (json!("#2E9E5C"), json!("#2E9E5C")), "one undo puts both back"); assert!(matches!(a.call("comment_add", &json!({ "doc": doc, "page": 2, "type": "replace", "find": "page 2" })), Err(ToolError::InvalidArgs(_)))); // A file attached as a comment. std::fs::write(dir.join("notes.txt"), b"remember").unwrap(); diff --git a/crates/engine/src/edit/run.rs b/crates/engine/src/edit/run.rs index 1d4a799397..265104c657 100644 --- a/crates/engine/src/edit/run.rs +++ b/crates/engine/src/edit/run.rs @@ -561,8 +561,8 @@ pub(crate) fn run_edit(doc: &mut pdfcraft_cos::Document, edit: &Edit, cx: &mut E } Edit::DeleteAttachment { key } => pdfcraft_organize::delete_attachment(doc, key)?, Edit::ReplaceText { page, quads, text, author, strike, caret } => { - // One `/NM` each: the caret replies to the strikeout, and XFDF names that reply by - // the strikeout's `/NM` (#2666). + // One `/NM` each: the strikeout is grouped under the caret, and XFDF names that link by + // the caret's `/NM` (#2666). let (strike_meta, caret_meta) = (cx.meta(), cx.meta()); pdfcraft_annot::add_text_replacement( doc, diff --git a/crates/ui-egui/src/panels/comments_panel.rs b/crates/ui-egui/src/panels/comments_panel.rs index 430bdbec85..f6b0043637 100644 --- a/crates/ui-egui/src/panels/comments_panel.rs +++ b/crates/ui-egui/src/panels/comments_panel.rs @@ -40,7 +40,7 @@ pub fn subtype_label(s: &str) -> &str { /// The comment type as the panel names it, telling callouts, clouds and replaced text apart by `/IT`. pub fn kind_label(a: &Annotation) -> &str { match a.intent.as_deref() { - Some("StrikeOutTextEdit") => "Replace text", + Some("StrikeOutTextEdit" | "Replace") => "Replace text", Some("FreeTextCallout") => "Callout", Some("PolygonCloud") => "Cloud", Some("FreeTextTypeWriter") => "Typewriter", diff --git a/crates/ui-egui/src/tools/comments.rs b/crates/ui-egui/src/tools/comments.rs index 34a9b24a09..56301969a2 100644 --- a/crates/ui-egui/src/tools/comments.rs +++ b/crates/ui-egui/src/tools/comments.rs @@ -441,7 +441,7 @@ pub fn tool_for(a: &Annotation) -> Option { ("FreeText", _) => CommentTool::TextBox, ("Highlight", _) => CommentTool::Highlight, ("Underline", _) => CommentTool::Underline, - ("StrikeOut", Some("StrikeOutTextEdit")) => CommentTool::ReplaceText, + ("StrikeOut", Some("StrikeOutTextEdit")) | ("Caret", Some("Replace")) => CommentTool::ReplaceText, ("StrikeOut", _) => CommentTool::StrikeOut, ("Squiggly", _) => CommentTool::Squiggly, ("Ink", Some("FreehandHighlight")) => CommentTool::FreehandHighlight, diff --git a/crates/ui-egui/src/tools/comments/composer.rs b/crates/ui-egui/src/tools/comments/composer.rs index 8c3dee280c..5ae441a0cd 100644 --- a/crates/ui-egui/src/tools/comments/composer.rs +++ b/crates/ui-egui/src/tools/comments/composer.rs @@ -154,7 +154,10 @@ pub(crate) fn composer(ctx: &egui::Context, view: &mut DocView, info: &DocInfo, text, author: prefs.author.clone(), strike: prefs.style(CommentTool::ReplaceText), - caret: prefs.style(CommentTool::Caret), + // One proposal, one look: the caret, the group's primary, takes the Replace text + // tool's style as the strikeout does, so a default saved from either part (Make + // Current Properties Default) applies to the next proposal as a whole. + caret: prefs.style(CommentTool::ReplaceText), }) } ComposerKind::Caret { size } => { diff --git a/crates/ui-egui/src/tools/comments/geometry.rs b/crates/ui-egui/src/tools/comments/geometry.rs index 7e291be4db..594abae86e 100644 --- a/crates/ui-egui/src/tools/comments/geometry.rs +++ b/crates/ui-egui/src/tools/comments/geometry.rs @@ -5,6 +5,13 @@ pub(super) fn is_markup(subtype: &str) -> bool { matches!(subtype, "Highlight" | "Underline" | "StrikeOut" | "Squiggly") } +/// What lies on the text and doesn't move with a drag: text markup, and the caret of a Replace +/// Text proposal (`/IT /Replace`), which is picked by its strikeout too and would otherwise be +/// dragged away from the text it replaces, leaving the strikeout behind. +pub(super) fn on_text(a: &Annotation) -> bool { + is_markup(&a.subtype) || a.subtype == "Caret" && a.intent.as_deref() == Some("Replace") +} + /// Rectangles, ovals, text boxes, stamps and drawings (drawn signatures among them). A callout's /// `/Rect` also holds its leader line, so it only moves. pub(super) fn resizable(a: &Annotation) -> bool { diff --git a/crates/ui-egui/src/tools/comments/input.rs b/crates/ui-egui/src/tools/comments/input.rs index 40c229e535..59a9b40ca2 100644 --- a/crates/ui-egui/src/tools/comments/input.rs +++ b/crates/ui-egui/src/tools/comments/input.rs @@ -1,5 +1,5 @@ use super::geometry::{ - CARET_SIZE, HANDLES, clamp_to, corner_ratio, drawn_box, handle_pos, held, is_markup, point_handle, reaches_points, reshapable, reshaped, + CARET_SIZE, HANDLES, clamp_to, corner_ratio, drawn_box, handle_pos, held, is_markup, on_text, point_handle, reaches_points, reshapable, reshaped, resizable, resized, snapped_caret, }; use super::paint::{closes_at, drawn_shape, edit_anchor, erase, new_comment}; @@ -137,7 +137,7 @@ pub(crate) fn hit(cx: &PageCx<'_>, view: &DocView, p: Pos2, on_link: bool) -> Op if cx.tool == QuickTool::Select && a.subtype == "Highlight" && on_link { return None; } - let markup = is_markup(&a.subtype); + let markup = on_text(a); let icon = if cx.allowed && !markup { egui::CursorIcon::Move } else { egui::CursorIcon::PointingHand }; // Text markup does not move: a click selects it, a drag across it selects the text. Some(Hit { declines_drag: markup, ..Hit::new(Owner::Comment { index: a.index, part: CommentPart::Body }, layer(a), icon) }) @@ -253,7 +253,7 @@ fn select_input( cv.gesture = Some(Gesture::Resize { page: cx.page, index: a.index, handle: (x, y), from: o, aspect_ratio }); consumed = true; } - CommentPart::Body if !is_markup(&a.subtype) => { + CommentPart::Body if !on_text(a) => { // Dragging one of several selected comments moves them all; any other drag starts // a selection of its own. let grabbed = (cx.page, a.index); @@ -341,14 +341,14 @@ fn finish_select_gesture(ui: &egui::Ui, cx: &PageCx<'_>, view: &mut DocView, p: let (a, b) = (cx.to_user(from), cx.to_user(p)); let (dx, dy) = (b[0] - a[0], b[1] - a[1]); if from.distance(p) >= 2.0 { - // The other selected comments on this page (not text markup, which is where - // its text is, nor a locked one, which would refuse the whole step) move with - // the dragged one, as one undo step. + // The other selected comments on this page (not text markup or a Replace Text + // caret, which are where their text is, nor a locked one, which would refuse the + // whole step) move with the dragged one, as one undo step. let others: Vec = view .comments .also .iter() - .filter(|(p, i)| *p == page && *i != index && cx.get(*i).is_some_and(|a| !is_markup(&a.subtype) && !a.locked)) + .filter(|(p, i)| *p == page && *i != index && cx.get(*i).is_some_and(|a| !on_text(a) && !a.locked)) .map(|(_, i)| *i) .collect(); view.pending_edit = Some(if others.is_empty() { @@ -759,7 +759,7 @@ pub(crate) fn nudge_keys(ctx: &egui::Context, view: &mut DocView, info: &DocInfo return; } let Some((page, index)) = cv.selected else { return }; - let movable = info.annotations.iter().any(|a| a.page == page && a.index == index && !a.is_reply() && !is_markup(&a.subtype) && !a.locked); + let movable = info.annotations.iter().any(|a| a.page == page && a.index == index && !a.is_reply() && !on_text(a) && !a.locked); let (Some(xf), Some(p)) = (view.page_xform(page), info.pages.get(page)) else { return }; if !movable { return; diff --git a/crates/ui-egui/src/tools/comments/state.rs b/crates/ui-egui/src/tools/comments/state.rs index cff7b1d8a6..b686c402b1 100644 --- a/crates/ui-egui/src/tools/comments/state.rs +++ b/crates/ui-egui/src/tools/comments/state.rs @@ -450,9 +450,23 @@ impl PageCx<'_> { } /// The topmost comment under `p`: the one drawn last, in `/Annots` order (inspection lists - /// comments by position, not in paint order). + /// comments by position, not in paint order). A Replace Text strikeout is grouped under its + /// caret (`/RT /Group`), which holds the proposal, so the strikeout is hit as that caret: the + /// proposal is picked by either part, not only by its small caret. pub(super) fn comment_at(&self, p: Pos2) -> Option<&Annotation> { - self.comments().filter(|a| self.screen_rects(a).any(|r| r.expand(3.0).contains(p))).max_by_key(|a| a.index) + let under = |a: &&Annotation| self.screen_rects(a).any(|r| r.expand(3.0).contains(p)); + let strikes = self + .info + .annotations + .iter() + .filter(|a| !self.hidden && a.page == self.page && a.grouped && a.intent.as_deref() == Some("StrikeOutTextEdit")) + .filter(under) + .filter_map(|s| { + let (page, index) = s.parent?; + let caret = self.get(index).filter(|c| page == self.page && c.subtype == "Caret")?; + Some((s.index, caret)) + }); + self.comments().filter(under).map(|a| (a.index, a)).chain(strikes).max_by_key(|(i, _)| *i).map(|(_, a)| a) } pub(crate) fn get(&self, index: usize) -> Option<&Annotation> { diff --git a/crates/ui-egui/tests/it/comments.rs b/crates/ui-egui/tests/it/comments.rs index 2308ba9e0f..56984b6882 100644 --- a/crates/ui-egui/tests/it/comments.rs +++ b/crates/ui-egui/tests/it/comments.rs @@ -2595,9 +2595,45 @@ fn highlighting_an_area_off_the_text() { assert_eq!(comments(&h).len(), 2); } +/// A Replace Text proposal is picked by its strikeout too, not only by its small caret: the +/// strikeout is grouped under the caret (`/RT /Group`), and a click on it selects the caret, the +/// proposal, so it can be deleted or restyled from either part. +#[test] +fn clicking_a_replace_text_strikeout_selects_the_proposal() { + let pdf = b"%PDF-1.7 +1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj +2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 >> endobj +3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 300 200] /Annots [5 0 R 4 0 R] >> endobj +4 0 obj << /Type /Annot /Subtype /Caret /Rect [135 95 145 105] /C [0 0 1] /T (Bob) /IT /Replace /Contents (slow red) >> endobj +5 0 obj << /Type /Annot /Subtype /StrikeOut /Rect [40 100 130 120] /QuadPoints [40 120 130 120 40 100 130 100] /C [1 0 0] /T (Bob) /IT /StrikeOutTextEdit /IRT 4 0 R /RT /Group >> endobj +trailer << /Root 1 0 R >> +%%EOF"; + let mut h = harness(move |app| { + app.open_bytes("replace.pdf", None, pdf.to_vec()).expect("opens"); + app.close_tab(0); + }); + h.run_steps(6); + let caret = comments(&h).into_iter().find(|a| a.subtype == "Caret").expect("the caret"); + let p = at(&h, 70.0, 110.0); + click(&mut h, p); + assert_eq!(h.state().views[0].comments.selected, Some((caret.page, caret.index)), "the strikeout picks the proposal"); + // Review on #5689 (CodeRabbit): a drag from the strikeout, or from the caret, moves neither: + // the proposal lies on the text it replaces, and the caret alone would leave the strikeout. + let caret_at = at(&h, 140.0, 100.0); + for from in [p, caret_at] { + drag(&mut h, from, from + egui::vec2(60.0, 40.0)); + h.run_steps(3); + let now = comments(&h).into_iter().find(|a| a.subtype == "Caret").expect("the caret"); + assert_eq!(now.rect, caret.rect, "the caret stays on the text"); + } + assert_eq!(h.state().session.get(h.state().views[0].id).unwrap().can_undo(), None, "nothing was moved"); +} + #[test] fn replacing_text_strikes_it_and_adds_a_caret() { let mut h = harness(|app| app.set_option("quick", "replace").unwrap()); + // Review on #5689 (CodeRabbit): both parts take the Replace text tool's style. + h.state_mut().comment_prefs.set_color(pdfcraft_ui_egui::comments::CommentTool::ReplaceText, [0.1, 0.6, 0.2]); drag_pt(&mut h, (20.0, 155.0), (120.0, 155.0)); assert!(h.state().views[0].comments.composer.is_some(), "the composer asks for the replacement"); field(&h, "Replacement text").type_text("slow red"); @@ -2608,15 +2644,19 @@ fn replacing_text_strikes_it_and_adds_a_caret() { let strike = c.iter().find(|a| a.subtype == "StrikeOut").expect("a strikeout"); let caret = c.iter().find(|a| a.subtype == "Caret").expect("a caret"); assert_eq!(caret.contents.as_deref(), Some("slow red")); - assert_eq!(caret.in_reply_to, strike.name, "grouped with the strikeout"); + assert_eq!((strike.color, caret.color), (Some([0.1, 0.6, 0.2]), Some([0.1, 0.6, 0.2])), "one style for the proposal"); + assert_eq!(strike.in_reply_to, caret.name, "the strikeout is grouped under the caret, which holds the text"); assert_eq!(h.state().session.get(h.state().views[0].id).unwrap().can_undo(), Some("Replace text")); assert_eq!(h.state().quick_tool, QuickTool::Select); - // The strikeout is the Replace text tool's, not Strikethrough's (#3947): the panel names it - // so, and Make Current Properties Default updates the Replace text tool. + // Both members are the Replace text tool's, not Strikethrough's or Insert text's (#3947), so + // Make Current Properties Default updates the Replace text tool. assert_eq!(pdfcraft_ui_egui::comments::tool_for(strike), Some(pdfcraft_ui_egui::comments::CommentTool::ReplaceText)); + assert_eq!(pdfcraft_ui_egui::comments::tool_for(caret), Some(pdfcraft_ui_egui::comments::CommentTool::ReplaceText)); + // The panel's card is the caret's, the group's primary: it shows the replacement, and + // nothing is named Strikethrough. h.state_mut().set_option("panel", "comments").unwrap(); h.run_steps(3); - assert!(h.query_by_role_and_label(egui::accesskit::Role::Label, "Replace text").is_some(), "the panel names the comment Replace text"); + assert!(h.query_by_role_and_label(egui::accesskit::Role::Label, "slow red").is_some(), "the panel shows the replacement"); assert!(h.query_by_role_and_label(egui::accesskit::Role::Label, "Strikethrough").is_none(), "not Strikethrough"); } diff --git a/crates/xfdf/src/tests.rs b/crates/xfdf/src/tests.rs index 96e9179faa..52ce9e9508 100644 --- a/crates/xfdf/src/tests.rs +++ b/crates/xfdf/src/tests.rs @@ -143,9 +143,9 @@ fn values(doc: &Document) -> Vec<(String, Vec)> { fields(doc).into_iter().map(|f| (f.name, f.value)).collect() } -/// #2666: Replace Text writes a StrikeOut and a Caret that replies to it. XFDF names a reply by -/// the `/NM` of what it replies to, so when both carried one id the caret's `inreplyto` was its -/// own `name`: importing it found a comment replying to itself and the pair was lost. +/// #2666: Replace Text writes a Caret and a StrikeOut grouped under it. XFDF names the link by +/// the `/NM` of the annotation it points at, so when both carried one id the `inreplyto` was the +/// strikeout's own `name`: importing it found a comment replying to itself and the pair was lost. #[test] fn a_replace_text_pair_survives_an_xfdf_round_trip() { let mut src = blank(); @@ -170,8 +170,8 @@ fn a_replace_text_pair_survives_an_xfdf_round_trip() { let xfdf = export_xfdf(&src, true, false, "a.pdf"); assert!(xfdf.contains("name=\"strike\"") && xfdf.contains("name=\"caret\""), "each gets its own name: {xfdf}"); - assert!(xfdf.contains("inreplyto=\"strike\""), "the caret replies to the strikeout: {xfdf}"); - assert!(!xfdf.contains("inreplyto=\"caret\""), "never a reply to itself"); + assert!(xfdf.contains("inreplyto=\"caret\""), "the strikeout is grouped under the caret: {xfdf}"); + assert!(!xfdf.contains("inreplyto=\"strike\""), "never a reply to itself"); let mut dst = blank(); let r = import(&mut dst, xfdf.as_bytes()).unwrap(); @@ -180,6 +180,135 @@ fn a_replace_text_pair_survives_an_xfdf_round_trip() { let kinds: Vec<&str> = found.iter().map(|s| s.subtype.as_str()).collect(); assert!(kinds.contains(&"StrikeOut") && kinds.contains(&"Caret"), "{kinds:?}"); assert_eq!(comment_view(&dst), comment_view(&src), "the pair and its thread survive"); + // Review on #5689 (CodeRabbit): the intents travel too, so the imported pair is still + // Replace Text, and deleting its strikeout takes the caret and its replacement with it. + assert!(xfdf.contains("intent=\"Replace\"") && xfdf.contains("intent=\"StrikeOutTextEdit\""), "{xfdf}"); + let strike = summaries(&dst).into_iter().find(|s| s.subtype == "StrikeOut").expect("the strikeout"); + pdfcraft_annot::delete_annotation(&mut dst, 0, strike.index).unwrap(); + assert!(summaries(&dst).iter().all(|s| s.subtype != "Caret"), "the caret goes with the strikeout"); +} + +/// Review on #5689 (CodeRabbit): XFDF links a reply to its target by the target's `name`, so a +/// group whose primary has no `/NM` lost its `inreplyto` on export and came back as two loose +/// comments. Both layouts: the caret primary (this PR) and the strikeout primary (older files). +#[test] +fn an_unnamed_group_primary_gets_an_export_only_name() { + for caret_primary in [true, false] { + let mut src = blank(); + let quads = [[100.0, 720.0, 200.0, 720.0, 100.0, 706.0, 200.0, 706.0]]; + let style = Style { color: [0.89, 0.13, 0.13], opacity: 1.0, width: 1.0, fill: None, ..Style::default() }; + let unnamed = Meta { date: None, id: String::new() }; + add_text_replacement( + &mut src, + TextReplacement { + page: 0, + quads: &quads, + replacement: "new words", + author: "Ada", + strike: &style, + caret: &style, + strike_meta: &unnamed, + caret_meta: &unnamed, + }, + ) + .unwrap(); + let page = pdfcraft_model::pages(&src)[0].obj; + let annots = src.get(page).as_dict().unwrap().get(b"Annots").and_then(|a| src.resolve(a).as_array().cloned()).unwrap(); + let refs: Vec = annots.iter().filter_map(Object::as_ref).collect(); + let is = |doc: &Document, r: ObjRef, s: &[u8]| doc.get(r).as_dict().and_then(|d| d.name(b"Subtype")) == Some(s); + let strike = *refs.iter().find(|r| is(&src, **r, b"StrikeOut")).unwrap(); + let caret = *refs.iter().find(|r| is(&src, **r, b"Caret")).unwrap(); + let (primary, sub) = if caret_primary { (caret, strike) } else { (strike, caret) }; + if !caret_primary { + src.update_dict(strike, |d| { + d.remove(b"IRT"); + d.remove(b"RT"); + }) + .unwrap(); + src.update_dict(caret, |d| { + d.set(b"IRT".to_vec(), Object::Ref(strike)); + d.set(b"RT".to_vec(), Object::name("Group")); + }) + .unwrap(); + } + // A comment already named what the made-up name would be: the two must not collide. + let clash = format!("pdfcraft-export-{}-{}", primary.num, primary.generation); + let note = NewAnnotation { + page: 0, + shape: Shape::Note { at: [300.0, 300.0], icon: Default::default() }, + style: style.clone(), + contents: "note".into(), + author: "Ada".into(), + }; + add_annotation(&mut src, ¬e, &Meta { date: None, id: clash.clone() }).unwrap(); + + let xfdf = export_xfdf(&src, true, false, "a.pdf"); + let doc = roxmltree::Document::parse(&xfdf).unwrap(); + let el = |t: &str| doc.descendants().find(|n| n.has_tag_name(t)).unwrap_or_else(|| panic!("no <{t}>: {xfdf}")); + let (tag, sub_tag) = if caret_primary { ("caret", "strikeout") } else { ("strikeout", "caret") }; + let name = el(tag).attribute("name").unwrap_or_else(|| panic!("the unnamed primary gets a name ({caret_primary}): {xfdf}")); + assert_eq!(el(sub_tag).attribute("inreplyto"), Some(name), "{xfdf}"); + assert_ne!(name, clash, "a made-up name never repeats a real /NM"); + assert_eq!(doc.descendants().filter(|n| n.attribute("name") == Some(name)).count(), 1, "{xfdf}"); + assert!(src.get(primary).as_dict().unwrap().get(b"NM").is_none(), "the name is for the export only"); + assert!(src.get(sub).as_dict().unwrap().get(b"NM").is_none()); + + let mut dst = blank(); + import(&mut dst, xfdf.as_bytes()).unwrap(); + // The imported subordinate is grouped under the imported primary again. + let dpage = pdfcraft_model::pages(&dst)[0].obj; + let dannots = dst.get(dpage).as_dict().unwrap().get(b"Annots").and_then(|a| dst.resolve(a).as_array().cloned()).unwrap(); + let drefs: Vec = dannots.iter().filter_map(Object::as_ref).collect(); + let (p_sub, s_sub): (&[u8], &[u8]) = if caret_primary { (b"Caret", b"StrikeOut") } else { (b"StrikeOut", b"Caret") }; + let dprimary = *drefs.iter().find(|r| is(&dst, **r, p_sub)).unwrap(); + let dsub = *drefs.iter().find(|r| is(&dst, **r, s_sub)).unwrap(); + let irt = dst.get(dsub).as_dict().unwrap().get(b"IRT").and_then(Object::as_ref); + assert_eq!(irt, Some(dprimary), "the group survives the round trip ({caret_primary})"); + } +} + +/// An annotation written inline in `/Annots` (a direct dictionary, not a reference) is exported +/// under its `/NM` too, so a made-up name must not repeat it either. +#[test] +fn an_export_only_name_never_repeats_an_inline_annotations_nm() { + let mut src = blank(); + let quads = [[100.0, 720.0, 200.0, 720.0, 100.0, 706.0, 200.0, 706.0]]; + let style = Style { color: [0.89, 0.13, 0.13], opacity: 1.0, width: 1.0, fill: None, ..Style::default() }; + let unnamed = Meta { date: None, id: String::new() }; + add_text_replacement( + &mut src, + TextReplacement { + page: 0, + quads: &quads, + replacement: "new words", + author: "Ada", + strike: &style, + caret: &style, + strike_meta: &unnamed, + caret_meta: &unnamed, + }, + ) + .unwrap(); + let page = pdfcraft_model::pages(&src)[0].obj; + let mut annots = src.get(page).as_dict().unwrap().get(b"Annots").and_then(|a| src.resolve(a).as_array().cloned()).unwrap(); + let primary = + annots.iter().filter_map(Object::as_ref).find(|r| src.get(*r).as_dict().and_then(|d| d.name(b"Subtype")) == Some(&b"Caret"[..])).unwrap(); + // An inline note already named what the made-up name would be. + let clash = format!("pdfcraft-export-{}-{}", primary.num, primary.generation); + let mut note = Dict::new(); + note.set(b"Type".to_vec(), Object::name("Annot")); + note.set(b"Subtype".to_vec(), Object::name("Text")); + note.set(b"NM".to_vec(), PdfString::text(&clash)); + note.set(b"Rect".to_vec(), Object::Array(vec![300.into(), 300.into(), 320.into(), 320.into()])); + annots.push(Object::Dict(note)); + src.update_dict(page, |p| p.set(b"Annots".to_vec(), Object::Array(annots))).unwrap(); + + let xfdf = export_xfdf(&src, true, false, "a.pdf"); + let doc = roxmltree::Document::parse(&xfdf).unwrap(); + let caret = doc.descendants().find(|n| n.has_tag_name("caret")).unwrap_or_else(|| panic!("no : {xfdf}")); + let name = caret.attribute("name").unwrap_or_else(|| panic!("the unnamed primary gets a name: {xfdf}")); + assert_ne!(name, clash, "a made-up name never repeats an inline annotation's /NM: {xfdf}"); + assert_eq!(doc.descendants().filter(|n| n.attribute("name") == Some(&clash[..])).count(), 1, "{xfdf}"); } #[test] diff --git a/crates/xfdf/src/xfdf.rs b/crates/xfdf/src/xfdf.rs index e2137a98f0..3368003483 100644 --- a/crates/xfdf/src/xfdf.rs +++ b/crates/xfdf/src/xfdf.rs @@ -99,31 +99,39 @@ fn read_own_keys(node: roxmltree::Node, d: &mut Dict) { } } -/// The name of every annotation, for `inreplyto`: its /NM, or, for one in `only` without one -/// (other tools leave /NM out), a name new to the document for this export, so a reply copied -/// with it still finds it. +/// The name of every annotation, for `inreplyto`: its /NM, or, for one without one (other tools +/// leave /NM out) that is in `only` or that another annotation replies to or is grouped under +/// (`/IRT`, ISO 32000-2 §12.5.6.2; a Replace Text pair is such a group), a name new to the +/// document for this export, so the link survives the round trip. Made-up names are never written +/// into the PDF. fn annotation_names(doc: &Document, pages: &[pdfcraft_model::Page], only: Option<&HashSet<(usize, usize)>>) -> HashMap { let mut names: HashMap = HashMap::new(); let mut unnamed: Vec<(usize, usize, ObjRef)> = Vec::new(); + let mut targets: HashSet = HashSet::new(); // Every name in use, those of annotations written straight into the page array too, so a // given name is new. let mut taken: HashSet = HashSet::new(); for (pi, p) in pages.iter().enumerate() { for (ai, a) in p.dict.get(b"Annots").map(|a| doc.resolve(a)).and_then(|a| a.as_array().cloned()).unwrap_or_default().into_iter().enumerate() { - let nm = doc.resolve(&a).as_dict().and_then(|d| text_of(doc, d, b"NM")); + let d = doc.resolve(&a).as_dict().cloned(); + if let Some(t) = d.as_ref().and_then(|d| d.get(b"IRT")).and_then(Object::as_ref) { + targets.insert(t); + } + let nm = d.as_ref().and_then(|d| text_of(doc, d, b"NM")).filter(|nm| !nm.is_empty()); if let Some(nm) = &nm { taken.insert(nm.clone()); } + // Only an indirect annotation can be an `/IRT` target, so only it is keyed here. let Some(r) = a.as_ref() else { continue }; match nm { Some(nm) => { names.insert(r, nm); } - None if only.is_some_and(|only| only.contains(&(pi, ai))) => unnamed.push((pi, ai, r)), - None => {} + None => unnamed.push((pi, ai, r)), } } } + unnamed.retain(|(pi, ai, r)| only.is_some_and(|only| only.contains(&(*pi, *ai))) || targets.contains(r)); for (pi, ai, r) in unnamed { let mut name = format!("pdfcraft-{pi}-{ai}"); while taken.contains(&name) { @@ -180,8 +188,8 @@ fn xfdf_annot_children(doc: &Document, out: &mut String, d: &Dict, pi: usize) { /// `own`: write each comment's PdfCraft keys too (see [`own_keys`]), for a copy and paste. fn xfdf_annots(doc: &Document, out: &mut String, only: Option<&HashSet<(usize, usize)>>, own: bool) { let pages = pdfcraft_model::pages(doc); - // NM of every annotation, for inreplyto. A selected annotation without one (other tools - // leave /NM out) gets a name for this export, so a reply copied with it still finds it. + // NM of every annotation, for inreplyto. A selected or replied-to annotation without one + // (other tools leave /NM out) gets a name for this export, so its replies still find it. let names = annotation_names(doc, &pages, only); for (pi, p) in pages.iter().enumerate() { for (ai, a) in p.dict.get(b"Annots").map(|a| doc.resolve(a)).and_then(|a| a.as_array().cloned()).unwrap_or_default().into_iter().enumerate() { @@ -203,8 +211,8 @@ fn xfdf_annots(doc: &Document, out: &mut String, only: Option<&HashSet<(usize, u n(rect[1].max(rect[3])) ); } - // The name: /NM, or the one given above for this export. - if let Some(nm) = text_of(doc, &d, b"NM").or_else(|| a.as_ref().and_then(|r| names.get(&r).cloned())) { + // Its `/NM`, or the name given above for this export. + if let Some(nm) = a.as_ref().and_then(|r| names.get(&r)).cloned().or_else(|| text_of(doc, &d, b"NM")) { let _ = write!(attrs, " name=\"{}\"", esc(&nm)); } for (key, attr) in [ diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index b2e651ec57..6405daf739 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -3735,7 +3735,7 @@ status = "shipped" commands = ["comment.replace"] tools = ["comment_add"] evidence = ["crates/annot/src/tests.rs::replace_text_groups_a_strikeout_and_a_caret", "crates/ui-egui/tests/it/comments.rs::replacing_text_strikes_it_and_adds_a_caret", "crates/automation/tests/it/automation.rs::drawing_comments_through_tools"] -notes = "strikeout (/IT StrikeOutTextEdit) and a grouped caret (/RT /Group) with the replacement" +notes = "a caret holding the replacement, the group's primary (/IT /Replace), with the strikeout (/IT /StrikeOutTextEdit) grouped under it (/IRT, /RT /Group), as ISO 32000-2 §12.5.6.2 reads a group's text from its primary; deleting either deletes both. Missing: /RT /Group is not read yet, so the Comments panel and comment_list show the strikeout as a reply of the caret (#4357)." [[feature]] id = "comment.insert-text" From bf409db55c22f125284ea376c9bbf9cb2e47ad69 Mon Sep 17 00:00:00 2001 From: "Hasan N." <42965049+hasannadeem@users.noreply.github.com> Date: Sun, 8 Nov 2026 19:38:40 -0500 Subject: [PATCH 30/92] M3: pages shorter than the window sit in its middle (#5708) (#5759) --- ROADMAP.md | 1 + crates/ui-egui/src/canvas.rs | 35 ++++++++----- crates/ui-egui/src/canvas/tests.rs | 20 ++++++++ crates/ui-egui/tests/it/dialog_chrome.rs | 16 +++--- crates/ui-egui/tests/it/view.rs | 62 ++++++++++++++++++++++++ docs/roadmap.md | 1 + parity/acrobat-features.toml | 4 +- 7 files changed, 118 insertions(+), 21 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 834e61cfd7..2df1bd26ca 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -256,6 +256,7 @@ Ranked; detail and the full milestone table in [docs/roadmap.md](docs/roadmap.md - **2026-11-02 (M10, #5743):** macOS print jobs name their title and owner (`com.apple.print.JobInfo.PMJobName`, `PMJobOwner`), as the system print dialog does, because a Kyocera filter crashes on a job without them. Checked by a test and through `doc_print` against a stand-in `lp`; not yet on a physical printer. +- **2026-11-02 (M3, #5708):** Pages shorter than the window sit in its middle, one page or row at a time and when scrolling continuously with every page in view. - **2026-11-02 (M14, #5737, #5460):** Preferences can turn off the update check at start. It stays on by default, and Help ▸ Check for updates still asks. - **2026-11-01 (M7, #5657):** Header/footer, watermark and background additions and replacements stage the complete call before committing. Synthetic later-page failures preserve exact incremental bytes, object count, dirty state and allocation cursor; success controls retain other marks and shared/inherited resources. Estimates unchanged. diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index bae59fc980..3e51dac3bf 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -2136,8 +2136,7 @@ fn single_page_paging(ui: &egui::Ui, view: &mut DocView, cx: &PagingCx<'_>) -> ( if !view.continuous { // The row's bounding box on screen (one page, or a two-page spread). let row_rect = row.iter().filter_map(|&i| rects.get(i)).copied().reduce(Rect::union); - // Within a point, so layout rounding can't stop a fitting page from turning. - let fits = row_rect.is_some_and(|r| r.height() + 2.0 * MARGIN <= avail.height() + 1.0); + let fits = row_rect.is_some_and(|r| fits_height(r, avail.height(), ui.ctx().pixels_per_point())); let can_turn = !middle_gesture && unobstructed && ui.rect_contains_pointer(avail); single_paging = can_turn && fits; // Every wheel event goes to the pager, so it follows each trackpad touch to its end @@ -2936,6 +2935,28 @@ fn refine_goto_point(mut scroll: egui::ScrollArea, view: &mut DocView, geometry: scroll } +/// Where the `shown` pages sit in the scroll content, as how far the content starts into the +/// layout and how tall it is. Scrolling continuously every page scrolls; otherwise only the +/// current row does, the content being its bounding box plus margins. Pages that fit a view +/// `view_height` tall sit in its middle, the spare height shared above and below (#5708): the +/// content fills the view, so there is nothing to scroll, and starts on a whole physical pixel +/// as the layout's pages do, or their edges would blur and the seams between them open. +fn vertical_placement(continuous: bool, rects: &[Rect], shown: &[usize], view_height: f32, pixels_per_point: f32) -> (f32, f32) { + let Some(r) = shown.iter().filter_map(|&i| rects.get(i)).copied().reduce(Rect::union) else { return (0.0, 0.0) }; + if fits_height(r, view_height, pixels_per_point) { + return (((r.center().y - view_height / 2.0) * pixels_per_point).round() / pixels_per_point, view_height); + } + if continuous { (0.0, r.bottom() + MARGIN) } else { (r.top() - MARGIN, r.height() + 2.0 * MARGIN) } +} + +/// Whether the pages in `r` show whole in a view `view_height` tall, without their margins. +/// Within half a physical pixel, which is all that snapping the pages to pixels can add: a +/// fitting page is centred and has nothing to scroll, so any more would be cut off unseen. +fn fits_height(r: Rect, view_height: f32, pixels_per_point: f32) -> bool { + let slack = if pixels_per_point.is_finite() && pixels_per_point > 0.0 { 0.5 / pixels_per_point } else { 0.0 }; + r.height() <= view_height + slack +} + /// Draws the document tab at `index`: its notice bars, then its pages (or the Organize grid) in a /// scroll area with this frame's zoom, scrolling, tool input, overlays and render requests. pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) { @@ -3057,15 +3078,7 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) { // row, the jump to the new page scrolled both out of view, a blank frame on every turn forward. let row = shown_row(view, &rects); let visible_pages: Vec = if view.continuous { (0..rects.len()).collect() } else { row.clone() }; - let (y_shift, content_h) = if view.continuous { - (0.0, rects.iter().map(|r| r.bottom()).reduce(f32::max).map(|bottom| bottom + MARGIN).unwrap_or(0.0)) - } else { - // Only the current row scrolls into view; the content is its bounding box plus margins. - match row.iter().filter_map(|&i| rects.get(i)).copied().reduce(Rect::union) { - Some(r) => (r.top() - MARGIN, r.height() + 2.0 * MARGIN), - None => (0.0, 0.0), - } - }; + let (y_shift, content_h) = vertical_placement(view.continuous, &rects, &visible_pages, avail.height(), ui.ctx().pixels_per_point()); let mut scroll = page_scroll_area( ui, diff --git a/crates/ui-egui/src/canvas/tests.rs b/crates/ui-egui/src/canvas/tests.rs index 013d643990..62c00d6b7a 100644 --- a/crates/ui-egui/src/canvas/tests.rs +++ b/crates/ui-egui/src/canvas/tests.rs @@ -2560,3 +2560,23 @@ fn enhancing_a_scan_re_renders_the_page_shown() { assert!(app.scan_clean_run.is_none()); assert_eq!(app.views[i].pages.get(&0).unwrap().tag, STALE_TAG, "the old raster is re-rendered"); } + +/// #5708 review: a page that fits is centred with nothing to scroll, so only pixel snapping may +/// pass for fitting. A page half a pixel or more too tall scrolls, or its ends would be cut off. +#[test] +fn only_pixel_snapping_passes_for_fitting_the_view() { + let page = |height: f32| [Rect::from_min_size(pos2(0.0, MARGIN), vec2(300.0, height))]; + for ppp in [1.0, 2.0] { + let slack = 0.5 / ppp; + let (_, content) = vertical_placement(false, &page(600.0 + slack), &[0], 600.0, ppp); + assert_eq!(content, 600.0, "at {ppp}x, snapping's half pixel still fits"); + for over in [slack + 0.1, 1.0] { + let (shift, content) = vertical_placement(false, &page(600.0 + over), &[0], 600.0, ppp); + assert_eq!((shift, content), (0.0, 600.0 + over + 2.0 * MARGIN), "at {ppp}x, {over} pt too tall scrolls"); + } + } + // A scale that isn't one has no slack and never panics. + for ppp in [0.0, f32::NAN, f32::INFINITY] { + assert!(!fits_height(Rect::from_min_size(pos2(0.0, 0.0), vec2(1.0, 600.5)), 600.0, ppp)); + } +} diff --git a/crates/ui-egui/tests/it/dialog_chrome.rs b/crates/ui-egui/tests/it/dialog_chrome.rs index 47462fcf2b..c495e608ff 100644 --- a/crates/ui-egui/tests/it/dialog_chrome.rs +++ b/crates/ui-egui/tests/it/dialog_chrome.rs @@ -236,25 +236,25 @@ fn page_hover_cursors_stay_off_while_a_dialog_is_open() { let mut app = PdfCraftApp::new(); app.set_option("language", "en").unwrap(); app.open_bytes("form.pdf", None, include_bytes!("../data/form.pdf").to_vec()).unwrap(); - // Small enough that the check box sits above the dialog card. - app.set_option("zoom", "100").unwrap(); + // Large enough that the drop-down sits below the dialog card. + app.set_option("zoom", "300").unwrap(); app }); for _ in 0..30 { h.run_steps(2); } - // The middle of the "agree" check box (/Rect [20 310 36 326] on a 300 × 400 page). + // The middle of the "country" drop-down (/Rect [20 220 200 240] on a 300 × 400 page). let page = h.state().views[0].page_screen_rect(0).expect("on screen"); let k = page.width() / 300.0; - let check = page.min + egui::vec2(28.0 * k, (400.0 - 318.0) * k); - h.hover_at(check); + let field = page.min + egui::vec2(110.0 * k, (400.0 - 230.0) * k); + h.hover_at(field); h.run_steps(2); - assert_eq!(h.output().platform_output.cursor_icon, egui::CursorIcon::PointingHand, "the check box takes clicks"); + assert_eq!(h.output().platform_output.cursor_icon, egui::CursorIcon::PointingHand, "the drop-down takes clicks"); h.state_mut().set_option("dialog", "security").unwrap(); h.run_steps(4); let card = card(&h, "dialog"); - assert!(!card.contains(check), "the check box {check:?} is beside the dialog {card:?}, under its backdrop"); - h.hover_at(check + egui::vec2(1.0, 0.0)); + assert!(!card.contains(field), "the drop-down {field:?} is beside the dialog {card:?}, under its backdrop"); + h.hover_at(field + egui::vec2(1.0, 0.0)); h.run_steps(2); assert_eq!(h.output().platform_output.cursor_icon, egui::CursorIcon::Default, "nothing under the dialog's backdrop can be clicked"); } diff --git a/crates/ui-egui/tests/it/view.rs b/crates/ui-egui/tests/it/view.rs index 59503bba30..f6384127e8 100644 --- a/crates/ui-egui/tests/it/view.rs +++ b/crates/ui-egui/tests/it/view.rs @@ -1345,6 +1345,68 @@ fn single_page_fit_page_fits_the_shown_page() { assert_ne!(h.state().views[0].zoom, z1, "the wider page refits instead of keeping page 1's zoom"); } +#[test] +fn pages_shorter_than_the_window_sit_in_its_middle() { + let centred = |h: &Harness<'static, PdfCraftApp>, r: egui::Rect| { + let vp = h.state().views[0].viewport_rect(); + assert!(r.height() < vp.height(), "{r:?} is shorter than {vp:?}"); + assert!((r.center().y - vp.center().y).abs() < 1.0, "{r:?} is in the middle of {vp:?}"); + }; + let mut h = harness(&[("layout", "single"), ("continuous", "off"), ("zoom", "50")]); + h.run_steps(4); + centred(&h, rect(&h, 0).expect("page 1")); + assert_eq!(h.state().views[0].current, 0); + + // The page drawn there is the page a click finds: its link still goes to page 3. + let r = rect(&h, 0).expect("page 1"); + let at = egui::pos2(r.min.x + r.width() * 0.5, r.min.y + r.height() * (1.0 - 325.0 / 400.0)); + h.hover_at(at); + h.run_steps(2); + h.drag_at(at); // press + h.drop_at(at); // release + h.run_steps(8); + assert_eq!(h.state().views[0].current, 2, "the link is where the page is drawn"); + + // Scrolling continuously, the column of pages as a whole. + let mut h = harness(&[("layout", "single"), ("continuous", "on"), ("zoom", "25")]); + h.run_steps(4); + let column = rect(&h, 0).expect("page 1").union(rect(&h, 2).expect("page 3")); + centred(&h, column); + + // A page taller than the view starts at its top, as before. + let mut h = harness(&[("layout", "single"), ("continuous", "off"), ("zoom", "250")]); + h.run_steps(4); + let (r, vp) = (rect(&h, 0).expect("page 1"), h.state().views[0].viewport_rect()); + assert!(r.height() > vp.height() && (r.min.y - vp.min.y).abs() < 40.0, "{r:?} in {vp:?}"); +} + +/// A page too tall for its margins still fits the window: it shows whole, in the middle. +#[test] +fn a_page_that_fits_without_its_margins_shows_whole() { + let mut h = harness(&[("layout", "single"), ("continuous", "off"), ("zoom", "50")]); + h.run_steps(4); + let (half, vp) = (rect(&h, 0).expect("page 1"), h.state().views[0].viewport_rect()); + let spare = 20.0; + let zoom = (50.0 * (vp.height() - spare) / half.height()).floor(); + h.state_mut().set_option("zoom", &zoom.to_string()).unwrap(); + h.run_steps(4); + let r = rect(&h, 0).expect("page 1"); + assert!(r.height() < vp.height() && r.height() + 2.0 * 28.0 > vp.height(), "{r:?} fits {vp:?} only without its margins"); + assert!(r.min.y >= vp.min.y && r.max.y <= vp.max.y, "{r:?} shows whole in {vp:?}"); + assert!((r.center().y - vp.center().y).abs() < 1.0, "{r:?} is in the middle of {vp:?}"); +} + +/// Two pages side by side are placed as the row they make: the taller decides, not the first. +#[test] +fn a_row_too_tall_for_the_window_starts_at_its_top_whichever_page_is_shorter() { + let h = mixed_harness(&[("layout", "two-page"), ("zoom", "180")]); + let vp = h.state().views[0].viewport_rect(); + let (first, second) = (rect(&h, 0).expect("page 1"), rect(&h, 1).expect("page 2")); + assert!(first.height() > vp.height() && second.height() < vp.height(), "{first:?}, {second:?} in {vp:?}"); + let row = first.union(second); + assert!((row.min.y - vp.min.y).abs() < 40.0, "{row:?} starts in {vp:?}"); +} + #[test] fn clicking_a_link_goes_to_its_destination() { // Default fit-width zoom: pages are taller than the window, so the target can scroll to the top. diff --git a/docs/roadmap.md b/docs/roadmap.md index 7ce026b3a2..ac9bcbc9e1 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -324,6 +324,7 @@ Estimates unchanged. | 2026-11-03 | patch | M5 #5850 review: indirect typewriter intent and composer edits preserve outer whitespace; movement regression commits by outside click; estimates unchanged. | | 2026-11-03 | patch | M5 #5850: Enter adds Fill & Sign line breaks; outside clicks commit, Escape cancels, and saved multiline appearances retain their text and height; estimates unchanged. | | 2026-11-02 | patch | M10 #5743: macOS print jobs carry the job name and owner some vendors' filters need; not yet checked on a physical printer | +| 2026-11-02 | patch | M3 #5708: pages shorter than the window sit in its middle, on a whole physical pixel | | 2026-11-02 | patch | M14 #5737, #5460: Preferences turns off the update check at start, on by default | | 2026-11-01 | patch | M7 #5657: header/footer, watermark and background operations commit all selected pages together; later-page errors preserve marks, content, shared/inherited resources and pending edits; estimates unchanged. | | 2026-10-31 | patch | #1577: reconcile optional page shadows with current viewer/organizer and settings modules; CI validation pending. | diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index 6405daf739..e0c7adf36a 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -878,8 +878,8 @@ milestone = "M3" status = "shipped" commands = ["view.layout.single"] tools = [] -evidence = ["crates/ui-egui/tests/it/view.rs::single_page_layout_shows_one_page_at_a_time", "crates/ui-egui/tests/it/view.rs::single_page_wheel_pans_instead_when_zoomed_in", "crates/ui-egui/tests/it/view.rs::wheel_turns_one_page_per_notch_and_per_trackpad_swipe", "crates/ui-egui/tests/it/view.rs::single_page_wheel_at_the_bottom_edge_turns_to_the_next_page_top_and_back", "crates/ui-egui/tests/it/view.rs::single_page_swipe_that_reaches_the_edge_does_not_turn_but_a_fresh_one_does", "crates/ui-egui/tests/it/view.rs::rail_page_display_button_switches_layouts", "crates/ui-egui/tests/it/view.rs::view_menu_page_display_uses_the_same_commands"] -notes = "when the whole page fits, each wheel notch turns a page and each trackpad swipe turns one, momentum included; zoomed in so the page is taller than the window, the wheel pans, and at the bottom (top) edge a wheel notch, or a fresh trackpad swipe, shows the next page at its top (the previous page at its bottom); a swipe that panned to the edge, momentum included, does not turn until the fingers lift and swipe again (#1924). Not verified against Acrobat's own thresholds. Switched from the View menu, the rail's Page display button or the palette" +evidence = ["crates/ui-egui/tests/it/view.rs::pages_shorter_than_the_window_sit_in_its_middle", "crates/ui-egui/tests/it/view.rs::a_page_that_fits_without_its_margins_shows_whole", "crates/ui-egui/tests/it/view.rs::a_row_too_tall_for_the_window_starts_at_its_top_whichever_page_is_shorter", "crates/ui-egui/tests/it/view.rs::single_page_layout_shows_one_page_at_a_time", "crates/ui-egui/tests/it/view.rs::single_page_wheel_pans_instead_when_zoomed_in", "crates/ui-egui/tests/it/view.rs::wheel_turns_one_page_per_notch_and_per_trackpad_swipe", "crates/ui-egui/tests/it/view.rs::single_page_wheel_at_the_bottom_edge_turns_to_the_next_page_top_and_back", "crates/ui-egui/tests/it/view.rs::single_page_swipe_that_reaches_the_edge_does_not_turn_but_a_fresh_one_does", "crates/ui-egui/tests/it/view.rs::rail_page_display_button_switches_layouts", "crates/ui-egui/tests/it/view.rs::view_menu_page_display_uses_the_same_commands"] +notes = "a page or row shorter than the window sits in its middle, the spare height shared above and below, continuous scrolling included when every page fits (#5708); when the whole page fits, each wheel notch turns a page and each trackpad swipe turns one, momentum included; zoomed in so the page is taller than the window, the wheel pans, and at the bottom (top) edge a wheel notch, or a fresh trackpad swipe, shows the next page at its top (the previous page at its bottom); a swipe that panned to the edge, momentum included, does not turn until the fingers lift and swipe again (#1924). Not verified against Acrobat's own thresholds. Switched from the View menu, the rail's Page display button or the palette" [[feature]] id = "view.layout-two-up" From 607f79e5e078f533c814871072179ada0fb5dccb Mon Sep 17 00:00:00 2001 From: mtlcl410 <2368091+mtlcl410@users.noreply.github.com> Date: Mon, 9 Nov 2026 08:39:01 +0800 Subject: [PATCH 31/92] M3: a protected file named before others at launch keeps its password prompt (#5756) (#5762) --- crates/ui-egui/src/app.rs | 6 +- crates/ui-egui/src/app/files.rs | 107 +++++++++++-- crates/ui-egui/src/control/host.rs | 11 +- crates/ui-egui/src/lib.rs | 18 +++ crates/ui-egui/src/platform/open_worker.rs | 2 +- .../ui-egui/src/platform/open_worker/tests.rs | 17 ++ crates/ui-egui/src/platform/pickers.rs | 1 + crates/ui-egui/tests/it/editing.rs | 147 ++++++++++++++++++ 8 files changed, 291 insertions(+), 18 deletions(-) diff --git a/crates/ui-egui/src/app.rs b/crates/ui-egui/src/app.rs index 91bad575c4..e37b199df8 100644 --- a/crates/ui-egui/src/app.rs +++ b/crates/ui-egui/src/app.rs @@ -144,6 +144,10 @@ impl PdfCraftApp { #[cfg(not(target_arch = "wasm32"))] pending_opens: Vec::new(), #[cfg(not(target_arch = "wasm32"))] + deferred_opens: Default::default(), + #[cfg(not(target_arch = "wasm32"))] + password_retry: None, + #[cfg(not(target_arch = "wasm32"))] next_open_token: 0, #[cfg(not(target_arch = "wasm32"))] launch_layout: None, @@ -537,7 +541,7 @@ impl eframe::App for PdfCraftApp { // Don't replace a pending prompt or import. A startup file may need its own password // prompt, so its bytes stay queued until the current interaction finishes. #[cfg(not(target_arch = "wasm32"))] - let opening = !self.pending_opens.is_empty(); + let opening = !self.pending_opens.is_empty() || !self.deferred_opens.is_empty(); #[cfg(target_arch = "wasm32")] let opening = false; if !opening diff --git a/crates/ui-egui/src/app/files.rs b/crates/ui-egui/src/app/files.rs index 76dbfc3921..c8ac133a00 100644 --- a/crates/ui-egui/src/app/files.rs +++ b/crates/ui-egui/src/app/files.rs @@ -1,6 +1,17 @@ use super::*; use crate::file_filter::AddFilterAnyCase as _; +/// Whether an open's reply asks for a password: the file is encrypted and none, or a wrong one, +/// was given. +#[cfg(not(target_arch = "wasm32"))] +fn asks_for_password(kind: &open_worker::ResultKind) -> bool { + matches!( + kind, + open_worker::ResultKind::Prepared { result, .. } + if matches!(**result, Err(pdfcraft_render::OpenError::NeedsPassword | pdfcraft_render::OpenError::WrongPassword)) + ) +} + /// Whether `a` and `b` name the same file: equal, or equal once made absolute against the /// current directory (links are not resolved, as in `last_session`). pub(crate) fn same_file(a: &str, b: &str) -> bool { @@ -122,16 +133,36 @@ impl PdfCraftApp { recovered: request.recovered, then: request.then, display_override: None, + ready: None, }); Ok(token) } + /// Hand waiting files to the worker, oldest first, while its queue has room. Every queued + /// job has a `pending_opens` entry, so fewer entries than the bound means room. + #[cfg(not(target_arch = "wasm32"))] + fn submit_deferred_opens(&mut self) { + let mut queue = std::mem::take(&mut self.deferred_opens); + while self.deferred_opens.is_empty() && self.pending_opens.len() < open_worker::MAX_QUEUED_OPENS { + let Some(next) = queue.pop_front() else { break }; + self.open_path_request(&next.path, next.then, next.display_override); + } + // A file put back (the queue filled again) stays ahead of the ones behind it. + self.deferred_opens.append(&mut queue); + } + #[cfg(not(target_arch = "wasm32"))] pub(crate) fn poll_pending_opens(&mut self) { let pending_opens = std::mem::take(&mut self.pending_opens); + // A retry that is no longer waiting holds nothing back. + if let Some(token) = self.password_retry + && !pending_opens.iter().any(|pending| pending.token == token) + { + self.password_retry = None; + } let mut remaining = pending_opens.into_iter(); let mut waiting = Vec::new(); - while let Some(pending) = remaining.next() { + while let Some(mut pending) = remaining.next() { // A tool, password prompt or file picker belongs to the document just admitted. // Keep later replies queued so another open cannot change its recipient. if self.modal_open() || self.pickers.pending() { @@ -139,19 +170,41 @@ impl PdfCraftApp { waiting.extend(remaining); break; } - match pending.receiver.try_recv() { - Ok(result) if result.token == pending.token => self.finish_pending_open(pending, result.kind), - Ok(_) => waiting.push(pending), - Err(std::sync::mpsc::TryRecvError::Empty) => waiting.push(pending), - Err(std::sync::mpsc::TryRecvError::Disconnected) => { - self.notify_fmt("Couldn't open {name}: the background worker stopped", &[("name", &pending.name)]); - } + let retry = self.password_retry == Some(pending.token); + let kind = match pending.ready.take() { + Some(kind) => kind, + None => match pending.receiver.try_recv() { + Ok(result) if result.token == pending.token => result.kind, + Ok(_) | Err(std::sync::mpsc::TryRecvError::Empty) => { + waiting.push(pending); + continue; + } + Err(std::sync::mpsc::TryRecvError::Disconnected) => { + if retry { + self.password_retry = None; + } + self.notify_fmt("Couldn't open {name}: the background worker stopped", &[("name", &pending.name)]); + continue; + } + }, + }; + // While a closed prompt's password is being tried, a later file that asks for its own + // password keeps its reply: its prompt would hide the retry's outcome. Others admit. + if !retry && self.password_retry.is_some() && asks_for_password(&kind) { + pending.ready = Some(kind); + waiting.push(pending); + continue; } + if retry { + self.password_retry = None; + } + self.finish_pending_open(pending, kind); } // A completion may schedule another open; preserve those requests as well as the // earlier waiting replies, without consuming a blocked receiver's result. waiting.append(&mut self.pending_opens); self.pending_opens = waiting; + self.submit_deferred_opens(); self.settle_launch_layout(); } @@ -452,6 +505,9 @@ impl PdfCraftApp { && let Some(pending) = self.pending_opens.iter_mut().find(|pending| pending.token == *token) { pending.display_override = p.display_override; + // Replies behind it that ask for a password wait for its outcome + // (`poll_pending_opens`). + self.password_retry = Some(*token); } if let Err(error) = result { let mut prompt = p; @@ -594,10 +650,24 @@ impl PdfCraftApp { } return OpenOutcome::Pending; } - if display_override.is_some() - && let Some(prompt) = self.password_prompt.as_mut().filter(|p| p.path.as_deref().is_some_and(|p| same_file(p, path))) - { - prompt.display_override = display_override; + // The same file named again while its password is being asked for joins that prompt + // instead of queuing a second one; a display override or a tool it brings is kept. + if let Some(prompt) = self.password_prompt.as_mut().filter(|p| p.path.as_deref().is_some_and(|p| same_file(p, path))) { + if display_override.is_some() { + prompt.display_override = display_override; + } + if then.is_some() { + prompt.then = then; + } + return OpenOutcome::Pending; + } + if let Some(deferred) = self.deferred_opens.iter_mut().find(|d| same_file(&d.path, path)) { + if display_override.is_some() { + deferred.display_override = display_override; + } + if then.is_some() { + deferred.then = then; + } return OpenOutcome::Pending; } let name = std::path::Path::new(path).file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_else(|| path.to_string()); @@ -606,8 +676,10 @@ impl PdfCraftApp { // Before the first frame there is no interaction to keep responsive; this also preserves // deterministic inline startup and test flows. Once live, reading and preparation use // the bounded worker and commit on the UI thread. - // Keep flagged launch files behind an existing password prompt or picker. - if (self.ctx.is_none() || self.run_inline) && !(display_override.is_some() && (self.modal_open() || self.pickers.pending())) { + // Keep later files behind an open password prompt: it belongs to an earlier file, and an + // inline open would clear it. Keep flagged launch files behind any dialog or picker too. + let held = self.password_prompt.is_some() || (display_override.is_some() && (self.modal_open() || self.pickers.pending())); + if (self.ctx.is_none() || self.run_inline) && !held { match std::fs::read(path) { Ok(bytes) => { let before = self.views.len(); @@ -649,6 +721,12 @@ impl PdfCraftApp { } } else { self.startup_superseded = true; + // The worker queue is bounded. A long file list (a launch behind a password prompt, + // or many files at once) waits here, in order, and is submitted as opens finish. + if self.pending_opens.len() >= open_worker::MAX_QUEUED_OPENS || !self.deferred_opens.is_empty() { + self.deferred_opens.push_back(DeferredOpen { path: path.to_string(), then, display_override }); + return OpenOutcome::Pending; + } match self.schedule_open(OpenRequest { name: name.clone(), path: Some(path.to_string()), @@ -686,6 +764,7 @@ impl PdfCraftApp { #[cfg(not(target_arch = "wasm32"))] fn settle_launch_layout(&mut self) { if self.pending_opens.iter().any(|open| open.display_override.is_some()) + || self.deferred_opens.iter().any(|open| open.display_override.is_some()) || self.password_prompt.as_ref().is_some_and(|prompt| prompt.display_override.is_some()) { return; diff --git a/crates/ui-egui/src/control/host.rs b/crates/ui-egui/src/control/host.rs index c2e82f98e5..12c98007cc 100644 --- a/crates/ui-egui/src/control/host.rs +++ b/crates/ui-egui/src/control/host.rs @@ -67,8 +67,15 @@ impl Host for crate::PdfCraftApp { }) .collect(); #[cfg(not(target_arch = "wasm32"))] - let pending_opens: Vec = - self.pending_opens.iter().map(|pending| json!({ "token": pending.token, "name": pending.name, "path": pending.path })).collect(); + let pending_opens: Vec = self + .pending_opens + .iter() + .map(|pending| json!({ "token": pending.token, "name": pending.name, "path": pending.path })) + .chain(self.deferred_opens.iter().map(|deferred| { + let name = std::path::Path::new(&deferred.path).file_name().map(|n| n.to_string_lossy().into_owned()); + json!({ "token": null, "name": name, "path": deferred.path }) + })) + .collect(); #[cfg(target_arch = "wasm32")] let pending_opens: Vec = Vec::new(); json!({ diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 1663c3be73..23625cb97b 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -262,6 +262,17 @@ struct PendingOpen { recovered: Option, then: Option<&'static str>, display_override: Option<(canvas::PageLayout, bool)>, + /// A reply taken from `receiver` and kept back for later (a password prompt waiting for an + /// earlier file's password retry). + ready: Option, +} + +/// A file named while the worker's queue was full: submitted once it has room. +#[cfg(not(target_arch = "wasm32"))] +struct DeferredOpen { + path: String, + then: Option<&'static str>, + display_override: Option<(canvas::PageLayout, bool)>, } /// Puts `(html, plain_text)` on a clipboard; `false` when it couldn't, and plain-text Copy is @@ -419,6 +430,13 @@ pub struct PdfCraftApp { open_worker: Option, #[cfg(not(target_arch = "wasm32"))] pending_opens: Vec, + /// Files waiting for room in the worker's bounded queue, in the order they were named. + #[cfg(not(target_arch = "wasm32"))] + deferred_opens: std::collections::VecDeque, + /// The open retrying a closed password prompt's file with its password: replies behind it + /// wait for it. + #[cfg(not(target_arch = "wasm32"))] + password_retry: Option, #[cfg(not(target_arch = "wasm32"))] next_open_token: u64, /// Explicit CLI layout waits for flagged launch opens, never targeting restored tabs. diff --git a/crates/ui-egui/src/platform/open_worker.rs b/crates/ui-egui/src/platform/open_worker.rs index fe564db398..274071ea78 100644 --- a/crates/ui-egui/src/platform/open_worker.rs +++ b/crates/ui-egui/src/platform/open_worker.rs @@ -9,7 +9,7 @@ use crate::RecoveryStore; use pdfcraft_engine::{OpenContext, PreparedDocument, SourceKind}; use pdfcraft_render::OpenError; -const MAX_QUEUED_OPENS: usize = 16; +pub(crate) const MAX_QUEUED_OPENS: usize = 16; pub(crate) enum Input { Path(PathBuf), diff --git a/crates/ui-egui/src/platform/open_worker/tests.rs b/crates/ui-egui/src/platform/open_worker/tests.rs index c0fe2f03a2..efe911bff6 100644 --- a/crates/ui-egui/src/platform/open_worker/tests.rs +++ b/crates/ui-egui/src/platform/open_worker/tests.rs @@ -763,6 +763,7 @@ fn launch_layout_waits_for_cli_opens_and_preserves_restored_tabs() { recovered: None, then: None, display_override: Some((PageLayout::TwoUp, false)), + ready: None, }); replies.push(( sender, @@ -820,6 +821,7 @@ fn launch_layout_failed_or_cancelled_opens_do_not_target_existing_documents() { recovered: None, then: None, display_override: Some((PageLayout::TwoUp, false)), + ready: None, }); app.set_launch_layout("single").unwrap(); assert!(app.set_launch_layout("invalid").is_err()); @@ -904,3 +906,18 @@ fn a_new_file_from_the_os_brings_the_window_forward_before_it_is_prepared() { assert_eq!(doc.info.pages.len(), 2); let _ = std::fs::remove_file(path); } + +/// A file waiting for room in the open queue, named again with a tool (Open and run), keeps +/// that tool; a later repeat without one doesn't drop it. +#[test] +fn a_repeated_waiting_open_keeps_the_tool_it_brings() { + let file = tool_open_file("deferred-tool", "pdf", &fixture(1)); + let path = file.0.to_string_lossy().into_owned(); + let mut app = PdfCraftApp::new(); + app.deferred_opens.push_back(crate::DeferredOpen { path: path.clone(), then: None, display_override: None }); + assert_eq!(app.open_path_then(&path, Some("export.text")), crate::OpenOutcome::Pending); + assert_eq!(app.deferred_opens.len(), 1); + assert_eq!(app.deferred_opens.front().and_then(|d| d.then), Some("export.text")); + assert_eq!(app.open_path(&path), crate::OpenOutcome::Pending); + assert_eq!(app.deferred_opens.front().and_then(|d| d.then), Some("export.text")); +} diff --git a/crates/ui-egui/src/platform/pickers.rs b/crates/ui-egui/src/platform/pickers.rs index 80d44d975b..c680eaeaba 100644 --- a/crates/ui-egui/src/platform/pickers.rs +++ b/crates/ui-egui/src/platform/pickers.rs @@ -377,6 +377,7 @@ mod tests { recovered: None, then: None, display_override: None, + ready: None, }); let got = Arc::new(Mutex::new(Vec::new())); app.pickers.showing.store(true, Ordering::SeqCst); diff --git a/crates/ui-egui/tests/it/editing.rs b/crates/ui-egui/tests/it/editing.rs index 4f3e15bd5d..500d504403 100644 --- a/crates/ui-egui/tests/it/editing.rs +++ b/crates/ui-egui/tests/it/editing.rs @@ -2727,6 +2727,153 @@ fn canceling_launch_display_override_does_not_affect_later_documents() { std::fs::remove_file(path).unwrap(); } +/// `pdfcraft locked.pdf other.pdf`: the files open before the first frame, and opening the +/// second one inline used to clear the first one's password prompt, so `locked.pdf` was never +/// opened or asked for. The later file now waits behind the prompt. +#[test] +fn a_startup_file_after_a_protected_one_keeps_its_password_prompt() { + for unlock in [true, false] { + let nonce = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_nanos(); + let locked = temp_path(&format!("startup-locked-{unlock}-{nonce}.pdf")); + let other = temp_path(&format!("startup-other-{unlock}-{nonce}.pdf")); + std::fs::write(&locked, protected("reader", "owner", -4)).unwrap(); + std::fs::write(&other, fixture(2)).unwrap(); + let (locked_path, other_path) = (locked.to_str().unwrap().to_string(), other.to_str().unwrap().to_string()); + let (first, second) = (locked_path.clone(), other_path.clone()); + let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).build_eframe(move |_| { + let mut app = PdfCraftApp::new(); + app.set_option("language", "en").unwrap(); + app.open_path(&first); + app.open_path(&second); + app + }); + h.run_steps(4); + let prompt = h.state().password_prompt.as_ref().map(|p| p.path.clone()); + assert_eq!(prompt, Some(Some(locked_path.clone())), "the protected file is asked for"); + assert!(h.state().views.is_empty(), "the later file waits behind the prompt"); + if unlock { + h.state_mut().submit_password(Some("reader".into())); + support::wait_until(&mut h, "both files", |app| app.views.len() == 2); + // The other file opened while the password was being typed; the unlocked one comes + // after it and is the one in view. + assert_eq!(open_paths(h.state()), vec![other_path, locked_path]); + assert_eq!(h.state().active_ids().map(|(i, _)| i), Some(1)); + } else { + h.state_mut().submit_password(None); + support::wait_until(&mut h, "the other file", |app| app.views.len() == 1); + assert_eq!(open_paths(h.state()), vec![other_path]); + } + std::fs::remove_file(locked).unwrap(); + std::fs::remove_file(other).unwrap(); + } +} + +/// `pdfcraft locked.pdf locked.pdf other.pdf`: the repeated file joins its open password +/// prompt. It used to be queued again behind the prompt, so the unlocked file asked for its +/// password a second time. +#[test] +fn a_protected_file_named_twice_at_startup_asks_for_its_password_once() { + let nonce = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_nanos(); + let locked = temp_path(&format!("startup-twice-locked-{nonce}.pdf")); + let other = temp_path(&format!("startup-twice-other-{nonce}.pdf")); + std::fs::write(&locked, protected("reader", "owner", -4)).unwrap(); + std::fs::write(&other, fixture(2)).unwrap(); + let (locked_path, other_path) = (locked.to_str().unwrap().to_string(), other.to_str().unwrap().to_string()); + let (first, second) = (locked_path.clone(), other_path.clone()); + let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).build_eframe(move |_| { + let mut app = PdfCraftApp::new(); + app.set_option("language", "en").unwrap(); + app.open_path(&first); + app.open_path(&first); + app.open_path(&second); + app + }); + h.run_steps(4); + assert!(h.state().password_prompt.is_some(), "the protected file is asked for"); + h.state_mut().submit_password(Some("reader".into())); + support::wait_until(&mut h, "both files, or a second prompt", |app| app.views.len() == 2 || app.password_prompt.is_some()); + assert!(h.state().password_prompt.is_none(), "the password is asked for once"); + let mut paths = open_paths(h.state()); + paths.sort(); + let mut expected = vec![locked_path, other_path]; + expected.sort(); + assert_eq!(paths, expected); + std::fs::remove_file(locked).unwrap(); + std::fs::remove_file(other).unwrap(); +} + +/// `pdfcraft locked-a.pdf locked-b.pdf`: B's reply is ready while A's password is asked for. +/// Once A's password is given, A's retry comes first: A opens (or asks again) before B's prompt +/// can take the dialog. B's prompt used to replace A's retry, so A waited for B's answer. +#[test] +fn a_password_retry_finishes_before_the_next_protected_files_prompt() { + let nonce = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_nanos(); + let a = temp_path(&format!("startup-retry-a-{nonce}.pdf")); + let b = temp_path(&format!("startup-retry-b-{nonce}.pdf")); + std::fs::write(&a, protected("reader", "owner", -4)).unwrap(); + std::fs::write(&b, protected("reader", "owner", -4)).unwrap(); + let (a_path, b_path) = (a.to_str().unwrap().to_string(), b.to_str().unwrap().to_string()); + let (first, second) = (a_path.clone(), b_path.clone()); + let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).build_eframe(move |_| { + let mut app = PdfCraftApp::new(); + app.set_option("language", "en").unwrap(); + app.open_path(&first); + app.open_path(&second); + app + }); + h.run_steps(4); + // B is prepared in the background meanwhile; its reply waits behind A's prompt. + std::thread::sleep(std::time::Duration::from_millis(300)); + h.run_steps(2); + assert_eq!(h.state().password_prompt.as_ref().and_then(|p| p.path.clone()), Some(a_path.clone())); + h.state_mut().submit_password(Some("reader".into())); + support::wait_until(&mut h, "the next prompt", |app| app.password_prompt.is_some()); + assert_eq!(open_paths(h.state()), vec![a_path], "A's retry finished first"); + assert_eq!(h.state().password_prompt.as_ref().and_then(|p| p.path.clone()), Some(b_path)); + std::fs::remove_file(a).unwrap(); + std::fs::remove_file(b).unwrap(); +} + +/// `pdfcraft locked.pdf` followed by more files than the open worker queues at once: the files +/// past its bound wait in order and open as it drains, instead of failing to open. +#[test] +fn a_long_startup_list_behind_a_password_prompt_opens_every_file() { + const OTHERS: usize = 24; + let nonce = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_nanos(); + let locked = temp_path(&format!("startup-long-locked-{nonce}.pdf")); + std::fs::write(&locked, protected("reader", "owner", -4)).unwrap(); + let others: Vec = (0..OTHERS).map(|i| temp_path(&format!("startup-long-{i:02}-{nonce}.pdf"))).collect(); + for path in &others { + std::fs::write(path, fixture(1)).unwrap(); + } + let locked_path = locked.to_str().unwrap().to_string(); + let other_paths: Vec = others.iter().map(|p| p.to_str().unwrap().to_string()).collect(); + let (first, rest) = (locked_path.clone(), other_paths.clone()); + let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).build_eframe(move |_| { + let mut app = PdfCraftApp::new(); + app.set_option("language", "en").unwrap(); + app.open_path(&first); + for path in &rest { + app.open_path(path); + } + app + }); + h.run_steps(4); + assert!(h.state().password_prompt.is_some(), "the protected file is asked for"); + h.state_mut().submit_password(Some("reader".into())); + support::wait_until(&mut h, "every file", |app| app.views.len() == OTHERS + 1); + let mut paths = open_paths(h.state()); + paths.sort(); + let mut expected = other_paths; + expected.push(locked_path); + expected.sort(); + assert_eq!(paths, expected); + std::fs::remove_file(locked).unwrap(); + for path in others { + std::fs::remove_file(path).unwrap(); + } +} + #[test] fn password_prompt_opens_and_security_tab_reports_the_details() { let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).build_eframe(|_cc| { From 67a6037be1319a68569ac74de1d829e281fefa9d Mon Sep 17 00:00:00 2001 From: NetterSilver <133535268+Silvertree2010@users.noreply.github.com> Date: Mon, 9 Nov 2026 01:39:16 +0100 Subject: [PATCH 32/92] Keep the font size when making text box properties default (#5784) --- crates/ui-egui/src/app/prefs.rs | 16 +++++ crates/ui-egui/src/dialogs/comment_props.rs | 5 +- crates/ui-egui/src/tools/comments.rs | 34 ++++++++++ crates/ui-egui/src/tools/comments/composer.rs | 12 ++-- crates/ui-egui/tests/it/comments.rs | 64 +++++++++++++++++++ 5 files changed, 125 insertions(+), 6 deletions(-) diff --git a/crates/ui-egui/src/app/prefs.rs b/crates/ui-egui/src/app/prefs.rs index 1a8c2d269e..aa40ea0d24 100644 --- a/crates/ui-egui/src/app/prefs.rs +++ b/crates/ui-egui/src/app/prefs.rs @@ -127,6 +127,12 @@ impl PdfCraftApp { "border_shape": s.border_shape.name(), })) .collect::>(), + // Default font sizes of new text boxes and callouts (#5767). + "comment_font_sizes": self + .comment_prefs + .font_sizes() + .map(|(t, size)| serde_json::json!({ "tool": t.command(), "font_size": size })) + .collect::>(), // Drawn signatures keep their original form (older settings read the same). "signature": match &self.signature { Some(fill_sign::SavedSig::Drawn(s)) => Some(s), _ => None }, "signature_text": match &self.signature { Some(fill_sign::SavedSig::Typed(t)) => Some(t), _ => None }, @@ -420,6 +426,16 @@ impl PdfCraftApp { } } } + // Default font sizes (#5767), clamped by the setter; a missing or malformed size keeps + // the tool's own. + if let Some(sizes) = v["comment_font_sizes"].as_array() { + for e in sizes { + let Some(tool) = e["tool"].as_str().and_then(crate::comments::CommentTool::from_command) else { continue }; + if let Some(size) = e["font_size"].as_f64() { + self.comment_prefs.set_font_size(tool, size); + } + } + } } /// Saved signatures, stamps, actions and trusted certificates. diff --git a/crates/ui-egui/src/dialogs/comment_props.rs b/crates/ui-egui/src/dialogs/comment_props.rs index 12752dde3c..7b404c2f76 100644 --- a/crates/ui-egui/src/dialogs/comment_props.rs +++ b/crates/ui-egui/src/dialogs/comment_props.rs @@ -97,7 +97,7 @@ impl PdfCraftApp { } /// New comments of the kind at `(page, index)` take the look in `p`: its colour, opacity, - /// line width and (for shapes) fill. `p` may be a Comment Properties draft not applied yet. + /// line width, (for shapes) fill and (for text boxes and callouts) font size. `p` may be a Comment Properties draft not applied yet. pub fn make_props_default(&mut self, page: usize, index: usize, p: &CommentProps) { let Some((_, id)) = self.active_ids() else { return }; let Some(doc) = self.session.get(id) else { return }; @@ -143,6 +143,9 @@ impl PdfCraftApp { if let Some(w) = p.width { self.comment_prefs.set_width(tool, w); } + if let Some(size) = p.font_size { + self.comment_prefs.set_font_size(tool, size); + } self.notify_fmt("New {tool} comments will look like this one", &[("tool", &crate::i18n::in_sentence(tl!(tool.label())))]); } diff --git a/crates/ui-egui/src/tools/comments.rs b/crates/ui-egui/src/tools/comments.rs index 56301969a2..bfd0592a08 100644 --- a/crates/ui-egui/src/tools/comments.rs +++ b/crates/ui-egui/src/tools/comments.rs @@ -56,6 +56,9 @@ const SELECT_BLUE: Color32 = Color32::from_rgb(0x14, 0x73, 0xE6); /// Maximum stroke thickness offered by commenting controls, in PDF points. pub const MAX_LINE_WIDTH: f64 = 100.0; +/// The font sizes a text box or callout default may take (Comment Properties' range). +const MIN_FONT_SIZE: f64 = 1.0; +const MAX_FONT_SIZE: f64 = 400.0; #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] pub enum CommentTool { @@ -258,6 +261,14 @@ impl CommentTool { matches!(self, Self::Rectangle | Self::Oval | Self::Polygon | Self::Cloud | Self::TextBox | Self::Callout) } + /// The font size new comments of this kind start with (`None`: no text). + fn default_font_size(self) -> Option { + match self.sample() { + Shape::TextBox { font_size, .. } | Shape::Callout { font_size, .. } => Some(font_size), + _ => None, + } + } + /// A placeholder shape of this kind (for per-tool default styles). fn sample(self) -> Shape { match self { @@ -309,6 +320,8 @@ pub struct CommentPrefs { /// `/T` of new comments (Acrobat: Preferences ▸ Identity, default the login name). pub author: String, styles: Vec<(CommentTool, Style)>, + /// The font size of new text boxes and callouts (Make Properties Default). + font_sizes: Vec<(CommentTool, f64)>, /// Keep the tool selected after use (the pin). pub pinned: bool, /// The tool each flyout group shows (the last one used). @@ -322,6 +335,7 @@ impl Default for CommentPrefs { Self { author: login_name(), styles: ALL.iter().map(|t| (*t, Style::default_for(&t.sample()))).collect(), + font_sizes: ALL.iter().filter_map(|t| t.default_font_size().map(|s| (*t, s))).collect(), pinned: false, group_tool: [CommentTool::Note, CommentTool::Highlight, CommentTool::Ink], hide_empty_highlight_hover: false, @@ -390,6 +404,26 @@ impl CommentPrefs { } } + /// The font size of new comments of `tool` (`None`: the tool has no text). + pub fn font_size(&self, tool: CommentTool) -> Option { + self.font_sizes.iter().find(|(t, _)| *t == tool).map(|(_, s)| *s) + } + + /// Every text tool with its default font size (the persisted settings read this). + pub fn font_sizes(&self) -> impl Iterator + '_ { + self.font_sizes.iter().copied() + } + + /// Clamp and store the font size (1–400 pt, as Comment Properties allows) for a text tool; + /// a non-finite size keeps the current one. + pub fn set_font_size(&mut self, tool: CommentTool, size: f64) { + if let Some((_, s)) = self.font_sizes.iter_mut().find(|(t, _)| *t == tool) + && size.is_finite() + { + *s = size.clamp(MIN_FONT_SIZE, MAX_FONT_SIZE); + } + } + pub fn set_style(&mut self, tool: CommentTool, style: Style) { if let Some((_, s)) = self.styles.iter_mut().find(|(t, _)| *t == tool) { *s = style; diff --git a/crates/ui-egui/src/tools/comments/composer.rs b/crates/ui-egui/src/tools/comments/composer.rs index 5ae441a0cd..41c3a1e6c5 100644 --- a/crates/ui-egui/src/tools/comments/composer.rs +++ b/crates/ui-egui/src/tools/comments/composer.rs @@ -125,16 +125,18 @@ pub(crate) fn composer(ctx: &egui::Context, view: &mut DocView, info: &DocInfo, view.comments.tool_done = true; let rotation = page_rotation(info, page); let border = prefs.style(CommentTool::TextBox).width; + let font_size = prefs.font_size(CommentTool::TextBox).unwrap_or(12.0); let rect = match (size, info.pages.get(page)) { - (Some(size), Some(p)) => drawn_text_box_rect(p, c.at, size, &text, 12.0, border), - _ => text_box_rect(c.at, &text, 12.0, rotation), + (Some(size), Some(p)) => drawn_text_box_rect(p, c.at, size, &text, font_size, border), + _ => text_box_rect(c.at, &text, font_size, rotation), }; - Some(new_comment(&cx, CommentTool::TextBox, Shape::TextBox { rect, font_size: 12.0 }, text)) + Some(new_comment(&cx, CommentTool::TextBox, Shape::TextBox { rect, font_size }, text)) } ComposerKind::Callout { point } => { view.comments.tool_done = true; // A callout's leader and box stay in user-space axes (its appearance isn't turned). - let rect = text_box_rect(c.at, &text, 10.0, 0); + let font_size = prefs.font_size(CommentTool::Callout).unwrap_or(10.0); + let rect = text_box_rect(c.at, &text, font_size, 0); let side = if point[0] < rect[0] { rect[0] } else if point[0] > rect[2] { @@ -143,7 +145,7 @@ pub(crate) fn composer(ctx: &egui::Context, view: &mut DocView, info: &DocInfo, (rect[0] + rect[2]) / 2.0 }; let knee = [(point[0] + side) / 2.0, (rect[1] + rect[3]) / 2.0]; - Some(new_comment(&cx, CommentTool::Callout, Shape::Callout { rect, knee, point, font_size: 10.0, ending: LineEnding::OpenArrow }, text)) + Some(new_comment(&cx, CommentTool::Callout, Shape::Callout { rect, knee, point, font_size, ending: LineEnding::OpenArrow }, text)) } ComposerKind::Replace => { view.comments.tool_done = true; diff --git a/crates/ui-egui/tests/it/comments.rs b/crates/ui-egui/tests/it/comments.rs index 56984b6882..532c45de91 100644 --- a/crates/ui-egui/tests/it/comments.rs +++ b/crates/ui-egui/tests/it/comments.rs @@ -4355,3 +4355,67 @@ fn escape_with_a_fill_tool_deselects_every_comment_then_drops_the_tool() { h.run_steps(2); assert_eq!(h.state().quick_tool, QuickTool::Select); } + +/// #5767: Make Properties Default on a text box or callout keeps its font size too, so the next +/// one (also after a restart) takes it, along with the colour. +#[test] +fn properties_dialog_makes_the_text_box_font_size_default() { + let blue = [0.0, 0.47, 0.84]; + let mut h = harness(|_| {}); + let font_size_of_last = |h: &Harness<'static, PdfCraftApp>| { + let s = h.state(); + let doc = s.session.get(s.views[0].id).unwrap(); + let last = doc.info.annotations.len() - 1; + doc.comment_props(0, last).unwrap() + }; + for (quick, size, first, second, third) in + [("freetext", 24.0, (40.0, 60.0), (40.0, 120.0), (40.0, 100.0)), ("callout", 18.0, (150.0, 40.0), (150.0, 100.0), (150.0, 60.0))] + { + h.state_mut().set_option("quick", quick).unwrap(); + if quick == "callout" { + drag_pt(&mut h, first, (first.0 + 60.0, first.1 + 20.0)); + } else { + click_pt(&mut h, first); + } + post_text(&mut h, "First"); + let index = comments(&h).len() - 1; + h.state_mut().open_comment_props(0, index); + h.run_steps(2); + { + let d = h.state_mut().comment_props.as_mut().expect("open"); + d.edited.font_size = Some(size); + d.edited.color = Some(blue); + } + h.get_by_label("Make Properties Default").click(); + h.run_steps(1); + h.get_by_label("OK").click(); + h.run_steps(3); + assert_eq!(font_size_of_last(&h).font_size, Some(size)); + + // The next one takes the font size and the colour. + h.state_mut().set_option("quick", quick).unwrap(); + if quick == "callout" { + drag_pt(&mut h, second, (second.0 + 60.0, second.1 + 20.0)); + } else { + click_pt(&mut h, second); + } + post_text(&mut h, "Second"); + let p = font_size_of_last(&h); + assert_eq!(p.font_size, Some(size), "{quick}: the next one takes the default font size"); + assert_eq!(p.color, Some(blue), "{quick}: and the default colour"); + + // The default survives a restart. + let json = h.state().persist(); + let mut fresh = harness(move |app| { + app.restore(&json); + app.set_option("quick", quick).unwrap(); + }); + if quick == "callout" { + drag_pt(&mut fresh, third, (third.0 + 60.0, third.1 + 20.0)); + } else { + click_pt(&mut fresh, third); + } + post_text(&mut fresh, "Third"); + assert_eq!(font_size_of_last(&fresh).font_size, Some(size), "{quick}: the default font size persisted"); + } +} From 359cef8b0d8c9dc7ba300fdf1ffa57ba1b6c5fc5 Mon Sep 17 00:00:00 2001 From: mtlcl410 <2368091+mtlcl410@users.noreply.github.com> Date: Mon, 9 Nov 2026 08:39:31 +0800 Subject: [PATCH 33/92] M12: an XFA page area keeps at most 128 content areas, so a subform split across them lays out within its budget (#5843) (#5844) --- crates/xfa/src/layout/flow.rs | 10 +++++- crates/xfa/src/parse.rs | 16 +++++++++ crates/xfa/src/tests.rs | 63 +++++++++++++++++++++++++++++++++++ 3 files changed, 88 insertions(+), 1 deletion(-) diff --git a/crates/xfa/src/layout/flow.rs b/crates/xfa/src/layout/flow.rs index 8f656d7eaf..b65e72477a 100644 --- a/crates/xfa/src/layout/flow.rs +++ b/crates/xfa/src/layout/flow.rs @@ -1,6 +1,6 @@ use super::common::{clamp_opt, divide, is_row, moved, solid_extent, splittable}; use super::items::Item; -use super::{Ctx, Frame, Layout, Layouter, MAX_DEPTH, Node, Rect, SomIndices, Subform, XfaError, data_path, instance_count}; +use super::{Ctx, Frame, Layout, Layouter, MAX_DEPTH, MAX_MEASURES, Node, Rect, SomIndices, Subform, XfaError, data_path, instance_count}; impl Layouter<'_> { /// Place `node` as one block of size (`w`, `h`) at the cursor: in this content area when it fits, @@ -75,6 +75,14 @@ impl Layouter<'_> { let mut seg_top = top; let mut first_segment = true; loop { + // Each pass rescans every item still to be placed. A block split across very many + // content areas, and so across very many pages, would do this once per page, costing + // time quadratic in its items; the still-unplaced count spends the work budget so a + // crafted template stops rather than run for seconds before the page limit catches it. + self.measures.set(self.measures.get().saturating_add(rest.len())); + if self.measures.get() > MAX_MEASURES { + return Err(XfaError::TooLarge("a subform is split across too many content areas to lay out".into())); + } let (rect, _, placed) = self.cursor(); let limit = rect.bottom() + shift; let mut cut = limit; diff --git a/crates/xfa/src/parse.rs b/crates/xfa/src/parse.rs index 76409dc697..a627c70e5f 100644 --- a/crates/xfa/src/parse.rs +++ b/crates/xfa/src/parse.rs @@ -21,6 +21,10 @@ const MAX_IMAGE: usize = 32 << 20; const MAX_ITEMS: usize = 1000; /// Longest text run kept. const MAX_TEXT: usize = 1 << 20; +/// Most content areas kept on one page area. A master page has a handful (the most in the +/// 500-form corpus is nine); laying a splittable subform out across tens of thousands of them, +/// which `next_area` walks before starting a new page, costs time quadratic in the paragraphs. +const MAX_CONTENT_AREAS: usize = 128; /// Parse a measurement such as `12.7mm`, `72pt`, `1in`, `2.54cm` into points. Bare numbers are /// points. `None` for anything else (including `em`). @@ -609,7 +613,19 @@ fn parse_page_set(n: XmlNode, warnings: &mut Vec, depth: usize) -> PageS attr_measure(c, "h").unwrap_or(height).clamp(1.0, height), ) }) + .take(MAX_CONTENT_AREAS + 1) .collect(); + if content.len() > MAX_CONTENT_AREAS { + content.truncate(MAX_CONTENT_AREAS); + // Said once however many warnings came before: other warnings fill the shared cap of + // 100 without limit of their own, and this one must not go silent behind them. It is + // kept first, so whether it was said is one comparison, not a scan of every warning + // for every page area (nothing else is put first). + let said = format!("a page area has more than {MAX_CONTENT_AREAS} content areas; the rest are left out"); + if warnings.first() != Some(&said) { + warnings.insert(0, said); + } + } if content.is_empty() { content.push(Rect::new(0.0, 0.0, width, height)); } diff --git a/crates/xfa/src/tests.rs b/crates/xfa/src/tests.rs index 2e70a81472..b4c59df5d0 100644 --- a/crates/xfa/src/tests.rs +++ b/crates/xfa/src/tests.rs @@ -188,6 +188,46 @@ fn the_template_parses_with_fonts_captions_and_rich_text() { assert!(last.break_before_page); } +/// A page area that declares far more content areas than any real master keeps only the first +/// 128, with a warning. `split_block` walks every content area of a page before starting a new +/// one, re-scanning the unplaced items each time, so tens of thousands of them cost time +/// quadratic in a splittable subform's paragraphs. Capping the count bounds that walk. +#[test] +fn a_page_area_keeps_at_most_a_hundred_and_twenty_eight_content_areas() { + let areas = (0..200).map(|i| format!("", i)).collect::(); + let xml = format!( + r##" + + + +"## + ); + let (tpl, warnings) = parse(&xml).unwrap(); + let ps = tpl.root.page_set.as_ref().unwrap(); + assert_eq!(ps.areas[0].content.len(), 128, "the content areas are capped"); + assert!(warnings.iter().any(|w| w.contains("content areas")), "a warning records the cap: {warnings:?}"); + // Still said after a hundred other warnings (CodeRabbit review on #5844): images that can't + // be decoded, parsed before the page set, fill the warnings' shared cap first. + let bad = "!!!".repeat(120); + let crowded = xml.replace("", &format!("{bad}")); + let (_, warnings) = parse(&crowded).unwrap(); + assert!(warnings.len() > 100, "the other warnings came first: {}", warnings.len()); + assert!(warnings.iter().any(|w| w.contains("content areas")), "the cap is still recorded"); + // Many capped page areas say it once, found without scanning every warning (it comes first). + let page_area = |n: usize| format!(r#"{areas}"#); + let many: String = (0..50).map(page_area).collect(); + let crowded = crowded.replacen(&page_area(0).replace("p0", "front"), &many, 1); + let (_, warnings) = parse(&crowded).unwrap(); + assert_eq!(warnings.iter().filter(|w| w.contains("content areas")).count(), 1, "said once"); + assert!(warnings[0].contains("content areas"), "kept first: {:?}", warnings.first()); +} + #[test] fn rich_text_max_length_reaches_the_field_and_layout() { for (attribute, expected) in [ @@ -678,6 +718,29 @@ fn empty_repeating_subforms_spend_the_layout_budget() { assert!(layout_xml(&tpl).is_ok()); } +#[test] +fn a_block_split_across_very_many_pages_spends_the_layout_budget() { + // One-point content areas slice a flowed subform's paragraphs one line to a page, so the + // block is split across hundreds of pages; split_block rescans the still-unplaced + // paragraphs once per page, which is quadratic in their number. Fewer paragraphs than this + // lay out (188 pages here), so the page limit does not catch it; the rescans spend the + // work budget instead. + let areas: String = (0..128).map(|i| format!("")).collect(); + let paras: String = (0..2500).map(|i| format!("

    Paragraph number {i} with a few words to fill a line.

    ")).collect(); + let tpl = format!( + "" + ); + match layout_xml(&tpl) { + // It says what ran out, not that the form is nested too deeply (CodeRabbit review on #5844). + Err(XfaError::TooLarge(m)) => assert!(m.contains("split across"), "{m}"), + other => panic!("a block split across very many pages must be refused: {other:?}"), + } +} + #[test] fn a_packet_stream_listed_many_times_is_read_once() { let mut doc = Document::open(Arc::new(shell(""))).unwrap(); From 87139acf2c0f472da86cc9049ee61e3fb2f85a16 Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Sun, 8 Nov 2026 19:39:52 -0500 Subject: [PATCH 34/92] fix(create): image export checks a JPEG's Extended XMP chunks before decoding it (#5860) --- crates/create/src/extract.rs | 25 ++++++++++- crates/create/src/tests.rs | 64 +++++++++++++++++++++++++++ crates/create/src/upright_image.rs | 71 ++++++++++++++++++++++-------- 3 files changed, 141 insertions(+), 19 deletions(-) diff --git a/crates/create/src/extract.rs b/crates/create/src/extract.rs index 863ad2da0a..46b203a071 100644 --- a/crates/create/src/extract.rs +++ b/crates/create/src/extract.rs @@ -302,9 +302,14 @@ thread_local! { } /// The size a JPEG's header gives, which is what decoding it goes by, whatever its dictionary -/// says: an image of one pixel can hold a header of 500 megapixels. +/// says: an image of one pixel can hold a header of 500 megapixels. `None`, as for a JPEG that +/// can't be read, when its metadata would take the decoder quadratic time +/// ([`decodable_metadata`](crate::upright_image::decodable_metadata)). fn jpeg_size(data: &[u8]) -> Option<(usize, usize)> { use zune_jpeg::zune_core::{bytestream::ZCursor, options::DecoderOptions}; + if !crate::upright_image::decodable_metadata(data) { + return None; + } let options = DecoderOptions::default().set_strict_mode(false).set_max_width(usize::from(u16::MAX)).set_max_height(usize::from(u16::MAX)); let mut dec = zune_jpeg::JpegDecoder::new_with_options(ZCursor::new(data), options); dec.decode_headers().ok()?; @@ -329,6 +334,15 @@ fn jpeg_samples(data: &[u8], space: &Space, w: usize, h: usize) -> Option<(Vec return cmyk_jpeg_samples(data, w, h).map(|px| (px, Space::Cmyk)), _ => {} } + // Past the decoder's metadata bound, drop the Extended XMP chunks rather than the mask: + // decoded as it stands, an over-chunked JPEG is written as is, without its /SMask. + let stripped; + let data = if crate::upright_image::decodable_metadata(data) { + data + } else { + stripped = crate::upright_image::without_extended_xmp(data); + &stripped[..] + }; let img = jpeg_of_size(data, w, h)?; Some(match space { Space::Gray => (img.into_luma8().into_raw(), Space::Gray), @@ -340,6 +354,15 @@ fn jpeg_samples(data: &[u8], space: &Space, w: usize, h: usize) -> Option<(Vec Option> { use zune_jpeg::zune_core::{bytestream::ZCursor, colorspace::ColorSpace, options::DecoderOptions}; + // Past the decoder's metadata bound, decode the image without its Extended XMP chunks + // rather than not at all: written out as stored, a CMYK JPEG shows inverted colours. + let stripped; + let data = if crate::upright_image::decodable_metadata(data) { + data + } else { + stripped = crate::upright_image::without_extended_xmp(data); + &stripped[..] + }; let options = DecoderOptions::default().set_strict_mode(false).set_max_width(usize::from(u16::MAX)).set_max_height(usize::from(u16::MAX)); let mut dec = zune_jpeg::JpegDecoder::new_with_options(ZCursor::new(data), options); dec.decode_headers().ok()?; diff --git a/crates/create/src/tests.rs b/crates/create/src/tests.rs index 88a6e05ac9..9d5a70ffcd 100644 --- a/crates/create/src/tests.rs +++ b/crates/create/src/tests.rs @@ -941,6 +941,43 @@ fn damaged_masks_and_sizes_never_panic() { } } +#[test] +fn a_jpeg_with_thousands_of_extended_xmp_chunks_keeps_its_mask() { + // Export decodes a JPEG with a soft mask, or a JPEG soft mask, to make a PNG with alpha. A JPEG + // whose Extended XMP chunks would take the decoder quadratic time is decoded without them, + // which is linear in what is left, so the mask survives; a JPEG mask past the same bound is + // still left out, without reaching the decoder. + let flood = crate::upright_image::tests::with_extended_xmp_chunks(&real_jpeg(), 5_000); + let mut doc = from_images(&[("flat.png".into(), png_bytes(false))]).unwrap(); + with_image(&mut doc, |doc| { + let mask = doc.add(Object::Stream(Stream::from_raw(image_dict(4, 2, "DeviceGray", 8), vec![0; 8]))); + let mut d = image_dict(4, 2, "DeviceGray", 8); + d.set(b"Filter".to_vec(), Object::name("DCTDecode")); + d.set(b"SMask".to_vec(), Object::Ref(mask)); + Stream::from_raw(d, flood.clone()) + }); + crate::extract::JPEGS_DECODED.with(|n| n.set(0)); + let out = extract_images(&doc, &[0], 0); + assert_eq!(out.images[0].extension, "png"); + assert_eq!(crate::extract::JPEGS_DECODED.with(std::cell::Cell::get), 1); + // The chunks themselves are gone, so the decoder never saw them. + assert!(!out.images[0].data.windows(5).any(|w| w == b"http:")); + + crate::extract::JPEGS_DECODED.with(|n| n.set(0)); + + let mut doc = from_images(&[("flat.png".into(), png_bytes(false))]).unwrap(); + with_image(&mut doc, |doc| { + let mut md = image_dict(4, 2, "DeviceGray", 8); + md.set(b"Filter".to_vec(), Object::name("DCTDecode")); + let mask = doc.add(Object::Stream(Stream::from_raw(md, flood.clone()))); + let mut d = image_dict(4, 2, "DeviceRGB", 8); + d.set(b"SMask".to_vec(), Object::Ref(mask)); + Stream::from_raw(d, vec![10; 24]) + }); + assert_eq!(alphas(&exported_rgba(&doc).1), [255; 8]); + assert_eq!(crate::extract::JPEGS_DECODED.with(std::cell::Cell::get), 0); +} + #[test] fn indexed_image_export_clamps_samples_to_hival() { for palette in [vec![255, 0, 0, 0, 0, 255], vec![255, 0, 0, 0, 0, 255, 0, 255, 0, 255, 255, 255]] { @@ -2788,3 +2825,30 @@ fn proportions_up_to_the_limit_scale_as_they_did() { let mask = noise(20, 10); assert_eq!(crate::extract::scaled(&mask, 20, 10), mask); } + +/// A CMYK JPEG past the decoder's Extended XMP bound is still decoded (its chunks left out) and +/// exported with the colours the page shows, not written as stored, which inverts them. +#[test] +fn a_cmyk_jpeg_flooded_with_extended_xmp_is_exported_in_its_colours() { + let jpeg = crate::upright_image::tests::with_extended_xmp_chunks(&flat_four_component_jpeg([255, 0, 0, 0], 0), 5_000); + let mut doc = from_images(&[("flat.png".into(), png_bytes(false))]).unwrap(); + with_image(&mut doc, |doc| { + let mut d = image_dict(8, 8, "DeviceCMYK", 8); + d.set(b"Filter".to_vec(), Object::name("DCTDecode")); + let m = doc.add(Object::Stream(Stream::from_raw(image_dict(8, 8, "DeviceGray", 8), vec![255; 64]))); + d.set(b"SMask".to_vec(), Object::Ref(m)); + Stream::from_raw(d, jpeg.clone()) + }); + let (_, rgba) = exported_rgba(&doc); + assert!(rgba.as_chunks::<4>().0.iter().all(|p| p[0] <= 6 && p[1] >= 249 && p[2] >= 249), "{:?}", &rgba[..4]); +} + +/// Stripping leaves everything but the Extended XMP chunks, byte for byte. +#[test] +fn stripping_extended_xmp_leaves_the_rest_of_the_jpeg() { + let plain = flat_four_component_jpeg([255, 0, 0, 0], 0); + let flooded = crate::upright_image::tests::with_extended_xmp_chunks(&plain, 3); + assert_ne!(flooded, plain); + // The helper puts a stray byte before the chunks, which the decoder skips: it stays. + assert_eq!(crate::upright_image::without_extended_xmp(&flooded), [&plain[..2], b"\0".as_slice(), &plain[2..]].concat()); +} diff --git a/crates/create/src/upright_image.rs b/crates/create/src/upright_image.rs index b89c517190..d193a38bfb 100644 --- a/crates/create/src/upright_image.rs +++ b/crates/create/src/upright_image.rs @@ -30,7 +30,7 @@ pub(crate) fn upright(bytes: &[u8]) -> Cow<'_, [u8]> { } fn turned(bytes: &[u8]) -> Option> { - if !bytes.starts_with(&[0xFF, 0xD8]) || extended_xmp_chunks(bytes) > MAX_EXTENDED_XMP_CHUNKS { + if !bytes.starts_with(&[0xFF, 0xD8]) || !decodable_metadata(bytes) { return None; } #[cfg(test)] @@ -65,10 +65,41 @@ fn turned(bytes: &[u8]) -> Option> { /// decoder re-sorts and rescans every chunk so far after each marker, quadratic in their number. const MAX_EXTENDED_XMP_CHUNKS: usize = 1024; +/// Whether JPEG `bytes` may be handed to a decoder (`image`'s or `zune-jpeg`'s, which `image` uses), +/// headers included: not when it carries more than [`MAX_EXTENDED_XMP_CHUNKS`]. Every path that +/// decodes a JPEG from a file checks this first: image import here, and image export. +pub(crate) fn decodable_metadata(bytes: &[u8]) -> bool { + extended_xmp_chunks(bytes) <= MAX_EXTENDED_XMP_CHUNKS +} + /// How many Extended XMP chunks (APP1 segments) come before the image data. fn extended_xmp_chunks(bytes: &[u8]) -> usize { + let mut chunks = 0usize; + each_extended_xmp(bytes, |_| chunks += 1); + chunks +} + +/// JPEG `bytes` without their Extended XMP chunks, which nothing here reads: what is left decodes +/// in time linear in its size however many chunks the file carried. +pub(crate) fn without_extended_xmp(bytes: &[u8]) -> Vec { + // Only the bytes that are kept: a CMYK JPEG can be mostly Extended XMP, and reserving the + // whole file for the little that remains aborts the export under a tight address-space limit. + let mut removed = 0usize; + each_extended_xmp(bytes, |r| removed = removed.saturating_add(r.len())); + let mut out = Vec::with_capacity(bytes.len().saturating_sub(removed)); + let mut at = 0; + each_extended_xmp(bytes, |r| { + out.extend_from_slice(bytes.get(at..r.start).unwrap_or_default()); + at = r.end; + }); + out.extend_from_slice(bytes.get(at..).unwrap_or_default()); + out +} + +/// Calls `f` with the byte range of each Extended XMP chunk (APP1 segment) before the image data. +fn each_extended_xmp(bytes: &[u8], mut f: impl FnMut(std::ops::Range)) { const PREFIX: &[u8] = b"http://ns.adobe.com/xmp/extension/\0"; - let (mut i, mut chunks) = (2usize, 0usize); + let mut i = 2usize; while let Some(&byte) = bytes.get(i) { // Stray bytes between segments, which the decoder skips too. if byte != 0xFF { @@ -86,14 +117,16 @@ fn extended_xmp_chunks(bytes: &[u8]) -> usize { _ => { let Some(&[hi, lo]) = bytes.get(i.saturating_add(2)..i.saturating_add(4)) else { break }; let len = usize::from(u16::from_be_bytes([hi, lo])); - if marker == 0xE1 && bytes.get(i.saturating_add(4)..).is_some_and(|seg| seg.starts_with(PREFIX)) { - chunks += 1; + let end = i.saturating_add(2).saturating_add(len.max(2)); + // The prefix must start the APP1 segment's own payload: a zero-length header + // followed by prefix bytes is not a chunk. + if marker == 0xE1 && bytes.get(i.saturating_add(4)..end.min(bytes.len())).is_some_and(|seg| seg.starts_with(PREFIX)) { + f(i..end.min(bytes.len())); } - i = i.saturating_add(2).saturating_add(len.max(2)); + i = end; } } } - chunks } /// The resolution in a JFIF header, if the file has one in inches or centimetres. @@ -120,7 +153,7 @@ fn jfif_density(bytes: &[u8]) -> Option { } #[cfg(test)] -mod tests { +pub(crate) mod tests { use super::*; use crate::tests::phone_jpeg; @@ -138,22 +171,24 @@ mod tests { assert!(matches!(upright(b"\xff\xd8 not a jpeg"), Cow::Borrowed(_))); } + /// `jpeg` with `n` empty chunks of one never-complete Extended XMP packet after its SOI. + pub(crate) fn with_extended_xmp_chunks(jpeg: &[u8], n: usize) -> Vec { + let mut seg = b"http://ns.adobe.com/xmp/extension/\0".to_vec(); + seg.extend_from_slice(&[b'0'; 32]); // the packet's GUID + seg.extend_from_slice(&1000u32.to_be_bytes()); // its full length + seg.extend_from_slice(&0u32.to_be_bytes()); // this chunk's offset + let mut chunk = vec![0xFF, 0xE1]; + chunk.extend_from_slice(&u16::try_from(seg.len() + 2).unwrap().to_be_bytes()); + chunk.extend_from_slice(&seg); + [&jpeg[..2], b"\0".as_slice(), &chunk.repeat(n), &jpeg[2..]].concat() + } + #[test] fn a_jpeg_with_thousands_of_extended_xmp_chunks_is_not_decoded() { // Empty chunks of one never-complete extended packet: the decoder re-sorts and rescans every // chunk so far after each marker, quadratic in their number. - let chunk = |n: usize| { - let mut seg = b"http://ns.adobe.com/xmp/extension/\0".to_vec(); - seg.extend_from_slice(&[b'0'; 32]); // the packet's GUID - seg.extend_from_slice(&1000u32.to_be_bytes()); // its full length - seg.extend_from_slice(&0u32.to_be_bytes()); // this chunk's offset - let mut out = vec![0xFF, 0xE1]; - out.extend_from_slice(&u16::try_from(seg.len() + 2).unwrap().to_be_bytes()); - out.extend_from_slice(&seg); - out.repeat(n) - }; let photo = phone_jpeg(6, 144); - let with = |n: usize| [&photo[..2], b"\0".as_slice(), &chunk(n), &photo[2..]].concat(); + let with = |n: usize| with_extended_xmp_chunks(&photo, n); DECODES.with(|c| c.set(0)); assert!(matches!(upright(&with(4)), Cow::Owned(_)), "a few chunks are an ordinary photo"); assert!(matches!(upright(&with(5_000)), Cow::Borrowed(_))); From ccbfa6d24a9e450066dad5192e2f4404bec778d3 Mon Sep 17 00:00:00 2001 From: "Hasan N." <42965049+hasannadeem@users.noreply.github.com> Date: Sun, 8 Nov 2026 19:40:07 -0500 Subject: [PATCH 35/92] M14: localization parity counts every catalog with the i18n report (#5805) (#5894) --- ROADMAP.md | 33 +++++----- docs/localization-parity.md | 119 ++++++++++++++++++++---------------- 2 files changed, 84 insertions(+), 68 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 2df1bd26ca..ee91096427 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -198,27 +198,28 @@ What's strong and missing in each: [target-app-parity.md](docs/target-app-parity ## Languages -Twelve key languages (historical % snapshot vs 2,143 keys, except Vietnamese, Brazilian -Portuguese and Spanish vs the current union). Detail and script notes: [localization-parity.md](docs/localization-parity.md). +Twelve key languages: the share of the 2,897 strings in the i18n report's denominator (the interface's strings plus every catalog key), from +`cargo xtask i18n-report` on 2026-11-03. Detail and script notes: [localization-parity.md](docs/localization-parity.md). | Language | Code | Strings | Status | |---|---|---|---| | English | `en` | 100% | full | -| Simplified Chinese | `zh-hans` | 97.4% | partial (native LXGW WenKai Lite; no Hans face in the web build, #826) | -| Spanish | `es` | 100% of current union | partial (dialogs / tooltips; no help) | -| Hindi | `hi` | 0% | none | -| Arabic | `ar` | 100% | partial (logical catalog, explicit RTL labels, mirrored stock chrome; native/browser/IME workflows unqualified) | -| French | `fr` | 99.3% | partial | -| Portuguese (Brazil) | `pt-br` | 100% of current union | partial | -| Indonesian | `id` | 0% | none | -| Japanese | `ja` | 99.1% | partial | -| German | `de` | 99.3% | partial | -| Korean | `ko` | 0% | none | -| Vietnamese | `vi` | 100% of current union | partial (dialogs / tooltips; no help) | +| Simplified Chinese | `zh-hans` | 88.4% | partial (native LXGW WenKai Lite; no Hans face in the web build, #826) | +| Spanish | `es` | 98.4% | partial (dialogs / tooltips; no help) | +| Hindi | `hi` | 97.5% | partial (Devanagari rendering unqualified) | +| Arabic | `ar` | 97.9% | partial (logical catalog, explicit RTL labels, mirrored stock chrome; native/browser/IME workflows unqualified) | +| French | `fr` | 98.4% | partial | +| Portuguese (Brazil) | `pt-br` | 98.4% | partial | +| Indonesian | `id` | 97.5% | partial | +| Japanese | `ja` | 89.6% | partial | +| German | `de` | 89.7% | partial | +| Korean | `ko` | 97.5% | partial (no Hangul UI face) | +| Vietnamese | `vi` | 98.4% | partial (dialogs / tooltips; no help) | Also shipped: Traditional Chinese, Russian, Bulgarian, Telugu, Hungarian, Ukrainian, Italian, -Kazakh, Polish, Slovenian, Greek, Turkish, Lithuanian, Romanian and Czech: **15 more catalogs** -(24 interface languages including English). +Kazakh, Polish, Slovenian, Greek, Turkish, Lithuanian, Finnish, Albanian, Catalan, Romanian, +Norwegian Bokmål, Persian, Central Kurdish, Thai, Lao and Czech: **23 more catalogs** +(35 interface languages including English). ## Upcoming @@ -252,7 +253,7 @@ Ranked; detail and the full milestone table in [docs/roadmap.md](docs/roadmap.md - **2026-11-03 (community, M7, Thai word breaking):** Thai rows end between words, found with ICU4X's dictionary line segmenter (`icu_segmenter` 2.3, compiled dictionary data, no LSTM model; Unicode-3.0), instead of only at spaces when rewrapping and at any cluster in Add text. A row never starts with a following vowel, tone or other mark, Mai Yamok or Paiyannoi, nor ends with a leading vowel; a word wider than the row still breaks between clusters, and the spaces where an Add text row ends are dropped as in Latin text. Rewrapping existing text works out the breaks once per run of non-space characters. Rewriting a paragraph into Thai still needs a source font with the Thai glyphs (there is no Thai fallback face for it yet). The compiled dictionaries (Thai, Lao, Khmer, Burmese) add about 1.8 MB to a stripped release binary. Estimates unchanged. - **2026-11-03 (M7, #5835):** In image crop mode, a right-click on the image, its crop box or its handles keeps the crop and opens no menu, so the menu no longer switches as the pointer moves. A right-click elsewhere clears the selection and opens the page menu. UI tests cover all four page rotations, overlapping images, a 45° image, and a check that the Done/Cancel/Remove Crop pills at a page edge still keep the crop. -- **2026-11-02 (M12, #1297):** `model_add` embeds a U3D or PRC file, recognised by its header, as a 3D annotation with a chosen activation and a poster (an image or PDF page fitted in the box, or a labelled frame); an older file's catalog `/Version` is raised to what the model needs. Views, a rendered poster and a UI command are later steps. Estimates unchanged. +- **2026-11-03 (M14 docs, #5805):** `docs/localization-parity.md` and the Languages table here count every catalog with `cargo xtask i18n-report`: the share of the 2,897 strings in its denominator, on `14ae839a`. The catalog-union measure it replaces had grown to 2,895 keys and left four catalogs at "100%" that were 44 keys short. Hindi, Indonesian and Korean are listed with their catalogs (97.5% each) instead of 0, and Thai, Lao and Central Kurdish join the table. The ≈ 68% localization score is not re-scored. - **2026-11-02 (M10, #5743):** macOS print jobs name their title and owner (`com.apple.print.JobInfo.PMJobName`, `PMJobOwner`), as the system print dialog does, because a Kyocera filter crashes on a job without them. Checked by a test and through `doc_print` against a stand-in `lp`; not yet on a physical printer. diff --git a/docs/localization-parity.md b/docs/localization-parity.md index 1944575ce3..4e30fcdf0a 100644 --- a/docs/localization-parity.md +++ b/docs/localization-parity.md @@ -1,24 +1,32 @@ # Localization parity -> **Last reviewed:** 2026-11-03 · **Last updated:** 2026-11-03 · **Change:** minor (Turkish: 2,867 entries, 0 missing in `cargo xtask i18n-report` on 2026-11-03; AI-assisted) -> **Last reviewed:** 2026-10-25 · **Last updated:** 2026-10-25 · **Change:** minor (Persian (`fa`, 2,785 entries) added right-to-left like Arabic and Kurdish; Arabic current state corrected: catalogs stay logical, RTL labels take an explicit direction and stock chrome mirrors; native/browser RTL input, IME and complex workflows remain unqualified. Native release Hans face via LXGW WenKai Lite; release-pinned craft-fonts still has no Telugu face. Current union 2,798 keys. Scores and estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS) +> **Last reviewed:** 2026-11-03 · **Last updated:** 2026-11-03 · **Change:** minor (every catalog restated from `cargo xtask i18n-report` on `main` at `14ae839a`: 2,897 strings in the report's denominator; Hindi, Indonesian, Korean, Thai, Lao and Central Kurdish listed with their catalogs. Scores and estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS) Per-language status of PdfCraft's interface against Acrobat Pro's. How translations work and how to add a language: [localization.md](localization.md). Summary in [ROADMAP.md](../ROADMAP.md). ## How this was measured -- **Strings translated** is measured: the catalogs in `crates/ui-egui/src/i18n/.tsv` (since moved to craft-i18n's `locales//`), counted - as distinct `(context, English)` keys with a non-empty translation. The denominator is the union of - keys across all catalogs, counting language-specific context rows such as `theme`/`Appearance`. - **That union is a moving target**: every change that adds a string widens it, so a count is only - comparable with others measured on the same date, and a catalog that reached 100% falls behind - without losing a single translation. Every figure here therefore carries its date. The current - union is **2,798** keys as of 2026-10-25, against the **2,143-key snapshot** of 2026-10-10; earlier - revisions quote the union of their own date (see the revision history), not this one. The - twelve-language percentage table below still quotes the 2026-10-10 snapshot for every language - except Spanish, Vietnamese, Lithuanian and Brazilian Portuguese, which are measured against the current union. English is +- **Strings translated** is measured with `cargo xtask i18n-report`, which CI also runs: the + English strings in its denominator, less the ones a catalog has no translation for. The + denominator is the UI source's literals, the command, All tools and engine wordings, and every + key of every bundled catalog, which brings in the contextual, plural and dynamically looked-up + strings the scan can't see. On `main` at `14ae839a` (2026-11-03) that is **2,897** strings, and + every count and percentage in the tables below is of that number. A key only some catalogs have + counts as missing in the others; a contextual one (such as `theme`/`Appearance`) is met by a + plain translation of the same English, but a `@plural` one only by its own plural forms. The + denominator still grows whenever the interface gains a string, so a catalog that was complete + falls behind without losing a translation, and every figure here carries its date. English is built in. +- **Catalogs:** the figures above were taken from the in-repo catalogs + (`crates/ui-egui/src/i18n/.tsv`), which have since moved to craft-i18n's `locales//`. + The report reads the catalogs the build embeds, so measuring again needs `CRAFT_I18N_DIR` set to a + craft-i18n checkout at the commit in `craft-i18n.rev`; without it the report says it was + skipped. +- **Before 2026-11-03** this page counted distinct `(context, English)` keys against the union of + keys across all catalogs: 2,143 keys on 2026-10-10, 2,798 on 2026-10-25, and 2,895 on + `14ae839a`. That union also grew whenever one catalog added a key no other had, so it moved every + row at once. Earlier revisions in the history quote it, at the size of their own date. - **Not counted:** error details from the engine and the operating system, which are shown in English inside a translated frame; there is no translated user documentation or help yet (Acrobat ships localized help). @@ -32,64 +40,71 @@ add a language: [localization.md](localization.md). Summary in [ROADMAP.md](../R ## The twelve key languages -| Language | Code | UI strings translated | Dialogs / tooltips / help | Script support | Native review | Status | Acrobat ships it | To `full` (h) | +| Language | Code | UI strings translated (of 2,897, 2026-11-03) | Dialogs / tooltips / help | Script support | Native review | Status | Acrobat ships it | To `full` (h) | |---|---|---|---|---|---|---|---|---| | English | `en` | built in (100%) | all; README and docs | Latin | yes | **full** | yes | 0 | -| Simplified Chinese | `zh-hans` | 2,088 (97.4%) | dialogs and tooltips through `tl!`; no help | CJK text and IME work; native craft-fonts builds embed the **LXGW WenKai Lite** Hans face, while the web build has no Hans face beyond BIZ UDPGothic, and missing-glyph reports (#826, #688, #728, #758; Flatpak #689) predate the native face | no | partial | yes | 6–12 | -| Spanish | `es` | 2,798 (100% of current key union) | dialogs and tooltips; no help | Latin | no | partial | yes | 2–4 | -| Hindi | `hi` | 0 | none | Devanagari not tested (egui shapes Telugu conjuncts, so shaping is likely workable); the pinned craft-fonts manifest has Noto Sans Devanagari Regular; rendering remains unqualified | no | none | no | 10–18 | -| Arabic | `ar` | 2,143 (100%) | dialogs and tooltips; no help | Logical-order catalog; egui 0.37's epaint reorders with UAX #9, Arabic, Kurdish and Persian labels use an explicit right-to-left paragraph direction, and stock chrome is mirrored. Native/browser RTL input and IME, and complex bidirectional workflows, are not yet qualified (`localization.md` §Arabic) | no | partial | yes (Middle East edition) | 25–45 | -| French | `fr` | 2,128 (99.3%) | dialogs and tooltips; no help | Latin | no | partial | yes | 1–2 | -| Portuguese (Brazil) | `pt-br` | 2,798 (100% of current union) | dialogs and tooltips; no help | Latin | community (#288) | partial | yes | 1–2 | -| Indonesian | `id` | 0 | none | Latin | no | none | no | 3–5 | -| Japanese | `ja` | 2,123 (99.1%) | dialogs and tooltips; no help | BIZ UDPGothic from craft-fonts; IME patched on macOS, Windows, X11, Wayland; vertical text renders but can't be edited | no | partial | yes | 4–8 | -| German | `de` | 2,128 (99.3%) | dialogs and tooltips; no help | Latin | community, in progress (#828: terminology, clipping) | partial | yes | 2–4 | -| Korean | `ko` | 0 | none | Hangul IME patched (Microsoft and Apple Korean, `vendor/README.md` winit 3, 6, 9, 10); no Hangul UI face in craft-fonts | no | none | yes | 6–10 | -| Vietnamese | `vi` | 2,798 (100% of current key union) | dialogs and tooltips; no help | Latin with stacked diacritics; bundled Inter covers them (fonts test) | community | partial | no | 2–4 | +| Simplified Chinese | `zh-hans` | 2,560 (88.4%) | dialogs and tooltips through `tl!`; no help | CJK text and IME work; native craft-fonts builds embed the **LXGW WenKai Lite** Hans face, while the web build has no Hans face beyond BIZ UDPGothic, and missing-glyph reports (#826, #688, #728, #758; Flatpak #689) predate the native face | no | partial | yes | 6–12 | +| Spanish | `es` | 2,851 (98.4%) | dialogs and tooltips; no help | Latin | no | partial | yes | 2–4 | +| Hindi | `hi` | 2,825 (97.5%) | dialogs and tooltips; no help | Devanagari not tested (egui shapes Telugu conjuncts, so shaping is likely workable); the pinned craft-fonts manifest has Noto Sans Devanagari Regular; rendering remains unqualified | no | partial | no | 10–18 | +| Arabic | `ar` | 2,836 (97.9%) | dialogs and tooltips; no help | Logical-order catalog; egui 0.37's epaint reorders with UAX #9, Arabic, Kurdish and Persian labels use an explicit right-to-left paragraph direction, and stock chrome is mirrored. Native/browser RTL input and IME, and complex bidirectional workflows, are not yet qualified (`localization.md` §Arabic) | no | partial | yes (Middle East edition) | 25–45 | +| French | `fr` | 2,851 (98.4%) | dialogs and tooltips; no help | Latin | no | partial | yes | 1–2 | +| Portuguese (Brazil) | `pt-br` | 2,851 (98.4%) | dialogs and tooltips; no help | Latin | community (#288) | partial | yes | 1–2 | +| Indonesian | `id` | 2,825 (97.5%) | dialogs and tooltips; no help | Latin | no | partial | no | 3–5 | +| Japanese | `ja` | 2,597 (89.6%) | dialogs and tooltips; no help | BIZ UDPGothic from craft-fonts; IME patched on macOS, Windows, X11, Wayland; vertical text renders but can't be edited | no | partial | yes | 4–8 | +| German | `de` | 2,600 (89.7%) | dialogs and tooltips; no help | Latin | community, in progress (#828: terminology, clipping) | partial | yes | 2–4 | +| Korean | `ko` | 2,825 (97.5%) | dialogs and tooltips; no help | Hangul IME patched (Microsoft and Apple Korean, `vendor/README.md` winit 3, 6, 9, 10); no Hangul UI face in craft-fonts | no | partial | yes | 6–10 | +| Vietnamese | `vi` | 2,851 (98.4%) | dialogs and tooltips; no help | Latin with stacked diacritics; bundled Inter covers them (fonts test) | community | partial | no | 2–4 | -**Localization dimension ≈ 68%**: the mean over the twelve, counting a catalog's percentage but only -60% for Chinese and Arabic until their script problems are fixed, and 0 for Hindi, Indonesian and -Korean (still missing). Vietnamese moved from 0% to 100% in that mean (59% → 68%). **≈ 55–110 h** +**Localization dimension ≈ 68%** (scored 2026-10-13, before the counts above): the mean over the +twelve, counting a catalog's percentage of the 2,143-key snapshot but only 60% for Chinese and +Arabic until their script problems are fixed, and 0 for Hindi, Indonesian and Korean, which had no +catalog yet. Vietnamese moved from 0% to 100% in that mean (59% → 68%). Hindi, Indonesian and +Korean now have catalogs, so the score understates them. It is not re-scored here, because that +decides how their script caveats count (Devanagari rendering unqualified, no Hangul UI face). **≈ 55–110 h** to bring all twelve to `full`, of which ≈ 35–60 h is engineering (remaining RTL qualification, a web Hans face, bidi text) and the rest catalogs and fixes; native-speaker review needs humans. No language is `full` except English: every catalog leaves engine error details in English, and -none has translated help. The **To `full`** hours of Vietnamese and Spanish are therefore residual -help/docs polish (catalog and script work are already done), not missing UI strings. +none has translated help. The **To `full`** hours of Vietnamese and Spanish are therefore mostly +help/docs polish: their script work is done, and each catalog lacks 46 strings on 2026-11-03. ## Other shipped languages -| Language | Code | UI strings translated | Status | Acrobat ships it | +| Language | Code | UI strings translated (of 2,897, 2026-11-03) | Status | Acrobat ships it | |---|---|---|---|---| -| Traditional Chinese | `zh-hant` | 2,016 (94.1%) | partial (same Hans/Hant face caveat) | yes | -| Russian | `ru` | 2,043 (95.3%) | partial | yes | -| Bulgarian | `bg` | 2,094 (97.7%) | partial | no | -| Czech | `cs` | 2,794 entries (all but four keys of the current union; the engine's sentences, weekdays and certificate fields) | partial | yes | -| Telugu | `te` | 2,058 (96.0%) | partial (the release-pinned craft-fonts has no Telugu face yet) | no | -| Hungarian | `hu` | 2,126 (99.2%) | partial | yes | -| Ukrainian | `uk` | 2,142 (100%) | partial | yes | -| Italian | `it` | 2,736 entries (all but 62 keys of the current union) | partial | yes | -| Kazakh | `kk` | 2,539 entries | partial | no | -| Polish | `pl` | 2,611 entries | partial | yes | -| Slovenian | `sl` | 2,536 entries | partial | no | -| Greek | `el` | 2,545 entries | partial | no | -| Turkish | `tr` | 2,867 entries (0 missing in `cargo xtask i18n-report`, 2026-11-03) | partial; AI-assisted, judgement calls reviewed by a native speaker, full proofreading pending | yes | -| Lithuanian | `lt` | 2,798 (100% of current key union) | partial; community native-speaker review ([#1401](https://github.com/storytold/pdfcraft/pull/1401)) | no | -| Finnish | `fi` | 2,798 entries (every current key) | partial; AI-assisted, native proofreading pending | yes | -| Albanian | `sq` | 2,524 entries | partial; not yet reviewed by a native speaker | no | -| Catalan | `ca` | 2,798 entries | partial: no translated help, engine error details in English; native-speaker review by the contributor | no | -| Romanian | `ro` | 2,798 entries (every current key) | partial; AI-assisted, not yet reviewed line by line by a native speaker | no | -| Norwegian Bokmål | `nb` | 2,707 entries | partial; native-speaker terminology review by the contributor ([#1836](https://github.com/storytold/pdfcraft/pull/1836)), not yet line by line | yes | -| Persian | `fa` | 2,785 entries (all but 13 keys of the current 2,798-key union) | partial; AI-assisted, not yet reviewed line by line by a native speaker | no | +| Traditional Chinese | `zh-hant` | 2,559 (88.3%) | partial (same Hans/Hant face caveat) | yes | +| Russian | `ru` | 2,515 (86.8%) | partial | yes | +| Bulgarian | `bg` | 2,564 (88.5%) | partial | no | +| Czech | `cs` | 2,847 (98.3%) | partial | yes | +| Telugu | `te` | 2,513 (86.7%) | partial (the release-pinned craft-fonts has no Telugu face yet) | no | +| Hungarian | `hu` | 2,599 (89.7%) | partial | yes | +| Ukrainian | `uk` | 2,600 (89.7%) | partial | yes | +| Italian | `it` | 2,790 (96.3%) | partial | yes | +| Kazakh | `kk` | 2,609 (90.1%) | partial | no | +| Polish | `pl` | 2,681 (92.5%) | partial | yes | +| Slovenian | `sl` | 2,618 (90.4%) | partial | no | +| Greek | `el` | 2,615 (90.3%) | partial | no | +| Turkish | `tr` | 2,895 (99.9%) | partial; AI-assisted, judgement calls reviewed by a native speaker, full proofreading pending | yes | +| Lithuanian | `lt` | 2,851 (98.4%) | partial; community native-speaker review ([#1401](https://github.com/storytold/pdfcraft/pull/1401)) | no | +| Finnish | `fi` | 2,849 (98.3%) | partial; AI-assisted, native proofreading pending | yes | +| Albanian | `sq` | 2,604 (89.9%) | partial; not yet reviewed by a native speaker | no | +| Catalan | `ca` | 2,849 (98.3%) | partial: no translated help, engine error details in English; native-speaker review by the contributor | no | +| Romanian | `ro` | 2,849 (98.3%) | partial; AI-assisted, not yet reviewed line by line by a native speaker | no | +| Norwegian Bokmål | `nb` | 2,758 (95.2%) | partial; native-speaker terminology review by the contributor ([#1836](https://github.com/storytold/pdfcraft/pull/1836)), not yet line by line | yes | +| Persian | `fa` | 2,801 (96.7%) | partial; AI-assisted, not yet reviewed line by line by a native speaker | no | +| Central Kurdish (Sorani) | `ckb` | 2,754 (95.1%) | partial; right-to-left like Arabic | no | +| Thai | `th` | 2,751 (95.0%) | partial | no | +| Lao | `lo` | 2,504 (86.4%) | partial | no | -**20 other languages** ship (34 catalogs plus English = **35** interface languages in Preferences). -The entry counts without percentages are as of 2026-10-25; they are not percentages of the older 2,143-key snapshot above. Their presence does not imply a detailed linguistic or runtime review. +**23 other languages** ship (34 catalogs plus English = **35** interface languages in Preferences). +Their presence does not imply a detailed linguistic or runtime review. Acrobat languages PdfCraft lacks: Danish, Dutch, Swedish, Hebrew. Vietnamese is shipped here but not by Acrobat. ## Revision history | Date | Change | Summary | |---|---|---| +| 2026-11-03 | minor | Every catalog restated from `cargo xtask i18n-report` on `14ae839a` (of the 2,897 strings in the report's denominator), replacing the catalog-union measure, which had reached 2,895 keys and left rows "100%" that were 44 short (#5805). Hindi, Indonesian and Korean restated from 0 to their catalogs, and Thai, Lao and Central Kurdish added to the table. The ≈ 68% score is not re-scored | | 2026-10-25 | minor | Arabic current state corrected: catalogs stay logical, Arabic/Kurdish labels take an explicit RTL direction, and stock chrome mirrors; native/browser RTL input, IME and complex workflows remain unqualified. Native Simplified Chinese uses LXGW WenKai Lite; the web build still has no Hans face and the release-pinned craft-fonts still has no Telugu face. Scores and estimates unchanged | | 2026-10-25 | minor | Added Persian (`fa`, 2,785 entries, all but 13 of the current 2,798-key union); right-to-left like Arabic and Kurdish, with logical entries reordered by egui 0.37's epaint; AI-assisted, not yet native-reviewed | | 2026-10-21 | minor | Brazilian Portuguese (`pt-br`) now covers all 2,674 distinct keys in the current catalog union; no help or engine/OS error-detail localization; twelve-language mean still rounds to ≈ 68% | From eaeffa748b2ce5aa798352cb85690e2d44838ebf Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Sun, 8 Nov 2026 19:40:21 -0500 Subject: [PATCH 36/92] fix(measure): charge snapping per filter stage and bound listing work (#5920) --- crates/measure/src/lib.rs | 47 +++++++++++++++++++++-- crates/measure/src/snap.rs | 39 +++++++++++-------- crates/measure/src/tests.rs | 74 +++++++++++++++++++++++++++++++++++++ 3 files changed, 141 insertions(+), 19 deletions(-) diff --git a/crates/measure/src/lib.rs b/crates/measure/src/lib.rs index d52d31dcc6..7ff8bfdcf1 100644 --- a/crates/measure/src/lib.rs +++ b/crates/measure/src/lib.rs @@ -452,6 +452,13 @@ pub struct Listing { pub truncated: bool, } const MAX_ANNOTATIONS: usize = 100_000; +/// Vertex pairs one listing compares for self-intersection in all: an area's boundary check +/// is quadratic in its vertices, and many areas (or many annotations sharing one indirect vertex +/// array) must not multiply that into a stall. +const MAX_LISTED_WORK: usize = 50_000_000; +/// Scales already read by one listing, by the indirect `/Measure` object: one scale shared by +/// many annotations is read (and its ratio decoded) once, failures included. +type Scales = std::collections::HashMap>; /// Points and text one listing keeps, as counted by [`retained`]: about a thousand /// measurements of [`MAX_POINTS`] points each. const MAX_LISTED_BYTES: usize = 32 << 20; @@ -462,8 +469,18 @@ fn retained(m: &Measurement) -> usize { .into_iter() .fold(m.points.len().saturating_mul(std::mem::size_of::()), usize::saturating_add) } -fn read_measurement(doc: &Document, d: &Dict, kind: Kind, page: usize, index: usize) -> Result { - let scale = Scale::read(doc, d.get(b"Measure").ok_or_else(|| invalid("measurement has no scale"))?)?; +/// The scale a measurement annotation reads with, through the listing's cache: a scale given by +/// reference is read once, and its error kept, however many annotations name it. +fn scale_of(doc: &Document, d: &Dict, scales: &mut Scales) -> Result { + let measure = d.get(b"Measure").ok_or_else(|| invalid("measurement has no scale"))?; + match measure.as_ref() { + Some(r) => scales.entry(r).or_insert_with(|| Scale::read(doc, measure).map_err(|e| e.to_string())).clone().map_err(|e| invalid(&e)), + None => Scale::read(doc, measure), + } +} + +fn read_measurement(doc: &Document, d: &Dict, kind: Kind, page: usize, index: usize, scales: &mut Scales) -> Result { + let scale = scale_of(doc, d, scales)?; let key = if kind == Kind::Distance { &b"L"[..] } else { b"Vertices" }; let o = doc.resolve(d.get(key).ok_or_else(|| invalid("measurement has no vertices"))?); let a = o.as_array().ok_or_else(|| invalid("invalid measurement vertices"))?; @@ -482,7 +499,9 @@ pub fn list(doc: &Document) -> Listing { let mut out = Listing::default(); let mut count = 0usize; let mut kept = 0usize; + let mut work = 0usize; let mut seen = std::collections::HashSet::new(); + let mut scales = Scales::new(); for (page, p) in pdfcraft_model::pages(doc).iter().enumerate() { let Some(o) = p.dict.get(b"Annots") else { continue }; let o = doc.resolve(o); @@ -507,7 +526,29 @@ pub fn list(doc: &Document) -> Listing { Some(b"PolygonDimension") => Kind::Area, _ => continue, }; - match read_measurement(doc, d, kind, page, index) { + // An area's boundary check compares every pair of edges: charge it before reading the + // vertices, so a listing stops at its work bound rather than at a stall. Other kinds + // read their vertices once. + let vertices = d.get(if kind == Kind::Distance { b"L".as_slice() } else { b"Vertices" }).map(|v| doc.resolve(v)); + let n = vertices.as_deref().and_then(Object::as_array).map_or(0, |a| a.len() / 2).min(MAX_POINTS); + // An area's boundary check compares every pair of edges: charge it before reading the + // vertices, so a listing stops at its work bound rather than at a stall. The scale is + // resolved first, as `read_measurement` reads it: one that is rejected reads nothing at + // all, so it must not spend the work that later, valid measurements need. + if kind == Kind::Area { + if let Err(e) = scale_of(doc, d, &mut scales) { + out.unsupported.push(Unsupported { page, index, reason: e.to_string() }); + continue; + } + work = work.saturating_add(n.saturating_mul(n)); + } else { + work = work.saturating_add(n.max(1)); + } + if work > MAX_LISTED_WORK { + out.truncated = true; + return out; + } + match read_measurement(doc, d, kind, page, index, &mut scales) { Ok(m) => { kept = kept.saturating_add(retained(&m)); if kept > MAX_LISTED_BYTES { diff --git a/crates/measure/src/snap.rs b/crates/measure/src/snap.rs index b26c389e96..c39d811d9f 100644 --- a/crates/measure/src/snap.rs +++ b/crates/measure/src/snap.rs @@ -1,7 +1,7 @@ //! Bounded path extraction and snapping in PDF user space, including nested Form XObjects. use crate::{Point, Result, check_points, distance, invalid}; -use pdfcraft_content::{Matrix, parse}; -use pdfcraft_cos::{Dict, Document, ObjRef, Object}; +use pdfcraft_content::{Matrix, ops}; +use pdfcraft_cos::{DecodeBudget, Dict, Document, ObjRef, Object}; use serde::Serialize; use std::collections::HashSet; @@ -199,7 +199,9 @@ struct Walker<'a> { doc: &'a Document, geometry: Geometry, seen: HashSet, - bytes: usize, + /// Decoded bytes left for the page: every filter stage's output counts, so a stream that + /// expands and then shrinks pays for the expansion each time it is drawn. + budget: DecodeBudget, streams: usize, } impl Walker<'_> { @@ -215,46 +217,44 @@ impl Walker<'_> { for part in arr { let s = self.doc.resolve(part); if let Object::Stream(s) = s.as_ref() { - let remaining = MAX_BYTES.saturating_sub(self.bytes).saturating_sub(joined.len()); + let remaining = self.budget.remaining(); if remaining == 0 { self.geometry.truncated = true; break; } // An undecodable part is skipped; the parts before it still snap. - match s.decoded_within(remaining) { + match self.budget.decode_within(s, remaining) { Ok(data) => { joined.extend(data); // A space, as the renderer joins pieces: a trailing comment runs on. joined.push(b' '); } - Err(_) => self.decode_failed(remaining), + Err(_) => self.decode_failed(), } } } return self.ops(&joined, resources, initial, depth); } let Object::Stream(stream) = o.as_ref() else { return Ok(()) }; - let remaining = MAX_BYTES.saturating_sub(self.bytes); + let remaining = self.budget.remaining(); if remaining == 0 { self.geometry.truncated = true; return Ok(()); } - match stream.decoded_within(remaining) { + match self.budget.decode_within(stream, remaining) { Ok(data) => self.ops(&data, resources, initial, depth), Err(_) => { - self.decode_failed(remaining); + self.decode_failed(); Ok(()) } } } - /// A tolerant decode fails on an unsupported filter or once it has produced `remaining` bytes, - /// so a failure uses up the byte budget: repeating a failing stream cannot repeat that work. - fn decode_failed(&mut self, remaining: usize) { + /// A failed decode has paid the budget for what it decoded before failing (all of it, past + /// the bound): repeating a failing stream cannot repeat that work for free. + fn decode_failed(&mut self) { self.geometry.unreadable = self.geometry.unreadable.saturating_add(1); - self.bytes = self.bytes.saturating_add(remaining); } fn ops(&mut self, data: &[u8], resources: &Dict, initial: Matrix, depth: usize) -> Result<()> { - self.bytes = self.bytes.saturating_add(data.len()); self.streams = self.streams.saturating_add(1); let mut matrix = initial; let mut stack = Vec::new(); @@ -268,7 +268,8 @@ impl Walker<'_> { let (x, y) = m.apply(x, y); [x, y] }; - for op in parse(data).ops { + // One operator at a time: a stream of millions of short operators is never held whole. + for op in ops(data) { if self.geometry.segments.len().saturating_add(path.segments.len()) >= MAX_SEGMENTS || self.geometry.targets().saturating_add(path.targets()) >= MAX_TARGETS { @@ -425,6 +426,11 @@ impl Walker<'_> { self.seen.insert(r); } self.walk(o, res, m, depth + 1)?; + // A form that hit a bound ends this walk too: more `Do`s would + // only repeat the work of copying its resources. + if self.geometry.truncated { + break; + } if let Some(r) = r { self.seen.remove(&r); } @@ -449,7 +455,8 @@ pub fn geometry(doc: &Document, page_index: usize) -> Result { let resources = p.dict.get(b"Resources").map(|o| doc.resolve(o)); let empty = Dict::new(); let resources = resources.as_ref().and_then(|o| o.as_dict()).unwrap_or(&empty); - let mut walker = Walker { doc, geometry: Geometry::default(), seen: HashSet::new(), bytes: 0, streams: 0 }; + let mut walker = + Walker { doc, geometry: Geometry::default(), seen: HashSet::new(), budget: DecodeBudget::new(MAX_BYTES, usize::MAX), streams: 0 }; // Extraction is lenient: limits and unreadable streams are reported in the geometry. if let Some(contents) = p.dict.get(b"Contents") { walker.walk(contents, resources, Matrix::IDENTITY, 0)?; diff --git a/crates/measure/src/tests.rs b/crates/measure/src/tests.rs index 88906910c7..f265f086ec 100644 --- a/crates/measure/src/tests.rs +++ b/crates/measure/src/tests.rs @@ -471,3 +471,77 @@ fn a_stream_over_the_byte_budget_uses_it_up_once() { assert!(g.truncated); assert_eq!(g.segments.len(), 1, "the parts before it still snap"); } + +/// A content stream drawn again and again pays for every filter stage's output each time, not +/// only for what the last stage leaves: `/ASCIIHexDecode /RunLengthDecode` turns 4 MiB of hex +/// into 2 MiB and then 1 MiB, and eight draws of it are 24 MiB of decoding against a 16 MiB bound. +#[test] +fn repeated_content_pays_for_every_filter_stage() { + let hex = "0058".repeat(1 << 20); + let pdf = Pdf::new() + .obj("<< /Type /Catalog /Pages 2 0 R >>") + .obj("<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 612 792] >>") + .obj("<< /Type /Page /Parent 2 0 R /Contents [4 0 R 4 0 R 4 0 R 4 0 R 4 0 R 4 0 R 4 0 R 4 0 R] >>") + .stream("/Filter [/ASCIIHexDecode /RunLengthDecode]", &hex); + let d = Document::open(Arc::new(pdf.build())).unwrap(); + let g = snap::geometry(&d, 0).unwrap(); + assert!(g.truncated, "eight draws decode past the page's bound"); +} + +/// One scale shared by many measurements is read once, and the self-intersection check of many +/// areas is charged against one work bound rather than run in full for each. +#[test] +fn listing_reads_a_shared_scale_once_and_bounds_boundary_checks() { + let measure = "<< /Type /Measure /Subtype /RL /R (1 pt = 1 pt) /X [<< /Type /NumberFormat /U (pt) /C 1 /D 100 >>] /D [<< /Type /NumberFormat /U (pt) /C 1 /D 100 >>] /A [<< /Type /NumberFormat /U (pt) /C 1 /D 100 >>] >>"; + // A convex 2,048-gon: every one of its edges is checked against every other. + let n = MAX_POINTS; + let verts: Vec = (0..n) + .map(|i| { + let a = i as f64 / n as f64 * std::f64::consts::TAU; + format!("{:.3} {:.3}", 300.0 + 100.0 * a.cos(), 400.0 + 100.0 * a.sin()) + }) + .collect(); + let verts = verts.join(" "); + let areas = 40; + let annots: Vec = (0..areas).map(|i| format!("{} 0 R", 6 + i)).collect(); + let mut pdf = Pdf::new() + .obj("<< /Type /Catalog /Pages 2 0 R >>") + .obj("<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 612 792] >>") + .obj(format!("<< /Type /Page /Parent 2 0 R /Contents 4 0 R /Annots [{}] >>", annots.join(" "))) + .stream("", "") + .obj(measure); + for _ in 0..areas { + pdf = pdf.obj(format!("<< /Type /Annot /Subtype /Polygon /IT /PolygonDimension /Rect [0 0 612 792] /Vertices [{verts}] /Measure 5 0 R >>")); + } + let d = Document::open(Arc::new(pdf.build())).unwrap(); + let l = list(&d); + // 40 areas of 2,048 vertices are 168 million pairs: the listing stops at its bound, with the + // areas before it listed. + assert!(l.truncated, "{} listed", l.measurements.len()); + assert!((5..areas).contains(&l.measurements.len()), "{} listed", l.measurements.len()); + assert!(l.unsupported.is_empty(), "{:?}", l.unsupported); +} + +/// Perimeters and distances read their vertices once: only areas pay for the pairwise boundary +/// check, so many maximal perimeters list in full. +#[test] +fn perimeters_are_charged_linear_work() { + let measure = "<< /Type /Measure /Subtype /RL /R (1 pt = 1 pt) /X [<< /Type /NumberFormat /U (pt) /C 1 /D 100 >>] /D [<< /Type /NumberFormat /U (pt) /C 1 /D 100 >>] /A [<< /Type /NumberFormat /U (pt) /C 1 /D 100 >>] >>"; + let verts: Vec = (0..MAX_POINTS).map(|i| format!("{} {}", 10 + i % 500, 10 + i / 500 * 3 + (i % 2))).collect(); + let verts = verts.join(" "); + let lines = 40; + let annots: Vec = (0..lines).map(|i| format!("{} 0 R", 6 + i)).collect(); + let mut pdf = Pdf::new() + .obj("<< /Type /Catalog /Pages 2 0 R >>") + .obj("<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 612 792] >>") + .obj(format!("<< /Type /Page /Parent 2 0 R /Contents 4 0 R /Annots [{}] >>", annots.join(" "))) + .stream("", "") + .obj(measure); + for _ in 0..lines { + pdf = pdf.obj(format!("<< /Type /Annot /Subtype /PolyLine /IT /PolyLineDimension /Rect [0 0 612 792] /Vertices [{verts}] /Measure 5 0 R >>")); + } + let d = Document::open(Arc::new(pdf.build())).unwrap(); + let l = list(&d); + assert!(!l.truncated, "{} listed", l.measurements.len()); + assert_eq!(l.measurements.len() + l.unsupported.len(), lines, "{:?}", l.unsupported.first()); +} From a55eaa90e3f2f4aee400f4b95ced62cbfd24a762 Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Sun, 8 Nov 2026 19:40:35 -0500 Subject: [PATCH 37/92] fix(redact): prune the resources of Forms a surviving Type 3 glyph draws (#5924) --- .../redact/src/tests/surviving_references.rs | 44 ++++++++++++++ crates/redact/src/type3.rs | 57 ++++++++++++++++++- 2 files changed, 100 insertions(+), 1 deletion(-) diff --git a/crates/redact/src/tests/surviving_references.rs b/crates/redact/src/tests/surviving_references.rs index 477f03f9a5..c561b7f8d1 100644 --- a/crates/redact/src/tests/surviving_references.rs +++ b/crates/redact/src/tests/surviving_references.rs @@ -161,6 +161,30 @@ fn redacted_type3_glyph_and_exclusive_resources_do_not_survive_full_save() { assert!(t3_xo.contains(b"ImB"), "Type 3 font resources lost surviving image ImB"); } +/// The surviving glyph B draws a Form whose own resources also name A's image, which the Form +/// never draws. Pruning only the font's resources left that entry, so the image of the removed +/// glyph stayed in the file through the Form. +#[test] +fn a_redacted_type3_glyphs_image_does_not_survive_in_a_surviving_glyphs_form() { + let mut doc = pdf(vec![ + b"<< /Type /Catalog /Pages 2 0 R >>".to_vec(), + b"<< /Type /Pages /Kids [3 0 R] /Count 1 >>".to_vec(), + b"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 300 300] /Contents 4 0 R /Resources 5 0 R >>".to_vec(), + stream("", b"BT /T3 10 Tf 10 100 Td (AB) Tj ET"), + b"<< /Font << /T3 6 0 R >> >>".to_vec(), + b"<< /Type /Font /Subtype /Type3 /FontBBox [0 -200 1000 800] /FontMatrix [0.001 0 0 0.001 0 0] /FirstChar 65 /LastChar 66 /Widths [500 500] /Encoding << /Differences [65 /A /B] >> /CharProcs << /A 7 0 R /B 8 0 R >> /Resources << /XObject << /ImA 9 0 R /Fm 10 0 R >> >> >>".to_vec(), + stream("", b"/ImA Do"), + stream("", b"/Fm Do"), + stream("/Type /XObject /Subtype /Image /Width 1 /Height 1 /ColorSpace /DeviceGray /BitsPerComponent 8", b"CONFIDENTIAL_IMAGE_A"), + stream("/Type /XObject /Subtype /Form /BBox [0 0 1000 1000] /Resources << /XObject << /ImA 9 0 R /ImB 11 0 R >> >>", b"/ImB Do"), + stream("/Type /XObject /Subtype /Image /Width 1 /Height 1 /ColorSpace /DeviceGray /BitsPerComponent 8", b"PUBLIC_IMAGE_B"), + ]); + mark(&mut doc, 0, &[[9.0, 95.0, 14.9, 110.0]], ""); + assert_eq!(apply(&mut doc, None).unwrap().glyphs, 1); + assert!(!full_save_holds(&doc, b"CONFIDENTIAL_IMAGE_A"), "the removed glyph's image survives through the surviving glyph's form"); + assert!(full_save_holds(&doc, b"PUBLIC_IMAGE_B"), "the image the surviving glyph's form draws was lost"); +} + /// When all glyphs of a Type 3 font are redacted, the entire font is removed from Resources (F-011). #[test] fn all_redacted_type3_glyphs_cause_font_removal_from_resources() { @@ -298,3 +322,23 @@ fn type3_font_used_in_a_form_xobject_has_its_redacted_glyph_removed() { assert_eq!(report.glyphs, 1); assert!(!full_save_holds(&doc, b"SECRET_FORM_GLYPH_PAYLOAD"), "redacted glyph program survived"); } + +/// A Form the surviving glyph draws holds `/Resources` as an array that references the removed +/// glyph's image: it can't be pruned, so the redaction refuses rather than keep the image. +#[test] +fn a_type3_glyphs_form_with_non_dictionary_resources_fails_closed() { + let mut doc = pdf(vec![ + b"<< /Type /Catalog /Pages 2 0 R >>".to_vec(), + b"<< /Type /Pages /Kids [3 0 R] /Count 1 >>".to_vec(), + b"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 300 300] /Contents 4 0 R /Resources 5 0 R >>".to_vec(), + stream("", b"BT /T3 10 Tf 10 100 Td (AB) Tj ET"), + b"<< /Font << /T3 6 0 R >> >>".to_vec(), + b"<< /Type /Font /Subtype /Type3 /FontBBox [0 -200 1000 800] /FontMatrix [0.001 0 0 0.001 0 0] /FirstChar 65 /LastChar 66 /Widths [500 500] /Encoding << /Differences [65 /A /B] >> /CharProcs << /A 7 0 R /B 8 0 R >> /Resources << /XObject << /ImA 9 0 R /Fm 10 0 R >> >> >>".to_vec(), + stream("", b"/ImA Do"), + stream("", b"/Fm Do"), + stream("/Type /XObject /Subtype /Image /Width 1 /Height 1 /ColorSpace /DeviceGray /BitsPerComponent 8", b"CONFIDENTIAL_IMAGE_A"), + stream("/Type /XObject /Subtype /Form /BBox [0 0 1000 1000] /Resources [9 0 R]", b""), + ]); + mark(&mut doc, 0, &[[9.0, 95.0, 14.9, 110.0]], ""); + assert!(apply(&mut doc, None).is_err()); +} diff --git a/crates/redact/src/type3.rs b/crates/redact/src/type3.rs index 57971571af..3bba33a645 100644 --- a/crates/redact/src/type3.rs +++ b/crates/redact/src/type3.rs @@ -135,7 +135,13 @@ fn collect_used_resources_in_stream(data: &[u8], used: &mut UsedInGlyphs) -> Res if parsed.skipped > 0 { return Err(CosError::Syntax { offset: 0, detail: "redaction could not fully inspect glyph stream syntax".into() }); } - for op in parsed.ops { + parsed.ops.iter().for_each(|op| record_used(op, used)); + Ok(()) +} + +/// Record the resource name `op` uses, if any. +fn record_used(op: &pdfcraft_content::Op, used: &mut UsedInGlyphs) { + { match op.op.as_slice() { b"Do" => { if let Some(n) = op.last_name() { @@ -175,6 +181,54 @@ fn collect_used_resources_in_stream(data: &[u8], used: &mut UsedInGlyphs) -> Res _ => {} } } +} + +/// Prune the own `/Resources` of every Form a Type 3 font's (already pruned) resources still +/// list, and of the Forms those list in turn, to the names each Form's content uses. A Form a +/// surviving glyph draws may name, unused, an image only a removed glyph drew; that entry would +/// keep the image in the file. Pruning only unused entries is safe for a shared Form: no drawer +/// of it can use a name its content doesn't. Fails closed when a Form can't be read. +fn prune_font_forms(doc: &mut Document, font_res: &Dict) -> Result<(), CosError> { + let mut pending: Vec<(ObjRef, usize)> = res_dict(doc, font_res, b"XObject").iter().filter_map(|(_, v)| v.as_ref()).map(|r| (r, 0)).collect(); + let mut seen = HashSet::new(); + while let Some((form_ref, depth)) = pending.pop() { + if !seen.insert(form_ref) { + continue; + } + if depth > MAX_DEPTH || seen.len() > MAX_HOLDERS { + return Err(CosError::Syntax { offset: 0, detail: "redaction could not establish safe Type 3 font cleanup: forms nest too deep".into() }); + } + let Object::Stream(form) = &*doc.get(form_ref) else { continue }; + if form.dict.name(b"Subtype") != Some(b"Form") { + continue; + } + // A Form without resources of its own (no key, or `null`) draws through its caller's, + // which were pruned already. Any other value that isn't a dictionary may still reference + // what a removed glyph drew, and can't be pruned: fail closed. + let mut res = match form.dict.get(b"Resources").map(|r| doc.resolve(r)) { + None => continue, + Some(r) if matches!(*r, Object::Null) => continue, + Some(r) => match r.as_dict() { + Some(d) => d.clone(), + None => { + return Err(CosError::Syntax { + offset: 0, + detail: "redaction could not establish safe Type 3 font cleanup: a form's /Resources is not a dictionary".into(), + }); + } + }, + }; + let data = form.decoded()?; + let mut used = UsedInGlyphs::default(); + crate::interp::resource_ops(doc, &res, std::slice::from_ref(&data))?.iter().for_each(|op| record_used(op, &mut used)); + // `rg`, `g` and `k` draw through these entries when the Form keeps them as its defaults, + // and nothing cites them with `cs`/`CS`: pruning them would change the Form's colours. + used.colorspaces.extend([b"DefaultRGB".to_vec(), b"DefaultGray".to_vec(), b"DefaultCMYK".to_vec()]); + if prune_type3_font_resources(doc, &mut res, &used) { + doc.update_dict(form_ref, |d| d.set(b"Resources".to_vec(), Object::Dict(res.clone())))?; + } + pending.extend(res_dict(doc, &res, b"XObject").iter().filter_map(|(_, v)| v.as_ref()).map(|r| (r, depth + 1))); + } Ok(()) } @@ -631,6 +685,7 @@ fn clean_type3_font( } })?; } + prune_font_forms(doc, &f_res)?; Ok(()) } From ae21773fefa919340e1b910e4eca24264007ba6f Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Sun, 8 Nov 2026 19:40:50 -0500 Subject: [PATCH 38/92] fix(edit): stop rescanning what a loop already holds in wrapping, grouping and flattening (#5963) --- crates/edit/src/added/layout.rs | 17 +++++++++++------ crates/edit/src/added/store.rs | 11 +++++++++-- crates/edit/src/flatten.rs | 17 +++++++++++++++-- crates/edit/src/tests.rs | 14 ++++++++++++++ crates/edit/src/text_regions/grouping.rs | 6 +++++- 5 files changed, 54 insertions(+), 11 deletions(-) diff --git a/crates/edit/src/added/layout.rs b/crates/edit/src/added/layout.rs index d1be9ef521..a6a55224d9 100644 --- a/crates/edit/src/added/layout.rs +++ b/crates/edit/src/added/layout.rs @@ -54,16 +54,21 @@ pub(super) fn wrapped(t: &AddedText) -> Vec<(String, bool, usize)> { let measure = |s: &str| if rtl_script || thai { shaped_width(t, s, rtl) } else { latin_width(t, s) }; let space = measure(" "); let (mut line, mut line_w) = (String::new(), 0.0); + // Widths add up (the standard-14 widths have no kerning), so each word is measured once + // and appended in place: a line that never fills (negative character spacing) stays + // linear instead of being copied and measured again for every word. for word in para.split(' ') { - let candidate = if line.is_empty() { word.to_string() } else { format!("{line} {word}") }; - let candidate_w = - if rtl_script || thai { if line.is_empty() { measure(word) } else { line_w + space + measure(word) } } else { measure(&candidate) }; + let word_w = measure(word); + let candidate_w = if line.is_empty() { word_w } else { line_w + space + word_w }; if !line.is_empty() && candidate_w > width { out.push((std::mem::take(&mut line), rtl, index)); - line = word.to_string(); - line_w = measure(word); + line.push_str(word); + line_w = word_w; } else { - line = candidate; + if !line.is_empty() { + line.push(' '); + } + line.push_str(word); line_w = candidate_w; } } diff --git a/crates/edit/src/added/store.rs b/crates/edit/src/added/store.rs index 3555910f04..a5b53581fc 100644 --- a/crates/edit/src/added/store.rs +++ b/crates/edit/src/added/store.rs @@ -328,8 +328,15 @@ pub fn isolate_content(doc: &mut Document, page: usize, index: usize) -> Result< /// names them so), so they go when the item is replaced or deleted. fn own_fonts(doc: &Document, obj: ObjRef) -> Vec> { let Object::Stream(s) = &*doc.get(obj) else { return Vec::new() }; - let Ok(data) = s.decoded() else { return Vec::new() }; - data.split(u8::is_ascii_whitespace).filter_map(|w| w.strip_prefix(b"/")).filter(|w| w.starts_with(b"PCAr")).map(<[u8]>::to_vec).collect() + // The stream is one this writer put there, so its decode is bounded by the stream itself: the + // page limit alone would refuse an item larger than a page's content, and its fonts would stay + // on the page's /Font, which `write` and `delete_content` are supposed to take them off. + let limit = pdfcraft_cos::MAX_PAGE_DECODED.max(s.raw.len().saturating_mul(16)); + let Ok(data) = s.decoded_within(limit) else { return Vec::new() }; + // Each name once: a stream that repeats a name is not copied out once per use. + let names: std::collections::BTreeSet<&[u8]> = + data.split(u8::is_ascii_whitespace).filter_map(|w| w.strip_prefix(b"/")).filter(|w| w.starts_with(b"PCAr")).collect(); + names.into_iter().map(<[u8]>::to_vec).collect() } fn drop_fonts(doc: &Document, res: &mut Dict, names: &[Vec]) { diff --git a/crates/edit/src/flatten.rs b/crates/edit/src/flatten.rs index ef86d6c67a..c54b062af6 100644 --- a/crates/edit/src/flatten.rs +++ b/crates/edit/src/flatten.rs @@ -183,6 +183,19 @@ fn flatten_which(doc: &mut Document, pages: &[usize], which: Which) -> Result pdfcraft_model::Page { + let mut dict = page.dict.clone(); + if let Some(own) = doc.get(page.obj).as_dict() { + for (k, v) in own.iter() { + dict.set(k.clone(), v.clone()); + } + } + pdfcraft_model::Page { obj: page.obj, dict } +} + fn flatten_in(doc: &mut Document, pages: &[usize], which: Which) -> Result { let all = page_list(doc); check(pages, all.len())?; @@ -322,10 +335,10 @@ fn flatten_in(doc: &mut Document, pages: &[usize], which: Which) -> Result doc.set(r, Object::Array(kept)), None => doc.update_dict(page.obj, |d| { diff --git a/crates/edit/src/tests.rs b/crates/edit/src/tests.rs index 1c4829b418..700117ac6f 100644 --- a/crates/edit/src/tests.rs +++ b/crates/edit/src/tests.rs @@ -9748,6 +9748,20 @@ fn added_text_is_wrapped_once_per_write() { assert!(calls <= 4, "the text was wrapped {calls} times for 40 lines"); } +/// Negative character spacing keeps a line from ever filling its box. Wrapping then holds every +/// word on one line, appended and measured once each rather than copied and measured again for +/// each word (which took minutes for this text); the widths still decide the breaks. +#[test] +fn added_text_that_never_fills_its_line_wraps_in_linear_time() { + let words = vec!["word"; 200_000].join(" "); + let packed = AddedText { rect: [0.0, 0.0, 100.0, 100.0], text: words.clone(), char_spacing: -10.0, ..AddedText::default() }; + let start = std::time::Instant::now(); + assert_eq!(added::lines(&packed), [words]); + assert!(start.elapsed() < std::time::Duration::from_secs(10), "{:?}", start.elapsed()); + let plain = AddedText { rect: [0.0, 0.0, 60.0, 100.0], text: "one two three four".into(), size: 10.0, ..AddedText::default() }; + assert_eq!(added::lines(&plain), ["one two", "three four"]); +} + /// Character spacing on added text (#4245) is the PDF's `Tc`, kept in `/PCAdded` so the item /// reopens with it; an item written without it reads 0. #[test] diff --git a/crates/edit/src/text_regions/grouping.rs b/crates/edit/src/text_regions/grouping.rs index 69b3a16850..1dcb9a9373 100644 --- a/crates/edit/src/text_regions/grouping.rs +++ b/crates/edit/src/text_regions/grouping.rs @@ -366,10 +366,13 @@ fn group_layout(lines: &[TextLine], mixed: bool) -> Vec { if !block.rows.is_empty() { block.editor_text.push('\n'); } + // The run placed before this one in the row, tracked rather than searched for: rows + // don't share runs, so it is the last one this loop pushed. + let mut prev_in_row: Option<&TextLine> = None; for &index in row { let Some(line) = lines.get(index) else { continue }; // Some producers position separate words without encoding a space. - if let Some(prev) = block.lines.last().filter(|i| row.contains(i)).and_then(|i| lines.get(*i)) + if let Some(prev) = prev_in_row && !row_text.ends_with(char::is_whitespace) && !line.text.starts_with(char::is_whitespace) && line.frame_rect()[0] - prev.frame_rect()[2] > line.size * 0.12 @@ -383,6 +386,7 @@ fn group_layout(lines: &[TextLine], mixed: bool) -> Vec { row_text.push_str(&line.text); block.invisible &= line.origin.state.render_mode == 3; block.lines.push(index); + prev_in_row = Some(line); } if !block.text.is_empty() { if block.text.ends_with('-') { From afaaaa641540848e559e430c63e7291deafaf2b3 Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Sun, 8 Nov 2026 19:41:04 -0500 Subject: [PATCH 39/92] fix(print): index page labels once and cap how many pages a range selects (#5967) --- crates/print/src/error.rs | 13 +++++- crates/print/src/range.rs | 92 ++++++++++++++++++++++++++++++++++++--- crates/print/src/tests.rs | 2 +- 3 files changed, 97 insertions(+), 10 deletions(-) diff --git a/crates/print/src/error.rs b/crates/print/src/error.rs index 4b1fe95f21..6f87c21c29 100644 --- a/crates/print/src/error.rs +++ b/crates/print/src/error.rs @@ -14,6 +14,9 @@ pub enum PrintError { /// A range token that is neither a page number nor a page label. #[error("{0:?} is not a page number or label")] NotAPage(String), + /// A range that names more pages than a listing will take, with the limit. + #[error("the range selects more than {0} pages")] + TooManySelected(usize), #[error("{0}")] Invalid(String), #[error("{0}")] @@ -33,9 +36,14 @@ pub fn in_english(wording: &str) -> String { /// What a person can get wrong about what to print, in English, with their holes still in them. /// -/// An interface that reports these in another language translates these three, which is the whole +/// An interface that reports these in another language translates these four, which is the whole /// reason they are gathered here; `wordings_are_said` keeps them in step with the errors. -pub const WORDINGS: &[&str] = &["there are no pages to print", "page {n} is out of range (1–{count})", "{page} is not a page number or label"]; +pub const WORDINGS: &[&str] = &[ + "there are no pages to print", + "page {n} is out of range (1–{count})", + "{page} is not a page number or label", + "the range selects more than {limit} pages", +]; impl PrintError { /// This error as one sentence, said in the language `words` translates into. @@ -51,6 +59,7 @@ impl PrintError { } // The token goes in quoted, the way it is typed, as the error itself prints it. PrintError::NotAPage(token) => say(&words("{page} is not a page number or label"), &[("{page}", format!("{token:?}"))]), + PrintError::TooManySelected(limit) => say(&words("the range selects more than {limit} pages"), &[("{limit}", limit.to_string())]), fault => fault.to_string(), } } diff --git a/crates/print/src/range.rs b/crates/print/src/range.rs index f0d8939da6..fffab083af 100644 --- a/crates/print/src/range.rs +++ b/crates/print/src/range.rs @@ -1,7 +1,41 @@ //! Pages to Print: all, a range ("1-3, 6, 9-", page labels allowed), odd or even pages, reverse. +use std::collections::HashMap; + use crate::PrintError; +/// Most pages one range may select (more when the document itself has more): every selection +/// becomes a placement on a sheet, and repeating `1-` costs a few bytes of input for each copy of +/// the whole document. +pub const MAX_SELECTED: usize = 100_000; + +/// The first page with each label, looked up once per token instead of scanning every label. +struct Labels<'a> { + pages: HashMap<&'a str, usize>, + /// The longest label, in bytes: a token longer than that is no label, and is not hashed (a + /// dash-heavy token would otherwise hash every split of itself). + longest: usize, +} + +impl<'a> Labels<'a> { + fn new(labels: &'a [String]) -> Self { + let mut pages = HashMap::with_capacity(labels.len()); + for (i, l) in labels.iter().enumerate() { + pages.entry(l.as_str()).or_insert(i); + } + let longest = pages.keys().map(|l| l.len()).max().unwrap_or(0); + Labels { pages, longest } + } + + fn get(&self, token: &str) -> Option { + if token.len() > self.longest { None } else { self.pages.get(token).copied() } + } + + fn contains_key(&self, token: &str) -> bool { + self.get(token).is_some() + } +} + #[derive(Clone, Copy, Debug, PartialEq, Eq, Default)] pub enum Subset { #[default] @@ -11,10 +45,10 @@ pub enum Subset { } /// Resolve one range token: a page label, or a number (1-based). -fn page_of(tok: &str, count: usize, labels: &[String]) -> Result { +fn page_of(tok: &str, count: usize, labels: &Labels) -> Result { let t = tok.trim(); // Page labels win over numbers (Acrobat's "use logical page numbers"). - if let Some(i) = labels.iter().position(|l| l == t) { + if let Some(i) = labels.get(t) { return Ok(i); } match t.parse::() { @@ -27,12 +61,12 @@ fn page_of(tok: &str, count: usize, labels: &[String]) -> Result Option<(usize, usize)> { +fn hyphenated_label_range(part: &str, count: usize, labels: &Labels) -> Option<(usize, usize)> { for (split, _) in part.match_indices('-') { let (Some(first), Some(last)) = (part.get(..split), part.get(split + 1..)) else { continue }; let (first, last) = (first.trim(), last.trim()); // Avoid changing the meaning of ordinary numeric and open-ended ranges. - if !labels.iter().any(|l| l.contains('-') && (l == first || l == last)) { + if ![first, last].iter().any(|l| labels.contains_key(l) && l.contains('-')) { continue; } let from = if first.is_empty() { Some(0) } else { page_of(first, count, labels).ok() }; @@ -52,12 +86,22 @@ pub fn select_pages(count: usize, range: Option<&str>, labels: &[String], subset match range.map(str::trim).filter(|r| !r.is_empty()) { None => pages.extend(0..count), Some(r) => { + let index = Labels::new(labels); + let labels = &index; + let limit = count.max(MAX_SELECTED); + let mut selected = 0usize; + // Checked before a span is expanded, so the list never grows past the limit. + let mut take = |from: usize, to: usize| { + selected = selected.saturating_add(from.abs_diff(to).saturating_add(1)); + if selected > limit { Err(PrintError::TooManySelected(limit)) } else { Ok(()) } + }; for part in r.split([',', ';']).map(str::trim).filter(|p| !p.is_empty()) { // A complete label (e.g. "A-1") still wins over interpreting it as a range. // A range of such labels (e.g. "A-1-A-3") needs a later '-' split. - if !labels.iter().any(|l| l == part) + if !labels.contains_key(part) && let Some((from, to)) = hyphenated_label_range(part, count, labels) { + take(from, to)?; if from <= to { pages.extend(from..=to); } else { @@ -67,17 +111,24 @@ pub fn select_pages(count: usize, range: Option<&str>, labels: &[String], subset } match part.split_once('-') { // A label may itself contain a dash ("A-1"): try the whole token first. - Some(_) if labels.iter().any(|l| l == part) => pages.push(page_of(part, count, labels)?), + Some(_) if labels.contains_key(part) => { + take(0, 0)?; + pages.push(page_of(part, count, labels)?); + } Some((a, b)) => { let from = if a.trim().is_empty() { 0 } else { page_of(a, count, labels)? }; let to = if b.trim().is_empty() { count.checked_sub(1).ok_or(PrintError::NoPages)? } else { page_of(b, count, labels)? }; + take(from, to)?; if from <= to { pages.extend(from..=to); } else { pages.extend((to..=from).rev()); } } - None => pages.push(page_of(part, count, labels)?), + None => { + take(0, 0)?; + pages.push(page_of(part, count, labels)?); + } } } } @@ -129,4 +180,31 @@ mod tests { fn open_ended_range_covers_all_pages() { assert_eq!(select_pages(5, Some("-"), &[], Subset::All, false).unwrap(), vec![0, 1, 2, 3, 4]); } + + /// Repeating a range is allowed up to the limit, and refused past it before the pages are + /// listed: 1,000 pages selected 100 times is the limit, once more is not. + #[test] + fn a_range_selects_at_most_the_limit() { + let whole = vec!["1-"; MAX_SELECTED / 1000].join(","); + assert_eq!(select_pages(1000, Some(&whole), &[], Subset::All, false).unwrap().len(), MAX_SELECTED); + let e = select_pages(1000, Some(&format!("{whole},1")), &[], Subset::All, false).unwrap_err(); + assert!(e.to_string().contains("more than 100000 pages"), "{e}"); + // A document longer than the limit can still be printed whole, once. + assert_eq!(select_pages(MAX_SELECTED + 1, Some("1-"), &[], Subset::All, false).unwrap().len(), MAX_SELECTED + 1); + } + + /// Labels resolve through one index: the first page with a label wins, and a hyphenated + /// label works as an endpoint, among many labels and many dashes. + #[test] + fn labels_resolve_through_the_index() { + let labels: Vec = (0..50_000).map(|i| format!("A-{i}")).collect(); + assert_eq!(select_pages(labels.len(), Some("A-3-A-5"), &labels, Subset::All, false).unwrap(), vec![3, 4, 5]); + let dashes = "-".repeat(10_000); + assert!(select_pages(labels.len(), Some(&dashes), &labels, Subset::All, false).is_err()); + // a token with no label as long as any of its halves costs no hashing of them + let long_token = format!("{}A-3", "-".repeat(200_000)); + assert!(select_pages(labels.len(), Some(&long_token), &labels, Subset::All, false).is_err()); + let twice = vec!["x".to_string(), "x".to_string()]; + assert_eq!(select_pages(2, Some("x"), &twice, Subset::All, false).unwrap(), vec![0]); + } } diff --git a/crates/print/src/tests.rs b/crates/print/src/tests.rs index 642af0b8c9..f51e505251 100644 --- a/crates/print/src/tests.rs +++ b/crates/print/src/tests.rs @@ -84,7 +84,7 @@ fn page_selection() { /// and the numbers apart from it, and says itself in English exactly as the error prints itself. #[test] fn wordings_are_said() { - let mistakes = [PrintError::NoPages, PrintError::OutOfRange(7, 5), PrintError::NotAPage("x".into())]; + let mistakes = [PrintError::NoPages, PrintError::OutOfRange(7, 5), PrintError::NotAPage("x".into()), PrintError::TooManySelected(1_000)]; for e in &mistakes { assert_eq!(e.said(&in_english), e.to_string(), "{e:?} says itself in English the way it prints itself"); } From ee6ae8eac4da186b9551818a8d6cbf2a8cd5b546 Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Sun, 8 Nov 2026 19:41:24 -0500 Subject: [PATCH 40/92] fix(engine): coalesce script resetForm requests and bound console output per edit (#5975) --- crates/engine/src/js.rs | 121 +++++++++++++++++++++++++++++++++++----- 1 file changed, 108 insertions(+), 13 deletions(-) diff --git a/crates/engine/src/js.rs b/crates/engine/src/js.rs index f581504204..7f0561d196 100644 --- a/crates/engine/src/js.rs +++ b/crates/engine/src/js.rs @@ -26,29 +26,70 @@ pub struct JsOutput { pub doc_errors: Vec, } +/// Console lines one output keeps, from every script an edit runs together (a form's +/// calculations, say): each run's own cap ([`pdfcraft_js::xfa::MAX_CONSOLE`]) would otherwise +/// be multiplied by the number of fields a document declares. +pub const MAX_CONSOLE_LINES: usize = 10_000; +/// Bytes of console text one output keeps, for the same reason. +pub const MAX_CONSOLE_BYTES: usize = 1 << 20; +/// Alerts one output keeps. +pub const MAX_ALERTS: usize = 1_000; + impl JsOutput { /// Whether scripts produced nothing: no alerts, console lines, requests or errors. pub fn is_empty(&self) -> bool { self.alerts.is_empty() && self.console.is_empty() && self.requests.is_empty() && self.errors.is_empty() } + /// Adds `lines` to the console within [`MAX_CONSOLE_LINES`] and [`MAX_CONSOLE_BYTES`]; what + /// is past them is left out, which one closing line says. + fn say(&mut self, lines: impl IntoIterator) { + let note = format!("more console lines were left out: the scripts printed more than {MAX_CONSOLE_LINES} lines or {MAX_CONSOLE_BYTES} bytes"); + // Once the notice is written the console is closed: nothing may follow it, and a later + // rejection must not add a second copy. + if self.console.last() == Some(¬e) { + return; + } + let mut bytes: usize = self.console.iter().map(String::len).sum(); + let mut dropped = false; + for l in lines { + if self.console.len() >= MAX_CONSOLE_LINES || bytes.saturating_add(l.len()) > MAX_CONSOLE_BYTES { + dropped = true; + break; + } + bytes += l.len(); + self.console.push(l); + } + if dropped && !self.console.iter().any(|l| l == ¬e) { + self.console.push(note); + } + } + + /// Adds `alerts` within [`MAX_ALERTS`]. + fn alert(&mut self, alerts: impl IntoIterator) { + let room = MAX_ALERTS.saturating_sub(self.alerts.len()); + self.alerts.extend(alerts.into_iter().take(room)); + } + /// Adds what a field's own script produced: its alerts, console lines, errors and requests, /// except a request to show a side panel or open Save As, which is noted on the console /// and dropped. fn absorb(&mut self, o: &Outcome) { - self.alerts.extend(o.alerts.iter().cloned()); - self.console.extend(o.console.iter().cloned()); + self.alert(o.alerts.iter().cloned()); + self.say(o.console.iter().cloned()); // A field's own scripts (keystroke, format, validate, calculate) run unasked: only a // button's may show or hide a side panel, or open Save As. + // The warnings go through `say` like any other console line, so they count against the + // line and byte caps instead of growing the console past them. + let mut notes = Vec::new(); for r in &o.requests { match r { - Request::ShowPanel(_) => self.console.push("A field script asked to show a side panel; only a button may show a side panel".into()), - Request::SaveAs => { - self.console.push("A field script asked to save the document as a new file; only a button may open Save As".into()) - } + Request::ShowPanel(_) => notes.push("A field script asked to show a side panel; only a button may show a side panel".to_string()), + Request::SaveAs => notes.push("A field script asked to save the document as a new file; only a button may open Save As".to_string()), r => self.requests.push(r.clone()), } } + self.say(notes); self.errors.extend(o.error.iter().cloned()); // The document scripts run before every field script, so a failing one would repeat // once per calculation; it is reported once. @@ -61,8 +102,8 @@ impl JsOutput { } pub(crate) fn append(&mut self, other: JsOutput) { - self.alerts.extend(other.alerts); - self.console.extend(other.console); + self.alert(other.alerts); + self.say(other.console); self.requests.extend(other.requests); // As in `absorb`: a document script's error, already reported, is not repeated; a field // script's errors are each their own, even one worded like a document script's. @@ -330,11 +371,9 @@ impl Session { if !changes.is_empty() { edits.push(Edit::ApplyScriptChanges { changes }); } - for names in resets { - let all: Vec = fields.iter().map(|f| f.name.clone()).collect(); - let names = (!names.is_empty()) - .then(|| all.iter().filter(|n| names.iter().any(|x| *n == x || n.starts_with(&format!("{x}.")))).cloned().collect()); - edits.push(Edit::ResetForm { names }); + if !resets.is_empty() { + let all: Vec<&str> = fields.iter().map(|f| f.name.as_str()).collect(); + edits.push(Edit::ResetForm { names: reset_targets(&all, &resets) }); } if !edits.is_empty() { self.apply(id, Edit::Batch { label: "Run JavaScript".into(), edits })?; @@ -343,6 +382,21 @@ impl Session { } } +/// The fields every `resetForm` call of one script asked for, as one reset: a reset is to the +/// defaults, so resetting the union once does what each call in turn would, and recalculates +/// once. `None` is the whole form (a call with no names). A field is named by a selector that +/// is its name or the name of a field above it, looked up in a set of the selectors, so the +/// work is the fields times their depth, not the fields times the selectors, which a script +/// controls. +fn reset_targets(fields: &[&str], requests: &[Vec]) -> Option> { + if requests.iter().any(Vec::is_empty) { + return None; + } + let wanted: std::collections::HashSet<&str> = requests.iter().flatten().map(String::as_str).collect(); + let matched = |name: &str| name.match_indices('.').map(|(i, _)| &name[..i]).chain(std::iter::once(name)).any(|p| wanted.contains(p)); + Some(fields.iter().filter(|n| matched(n)).map(|n| n.to_string()).collect()) +} + /// A page's words for form-field detection, in user space (underscore runs split from the /// text around them). fn detection_words(text: &pdfcraft_render::PageText, info: &pdfcraft_render::PageInfo) -> Vec { @@ -492,6 +546,47 @@ impl Session { mod review_tests { use super::*; + /// Many `resetForm` calls with many selectors over a big form: one reset of the union, found + /// by looking each field's name and parents up, not by comparing every field with every + /// selector. + #[test] + fn reset_requests_are_coalesced_and_matched_through_an_index() { + let fields: Vec = (0..20_000).map(|i| format!("group{}.row{i}.cell", i % 100)).collect(); + let names: Vec<&str> = fields.iter().map(String::as_str).collect(); + let requests: Vec> = (0..1_000).map(|k| (0..20).map(|j| format!("nothing{}", k * 20 + j)).collect()).collect(); + let started = std::time::Instant::now(); + assert_eq!(reset_targets(&names, &requests), Some(Vec::new()), "no selector names a field"); + let mut requests = requests; + requests.push(vec!["group7".into(), "group7.row7.cell".into(), "group8.row8".into()]); + let hit = reset_targets(&names, &requests).unwrap(); + assert_eq!(hit.len(), 200 + 1, "group7 has 200 fields, one more by its exact name"); + assert!(hit.iter().all(|n| n.starts_with("group7.") || n == "group8.row8.cell")); + assert!(started.elapsed() < std::time::Duration::from_secs(5), "{:?}", started.elapsed()); + requests.push(Vec::new()); + assert_eq!(reset_targets(&names, &requests), None, "a call without names resets the whole form"); + } + + /// A form of many calculated fields, each printing its own cap of console lines, keeps a + /// bounded console: past the budget, lines are left out and one closing line says so. + #[test] + fn console_output_of_one_edit_is_bounded() { + let mut out = JsOutput::default(); + let line = "x".repeat(4_096); + let run = Outcome { console: vec![line; 1_000], alerts: vec![Message::plain("hi"); 100], ..Default::default() }; + for _ in 0..1_000 { + out.absorb(&run); + } + let bytes: usize = out.console.iter().map(String::len).sum(); + assert!(bytes <= MAX_CONSOLE_BYTES + 256, "{bytes} bytes kept"); + assert!(out.console.len() <= MAX_CONSOLE_LINES + 1, "{} lines kept", out.console.len()); + assert!(out.console.last().unwrap().starts_with("more console lines were left out")); + assert_eq!(out.console.iter().filter(|l| l.starts_with("more console lines")).count(), 1, "said once"); + assert_eq!(out.alerts.len(), MAX_ALERTS); + let mut merged = JsOutput::default(); + merged.append(out); + assert!(merged.console.len() <= MAX_CONSOLE_LINES + 1); + } + /// A long run of underscores (a form's blank) is read in one pass: each glyph is compared with /// the word so far without reading the word again. #[test] From 85cd33e9c606a2ed777d2727612d28df8882f419 Mon Sep 17 00:00:00 2001 From: Harrison Booth <73434116+Harrison-Booth@users.noreply.github.com> Date: Sun, 8 Nov 2026 18:41:38 -0600 Subject: [PATCH 41/92] Harden Bates sequence validation (#6003) --- crates/automation/src/bates.rs | 264 ++++++++++++++++++++--- crates/automation/src/file_access.rs | 5 +- crates/automation/src/tools.rs | 4 +- crates/automation/tests/it/automation.rs | 34 ++- 4 files changed, 271 insertions(+), 36 deletions(-) diff --git a/crates/automation/src/bates.rs b/crates/automation/src/bates.rs index 5267716e85..ffb2d42723 100644 --- a/crates/automation/src/bates.rs +++ b/crates/automation/src/bates.rs @@ -8,6 +8,8 @@ use super::json::Args; use super::paths::read_input; use super::{Arc, Automation, Path, PathBuf}; +const MAX_BATES_BYTES: u64 = 512 * 1024 * 1024; + struct FilePlan { input: String, source: PathBuf, @@ -26,19 +28,23 @@ impl Automation { } let start = match args.0.get("start_number") { None | Some(Value::Null) => 1, - Some(value) => value - .as_u64() - .filter(|n| *n >= 1) - .ok_or_else(|| ToolError::InvalidArgs("start_number must be a whole number of at least 1".into()))?, + Some(value) => { + value.as_u64().filter(|n| *n >= 1).ok_or_else(|| ToolError::InvalidArgs("start_number must be an integer of at least 1".into()))? + } }; let digits = args.opt_int("digits")?.unwrap_or(6); let digits = u8::try_from(digits).ok().filter(|d| (1..=12).contains(d)).ok_or_else(|| ToolError::InvalidArgs("digits must be from 1 to 12".into()))?; let prefix = args.opt_str("prefix")?.unwrap_or(""); let suffix = args.opt_str("suffix")?.unwrap_or(""); - // `#` separates the token's fields and `>>` ends it: either would cut the token short. - if prefix.len() > 128 || suffix.len() > 128 || [prefix, suffix].iter().any(|s| s.contains('#') || s.contains(">>")) { - return Err(ToolError::InvalidArgs("prefix and suffix must be at most 128 bytes and cannot contain # or >>".into())); + if prefix.chars().count() > 128 + || suffix.chars().count() > 128 + || prefix.contains('#') + || suffix.contains('#') + || prefix.contains(">>") + || suffix.contains(">>") + { + return Err(ToolError::InvalidArgs("prefix and suffix must be at most 128 characters and cannot contain # or >>".into())); } let dry_run = args.opt_bool("dry_run")?.unwrap_or(false); @@ -70,16 +76,7 @@ impl Automation { targets.push((output.to_string(), target)); passwords.push(entry.get("password").and_then(Value::as_str).map(str::to_owned)); } - let input_paths: std::collections::HashSet<_> = sources.iter().map(|(_, path)| path.clone()).collect(); - let mut output_paths = std::collections::HashSet::new(); - for (name, path) in &targets { - if input_paths.contains(path) { - return Err(failed(format!("output {name} names an input file; choose a separate output path"))); - } - if !output_paths.insert(path.clone()) { - return Err(failed(format!("two files use the same output path: {name}"))); - } - } + check_unique_outputs(&targets, dry_run)?; let mut plans = Vec::with_capacity(files.len()); let mut next = start; @@ -88,13 +85,13 @@ impl Automation { let size = std::fs::metadata(input_path).map_err(failed)?.len(); input_bytes = input_bytes .checked_add(size) - .filter(|total| *total <= 512 * 1024 * 1024) + .filter(|total| *total <= MAX_BATES_BYTES) .ok_or_else(|| failed("combined Bates input exceeds the 512 MiB safety limit"))?; let bytes = read_input(input_path)?; input_bytes = input_bytes .saturating_sub(size) .checked_add(u64::try_from(bytes.len()).unwrap_or(u64::MAX)) - .filter(|total| *total <= 512 * 1024 * 1024) + .filter(|total| *total <= MAX_BATES_BYTES) .ok_or_else(|| failed("combined Bates input exceeds the 512 MiB safety limit"))?; let display_name = input_path.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default(); let password = passwords.get(index).and_then(Option::as_deref); @@ -106,7 +103,8 @@ impl Automation { return Err(failed(format!("{input_name} has no pages to number"))); } let count = u64::try_from(pages).map_err(|_| failed(format!("page count is too large for {input_name}")))?; - let last = next.checked_add(count.saturating_sub(1)).ok_or_else(|| failed(format!("Bates number range overflows for {input_name}")))?; + let last_offset = count.checked_sub(1).ok_or_else(|| failed(format!("{input_name} has no pages to number")))?; + let last = next.checked_add(last_offset).ok_or_else(|| failed(format!("Bates number range overflows for {input_name}")))?; plans.push(FilePlan { input: input_name.clone(), source: input_path.clone(), @@ -116,7 +114,7 @@ impl Automation { first: next, last, }); - if index + 1 < sources.len() { + if index + 1 < files.len() { next = last.checked_add(1).ok_or_else(|| failed(format!("Bates sequence overflows after {input_name}")))?; } } @@ -163,12 +161,14 @@ impl Automation { let result = applied.and_then(|()| self.session.save_full_bytes(id)); match result { Ok(bytes) => { - let total = output_bytes.saturating_add(u64::try_from(bytes.len()).unwrap_or(u64::MAX)); - if total > 512 * 1024 * 1024 { - results.push(json!({"input":plan.input,"output":plan.output,"status":"error","pages":plan.pages,"first":plan.first,"last":plan.last,"error":"combined Bates output exceeds the 512 MiB safety limit"})); - } else { + let candidate = output_bytes + .checked_add(u64::try_from(bytes.len()).unwrap_or(u64::MAX)) + .filter(|total| *total <= MAX_BATES_BYTES); + if let Some(total) = candidate { output_bytes = total; staged.push((plan.output.clone(), bytes)); + } else { + results.push(json!({"input":plan.input,"output":plan.output,"status":"error","pages":plan.pages,"first":plan.first,"last":plan.last,"error":"combined Bates output exceeds the 512 MiB safety limit"})); } } Err(error) => results.push(json!({"input":plan.input,"output":plan.output,"status":"error","pages":plan.pages,"first":plan.first,"last":plan.last,"error":error.to_string()})), @@ -198,6 +198,123 @@ impl Automation { } } +fn check_unique_outputs(targets: &[(String, PathBuf)], dry_run: bool) -> Result<()> { + if targets.len() < 2 { + return Ok(()); + } + + // Probe exact basenames in a directory under each canonical parent, so the filesystem applies its own name rules. + let mut groups: std::collections::HashMap)> = std::collections::HashMap::new(); + for (name, target) in targets { + let parent = target.parent().ok_or_else(|| failed(format!("output has no parent directory: {name}")))?; + let identity = parent.canonicalize().map_err(failed)?; + let probe_name = output_probe_name(target).map_err(failed)?; + groups.entry(identity.clone()).or_insert_with(|| (identity, Vec::new())).1.push((name.clone(), probe_name)); + } + + if dry_run { + // Dry runs cannot probe the volume: Windows uses a conservative case-insensitive comparison, + // while Unix preserves case, so unusual case-sensitive Windows or case-insensitive Unix volumes can differ. + for (_, (_, names)) in groups { + for (index, (name, candidate)) in names.iter().enumerate() { + if names.iter().take(index).any(|(_, prior)| same_output_name(prior, candidate)) { + return Err(failed(format!("two files use the same output path: {name}"))); + } + } + } + return Ok(()); + } + + for (_, (parent, names)) in groups { + match tempfile::tempdir_in(&parent) { + Ok(probes) => { + for (name, probe_name) in names { + let probe = probes.path().join(probe_name); + match std::fs::OpenOptions::new().write(true).create_new(true).open(&probe) { + Ok(file) => drop(file), + Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => { + return Err(failed(format!("two files use the same output path: {name}"))); + } + Err(error) => return Err(failed(format!("could not validate output path {name}: {error}"))), + } + } + } + #[cfg(windows)] + Err(error) if error.kind() == std::io::ErrorKind::PermissionDenied => probe_files_for_resolved_outputs(&parent, names)?, + Err(error) => return Err(failed(error)), + } + } + Ok(()) +} + +#[cfg(windows)] +fn probe_files_for_resolved_outputs(parent: &Path, names: Vec<(String, std::ffi::OsString)>) -> Result<()> { + use std::os::windows::fs::OpenOptionsExt; + + const FILE_FLAG_DELETE_ON_CLOSE: u32 = 0x0400_0000; + let mut probes = Vec::with_capacity(names.len()); + for (name, probe_name) in names { + let probe = parent.join(probe_name); + let mut options = std::fs::OpenOptions::new(); + options.write(true).create_new(true).share_mode(0).custom_flags(FILE_FLAG_DELETE_ON_CLOSE); + match options.open(&probe) { + Ok(file) => probes.push(file), + Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => { + return Err(failed(format!("two files use the same output path: {name}"))); + } + Err(error) => return Err(failed(format!("could not validate output path {name}: {error}"))), + } + } + drop(probes); + Ok(()) +} + +fn same_output_name(left: &std::ffi::OsStr, right: &std::ffi::OsStr) -> bool { + #[cfg(windows)] + { + use std::os::windows::ffi::OsStrExt; + fn key(name: &std::ffi::OsStr) -> Vec { + std::char::decode_utf16(name.encode_wide()) + .map(|decoded| match decoded { + Ok(character) => { + let mut uppercase = character.to_uppercase(); + match (uppercase.next(), uppercase.next()) { + (Some(single), None) => single as u32, + _ => character as u32, + } + } + Err(error) => 0x11_0000 + u32::from(error.unpaired_surrogate()), + }) + .collect() + } + return key(left) == key(right); + } + #[cfg(not(windows))] + { + left == right + } +} + +fn output_probe_name(path: &Path) -> std::io::Result { + let name = path.file_name().ok_or_else(|| std::io::Error::new(std::io::ErrorKind::InvalidInput, "output has no file name"))?; + #[cfg(windows)] + { + use std::os::windows::ffi::{OsStrExt, OsStringExt}; + let is_verbatim = path.components().any(|component| matches!(component, std::path::Component::Prefix(prefix) if matches!(prefix.kind(), std::path::Prefix::Verbatim(_) | std::path::Prefix::VerbatimUNC(_, _) | std::path::Prefix::VerbatimDisk(_)))); + let mut wide: Vec = name.encode_wide().collect(); + if !is_verbatim { + while wide.last().is_some_and(|unit| *unit == u16::from(b'.') || *unit == u16::from(b' ')) { + wide.pop(); + } + } + return Ok(std::ffi::OsString::from_wide(&wide)); + } + #[cfg(not(windows))] + { + Ok(name.to_os_string()) + } +} + /// Publish a PDF atomically while refusing a target created by another process after preflight. pub(super) fn publish_new(path: &Path, bytes: &[u8]) -> Result<()> { let parent = path.parent().ok_or_else(|| failed("output has no parent directory"))?; @@ -212,3 +329,100 @@ pub(super) fn publish_new(path: &Path, bytes: &[u8]) -> Result<()> { } }) } + +#[cfg(test)] +mod tests { + use super::check_unique_outputs; + + #[test] + fn dry_run_path_check_creates_no_probe_files() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("same.pdf"); + let targets = vec![("first.pdf".into(), path.clone()), ("second.pdf".into(), path)]; + assert!(check_unique_outputs(&targets, true).is_err()); + assert_eq!(std::fs::read_dir(dir.path()).unwrap().count(), 0); + } + + #[cfg(unix)] + #[test] + fn dry_run_detects_output_parent_aliases_without_writing() { + let dir = tempfile::tempdir().unwrap(); + let alias = dir.path().join("alias"); + std::os::unix::fs::symlink(dir.path(), &alias).unwrap(); + let first = dir.path().join("same.pdf"); + let second = alias.join("same.pdf"); + let targets = vec![("first.pdf".into(), first), ("second.pdf".into(), second)]; + assert!(check_unique_outputs(&targets, true).is_err()); + assert_eq!(std::fs::read_dir(dir.path()).unwrap().count(), 1); + } + + #[test] + fn long_output_names_fit_in_probe_directory() { + let dir = tempfile::tempdir().unwrap(); + let first = dir.path().join(format!("{}.pdf", "a".repeat(220))); + let second = dir.path().join(format!("{}.pdf", "b".repeat(220))); + let targets = vec![("first.pdf".into(), first), ("second.pdf".into(), second)]; + assert!(check_unique_outputs(&targets, false).is_ok()); + assert_eq!(std::fs::read_dir(dir.path()).unwrap().count(), 0); + } + + #[cfg(unix)] + #[test] + fn output_probe_paths_preserve_non_utf8_names() { + use std::os::unix::ffi::OsStringExt; + let dir = tempfile::tempdir().unwrap(); + let first = dir.path().join(std::ffi::OsString::from_vec(vec![b'o', 0xff])); + let second = dir.path().join(std::ffi::OsString::from_vec(vec![b'o', 0xfe])); + let targets = vec![("first.pdf".into(), first), ("second.pdf".into(), second)]; + assert!(check_unique_outputs(&targets, false).is_ok()); + assert_eq!(std::fs::read_dir(dir.path()).unwrap().count(), 0); + } + + #[cfg(windows)] + #[test] + fn windows_probe_names_trim_only_non_verbatim_paths() { + use std::ffi::OsString; + use std::path::Path; + + let normal = Path::new(r"C:\root\report.pdf. "); + assert_eq!(super::output_probe_name(normal).unwrap(), OsString::from("report.pdf")); + + let verbatim = Path::new(r"\\?\C:\root\report.pdf. "); + assert_eq!(super::output_probe_name(verbatim).unwrap(), OsString::from("report.pdf. ")); + } + + #[cfg(windows)] + #[test] + fn dry_run_detects_case_only_output_collisions_without_writing() { + let dir = tempfile::tempdir().unwrap(); + let first = dir.path().join("Case.pdf"); + let second = dir.path().join("CASE.pdf"); + let targets = vec![("first.pdf".into(), first), ("second.pdf".into(), second)]; + assert!(check_unique_outputs(&targets, true).is_err()); + assert_eq!(std::fs::read_dir(dir.path()).unwrap().count(), 0); + } + + #[cfg(windows)] + #[test] + fn permission_denied_child_directory_uses_delete_on_close_file_probes() { + use std::process::Command; + + let dir = tempfile::tempdir().unwrap(); + let identity = Command::new("whoami.exe").args(["/user", "/fo", "csv", "/nh"]).output().unwrap(); + assert!(identity.status.success()); + let identity = String::from_utf8(identity.stdout).unwrap(); + let sid = identity.split('"').nth(3).unwrap(); + let acl = Command::new("icacls.exe").arg(dir.path()).arg("/deny").arg(format!("*{sid}:(AD)")).status().unwrap(); + assert!(acl.success()); + + assert!(matches!(tempfile::tempdir_in(dir.path()), Err(error) if error.kind() == std::io::ErrorKind::PermissionDenied)); + let direct_probe = dir.path().join("direct-probe"); + let file = std::fs::OpenOptions::new().write(true).create_new(true).open(&direct_probe).unwrap(); + drop(file); + std::fs::remove_file(&direct_probe).unwrap(); + + let targets = vec![("first.pdf".into(), dir.path().join("first.pdf")), ("second.pdf".into(), dir.path().join("second.pdf"))]; + assert!(check_unique_outputs(&targets, false).is_ok()); + assert_eq!(std::fs::read_dir(dir.path()).unwrap().count(), 0); + } +} diff --git a/crates/automation/src/file_access.rs b/crates/automation/src/file_access.rs index dab2760f5d..ff91152567 100644 --- a/crates/automation/src/file_access.rs +++ b/crates/automation/src/file_access.rs @@ -393,7 +393,8 @@ mod tests { fn tools_do_not_touch_the_file_system_without_resolving_the_path() { // Helpers that are only called with a resolved path. `doc_batch` (batch.rs) resolves its // inputs and out_dir, walks only inside them (skipping links) and resolves every - // discovered file again before `process` reads it or `publish` writes it. + // discovered file again before `process` reads it or `publish` writes it. `doc_bates_multi` + // resolves its paths before checking output collisions and using the probe helpers. const RESOLVED_BY_CALLER: &[&str] = &[ "write_atomic", "write_atomic_with", @@ -405,6 +406,8 @@ mod tests { "linked", "walk", "publish", + "check_unique_outputs", + "probe_files_for_resolved_outputs", ]; let mut dirs = vec![Path::new(env!("CARGO_MANIFEST_DIR")).join("src")]; let mut files = vec![]; diff --git a/crates/automation/src/tools.rs b/crates/automation/src/tools.rs index 16371c6a70..8188045b87 100644 --- a/crates/automation/src/tools.rs +++ b/crates/automation/src/tools.rs @@ -140,8 +140,8 @@ pub fn tools() -> Vec { .with(schema(json!({ "files": {"type":"array","minItems":1,"maxItems":1000,"description":"PDFs in Bates sequence order.","items":{"type":"object","properties":{"input":{"type":"string","minLength":1,"description":"Input PDF path."},"output":{"type":"string","minLength":1,"description":"New output PDF path; must not exist."},"password":{"type":"string","description":"Open or permissions password, if needed."}},"required":["input","output"],"additionalProperties":false}}, "start_number": {"type":"integer","minimum":1,"description":"First number in the sequence (default 1)."}, "digits": {"type":"integer","minimum":1,"maximum":12,"description":"Zero-padded number width (default 6)."}, - "prefix": {"type":"string","maxLength":128,"description":"Text before the number: at most 128 UTF-8 bytes, without # or >>."}, "suffix": {"type":"string","maxLength":128,"description":"Text after the number: at most 128 UTF-8 bytes, without # or >>."}, - "dry_run": {"type":"boolean","description":"Return each file's inclusive number interval without writing (default false)."} + "prefix": {"type":"string","maxLength":128,"description":"Bates prefix, up to 128 characters; cannot contain # or >>."}, "suffix": {"type":"string","maxLength":128,"description":"Bates suffix, up to 128 characters; cannot contain # or >>."}, + "dry_run": {"type":"boolean","description":"Return each file's inclusive number interval without writing (default false). Output collisions are checked conservatively: Windows compares case-insensitively, while Unix preserves case, so case-sensitive Windows and case-insensitive Unix volumes can differ from normal runs."} }), &["files"])), t("doc_batch", "Process files and folders", "Run convert, ocr and/or optimize over files and folders, one file at a time. recursive defaults to false. Preserve paths relative to each selected folder inside out_dir; duplicate inputs run once. Skip unsupported files, links and the output subtree. Existing outputs receive numbered names and are never replaced. Continue after per-file errors unless stop_on_error is true. Return completed/failed/skipped counts and per-file results. Discovery is bounded to 10000 entries and 64 folder levels. Convert must be first; each step can appear once. OCR uses installed models. Optimize uses Reduce File Size settings, optionally overriding target ppi (threshold 1.5 times ppi; black-and-white images go to the larger of ppi and 300) and JPEG quality; a larger candidate keeps the original bytes. Sources and open documents are unchanged.") .with(schema(json!({ diff --git a/crates/automation/tests/it/automation.rs b/crates/automation/tests/it/automation.rs index da634490e4..1192050e51 100644 --- a/crates/automation/tests/it/automation.rs +++ b/crates/automation/tests/it/automation.rs @@ -2969,6 +2969,15 @@ fn bates_numbers_continue_across_files_and_report_preflight_and_permission_failu {"input":"a.pdf","output":"a-numbered.pdf"} ]); let mut a = auto(&dir); + for invalid_start in [json!(0), json!(-1), json!(1.5), json!("17")] { + let error = err(&mut a, "doc_bates_multi", json!({"files":files,"start_number":invalid_start})); + assert!(matches!(error, ToolError::InvalidArgs(_))); + } + let unicode = ok(&mut a, "doc_bates_multi", json!({"files":files,"prefix":"é".repeat(65),"dry_run":true})); + assert_eq!(unicode["files"].as_array().unwrap().len(), 2); + assert!(matches!(err(&mut a, "doc_bates_multi", json!({"files":files,"prefix":"CASE>>"})), ToolError::InvalidArgs(_))); + assert!(matches!(err(&mut a, "doc_bates_multi", json!({"files":files,"suffix":"A>>B"})), ToolError::InvalidArgs(_))); + let allocation = ok(&mut a, "doc_bates_multi", json!({"files":files,"start_number":50,"dry_run":true})); assert_eq!(allocation["files"][0]["first"], 50); assert_eq!(allocation["files"][0]["last"], 51); @@ -2987,15 +2996,24 @@ fn bates_numbers_continue_across_files_and_report_preflight_and_permission_failu let overflow = err(&mut a, "doc_bates_multi", json!({"files":[{"input":"b.pdf","output":"overflow.pdf"}],"start_number":u64::MAX})); assert!(overflow.to_string().contains("overflows")); assert!(!dir.join("overflow.pdf").exists()); - // A range may end at u64::MAX itself. - let at_max = ok(&mut a, "doc_bates_multi", json!({"files":[{"input":"b.pdf","output":"max.pdf"}],"start_number":u64::MAX - 1,"dry_run":true})); - assert_eq!(at_max["files"][0]["last"], u64::MAX); - for bad in - [json!({"start_number":-3}), json!({"start_number":2.5}), json!({"start_number":"7"}), json!({"prefix":"CASE>>"}), json!({"suffix":"A>>B"})] + std::fs::write(dir.join("one.pdf"), fixture(1)).unwrap(); + let max_number = + ok(&mut a, "doc_bates_multi", json!({"files":[{"input":"one.pdf","output":"one-numbered.pdf"}],"start_number":u64::MAX,"dry_run":true})); + assert_eq!(max_number["files"][0]["first"], u64::MAX); + assert_eq!(max_number["files"][0]["last"], u64::MAX); + + #[cfg(windows)] { - let mut args = json!({"files":[{"input":"b.pdf","output":"bad.pdf"}],"dry_run":true}); - args.as_object_mut().unwrap().extend(bad.as_object().unwrap().clone()); - assert!(a.call("doc_bates_multi", &args).is_err(), "{bad}"); + let collision = err( + &mut a, + "doc_bates_multi", + json!({"files":[ + {"input":"b.pdf","output":"Case.pdf"}, + {"input":"a.pdf","output":"CASE.pdf"} + ]}), + ); + assert!(collision.to_string().contains("same output path")); + assert!(!dir.join("Case.pdf").exists()); } use pdfcraft_engine::{Changes, Edit, Protection, Session}; From 9642ed6f87584908a65d5bf78726e102c94a0f54 Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Sun, 8 Nov 2026 19:42:09 -0500 Subject: [PATCH 42/92] fix(sign): read each existing DSS stream once and within a decoded-size allowance (#6040) --- crates/sign/src/dss.rs | 87 +++++++++++++++++++++++++++++++++++++----- 1 file changed, 77 insertions(+), 10 deletions(-) diff --git a/crates/sign/src/dss.rs b/crates/sign/src/dss.rs index 0d82c797a7..0c76ca1967 100644 --- a/crates/sign/src/dss.rs +++ b/crates/sign/src/dss.rs @@ -37,9 +37,12 @@ pub fn embed(doc: &Document, evidence: &Evidence) -> Result, SignError> let mut ocsps: Vec> = Vec::new(); let mut crls: Vec> = Vec::new(); if let Some(d) = &existing { - pull(&doc, d, b"Certs", &mut certs)?; - pull(&doc, d, b"OCSPs", &mut ocsps)?; - pull(&doc, d, b"CRLs", &mut crls)?; + let mut budget = MAX_EXISTING_EVIDENCE; + // Shared by the three reads: a stream listed under two keys is decoded and charged once. + let mut decoded = std::collections::HashMap::new(); + pull(&doc, d, b"Certs", &mut certs, &mut budget, &mut decoded)?; + pull(&doc, d, b"OCSPs", &mut ocsps, &mut budget, &mut decoded)?; + pull(&doc, d, b"CRLs", &mut crls, &mut budget, &mut decoded)?; } let add = |list: &mut Vec>, blob: &[u8]| { if !list.iter().any(|b| b.as_slice() == blob) { @@ -105,19 +108,83 @@ fn embed_stream(doc: &mut Document, blob: &[u8]) -> pdfcraft_cos::ObjRef { doc.add(Object::Stream(Stream::from_raw(d, blob.to_vec()))) } -/// Pull the raw bytes of an existing DSS array into `into`. -fn pull(doc: &Document, dss: &Dict, key: &[u8], into: &mut Vec>) -> Result<(), SignError> { +/// Most decoded bytes of the store's existing evidence one [`embed`] reads. The store is rewritten +/// as a union, so what is there has to be read; a file whose evidence is larger than this is +/// refused rather than extended, never silently trimmed. +const MAX_EXISTING_EVIDENCE: usize = 256 << 20; + +/// Pull the raw bytes of an existing DSS array into `into`, each stream once (however often the +/// array lists it) and each distinct content once, spending the decoded size from `budget`. +fn pull( + doc: &Document, + dss: &Dict, + key: &[u8], + into: &mut Vec>, + budget: &mut usize, + decoded: &mut std::collections::HashMap>, +) -> Result<(), SignError> { let Some(a) = dss.get(key).map(|o| doc.resolve(o)).and_then(|o| o.as_array().cloned()) else { return Ok(()); }; + let mut seen_refs = std::collections::HashSet::new(); + let mut seen_content: std::collections::HashSet> = into.iter().map(|b| crate::keys::DigestAlg::Sha256.digest(&[b.as_slice()])).collect(); for item in a { - let o = doc.resolve(&item); - let Object::Stream(s) = &*o else { continue }; - if let Ok(bytes) = s.decoded() - && !into.iter().any(|b| b == &bytes) - { + if item.as_ref().is_some_and(|r| !seen_refs.insert(r)) { + continue; + } + // A stream another DSS array already read is reused: it is neither decoded again nor + // charged twice, so a store that lists the same evidence under two keys still fits. + let bytes = match item.as_ref().and_then(|r| decoded.get(&r).cloned()) { + Some(bytes) => bytes, + None => { + let o = doc.resolve(&item); + let Object::Stream(s) = &*o else { continue }; + let too_big = || SignError::Pdf("the document's existing validation data is too large to extend".into()); + match s.decoded_within(*budget) { + Ok(bytes) => { + *budget = budget.checked_sub(bytes.len()).ok_or_else(too_big)?; + if let Some(r) = item.as_ref() { + decoded.insert(r, bytes.clone()); + } + bytes + } + // The allowance is spent: refuse, rather than drop evidence the store holds. + Err(e) if e.to_string().contains("exceeds the limit") => return Err(too_big()), + // Not decodable at all: skipped, as before. + Err(_) => continue, + } + } + }; + if seen_content.insert(crate::keys::DigestAlg::Sha256.digest(&[bytes.as_slice()])) { into.push(bytes); } } Ok(()) } + +#[cfg(test)] +mod tests { + use super::*; + + fn store(blobs: &[&[u8]], listed: &[usize]) -> (Document, Dict) { + let mut doc = Document::new_empty(); + let refs: Vec<_> = blobs.iter().map(|b| doc.add(Object::Stream(Stream::from_raw(Dict::new(), b.to_vec())))).collect(); + let mut dss = Dict::new(); + dss.set(b"Certs".to_vec(), Object::Array(listed.iter().map(|i| Object::Ref(refs[*i])).collect())); + (doc, dss) + } + + #[test] + fn a_stream_listed_many_times_is_read_once_and_the_decoded_size_is_spent() { + let (doc, dss) = store(&[&[1; 100], &[1; 100], &[2; 50]], &[0, 0, 0, 1, 2, 2]); + let mut out = Vec::new(); + let mut budget = 1000; + pull(&doc, &dss, b"Certs", &mut out, &mut budget, &mut std::collections::HashMap::new()).unwrap(); + // Two distinct streams are listed with the same bytes: one copy; the repeats were not read. + assert_eq!(out, [vec![1; 100], vec![2; 50]]); + assert_eq!(budget, 1000 - 100 - 100 - 50); + // Past the allowance the store is refused, not trimmed. + let mut budget = 120; + assert!(pull(&doc, &dss, b"Certs", &mut Vec::new(), &mut budget, &mut std::collections::HashMap::new()).is_err()); + } +} From 74e1e40e676456d4eb92556db097f96c7ed24731 Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Sun, 8 Nov 2026 19:42:24 -0500 Subject: [PATCH 43/92] fix(a11y): heading titles decode their source strings within a shared allowance (#6049) --- crates/a11y/src/headings.rs | 33 +++++++++++++++++++++++++++++---- crates/a11y/src/tests.rs | 18 ++++++++++++++++++ 2 files changed, 47 insertions(+), 4 deletions(-) diff --git a/crates/a11y/src/headings.rs b/crates/a11y/src/headings.rs index 0fa31ccbb1..235ba4b452 100644 --- a/crates/a11y/src/headings.rs +++ b/crates/a11y/src/headings.rs @@ -37,9 +37,16 @@ struct Walker<'a> { visits: usize, /// Per page: the text each MCID shows (read once per page). texts: HashMap>, + /// What is left of [`MAX_TITLE_SOURCE_BYTES`]. + title_left: usize, out: Vec, } +/// Bytes of `/ActualText`, `/Alt` and `/T` strings the headings of one document decode in all. +/// Titles are cut to [`MAX_TITLE`] characters, but many headings can name one large string, and +/// each would otherwise be decoded in full first. +const MAX_TITLE_SOURCE_BYTES: usize = 32 << 20; + /// The headings, in document order. Headings without text or a page are left out. pub fn headings(doc: &Document) -> Vec { let Some(root) = tree_root(doc) else { return Vec::new() }; @@ -52,6 +59,7 @@ pub fn headings(doc: &Document) -> Vec { seen: HashSet::new(), visits: 0, texts: HashMap::new(), + title_left: MAX_TITLE_SOURCE_BYTES, out: Vec::new(), }; if let Some(k) = root.get(b"K") { @@ -131,17 +139,34 @@ impl Walker<'_> { self.marks(kids, pg, depth + 1, &mut marks); } let Some(page) = pg.or_else(|| marks.iter().find_map(|m| m.0)).and_then(|p| self.page_index.get(&p).copied()) else { return }; - let doc = self.doc; - let attr = |key: &[u8]| d.get(key).map(|o| doc.resolve(o)).and_then(|o| o.as_string().map(|s| s.to_text())).filter(|t| !t.trim().is_empty()); - let title = match attr(b"ActualText") { + let title = match self.attr(d, b"ActualText") { Some(t) => Some(t), - None => self.marked_text(&marks, page).or_else(|| attr(b"Alt")).or_else(|| attr(b"T")), + None => match self.marked_text(&marks, page) { + Some(t) => Some(t), + None => self.attr(d, b"Alt").or_else(|| self.attr(d, b"T")), + }, }; let Some(title) = title else { return }; let title: String = title.split_whitespace().collect::>().join(" ").chars().take(MAX_TITLE).collect(); self.out.push(Heading { obj, level, title, page }); } + /// The non-blank text of the string `key` holds, charged against the title allowance by the + /// longer of its raw and decoded size (a string that does not fit is not decoded). + fn attr(&mut self, d: &Dict, key: &[u8]) -> Option { + let o = self.doc.resolve(d.get(key)?); + let s = o.as_string()?; + // A text string decodes to at most three bytes per raw byte, so one whose worst case does + // not fit the remaining allowance is never decoded: the raw check alone let a string at + // the raw limit decode to three times it before the allowance was reduced. + if s.bytes.len().saturating_mul(3) > self.title_left { + return None; + } + let text = s.to_text(); + self.title_left = self.title_left.saturating_sub(text.len().max(s.bytes.len())); + Some(text).filter(|t| !t.trim().is_empty()) + } + /// The text the marked content shows (`page`: where marks without a page of their own are). fn marked_text(&mut self, marks: &[(Option, i64)], page: usize) -> Option { let mut parts = Vec::new(); diff --git a/crates/a11y/src/tests.rs b/crates/a11y/src/tests.rs index d8566eddf1..66ae06fce4 100644 --- a/crates/a11y/src/tests.rs +++ b/crates/a11y/src/tests.rs @@ -1559,6 +1559,24 @@ fn q_inside_a_text_object_restores_the_text_matrices() { assert_eq!(titles, ["Chapter One", "Chapter Two"]); } +#[test] +fn headings_naming_one_large_string_stop_decoding_it_at_the_allowance() { + let count = 100; + let kids = (0..count).map(|i| format!("{} 0 R", 8 + i)).collect::>().join(" "); + let mut objects = vec![ + "<< /Type /Catalog /Pages 2 0 R /MarkInfo << /Marked true >> /StructTreeRoot 5 0 R >>".to_string(), + "<< /Type /Pages /Kids [3 0 R] /Count 1 >>".to_string(), + "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 200 200] /Contents 4 0 R >>".to_string(), + stream("BT ET"), + "<< /Type /StructTreeRoot /K 6 0 R >>".to_string(), + format!("<< /S /Document /P 5 0 R /K [{kids}] >>"), + format!("({})", "a".repeat(512 << 10)), + ]; + objects.extend((0..count).map(|_| "<< /S /H1 /P 6 0 R /Pg 3 0 R /ActualText 7 0 R >>".to_string())); + let found = headings(&pdf(&objects, "")); + assert!(!found.is_empty() && found.len() < count, "{}", found.len()); +} + #[test] fn an_untagged_document_has_no_headings() { let mut objs = headings_doc(); From 3b731f9458e74a9abf406fb0e759bcc8993c5855 Mon Sep 17 00:00:00 2001 From: Harrison Booth <73434116+Harrison-Booth@users.noreply.github.com> Date: Sun, 8 Nov 2026 18:42:39 -0600 Subject: [PATCH 44/92] Expose widget rectangles to field scripts (#6093) --- crates/engine/src/js.rs | 4 +++- crates/engine/src/tests.rs | 14 ++++++++++++++ crates/forms/src/scripting.rs | 5 +++++ crates/forms/src/tests.rs | 1 + crates/js/src/document.rs | 31 +++++++++++++++++++++++++++++++ crates/js/src/model.rs | 3 +++ crates/js/src/tests.rs | 16 ++++++++++++++++ crates/ui-egui/src/canvas/area.rs | 1 + parity/acrobat-features.toml | 4 ++-- 9 files changed, 76 insertions(+), 3 deletions(-) diff --git a/crates/engine/src/js.rs b/crates/engine/src/js.rs index 7f0561d196..fead2dbebc 100644 --- a/crates/engine/src/js.rs +++ b/crates/engine/src/js.rs @@ -159,6 +159,7 @@ fn field_state_with_display(f: &Field, display: i32) -> pdfcraft_js::FieldState s.readonly = f.read_only(); s.required = f.has(pdfcraft_forms::flags::REQUIRED); s.display = display; + s.rect = f.widgets.first().map(|w| w.rect); s.char_limit = f.max_len; s.options = match f.kind { FieldKind::CheckBox | FieldKind::Radio => f.widgets.iter().filter_map(|w| w.on_state.clone()).map(|o| (o.clone(), o)).collect(), @@ -188,8 +189,9 @@ fn changes(before: &[pdfcraft_js::FieldState], after: &[pdfcraft_js::FieldState] read_only: (a.readonly != b.readonly).then_some(a.readonly), required: (a.required != b.required).then_some(a.required), display: (a.display != b.display).then_some(a.display), + rect: (a.rect != b.rect).then_some(a.rect).flatten(), }; - (c.value.is_some() || c.read_only.is_some() || c.required.is_some() || c.display.is_some()).then_some(c) + (c.value.is_some() || c.read_only.is_some() || c.required.is_some() || c.display.is_some() || c.rect.is_some()).then_some(c) }) .collect() } diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 4df8608946..181d5de452 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -6080,6 +6080,20 @@ fn form_javascript_validates_calculates_and_formats() { assert!(s.run_javascript(id, "1", None).is_err()); } +#[test] +fn javascript_field_rect_moves_first_widget_and_undoes() { + let mut s = Session::new(); + let id = s.open("rect.pdf", None, Arc::new(scripted_form()), None).unwrap(); + let initial = s.get(id).unwrap().form.iter().find(|f| f.name == "price").unwrap().widgets[0].rect; + s.run_javascript(id, "getField('price').rect = [30, 40, 130, 60];", Some("go")).unwrap(); + assert_eq!(s.get(id).unwrap().form.iter().find(|f| f.name == "price").unwrap().widgets[0].rect, [30.0, 40.0, 130.0, 60.0]); + assert_eq!(s.get(id).unwrap().can_undo(), Some("Run JavaScript")); + s.undo(id).unwrap(); + assert_eq!(s.get(id).unwrap().form.iter().find(|f| f.name == "price").unwrap().widgets[0].rect, initial); + s.redo(id).unwrap(); + assert_eq!(s.get(id).unwrap().form.iter().find(|f| f.name == "price").unwrap().widgets[0].rect, [30.0, 40.0, 130.0, 60.0]); +} + /// XFA forms check `xfa_installed` and `xfa_version` in a document script that runs before /// each of their scripts, and alert when either is missing. #[test] diff --git a/crates/forms/src/scripting.rs b/crates/forms/src/scripting.rs index 31e6812e6b..0f7452d96e 100644 --- a/crates/forms/src/scripting.rs +++ b/crates/forms/src/scripting.rs @@ -42,6 +42,8 @@ pub struct FieldChange { pub required: Option, /// `display.visible` 0, `hidden` 1, `noPrint` 2, `noView` 3. pub display: Option, + /// Move/resize the first widget, in PDF user space. + pub rect: Option<[f64; 4]>, } /// What a script did. @@ -173,6 +175,9 @@ pub(crate) fn apply_changes(doc: &mut Document, changes: &[FieldChange], except: } } } + if let Some(rect) = c.rect { + crate::set_props(doc, &c.name, &crate::FieldProps { rect: Some((0, rect)), ..Default::default() })?; + } } Ok(()) } diff --git a/crates/forms/src/tests.rs b/crates/forms/src/tests.rs index 6cf207d0de..5080ed03bd 100644 --- a/crates/forms/src/tests.rs +++ b/crates/forms/src/tests.rs @@ -2994,6 +2994,7 @@ fn recalc_chain_where_script_changes_checkbox() { read_only: None, required: None, display: None, + rect: None, }], message: None, } diff --git a/crates/js/src/document.rs b/crates/js/src/document.rs index e977457e2b..54274bb140 100644 --- a/crates/js/src/document.rs +++ b/crates/js/src/document.rs @@ -94,6 +94,36 @@ pub(crate) fn field_object(ctx: &mut Context, name: &str) -> JsObject { } function(ctx, NativeFunction::from_copy_closure_with_captures(set, n.clone())) }; + let rect_get = getter!(ctx, n, |f, c| match f.rect { + Some(rect) => JsArray::from_iter(rect.into_iter().map(JsValue::from).collect::>(), c).into(), + None => JsValue::null(), + }); + let rect_set = { + fn set(_: &JsValue, args: &[JsValue], name: &JsString, c: &mut Context) -> JsResult { + let value = arg(args, 0); + let object = value.as_object().filter(|o| o.is_array()).ok_or_else(|| error("rect must be an array of four finite numbers"))?; + let array = JsArray::from_object(object.clone())?; + if array.length(c)? != 4 { + return Err(error("rect must be an array of four finite numbers")); + } + let mut rect = [0.0; 4]; + for (i, coordinate) in rect.iter_mut().enumerate() { + *coordinate = array.get(i as u64, c)?.to_number(c)?; + } + if !rect.iter().all(|coordinate| coordinate.is_finite()) { + return Err(error("rect coordinates must be finite")); + } + let width = (rect[2] - rect[0]).abs(); + let height = (rect[3] - rect[1]).abs(); + if !width.is_finite() || !height.is_finite() || width < 4.0 || height < 4.0 { + return Err(error("rect must be at least 4 points wide and high")); + } + let rect = [rect[0].min(rect[2]), rect[1].min(rect[3]), rect[0].max(rect[2]), rect[1].max(rect[3])]; + write(c, name, |f| f.rect = Some(rect))?; + Ok(JsValue::undefined()) + } + function(ctx, NativeFunction::from_copy_closure_with_captures(set, n.clone())) + }; fn get_item_at(_: &JsValue, args: &[JsValue], name: &JsString, c: &mut Context) -> JsResult { let f = read(c, name)?; @@ -150,6 +180,7 @@ pub(crate) fn field_object(ctx: &mut Context, name: &str) -> JsObject { .accessor(js_string!("currentValueIndices"), Some(indices), None, rw) .accessor(js_string!("textColor"), Some(text_color_get), Some(text_color_set), rw) .accessor(js_string!("fillColor"), Some(fill_color_get), Some(fill_color_set), rw) + .accessor(js_string!("rect"), Some(rect_get), Some(rect_set), rw) .function(get_item_at, js_string!("getItemAt"), 2) .function(is_box_checked, js_string!("isBoxChecked"), 1) .function(check_this_box, js_string!("checkThisBox"), 2) diff --git a/crates/js/src/model.rs b/crates/js/src/model.rs index 36b09bec97..44d5ac2dee 100644 --- a/crates/js/src/model.rs +++ b/crates/js/src/model.rs @@ -52,6 +52,8 @@ pub struct FieldState { /// `textColor` / `fillColor` as Acrobat colour arrays (`["RGB", r, g, b]` etc.), when set. pub text_color: Option>, pub fill_color: Option>, + /// Rectangle of the first widget, in PDF user space. + pub rect: Option<[f64; 4]>, } impl FieldState { @@ -68,6 +70,7 @@ impl FieldState { char_limit: None, text_color: None, fill_color: None, + rect: None, } } diff --git a/crates/js/src/tests.rs b/crates/js/src/tests.rs index b61649b729..7f8595ab56 100644 --- a/crates/js/src/tests.rs +++ b/crates/js/src/tests.rs @@ -73,6 +73,22 @@ fn scripts_change_other_fields_and_their_properties() { assert_eq!(o.changed.iter().find(|f| f.name == "size").unwrap().value, ["l"]); } +#[test] +fn widget_rect_reads_and_writes_a_four_number_array() { + let o = go( + "var t = getField('total'); t.rect = [10, 20, 110, 50]; event.value = getField('total').rect.join(',');", + &Event::field("Mouse Up", "agree", ""), + ); + assert_eq!(o.error, None); + assert_eq!(o.value, "10,20,110,50"); + assert_eq!(o.changed.iter().find(|f| f.name == "total").and_then(|f| f.rect), Some([10.0, 20.0, 110.0, 50.0])); + let o = go("getField('total').rect = [110, 50, 10, 20];", &Event::field("Mouse Up", "agree", "")); + assert_eq!(o.changed.iter().find(|f| f.name == "total").and_then(|f| f.rect), Some([10.0, 20.0, 110.0, 50.0])); + let o = go("getField('total').rect = [0, 0, Infinity, 10];", &Event::field("Mouse Up", "agree", "")); + assert!(o.error.is_some()); + assert!(o.changed.iter().all(|f| f.name != "total")); +} + #[test] fn choice_and_check_box_object_model() { let o = go( diff --git a/crates/ui-egui/src/canvas/area.rs b/crates/ui-egui/src/canvas/area.rs index 9fd1cdd615..64cbd6e939 100644 --- a/crates/ui-egui/src/canvas/area.rs +++ b/crates/ui-egui/src/canvas/area.rs @@ -89,6 +89,7 @@ pub(super) fn run_button(app: &mut PdfCraftApp, index: usize, name: &str, action read_only: None, required: None, display: Some(if hide { 1 } else { 0 }), + rect: None, }) .collect(); if !changes.is_empty() { diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index e0c7adf36a..99a30c0b0f 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -5351,8 +5351,8 @@ milestone = "M6" status = "partial" commands = ["tools.js_console"] tools = ["js_run"] -evidence = ["crates/js/src/tests.rs::calculate_scripts_read_fields_and_set_the_value", "crates/engine/src/tests.rs::form_javascript_validates_calculates_and_formats", "crates/automation/tests/it/automation.rs::javascript_through_tools", "crates/js/src/tests.rs::an_xfa_form_sees_the_xfa_plugin_installed", "crates/js/src/tests.rs::a_plain_form_sees_the_xfa_version_but_not_installed", "crates/engine/src/tests.rs::scripts_in_an_xfa_form_see_the_xfa_plugin_installed", "crates/js/src/tests.rs::scand_allows_separators_between_fields_but_nothing_before_or_after_them", "crates/js/src/tests.rs::app_alert_keeps_its_title_icon_and_buttons", "crates/js/src/tests.rs::app_alert_reads_the_object_form", "crates/js/src/tests.rs::app_alert_falls_back_on_bad_icon_and_button_numbers", "crates/js/src/tests.rs::app_alert_titles_are_clipped_and_charged", "crates/ui-egui/tests/it/javascript.rs::every_message_a_script_shows_takes_the_toast_in_turn"] -notes = "boa (pure Rust) with the forms object model: event, getField/Field (value, readonly, display, colours, items, check boxes), app (with execMenuItem for the side panels and Save As, from a button only, and Print where this.print() works), util.printf/printd/printx/scand (scand reads dates as another viewer was observed to, in AcroForm and XFA scripts), console, display, color; xfa_version (3.5) in every document and xfa_installed in XFA forms, so their version check passes; no annotations, layers or dialogs (app.response returns null) yet, and no app.findComponent. app.alert keeps its title, icon (error, warning, question, status) and buttons (OK, OK/Cancel, Yes/No, Yes/No/Cancel), given in order or as one object; js_run reports them with each message, and the app shows every message in turn in the toast with its title and icon. Still missing: answers. Every app.alert returns 1 (OK), so a script is never told No or Cancel (#4291)." +evidence = ["crates/js/src/tests.rs::calculate_scripts_read_fields_and_set_the_value", "crates/engine/src/tests.rs::form_javascript_validates_calculates_and_formats", "crates/automation/tests/it/automation.rs::javascript_through_tools", "crates/js/src/tests.rs::an_xfa_form_sees_the_xfa_plugin_installed", "crates/js/src/tests.rs::a_plain_form_sees_the_xfa_version_but_not_installed", "crates/engine/src/tests.rs::scripts_in_an_xfa_form_see_the_xfa_plugin_installed", "crates/js/src/tests.rs::scand_allows_separators_between_fields_but_nothing_before_or_after_them", "crates/js/src/tests.rs::app_alert_keeps_its_title_icon_and_buttons", "crates/js/src/tests.rs::app_alert_reads_the_object_form", "crates/js/src/tests.rs::app_alert_falls_back_on_bad_icon_and_button_numbers", "crates/js/src/tests.rs::app_alert_titles_are_clipped_and_charged", "crates/ui-egui/tests/it/javascript.rs::every_message_a_script_shows_takes_the_toast_in_turn", "crates/js/src/tests.rs::widget_rect_reads_and_writes_a_four_number_array", "crates/engine/src/tests.rs::javascript_field_rect_moves_first_widget_and_undoes"] +notes = "boa (pure Rust) with the forms object model: event, getField/Field (value, readonly, display, colours, items, check boxes, rect), app (with execMenuItem for the side panels and Save As, from a button only, and Print where this.print() works), util.printf/printd/printx/scand (scand reads dates as another viewer was observed to, in AcroForm and XFA scripts), console, display, color; xfa_version (3.5) in every document and xfa_installed in XFA forms, so their version check passes. field.rect reads and writes the first widget in PDF user space. no annotations, layers or dialogs (app.response returns null) yet, and no app.findComponent. app.alert keeps its title, icon (error, warning, question, status) and buttons (OK, OK/Cancel, Yes/No, Yes/No/Cancel), given in order or as one object; js_run reports them with each message, and the app shows every message in turn in the toast with its title and icon. Still missing: answers. Every app.alert returns 1 (OK), so a script is never told No or Cancel (#4291)." [[feature]] id = "form.js-console" From 9b2c8d6026aa8fa454fb1ffb3a7e4299bce559b7 Mon Sep 17 00:00:00 2001 From: jonathanwxh-cell Date: Mon, 9 Nov 2026 08:42:54 +0800 Subject: [PATCH 45/92] fix(xfa): read and validate the first alternative in typed_date (#6120) --- crates/xfa/src/picture.rs | 50 +++++++++++++++++++++++++++++++++++++-- 1 file changed, 48 insertions(+), 2 deletions(-) diff --git a/crates/xfa/src/picture.rs b/crates/xfa/src/picture.rs index 3ddb392114..f1296e495a 100644 --- a/crates/xfa/src/picture.rs +++ b/crates/xfa/src/picture.rs @@ -39,14 +39,15 @@ pub fn typed_date(tpl: &Template, som: &str, text: &str) -> Option { } let picture = f.picture.as_deref()?; let reads = |pattern: &str| pattern_to_iso(text, pattern).filter(|iso| iso_to_pattern(iso, pattern).eq_ignore_ascii_case(text.trim())); - let first = date_pattern(Some(picture)); + let mut alts = alternatives(picture).into_iter(); + let first = alts.next().filter(|a| is_date_clause(a) && date_letters(a).is_some()).map(|a| date_pattern(Some(a)))?; if reads(&first).is_some() { return None; } // The first later date pattern that reads the text and gives a date the first pattern keeps: // written in the first, it must read back as the same date (a two-digit year can't hold 1901 // or 1203), else the next pattern is tried. - alternatives(picture).into_iter().skip(1).filter(|a| is_date_clause(a)).filter_map(date_letters).find_map(|p| { + alts.filter(|a| is_date_clause(a)).filter_map(date_letters).find_map(|p| { let iso = reads(&p)?; let shown = iso_to_pattern(&iso, &first); (pattern_to_iso(&shown, &first).as_deref() == Some(iso.as_str())).then_some(shown) @@ -63,3 +64,48 @@ fn is_date_clause(clause: &str) -> bool { None => true, } } + +#[cfg(test)] +mod tests { + use super::*; + + fn tpl_with_pictures(fields: &[(&str, &str)]) -> Template { + let mut body = String::new(); + for (name, pic) in fields { + body.push_str(&format!( + "{pic}" + )); + } + let xml = format!( + "\ + \ + " + ); + crate::parse(&xml).expect("valid template").0 + } + + #[test] + fn typed_date_reads_bare_first_alternative_instead_of_whole_picture_or_later_braces() { + let tpl = tpl_with_pictures(&[("bare", "YYYYMMDD|YYMMDD"), ("mixed", "YYYYMMDD|date{YYMMDD}")]); + assert_eq!(typed_date(&tpl, "form[0].bare[0]", "261018").as_deref(), Some("20261018")); + assert_eq!(typed_date(&tpl, "form[0].bare[0]", "20261018"), None); + assert_eq!(typed_date(&tpl, "form[0].mixed[0]", "261018").as_deref(), Some("20261018")); + assert_eq!(typed_date(&tpl, "form[0].mixed[0]", "20261018"), None); + } + + #[test] + fn typed_date_rejects_non_date_or_unreadable_first_alternative() { + let tpl = tpl_with_pictures(&[ + ("text_first", "text{YYYYMMDD}|date{YYMMDD}"), + ("num_first", "num{99999999}|date{YYMMDD}"), + ("datetime_first", "date{YYYY-MM-DD HH:MM}|date{YYMMDD}"), + ]); + assert_eq!(typed_date(&tpl, "form[0].text_first[0]", "261018"), None); + assert_eq!(typed_date(&tpl, "form[0].num_first[0]", "261018"), None); + assert_eq!(typed_date(&tpl, "form[0].datetime_first[0]", "261018"), None); + } +} From 19619ad2ccd85d9caba26a554aca6bda91874391 Mon Sep 17 00:00:00 2001 From: jonathanwxh-cell Date: Mon, 9 Nov 2026 08:43:09 +0800 Subject: [PATCH 46/92] fix(js): decode FormCalc Unicode string escapes and preserve chained unary not/! (#6122) --- crates/js/src/formcalc/lexer.rs | 167 +++++++++++++++++++++++-------- crates/js/src/formcalc/parser.rs | 33 +++++- 2 files changed, 152 insertions(+), 48 deletions(-) diff --git a/crates/js/src/formcalc/lexer.rs b/crates/js/src/formcalc/lexer.rs index 46af19f761..9d01abf331 100644 --- a/crates/js/src/formcalc/lexer.rs +++ b/crates/js/src/formcalc/lexer.rs @@ -49,52 +49,12 @@ pub(super) fn lex(src: &str) -> Result, String> { } } '"' => { - let mut s = String::new(); - i += 1; - loop { - match chars.get(i) { - None => return Err(format!("line {line}: unterminated string")), - Some('"') if chars.get(i + 1) == Some(&'"') => { - s.push('"'); - i += 2; - } - Some('"') => { - i += 1; - break; - } - Some(&ch) => { - if ch == '\n' { - line += 1; - } - s.push(ch); - i += 1; - } - } - if s.len() > MAX_STRING { - return Err(format!("line {line}: a string is too long")); - } - } - out.push(Token { tok: Tok::Str(s), line }); + let tok_line = line; + let s = lex_string(&chars, &mut i, &mut line)?; + out.push(Token { tok: Tok::Str(s), line: tok_line }); } c if c.is_ascii_digit() || (c == '.' && chars.get(i + 1).is_some_and(char::is_ascii_digit)) => { - let start = i; - while i < chars.len() && (chars[i].is_ascii_digit() || (chars[i] == '.' && chars.get(i + 1) != Some(&'.'))) { - i += 1; - } - if i < chars.len() && (chars[i] == 'e' || chars[i] == 'E') { - let mut j = i + 1; - if j < chars.len() && (chars[j] == '+' || chars[j] == '-') { - j += 1; - } - if j < chars.len() && chars[j].is_ascii_digit() { - i = j; - while i < chars.len() && chars[i].is_ascii_digit() { - i += 1; - } - } - } - let text: String = chars[start..i].iter().collect(); - let n: f64 = text.parse().map_err(|_| format!("line {line}: bad number {text}"))?; + let n = lex_number(&chars, &mut i, line)?; out.push(Token { tok: Tok::Num(n), line }); } '!' => { @@ -127,3 +87,122 @@ pub(super) fn lex(src: &str) -> Result, String> { out.push(Token { tok: Tok::Eof, line }); Ok(out) } + +fn hex_u32(digits: &[char]) -> Option { + let mut v = 0u32; + for &c in digits { + v = (v << 4) | c.to_digit(16)?; + } + Some(v) +} + +/// FormCalc string escape (`\uXXXX` or `\UXXXXXXXX`, XFA 3.3 part 8 p. 1054); any other `\` stays literal. +fn unicode_escape(chars: &[char], at: usize) -> Option<(char, usize)> { + if chars.get(at) != Some(&'\\') { + return None; + } + match chars.get(at + 1)? { + 'u' => { + let hi = hex_u32(chars.get(at + 2..at + 6)?)?; + if (0xD800..=0xDBFF).contains(&hi) + && chars.get(at + 6..at + 8) == Some(&['\\', 'u']) + && let Some(lo) = chars.get(at + 8..at + 12).and_then(hex_u32).filter(|lo| (0xDC00..=0xDFFF).contains(lo)) + { + let scalar = 0x1_0000 + ((hi - 0xD800) << 10) + (lo - 0xDC00); + return Some((char::from_u32(scalar)?, 12)); + } + Some((char::from_u32(hi)?, 6)) + } + 'U' => { + let code = hex_u32(chars.get(at + 2..at + 10)?)?; + Some((char::from_u32(code)?, 10)) + } + _ => None, + } +} + +fn lex_string(chars: &[char], i: &mut usize, line: &mut usize) -> Result { + let mut s = String::new(); + *i += 1; + loop { + match chars.get(*i) { + None => return Err(format!("line {line}: unterminated string")), + Some('"') if chars.get(*i + 1) == Some(&'"') => { + s.push('"'); + *i += 2; + } + Some('"') => { + *i += 1; + return Ok(s); + } + Some(&ch) => { + if let Some((decoded, step)) = unicode_escape(chars, *i) { + s.push(decoded); + *i += step; + } else { + if ch == '\n' { + *line += 1; + } + s.push(ch); + *i += 1; + } + } + } + if s.len() > MAX_STRING { + return Err(format!("line {line}: a string is too long")); + } + } +} + +/// Whether `c` may start a FormCalc identifier. +fn is_ident_start(c: char) -> bool { + c.is_alphabetic() || c == '_' || c == '$' +} + +/// End index of a numeric exponent starting at `at` (`e`/`E`, optional sign, digits), or `None` +/// if no valid exponent starts there or if identifier characters immediately follow the digits. +fn exponent_end(chars: &[char], at: usize) -> Option { + if !matches!(chars.get(at), Some('e' | 'E')) { + return None; + } + let mut j = at + 1; + if matches!(chars.get(j), Some('+' | '-')) { + j += 1; + } + if !chars.get(j).is_some_and(char::is_ascii_digit) { + return None; + } + while chars.get(j).is_some_and(char::is_ascii_digit) { + j += 1; + } + if chars.get(j).is_some_and(|&c| c.is_alphanumeric() || c == '_' || c == '$') { + return None; + } + Some(j) +} + +/// Scan a FormCalc number literal starting at `*i`, stopping before `.member` or `..` while +/// rejecting malformed multi-dot literals such as `1.2.3`. +fn lex_number(chars: &[char], i: &mut usize, line: usize) -> Result { + let start = *i; + let mut seen_dot = false; + while *i < chars.len() { + let next = chars.get(*i + 1).copied(); + let next_is_member = next.is_some_and(is_ident_start) && exponent_end(chars, *i + 1).is_none(); + if chars[*i].is_ascii_digit() { + *i += 1; + } else if chars[*i] == '.' && !seen_dot && next != Some('.') && !next_is_member { + seen_dot = true; + *i += 1; + } else if chars[*i] == '.' && seen_dot && next.is_some_and(|c| c.is_ascii_digit()) { + return Err(format!("line {line}: bad number")); + } else { + break; + } + } + if let Some(end) = exponent_end(chars, *i) { + *i = end; + } + let text: String = chars[start..*i].iter().collect(); + text.parse().map_err(|_| format!("line {line}: bad number {text}")) +} diff --git a/crates/js/src/formcalc/parser.rs b/crates/js/src/formcalc/parser.rs index fceedadf5f..871d3874de 100644 --- a/crates/js/src/formcalc/parser.rs +++ b/crates/js/src/formcalc/parser.rs @@ -341,10 +341,7 @@ impl Parser { Ok(Expr::Neg(Box::new(self.unary()?))) } else if self.eat_op("+") { self.unary() - } else if self.eat_kw("not") || matches!(self.peek(), Tok::Ident(w) if w == "!") { - if matches!(self.peek(), Tok::Ident(w) if w == "!") { - self.at += 1; - } + } else if self.eat_kw("not") || self.eat_kw("!") { Ok(Expr::Not(Box::new(self.unary()?))) } else { self.postfix() @@ -449,3 +446,31 @@ pub(super) fn parse(src: &str) -> Result, String> { t => Err(format!("line {}: unexpected {}", p.line(), describe(t))), } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn unary_not_does_not_swallow_following_bang() { + assert_eq!(parse("not !1"), Ok(vec![Stmt::Expr(Expr::Not(Box::new(Expr::Not(Box::new(Expr::Num(1.0))))))])); + assert_eq!(parse("not !flag"), Ok(vec![Stmt::Expr(Expr::Not(Box::new(Expr::Not(Box::new(Expr::Ident("flag".into()))))))])); + assert_eq!(parse("!!1"), Ok(vec![Stmt::Expr(Expr::Not(Box::new(Expr::Not(Box::new(Expr::Num(1.0))))))])); + } + + #[test] + fn string_literals_decode_xfa_unicode_escapes_and_keep_other_backslashes() { + assert_eq!(parse(r#""\u0041\u20ac\U00000042\U0001F600""#), Ok(vec![Stmt::Expr(Expr::Str("A\u{20ac}B\u{1F600}".into()))])); + assert_eq!(parse(r#""\uD83D\uDE00""#), Ok(vec![Stmt::Expr(Expr::Str("\u{1F600}".into()))])); + assert_eq!(parse(r#""C:\temp\new\u12""#), Ok(vec![Stmt::Expr(Expr::Str(r"C:\temp\new\u12".into()))])); + } + + #[test] + fn number_literals_stop_before_second_dot_for_member_access() { + assert_eq!(parse("1.5.rawValue"), Ok(vec![Stmt::Expr(Expr::Member(Box::new(Expr::Num(1.5)), "rawValue".into()))])); + assert_eq!(parse("1.rawValue"), Ok(vec![Stmt::Expr(Expr::Member(Box::new(Expr::Num(1.0)), "rawValue".into()))])); + assert_eq!(parse("1.e2foo"), Ok(vec![Stmt::Expr(Expr::Member(Box::new(Expr::Num(1.0)), "e2foo".into()))])); + assert_eq!(parse("1.e2"), Ok(vec![Stmt::Expr(Expr::Num(100.0))])); + assert!(parse("1.2.3").is_err(), "a second decimal point followed by digits is a bad number"); + } +} From d22aab133c0148ad45921893be48a82b1c718b5f Mon Sep 17 00:00:00 2001 From: ".CooCoo" Date: Sun, 8 Nov 2026 16:43:49 -0800 Subject: [PATCH 47/92] fix(ui): two-page continuous view prefetches whole rows ahead (#6126) --- crates/ui-egui/src/canvas.rs | 18 ++++++++++++++++-- crates/ui-egui/src/canvas/tests.rs | 15 +++++++++++++++ 2 files changed, 31 insertions(+), 2 deletions(-) diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 3e51dac3bf..b5cfd14910 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -2262,13 +2262,27 @@ fn scroll_prefetch(view: &DocView, visible_now: &[usize]) -> Vec { return Vec::new(); } let n = PREFETCH_ROWS * if view.layout == PageLayout::Single { 1 } else { 2 }; + // At high zoom the other page of a two-page row can be off to the side and not on screen, so + // the rows are counted from the edges of the rows shown, and that page is prefetched too. match (view.scroll_dir, visible_now.iter().min(), visible_now.iter().max()) { - (1, _, Some(&last)) => (last.saturating_add(1)..=last.saturating_add(n)).collect(), - (-1, Some(&first), _) => (first.saturating_sub(n)..first).rev().collect(), + (1, _, Some(&last)) => (last.saturating_add(1)..=row_bounds(view, last).1.saturating_add(n)).collect(), + (-1, Some(&first), _) => (row_bounds(view, first).0.saturating_sub(n)..first).rev().collect(), _ => Vec::new(), } } +/// The first and last page of the row that page `i` is in, paired as [`shown_row`] pairs them. +fn row_bounds(view: &DocView, i: usize) -> (usize, usize) { + match view.layout { + PageLayout::Single => (i, i), + PageLayout::TwoUp if view.cover && i == 0 => (0, 0), + PageLayout::TwoUp => { + let first = view.spread_first(i); + (first, first.saturating_add(1)) + } + } +} + /// The whole-page raster a prefetch asks for: the page at this scale, or the backdrop of a page /// that is tiled at this scale. A cached raster sharper than the backdrop stays demanded as it /// is, as `paint_page_surface` demands it under tiles. diff --git a/crates/ui-egui/src/canvas/tests.rs b/crates/ui-egui/src/canvas/tests.rs index 62c00d6b7a..0e86db7914 100644 --- a/crates/ui-egui/src/canvas/tests.rs +++ b/crates/ui-egui/src/canvas/tests.rs @@ -1536,6 +1536,21 @@ fn continuous_view_prefetches_the_rows_ahead_of_the_way_it_moved() { assert!(scroll_prefetch(&single, &[9]).is_empty(), "single-page view keeps its own prefetch"); } +#[test] +fn continuous_two_page_prefetch_counts_rows_from_the_end_of_the_row_shown() { + let mut two = view(20, PageLayout::TwoUp, true); + two.scroll_dir = 1; + assert_eq!(scroll_prefetch(&two, &[8]), [9, 10, 11, 12, 13], "the right page off to the side, then two rows"); + two.scroll_dir = -1; + assert_eq!(scroll_prefetch(&two, &[9]), [8, 7, 6, 5, 4], "the left page off to the side, then two rows"); + two.cover = true; + two.scroll_dir = 1; + assert_eq!(scroll_prefetch(&two, &[0]), [1, 2, 3, 4], "the cover is a row of its own"); + assert_eq!(scroll_prefetch(&two, &[3]), [4, 5, 6, 7, 8]); + two.scroll_dir = -1; + assert_eq!(scroll_prefetch(&two, &[4]), [3, 2, 1, 0]); +} + #[test] fn scrolling_the_other_way_drops_the_prefetch_from_the_queue() { let info = DocInfo { From e877b0822a79a18c6ea501b0bbf1435e833b41cb Mon Sep 17 00:00:00 2001 From: ".CooCoo" Date: Sun, 8 Nov 2026 16:44:04 -0800 Subject: [PATCH 48/92] Scroll: a touch zoom keeps zooming after its keys are let go (#6130) --- crates/ui-egui/src/canvas.rs | 33 +++++---- crates/ui-egui/src/platform/motion.rs | 76 ++++++++++++++++++--- crates/ui-egui/src/platform/motion/tests.rs | 31 +++++++-- crates/ui-egui/tests/it/view.rs | 45 ++++++++++++ 4 files changed, 152 insertions(+), 33 deletions(-) diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index b5cfd14910..dd0fa285a0 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -2141,19 +2141,18 @@ fn single_page_paging(ui: &egui::Ui, view: &mut DocView, cx: &PagingCx<'_>) -> ( single_paging = can_turn && fits; // Every wheel event goes to the pager, so it follows each trackpad touch to its end // even while the page can't turn. - ui.input(|i| { - for e in &i.events { - if let egui::Event::MouseWheel { unit, delta, phase, modifiers, .. } = e { - // Zooming (⌘ or Ctrl) and sideways scrolling (Shift) never turn pages. - let dy = if modifiers.command || modifiers.ctrl || modifiers.shift { 0.0 } else { delta.y }; - if fits { - view.single_page_wheel(*unit, dy, *phase, i.time, can_turn); - } else { - edge_turned |= view.single_page_wheel_edge(*unit, dy, *phase, i.time, can_turn); - } + let now = ui.input(|i| i.time); + for e in &crate::motion::gesture_wheel_events(ui.ctx()) { + if let egui::Event::MouseWheel { unit, delta, phase, modifiers, .. } = e { + // Zooming (⌘ or Ctrl) and sideways scrolling (Shift) never turn pages. + let dy = if modifiers.command || modifiers.ctrl || modifiers.shift { 0.0 } else { delta.y }; + if fits { + view.single_page_wheel(*unit, dy, *phase, now, can_turn); + } else { + edge_turned |= view.single_page_wheel_edge(*unit, dy, *phase, now, can_turn); } } - }); + } if single_paging { // Paging owns vertical wheel motion here. A diagonal gesture keeps its sideways // part, as `ScrollArea` itself handles each axis. @@ -2202,10 +2201,10 @@ fn read_wheel(ui: &mut egui::Ui, view: &mut DocView, avail: Rect, single_paging: } return Wheel { precise_scroll: Vec2::ZERO, line_base: Vec2::ZERO, coarse_scroll: Vec2::ZERO, has_page: false, paging_owns_vertical }; } - let consumed = ui.input(|i| { - i.events.iter().any(|event| { - matches!(event, egui::Event::MouseWheel { delta, modifiers, .. } if *delta != Vec2::ZERO && delta.is_finite() && !modifiers.command && !modifiers.ctrl) - }) + // A touch gesture zooms to its end once it began with ⌘ or Ctrl, the keys let go or not. + let events = crate::motion::gesture_wheel_events(ui.ctx()); + let consumed = events.iter().any(|event| { + matches!(event, egui::Event::MouseWheel { delta, modifiers, .. } if *delta != Vec2::ZERO && delta.is_finite() && !modifiers.command && !modifiers.ctrl) }); if consumed { wheel_reset::consumed(ui.ctx()); @@ -2214,7 +2213,7 @@ fn read_wheel(ui: &mut egui::Ui, view: &mut DocView, avail: Rect, single_paging: // Match the line distance used by egui's other scroll areas. let live = ui.ctx().options(|o| o.input_options.line_scroll_speed); let line_points = if live.is_finite() && live > 0.0 { live } else { crate::scroll_speed::EGUI_LINE_POINTS }; - ui.input(|i| crate::wheel_boost::accumulate(&i.events, line_points, single_paging)) + crate::wheel_boost::accumulate(&events, line_points, single_paging) }; let mut precise_scroll = input.precise; let mut line_base = input.line; @@ -2870,7 +2869,7 @@ struct GlideCx { fn step_glide(ui: &egui::Ui, view: &mut DocView, avail: Rect, cx: &GlideCx) -> Vec2 { let GlideCx { now, may_glide, key_scroll, key_side, auto_delta, zoom_delta, edge_delta, wheel_scrolled } = *cx; let over_page = ui.rect_contains_pointer(avail); - ui.input(|i| view.fling.read(&i.events, now, may_glide && over_page)); + view.fling.read(&crate::motion::gesture_wheel_events(ui.ctx()), now, may_glide && over_page); let interrupted = glide_interrupted(ui); if !may_glide || interrupted || key_scroll != 0.0 || key_side != 0.0 || auto_delta != Vec2::ZERO || zoom_delta != 1.0 { view.fling.stop(); diff --git a/crates/ui-egui/src/platform/motion.rs b/crates/ui-egui/src/platform/motion.rs index b1d615df14..79878de20b 100644 --- a/crates/ui-egui/src/platform/motion.rs +++ b/crates/ui-egui/src/platform/motion.rs @@ -19,7 +19,7 @@ //! How far anything scrolls or zooms doesn't change, only how long it takes. use egui::style::ScrollAnimation; -use egui::{Event, Modifiers, MouseWheelUnit, TouchPhase, Vec2, vec2}; +use egui::{Event, Modifiers, MouseWheelSource, MouseWheelUnit, TouchPhase, Vec2, vec2}; /// Preferences ▸ Animation effects: whether anything in the window is animated, the wheel's /// scrolling and zooming included. @@ -119,12 +119,6 @@ impl Motion { } let animate = effects.animates(self.reduced); self.sync_style(ctx, animate); - if animate { - // A gesture begun while easing is egui's to finish. - self.wheel = WheelReader::default(); - self.frame = None; - return; - } if crate::canvas::wheel_reset::reset_this_pass(ctx) { // The document's hidden cancel resets egui before fresh input. Its app reader // must forget the same older gesture modifiers before reading those events. @@ -146,11 +140,18 @@ impl Motion { let motion = match self.frame { Some((read_in, motion)) if read_in == frame => motion, _ => { - let motion = ctx.input(|i| self.wheel.read(&i.events, i.viewport_rect().height(), &options)); + let motion = ctx.input(|i| self.wheel.read(&i.events, i.time, i.viewport_rect().height(), &options)); self.frame = Some((frame, motion)); + let modifiers = self.wheel.modifiers.clone(); + ctx.data_mut(|d| d.insert_temp(gesture_id(), (frame, modifiers))); motion } }; + if animate { + // egui eases the wheel itself. The reader still follows each touch gesture, as + // egui does, for the modifiers [`gesture_wheel_events`] hands the document. + return; + } let (scroll, zoom) = if first_pass { (motion.scroll, motion.zoom) } else { (Vec2::ZERO, 1.0) }; ctx.input_mut(|i| i.smooth_scroll_delta = scroll); let pass = ctx.cumulative_pass_nr(); @@ -202,6 +203,34 @@ pub(crate) fn zoom_delta(ctx: &egui::Context) -> f32 { }) } +/// Return the stable egui id holding the modifiers of this frame's wheel events. +fn gesture_id() -> egui::Id { + egui::Id::unique("pdfcraft-motion-wheel-gesture") +} + +/// This pass's wheel events, each with the modifiers it acts with: a touch gesture keeps +/// ⌘/Ctrl or Shift from its `Start` until its `End`, as egui and [`WheelReader`] read it. A view +/// that reads the raw wheel then scrolls none of a gesture that egui or [`zoom_delta`] zooms, +/// after the keys are let go too. The events keep their own modifiers when +/// [`Motion::begin_pass`] did not read this frame's wheel. +pub(crate) fn gesture_wheel_events(ctx: &egui::Context) -> Vec { + let read: Option<(u64, Vec)> = ctx.data(|d| d.get_temp(gesture_id())); + let frame = ctx.cumulative_frame_nr(); + let mut events: Vec = ctx.input(|i| i.events.iter().filter(|e| matches!(e, Event::MouseWheel { .. })).cloned().collect()); + if let Some((at, held)) = read + && at == frame + // A later pass of the frame has no events left; only the pass that was read matches. + && events.len() == held.len() + { + for (event, held) in events.iter_mut().zip(held) { + if let Event::MouseWheel { modifiers, .. } = event { + *modifiers = held; + } + } + } + events +} + /// One pass's wheel input. #[derive(Clone, Copy, Debug, PartialEq)] struct WheelMotion { @@ -221,20 +250,44 @@ struct WheelReader { /// The modifiers of the touch gesture in progress (`TouchPhase::Start` to `End`). They hold /// for the whole gesture, as in egui: its momentum arrives after the keys are let go. touch: Option, + /// The time of the last wheel event and what drove it. A touch whose `End` never arrives + /// (some mouse wheels on Wayland send `Start` alone) ends after egui's timeout. + last: Option<(f64, MouseWheelSource)>, + /// The modifiers each wheel event of the last [`Self::read`] acted with, in order. + modifiers: Vec, } +/// egui's `SCROLL_ACTION_TIMEOUT`: seconds without a wheel event that end a touch. +const TOUCH_TIMEOUT: f64 = 0.150; + +/// egui's `RESTING_FINGER_TIMEOUT`: the same for a touch driven by fingers on a trackpad. +const RESTING_FINGER_TIMEOUT: f64 = 1.0; + impl WheelReader { - /// `page_height` is what one `MouseWheelUnit::Page` scrolls: the window's height in points. - fn read(&mut self, events: &[Event], page_height: f32, options: &egui::InputOptions) -> WheelMotion { + /// `time` is the pass's input time; `page_height` is what one `MouseWheelUnit::Page` + /// scrolls: the window's height in points. + fn read(&mut self, events: &[Event], time: f64, page_height: f32, options: &egui::InputOptions) -> WheelMotion { let mut delta = Vec2::ZERO; let mut zoom = 1.0_f32; let mut modifiers = self.touch.unwrap_or_default(); + self.modifiers.clear(); for event in events { + if let Event::MouseWheel { source, .. } = event { + if let Some((at, last_source)) = self.last { + let timeout = if last_source == MouseWheelSource::Trackpad { RESTING_FINGER_TIMEOUT } else { TOUCH_TIMEOUT }; + // `>` is false for a NaN time, which then keeps the touch, as egui does. + if time - at > timeout { + self.touch = None; + } + } + self.last = Some((time, *source)); + } match event { Event::MouseWheel { unit, delta: step, phase, modifiers: latest, .. } => match phase { TouchPhase::Start => { self.touch = Some(*latest); modifiers = *latest; + self.modifiers.push(modifiers); } TouchPhase::Move => { modifiers = match &mut self.touch { @@ -244,6 +297,7 @@ impl WheelReader { } None => *latest, }; + self.modifiers.push(modifiers); let mut step = match unit { MouseWheelUnit::Point => *step, MouseWheelUnit::Line => options.line_scroll_speed * *step, @@ -264,6 +318,8 @@ impl WheelReader { } // egui drops what the gesture moved earlier in the pass it ends in. TouchPhase::End | TouchPhase::Cancel => { + // The end belongs to the gesture: it carries the gesture's modifiers. + self.modifiers.push(self.touch.unwrap_or(*latest)); self.touch = None; modifiers = Modifiers::NONE; delta = Vec2::ZERO; diff --git a/crates/ui-egui/src/platform/motion/tests.rs b/crates/ui-egui/src/platform/motion/tests.rs index 2db565df6a..4ccb3c0230 100644 --- a/crates/ui-egui/src/platform/motion/tests.rs +++ b/crates/ui-egui/src/platform/motion/tests.rs @@ -11,7 +11,7 @@ fn notch(dy: f32, modifiers: Modifiers) -> Event { } fn read(events: &[Event]) -> WheelMotion { - WheelReader::default().read(events, 600.0, &egui::InputOptions::default()) + WheelReader::default().read(events, 0.0, 600.0, &egui::InputOptions::default()) } #[test] @@ -46,14 +46,33 @@ fn a_touch_gesture_keeps_its_modifiers_and_ends_clean() { let options = egui::InputOptions::default(); let point = |dy: f32, phase, modifiers| wheel(MouseWheelUnit::Point, vec2(0.0, dy), phase, modifiers); // ⌘ held as the fingers land, let go before the momentum: still zooming. - let began = reader.read(&[point(0.0, TouchPhase::Start, Modifiers::COMMAND), point(20.0, TouchPhase::Move, Modifiers::COMMAND)], 600.0, &options); + let began = + reader.read(&[point(0.0, TouchPhase::Start, Modifiers::COMMAND), point(20.0, TouchPhase::Move, Modifiers::COMMAND)], 0.0, 600.0, &options); assert!(began.zoom > 1.0 && began.scroll == Vec2::ZERO, "{began:?}"); - let momentum = reader.read(&[point(20.0, TouchPhase::Move, Modifiers::NONE)], 600.0, &options); + let momentum = reader.read(&[point(20.0, TouchPhase::Move, Modifiers::NONE)], 0.0, 600.0, &options); assert!(momentum.zoom > 1.0 && momentum.scroll == Vec2::ZERO, "{momentum:?}"); + assert_eq!(reader.modifiers, vec![Modifiers::COMMAND], "the momentum acts with the gesture's modifiers"); // The pass it ends in moves nothing, and the next gesture starts without the modifier. - let ended = reader.read(&[point(20.0, TouchPhase::Move, Modifiers::NONE), point(0.0, TouchPhase::End, Modifiers::NONE)], 600.0, &options); + let ended = reader.read(&[point(20.0, TouchPhase::Move, Modifiers::NONE), point(0.0, TouchPhase::End, Modifiers::NONE)], 0.0, 600.0, &options); assert_eq!(ended, WheelMotion { scroll: Vec2::ZERO, zoom: 1.0 }); - assert_eq!(reader.read(&[point(-20.0, TouchPhase::Move, Modifiers::NONE)], 600.0, &options).scroll, vec2(0.0, -20.0)); + assert_eq!(reader.read(&[point(-20.0, TouchPhase::Move, Modifiers::NONE)], 0.0, 600.0, &options).scroll, vec2(0.0, -20.0)); +} + +/// A touch whose `End` never arrives (some mouse wheels on Wayland) ends after egui's timeout, +/// so its modifiers don't latch onto later scrolling. +#[test] +fn a_touch_without_an_end_lets_its_modifiers_go_after_a_pause() { + let options = egui::InputOptions::default(); + for (source, pause) in [(egui::MouseWheelSource::Unknown, 0.2), (egui::MouseWheelSource::Trackpad, 1.1)] { + let mut reader = WheelReader::default(); + let point = |dy: f32, phase, modifiers| Event::MouseWheel { unit: MouseWheelUnit::Point, delta: vec2(0.0, dy), phase, modifiers, source }; + reader.read(&[point(0.0, TouchPhase::Start, Modifiers::COMMAND), point(20.0, TouchPhase::Move, Modifiers::COMMAND)], 1.0, 600.0, &options); + let soon = reader.read(&[point(20.0, TouchPhase::Move, Modifiers::NONE)], 1.1, 600.0, &options); + assert!(soon.zoom > 1.0 && soon.scroll == Vec2::ZERO, "{source:?}: still the same touch: {soon:?}"); + let later = reader.read(&[point(-20.0, TouchPhase::Move, Modifiers::NONE)], 1.1 + pause, 600.0, &options); + assert_eq!(later, WheelMotion { scroll: vec2(0.0, -20.0), zoom: 1.0 }, "{source:?}"); + assert_eq!(reader.modifiers, vec![Modifiers::NONE], "{source:?}"); + } } #[test] @@ -103,7 +122,7 @@ fn reading_at_once_goes_as_far_as_egui_eases() { }); } let page_height = ctx.input(|i| i.viewport_rect().height()); - let at_once = WheelReader::default().read(&[event], page_height, &options); + let at_once = WheelReader::default().read(&[event], 0.0, page_height, &options); assert!((scrolled - at_once.scroll).length() < 0.01, "{modifiers:?} {unit:?}: egui scrolled {scrolled:?}, read {:?}", at_once.scroll); assert!((zoomed / at_once.zoom - 1.0).abs() < 1e-3, "{modifiers:?} {unit:?}: egui zoomed {zoomed}, read {}", at_once.zoom); // What makes the reader necessary: egui's first pass holds part of the step back. diff --git a/crates/ui-egui/tests/it/view.rs b/crates/ui-egui/tests/it/view.rs index f6384127e8..d912fa18a4 100644 --- a/crates/ui-egui/tests/it/view.rs +++ b/crates/ui-egui/tests/it/view.rs @@ -1016,6 +1016,51 @@ fn the_wheel_zooms_a_whole_step_at_once_with_animation_effects_off() { } } +/// A touch gesture begun with ⌘/Ctrl zooms to its end after the keys are let go, as egui reads +/// it: its later motion neither scrolls the page as well nor turns the rest of it into scrolling. +#[test] +fn a_zoom_gesture_zooms_to_its_end_after_its_keys_are_let_go() { + /// The view's zoom and page 1 on screen, settled, after a gesture of six 5-point motions, + /// the keys let go after the second one or held throughout. + fn zoom_gesture(effects: &'static str, let_go: bool) -> (f32, f32, egui::Rect) { + let mut h = harness_stepping(1.0 / 60.0, &[("layout", "continuous"), ("zoom", "300")]); + h.state_mut().set_option("animation_effects", effects).unwrap(); + let at = h.state().views[0].viewport_rect().center(); + h.hover_at(at); + h.run_steps(2); + let before = h.state().views[0].zoom; + let touch = |phase, dy, modifiers| egui::Event::MouseWheel { + unit: MouseWheelUnit::Point, + delta: egui::vec2(0.0, dy), + phase, + modifiers, + source: egui::MouseWheelSource::Unknown, + }; + h.event(touch(TouchPhase::Start, 0.0, Modifiers::COMMAND)); + h.step(); + for k in 0..6 { + let modifiers = if let_go && k >= 2 { Modifiers::NONE } else { Modifiers::COMMAND }; + h.event(touch(TouchPhase::Move, 5.0, modifiers)); + h.step(); + } + h.event(touch(TouchPhase::End, 0.0, Modifiers::NONE)); + h.run_steps(40); + (before, h.state().views[0].zoom, rect(&h, 0).expect("page 1 on screen")) + } + for effects in ["off", "on"] { + let (before, held, held_page) = zoom_gesture(effects, false); + assert!(held > before, "{effects}: the gesture zoomed in: {before} to {held}"); + let (_, let_go, let_go_page) = zoom_gesture(effects, true); + assert!((let_go / held - 1.0).abs() < 1e-4, "{effects}: the whole gesture zooms: {let_go}, held throughout {held}"); + assert!( + (let_go_page.min - held_page.min).length() < 0.5, + "{effects}: and nothing scrolls: page 1 at {:?}, held throughout {:?}", + let_go_page.min, + held_page.min + ); + } +} + #[test] fn page_display_commands_switch_layouts() { let mut h = harness(&[]); From 5d3f3a2e42414dd7ab13e0e3b9fb943b0f99ee92 Mon Sep 17 00:00:00 2001 From: jonathanwxh-cell Date: Mon, 9 Nov 2026 08:44:20 +0800 Subject: [PATCH 49/92] fix(export): skip empty Markdown runs before merging and emphasis lookahead (#6134) --- crates/export/src/as_markdown.rs | 41 ++++++++++++++++++++++++++------ 1 file changed, 34 insertions(+), 7 deletions(-) diff --git a/crates/export/src/as_markdown.rs b/crates/export/src/as_markdown.rs index a5c986c6b3..949264494d 100644 --- a/crates/export/src/as_markdown.rs +++ b/crates/export/src/as_markdown.rs @@ -60,20 +60,23 @@ pub(crate) fn emphasis(text: &str, bold: bool, italic: bool, open: bool, close: pub(crate) fn paragraph(runs: &[(&str, bool, bool)]) -> String { let mut merged: Vec<(String, bool, bool)> = Vec::new(); for &(text, bold, italic) in runs { + let text = md_text(text); + if text.is_empty() { + continue; + } match merged.last_mut() { - Some(last) if last.1 == bold && last.2 == italic => last.0.push_str(text), - _ => merged.push((text.to_string(), bold, italic)), + Some(last) if last.1 == bold && last.2 == italic => last.0.push_str(&text), + _ => merged.push((text, bold, italic)), } } - let texts: Vec = merged.iter().map(|(text, ..)| md_text(text)).collect(); let mut out = String::new(); - for (k, ((_, bold, italic), text)) in merged.iter().zip(&texts).enumerate() { + for (k, (text, bold, italic)) in merged.iter().enumerate() { let core = text.trim(); let open = marks(core.chars().next(), out.chars().last()); // Markers of a styled neighbour right next to these would join them into one run. - let close = match (merged.get(k.saturating_add(1)), texts.get(k.saturating_add(1))) { - (Some((_, b, i)), Some(next)) if (*b || *i) && !next.starts_with(char::is_whitespace) => false, - (_, next) => marks(core.chars().last(), next.and_then(|n| n.chars().next())), + let close = match merged.get(k.saturating_add(1)) { + Some((next, b, i)) if (*b || *i) && !next.starts_with(char::is_whitespace) => false, + next => marks(core.chars().last(), next.and_then(|(n, ..)| n.chars().next())), }; out.push_str(&emphasis(text, *bold, *italic, open, close)); } @@ -149,3 +152,27 @@ pub fn markdown(pages: &[Page]) -> String { s.push('\n'); s } + +#[cfg(test)] +mod tests { + use super::paragraph; + + #[test] + fn empty_or_control_run_does_not_split_styled_runs() { + assert_eq!(paragraph(&[("hello", true, false), ("", false, false), ("world", true, false)]), "**helloworld**"); + assert_eq!(paragraph(&[("hello", true, false), ("\u{1}", false, false), ("world", true, false)]), "**helloworld**"); + } + + #[test] + fn empty_styled_run_does_not_affect_neighbor_emphasis() { + assert_eq!(paragraph(&[("x", true, false), ("", false, true)]), "**x**"); + assert_eq!(paragraph(&[("foo", false, false), (".", true, false), ("", true, true), ("bar", false, false)]), "foo.bar"); + // Empty/control runs between differently styled runs still look ahead to the next non-empty run. + assert_eq!(paragraph(&[("bold", true, false), ("", false, false), ("italic", false, true)]), "bold*italic*"); + assert_eq!(paragraph(&[("bold", true, false), ("\u{1}", false, false), (" italic", false, true)]), "**bold** *italic*"); + // Leading empty/control runs do not affect the opening delimiter. + assert_eq!(paragraph(&[("", false, true), ("\u{1}", true, false), ("lead", true, false)]), "**lead**"); + // All-empty/control paragraphs produce empty output. + assert_eq!(paragraph(&[("", true, false), ("\u{1}", false, true), ("\u{7f}", false, false)]), ""); + } +} From 5ee4a85876d0254e72bb21cdc6cd5c9b9bf63988 Mon Sep 17 00:00:00 2001 From: jonathanwxh-cell Date: Mon, 9 Nov 2026 08:44:34 +0800 Subject: [PATCH 50/92] fix(sign): merge CMS unsignedAttrs into one SET and validate SignerIdentifier (#6137) --- crates/sign/src/cms.rs | 224 +++++++++++++++++++++++++++++++---------- 1 file changed, 172 insertions(+), 52 deletions(-) diff --git a/crates/sign/src/cms.rs b/crates/sign/src/cms.rs index 4e232af616..ee1882ccdc 100644 --- a/crates/sign/src/cms.rs +++ b/crates/sign/src/cms.rs @@ -230,66 +230,94 @@ impl SignedData { } } -fn strip(b: &[u8]) -> &[u8] { - match b { - [0, rest @ ..] if !rest.is_empty() => rest, - _ => b, +fn strip(mut b: &[u8]) -> &[u8] { + while let [0, rest @ ..] = b + && !rest.is_empty() + { + b = rest; } + b +} + +fn parse_signer_id(sid: &Tlv<'_>) -> Result { + match sid.tag { + tag::SEQUENCE => { + let p = sid.children()?; + let [issuer, serial] = p.as_slice() else { return Err(bad("issuerAndSerialNumber")) }; + if issuer.tag != tag::SEQUENCE || serial.tag != tag::INTEGER || serial.value.is_empty() { + return Err(bad("issuerAndSerialNumber")); + } + Ok(SignerId::IssuerSerial { issuer: issuer.raw.to_vec(), serial: serial.value.to_vec() }) + } + t if t == tag::ctx_prim(0) => Ok(SignerId::KeyId(sid.value.to_vec())), + t if t == tag::ctx(0) => { + let key_id = match sid.inner() { + Ok(inner) if matches!(inner.tag, tag::OCTET_STRING | tag::OCTET_STRING_SEGMENTS) => inner.octets("sid")?.into_owned(), + _ => { + let mut seg = *sid; + seg.tag = tag::OCTET_STRING_SEGMENTS; + seg.octets("sid")?.into_owned() + } + }; + Ok(SignerId::KeyId(key_id)) + } + _ => Err(bad("sid")), + } +} + +type SignedAttrs = (Vec, Option>, Option, Option